164 lines
8.7 KiB
Python
164 lines
8.7 KiB
Python
"""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()
|