Randoloot/tools/audio/build_freighter_audio.py
2026-10-18 15:11:06 +02:00

164 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Edit CC0 field recordings into the frigate's restrained industrial soundscape.
Run fetch_freighter_sources.py first. Requires numpy, no audio service/API.
Output: 48 kHz, stereo, PCM16 WAV. Source credits live beside the assets.
"""
from pathlib import Path
import json
import shutil
import struct
import wave
import numpy as np
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "tmp/freighter_audio_sources"
OUT = ROOT / "assets/sounds/freighter"
RATE = 48000
MANIFEST = {}
def read(relative):
raw = (SOURCE / relative).read_bytes()
cursor = 12
fmt = data = None
while cursor + 8 <= len(raw):
name, size = struct.unpack_from("<4sI", raw, cursor)
chunk = raw[cursor+8:cursor+8+size]
if name == b"fmt ": fmt = chunk
if name == b"data": data = chunk
cursor += 8 + size + size % 2
kind, channels, rate, _, _, bits = struct.unpack_from("<HHIIHH", fmt)
if kind == 0xFFFE: kind = struct.unpack_from("<H", fmt, 24)[0]
if kind == 3:
signal = np.frombuffer(data, dtype="<f4" if bits == 32 else "<f8").astype(float)
elif bits == 24:
values = np.frombuffer(data, dtype=np.uint8).reshape(-1,3).astype(np.int32)
values = values[:,0] | values[:,1] << 8 | values[:,2] << 16
signal = ((values ^ 0x800000) - 0x800000) / 8388608.0
else:
signal = np.frombuffer(data, dtype="<i2" if bits == 16 else "<i4").astype(float) / 2**(bits-1)
signal = signal.reshape(-1,channels)
if channels == 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 peak(x, db=-3):
return x * (10**(db/20) / max(1e-9,float(np.max(np.abs(x)))))
def eq(x, low=35, high=6000):
frequencies = np.fft.rfftfreq(len(x),1/RATE)
response = (1-np.exp(-(frequencies/low)**4))*np.exp(-(frequencies/high)**4)
return np.fft.irfft(np.fft.rfft(x,axis=0)*response[:,None],n=len(x),axis=0)
def pitch(x, ratio):
at = np.arange(0,len(x)-1,ratio)
return np.stack([np.interp(at,np.arange(len(x)),x[:,c]) for c in range(2)],axis=1)
def fade(x, start=.018, end=.20):
x = x.copy()
a, b = min(int(start*RATE),len(x)//2), min(int(end*RATE),len(x)//2)
if a: x[:a] *= np.linspace(0,1,a)[:,None]
if b: x[-b:] *= np.linspace(1,0,b)[:,None]
return x
def trim(x):
block = 480
length = len(x)//block*block
rms = np.sqrt((x[:length].reshape(-1,block,2)**2).mean(axis=(1,2)))
active = np.where(rms > max(rms.max()*.045,.00001))[0]
if not len(active): raise ValueError("Silent recording")
return x[max(0,int(active[0])*block-960):min(len(x),(int(active[-1])+1)*block+4800)]
def room(x, amount=.14):
# Short non-musical reflections, staggered between left and right walls.
output = np.pad(x,((0,int(RATE*.65)),(0,0)))
wet = eq(x,80,3300)
for index, delay in enumerate([.037,.061,.089,.127,.173,.229,.307,.413,.521]):
offset = int(delay*RATE)
output[offset:offset+len(x)] += np.roll(wet,index%2,axis=1)*amount*.76**index
return output
def mix(*entries):
length = max(int(offset*RATE)+len(x) for x,offset,gain in entries)
output = np.zeros((length,2))
for x,offset,gain in entries:
start = int(offset*RATE)
output[start:start+len(x)] += x*gain
return output
def loop(x, seconds=1.2):
n = min(int(seconds*RATE),len(x)//4)
ramp = np.linspace(0,1,n)[:,None]
return np.concatenate([x[n:-n],x[-n:]*(1-ramp)+x[:n]*ramp])
def save(name, x, sources, processing, looping=False):
x = peak(x,-3.5 if looping else -2)
assert np.isfinite(x).all() and np.max(np.abs(x)) < 1
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":round(20*np.log10(np.abs(x).max()),2),"rms_dbfs":round(20*np.log10(np.sqrt((x*x).mean())),2)}
def workshop(name): return "workshop/workshop/workshop - "+name+".wav"
def sample(name, ratio=1, high=6000): return peak(eq(pitch(trim(read(workshop(name))),ratio),high=high))
def main():
OUT.mkdir(parents=True,exist_ok=True)
fridge = "shop/TheShopCollection_convenience_store_drinks_fridge_drone.wav"
fridge2 = "shop/TheShopCollection_convenience_store_drinks_fridge_drone_2.wav"
reactor = loop(eq(pitch(read(fridge),.64),low=28,high=1150))
save("reactor_loop",reactor,[fridge],"Slowed 0.64x; DC removal; band limiting 28–1150 Hz; 1.2 s loop crossfade",True)
vent = loop(eq(read(fridge2),low=110,high=2600))
save("ventilation_loop",vent,[fridge2],"DC removal; band limiting 110–2600 Hz; 1.2 s loop crossfade",True)
for i,name in enumerate(["drill short 1","drill short 2","ratchet1"],1):
x = sample(name,.88 if i<3 else .94,4700)
save("tool_%d"%i,fade(room(x,.12)),[workshop(name)],"Trim, reduced motor speed, softened highs, short industrial room reflections")
for i,name in enumerate(["tool clatter","metal tapping"],1):
save("metal_%d"%i,fade(room(sample(name,.9,4200),.15)),[workshop(name)],"Trim, mild pitch lowering, low-pass, short stereo reflections")
steam_path = "steam/steam hisses - Marker #1.wav"
steam = peak(eq(trim(read(steam_path)),low=150,high=5200))
steam = fade(steam[:int(2.2*RATE)],.06,.55)
slide = fade(sample("dragging metal",.72,2900)[:int(1.5*RATE)],.1,.3)
latch = fade(sample("clink thud",.78,3700)[:int(.65*RATE)])
save("airlock_open",fade(room(mix((latch,0,.52),(steam,.12,.38),(slide,.38,.68)),.17)),[workshop("clink thud"),steam_path,workshop("dragging metal")],"Layered latch, pressure equalization, slow metal slide; EQ and shared room reflections")
save("airlock_close",fade(room(mix((slide,0,.6),(steam,.2,.24),(latch,1.18,.65)),.17)),[workshop("dragging metal"),steam_path,workshop("clink thud")],"Metal slide, pressure seal and locking impact; shared short reflections")
save("pressure_release",fade(room(steam,.11)),[steam_path],"Softened steam recording, pressure-release envelope, short reflections")
clamp = mix((sample("low hammering",.75,3000)[:int(.6*RATE)],0,.7),(latch,.33,.4))
save("docking_clamp",fade(room(clamp,.20)),[workshop("low hammering"),workshop("clink thud")],"Layered low mechanical impact and latch, softened transient, stereo reflections")
# Alarms are naturally electronic: restrained two-tone annunciator, not a siren bed.
length = int(1.5*RATE)
t = np.arange(length)/RATE
tone = np.zeros(length)
for offset,freq in [(0.12,540),(.54,680)]:
local = t-offset
env = np.where((local>=0)&(local<.24),np.sin(np.pi*np.clip(local/.24,0,1))**2,0)
tone += env*(np.sin(2*np.pi*freq*t)+.11*np.sin(2*np.pi*freq*2*t))
save("service_chime",fade(room(np.repeat(tone[:,None],2,axis=1),.13)),[],"Original two-tone 540/680 Hz service annunciator, smooth envelope, short reflections")
warning = mix((np.repeat(tone[:,None],2,axis=1),0,.8),(np.repeat(tone[:,None],2,axis=1),1.15,.7))
save("maintenance_alarm",fade(room(warning,.19)),[],"Original restrained maintenance warning, two paired tones with room reflections")
for i in [0,1]:
source = "scifi/Audio/computerNoise_%03d.ogg"%i
shutil.copyfile(SOURCE/source,OUT/("console_%d.ogg"%(i+1)))
MANIFEST["console_%d"%(i+1)] = {"sources":[source],"processing":"Unmodified CC0 Kenney electronic effect, mixed quietly in-engine"}
(OUT/"manifest.json").write_text(json.dumps(MANIFEST,indent=2,ensure_ascii=False),encoding="utf-8")
preview = np.zeros((int(34*RATE),2))
for name,db in [("reactor_loop",-14),("ventilation_loop",-21)]:
bed = read(OUT/(name+".wav"))
preview += bed[np.arange(len(preview))%len(bed)]*10**(db/20)
for name,offset,db,pan in [("docking_clamp",1,-13,.65),("airlock_open",3,-13.5,-.48),("tool_1",8,-13,-.48),("metal_1",13,-15,-.48),("tool_3",17,-15,-.48),("pressure_release",23,-23,.6),("maintenance_alarm",26,-22,-.7),("airlock_close",30,-17,.2)]:
clip = read(OUT/(name+".wav"))*10**(db/20)
clip *= np.array([min(1,1-pan),min(1,1+pan)])
start = int(offset*RATE)
preview[start:start+len(clip)] += clip[:len(preview)-start]
preview = fade(peak(preview,-3),1.0,1.4)
path = ROOT/"artifacts/freighter_ambience_demo.wav"
with wave.open(str(path),"wb") as stream:
stream.setparams((2,2,RATE,0,"NONE","not compressed"))
stream.writeframes((preview*32767).astype("<i2").tobytes())
print(json.dumps(MANIFEST,indent=2))
if __name__ == "__main__": main()