Randoloot/tools/audio/build_surface_audio.py
2026-10-18 16:22:39 +02:00

78 lines
4.6 KiB
Python

"""Nature field recordings edited into planet ambience. Requires numpy + soundfile."""
from pathlib import Path
import sys
import json
import wave
import numpy as np
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0,str(ROOT/"tmp/surface_audio_tools"))
import soundfile as sf
from build_freighter_audio import eq, peak, pitch, fade, loop, RATE
SOURCE = ROOT/"tmp/surface_audio_sources"
OUT = ROOT/"assets/sounds/surface"
manifest = {}
def read(path, start=0, seconds=None):
with sf.SoundFile(SOURCE/path) as stream:
rate = stream.samplerate
stream.seek(int(start*rate))
signal = stream.read(-1 if seconds is None else int(seconds*rate),always_2d=True)
if signal.shape[1] == 1: signal = np.repeat(signal,2,axis=1)
signal = signal[:,:2]
if rate != RATE:
at = np.arange(int(len(signal)*RATE/rate))*rate/RATE
signal = np.stack([np.interp(at,np.arange(len(signal)),signal[:,c]) for c in range(2)],axis=1)
signal -= signal.mean(axis=0)
return signal
def save(name, x, sources, processing, looping=False):
x = peak(x,-3)
assert np.isfinite(x).all() and np.max(np.abs(x)) < .9
with wave.open(str(OUT/(name+".wav")),"wb") as stream:
stream.setparams((2,2,RATE,0,"NONE","not compressed"))
stream.writeframes((x*32767).astype("<i2").tobytes())
manifest[name] = {"sources":sources,"processing":processing,"loop":looping,"seconds":round(len(x)/RATE,3),"peak_dbfs":-3,"rms_dbfs":round(20*np.log10(np.sqrt((x*x).mean())),2)}
return x
def main():
OUT.mkdir(parents=True,exist_ok=True)
# Avoid recording start/handling noise; remove rumble and distant bird highs.
wind = read("wind.wav",start=8,seconds=24)
wind = save("wind_loop",loop(eq(wind,low=45,high=1900),2),["wind.wav: 8..32 seconds"],"Real open-field wind; rumble/high-bird reduction; two-second seam crossfade",True)
gust = fade(eq(read("wind.wav",start=45,seconds=8),low=70,high=2900),2,3)
gust = save("wind_gust",gust,["wind.wav: 45..53 seconds"],"Natural gust with long soft attack/release and low-pass")
leaves = [eq(read("leaves/rustle/rustle%02d.flac"%i),low=180,high=5200) for i in [1,4,9]]
foliage = np.zeros((int(16*RATE),2))
for index in range(12):
leaf = fade(peak(leaves[index%3]),.25,.55)
start = int(index*1.15*RATE)
end = min(len(foliage),start+len(leaf))
foliage[start:end] += leaf[:end-start]*.30
foliage = save("foliage_loop",loop(eq(foliage,low=160,high=3300),1.5),["leaves/rustle/rustle01.flac","leaves/rustle/rustle04.flac","leaves/rustle/rustle09.flac"],"Layered leaf recordings with staggered overlaps, soft envelopes, filtering, crossfaded loop",True)
for index,leaf in enumerate(leaves,1):
save("leaves_%d"%index,fade(leaf,.1,.3),["leaves/rustle/rustle%02d.flac"%[1,4,9][index-1]],"Trimmed leaf rustle; softened attack and release")
wood = read("wood_creak.mp3")
for index,ratio in enumerate([.78,.94],1):
save("branch_creak_%d"%index,fade(eq(pitch(wood,ratio),low=120,high=3400),.04,.3),["wood_creak.mp3"],"Real wood flex/creak; mild pitch variation; no falling-tree impact")
for index,offset in enumerate([3,15,29],1):
save("bird_%d"%index,fade(eq(read("birds.ogg",offset,5),low=650,high=7000),.5,1),["birds.ogg: %d..%d seconds"%(offset,offset+5)],"Natural distant birds, cut into independent calls; filtered noise; soft boundaries")
cricket = next((SOURCE/"fauna").rglob("amb_cricket_1.flac"))
frog = next((SOURCE/"fauna").rglob("amb_frog_1.flac"))
save("insects",fade(eq(read(cricket),low=900,high=7500),.08,.25),[str(cricket.relative_to(SOURCE))],"Cricket recording, gentle filtering and envelope")
save("marsh_call",fade(eq(pitch(read(frog),.92),low=180,high=4600),.05,.25),[str(frog.relative_to(SOURCE))],"Frog recording, slightly lower pitch, restrained high frequencies")
(OUT/"manifest.json").write_text(json.dumps(manifest,indent=2),encoding="utf-8")
preview = np.zeros((int(32*RATE),2))
for bed,level in [(wind,-17),(foliage,-25)]: preview += bed[np.arange(len(preview))%len(bed)]*10**(level/20)
for name,offset,level in [("bird_1",2,-13),("leaves_1",7,-14),("branch_creak_1",12,-14),("wind_gust",16,-20),("marsh_call",22,-16),("insects",27,-19)]:
clip,_ = sf.read(OUT/(name+".wav"),always_2d=True)
at = int(offset*RATE)
end = min(len(preview),at+len(clip))
preview[at:end] += clip[:end-at]*10**(level/20)
preview = fade(peak(preview,-3),1,1.5)
sf.write(ROOT/"artifacts/planet_ambience_demo.wav",preview,RATE,subtype="PCM_16")
print(json.dumps(manifest,indent=2))
if __name__ == "__main__": main()