#!/usr/bin/env python3 """PROCITY audio pack — 100% procedural synthesis, on-device, $0 (round-11 audio round). TOOL CHOICE (Fable asked E to pick + document): **pure procedural synthesis in numpy**, not a neural model. MODELBEAST has no audio operator, and for THIS pack synthesis wins on every axis: * $0, no 2–15 GB model download, runs in the mflux venv (numpy only). * Deterministic + seeded (citySeed/shopId flavour is house law) — same seed, same sound. * Perfect **seamless loops** (we crossfade the tail into the head) — neural gens don't loop. * **Parody-safe by construction**: every sample is an original oscillator/noise render, so there is no way to imitate a specific real recording. Instrumental originals only. * Precise budget control (≤25 MB): we choose length + Opus bitrate per asset. Pipeline: synth (numpy float32) -> WAV (stdlib wave, int16) -> ffmpeg -> OGG/Opus (+ M4A/AAC). Loudness: long beds/music -> ffmpeg loudnorm (EBU R128, target -16 LUFS integrated); short SFX -> peak-normalized in-synth (loudnorm is unreliable under ~3 s). pipeline/gen_audio.py --dry-run # list the pack + budget, no render pipeline/gen_audio.py # render everything -> web/assets/audio/ pipeline/gen_audio.py --only music # just the music beds pipeline/gen_audio.py --wav-only # keep .genaudio/*.wav, skip ffmpeg (debug) Run with the numpy-having interpreter: ~/Documents/MODELBEAST/venvs/mflux/bin/python House audio law: the game runs silent-and-happy with zero assets — none of this blocks anyone. """ import os, sys, math, wave, struct, subprocess, hashlib, json, zlib import numpy as np SR = 44100 ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) RAW = os.path.join(ROOT, "pipeline", ".genaudio") # WAV scratch (git-ignored) OUT = os.path.join(ROOT, "web", "assets", "audio") # shipped OGG/M4A os.makedirs(RAW, exist_ok=True) os.makedirs(OUT, exist_ok=True) FF = "ffmpeg" # ───────────────────────────────────────────────────────────────────────────── # DSP toolkit (pure numpy) # ───────────────────────────────────────────────────────────────────────────── def seed_of(name): return zlib.crc32(name.encode()) & 0xFFFFFFFF def rng(name): return np.random.default_rng(seed_of(name)) def tarr(dur): return np.arange(int(dur * SR)) / SR def sine(f, dur, phase=0.0): return np.sin(2 * np.pi * f * tarr(dur) + phase) def _phase(f, dur): # allow f to be scalar or array (for vibrato/glide) n = int(dur * SR) if np.isscalar(f): f = np.full(n, f, dtype=np.float64) else: f = np.resize(f, n) return np.cumsum(2 * np.pi * f / SR) def osc(f, dur, kind="sine"): ph = _phase(f, dur) if kind == "sine": return np.sin(ph) if kind == "tri": return 2 / np.pi * np.arcsin(np.sin(ph)) if kind == "saw": return 2 * ((ph / (2 * np.pi)) % 1.0) - 1 if kind == "square": return np.sign(np.sin(ph)) if kind == "pulse25": return np.where((ph / (2 * np.pi)) % 1.0 < 0.25, 1.0, -1.0) raise ValueError(kind) def noise(dur, r=None, kind="white"): n = int(dur * SR) r = r or np.random.default_rng(0) x = r.standard_normal(n) if kind == "pink": # ~ -3 dB/oct via cheap one-pole cascade b = [0.0, 0.0, 0.0] out = np.empty(n) for i in range(n): b[0] = 0.99765 * b[0] + x[i] * 0.0990460 b[1] = 0.96300 * b[1] + x[i] * 0.2965164 b[2] = 0.57000 * b[2] + x[i] * 1.0526913 out[i] = b[0] + b[1] + b[2] + x[i] * 0.1848 return out / 4 return x def adsr(dur, a=0.01, d=0.1, s=0.7, r=0.1, sl=None): n = int(dur * SR) sl = (dur - a - d - r) if sl is None else sl sl = max(sl, 0.0) segs = [(a, 0, 1), (d, 1, s), (sl, s, s), (r, s, 0)] env = np.concatenate([np.linspace(x0, x1, max(int(t * SR), 1)) for t, x0, x1 in segs]) return np.resize(env, n) def lp1(x, cutoff): # one-pole low-pass a = math.exp(-2 * math.pi * cutoff / SR) y = np.empty_like(x) acc = 0.0 for i in range(len(x)): acc = (1 - a) * x[i] + a * acc y[i] = acc return y def hp1(x, cutoff): return x - lp1(x, cutoff) def biquad_lp(x, cutoff, q=0.707): # RBJ low-pass biquad w0 = 2 * math.pi * cutoff / SR alpha = math.sin(w0) / (2 * q) cw = math.cos(w0) b0 = (1 - cw) / 2; b1 = 1 - cw; b2 = (1 - cw) / 2 a0 = 1 + alpha; a1 = -2 * cw; a2 = 1 - alpha b0, b1, b2, a1, a2 = (b0/a0, b1/a0, b2/a0, a1/a0, a2/a0) y = np.empty_like(x); x1 = x2 = y1 = y2 = 0.0 for i in range(len(x)): y0 = b0*x[i] + b1*x1 + b2*x2 - a1*y1 - a2*y2 x2, x1 = x1, x[i]; y2, y1 = y1, y0 y[i] = y0 return y def bandpass(x, lo, hi): return lp1(hp1(x, lo), hi) def softclip(x, drive=1.0): return np.tanh(x * drive) def fade(x, fin=0.01, fout=0.01): n = len(x); ai = int(fin * SR); ao = int(fout * SR) if ai: x[:ai] *= np.linspace(0, 1, ai) if ao: x[-ao:] *= np.linspace(1, 0, ao) return x def peaknorm(x, target_db=-3.0): p = np.max(np.abs(x)) or 1.0 return x * (10 ** (target_db / 20) / p) def seamless(x, xf=0.75): """Return a loop of length len(x)-xf*SR that plays end->start seamlessly, by crossfading the tail (xf s) over the head. Input should be rendered xf longer than the wanted loop.""" k = int(xf * SR) if k <= 0 or len(x) <= k: return x body = x[:-k].copy() tail = x[-k:] w = np.linspace(0, 1, k) if body.ndim == 2: # stereo: broadcast window across channels w = w[:, None] body[:k] = body[:k] * w + tail * (1 - w) # head = head*rise + tail*fall return body def stereo(l, r=None): if r is None: r = l n = max(len(l), len(r)) out = np.zeros((n, 2), dtype=np.float64) out[:len(l), 0] = l; out[:len(r), 1] = r return out def pan(x, p): # p in [-1,1] p = (p + 1) / 2 return stereo(x * math.cos(p * math.pi / 2), x * math.sin(p * math.pi / 2)) def mix(*layers): n = max(len(a) for a in layers) ch = max((a.ndim == 2 and a.shape[1] or 1) for a in layers) out = np.zeros((n, ch) if ch == 2 else n, dtype=np.float64) for a in layers: if ch == 2 and a.ndim == 1: a = stereo(a) if ch == 2: out[:len(a)] += a else: out[:len(a)] += a return out def schroeder_reverb(x, mix_amt=0.2, decay=0.5): if x.ndim == 2: return np.stack([schroeder_reverb(x[:, c], mix_amt, decay) for c in range(2)], axis=1) out = np.zeros(len(x) + SR) out[:len(x)] = x for dl, g in [(1116, 0.805), (1188, 0.827), (1277, 0.783), (1356, 0.764)]: buf = np.zeros(len(out)) gg = g * decay for i in range(dl, len(out)): buf[i] = out[i] + gg * buf[i - dl] out = out + buf * 0.25 wet = out[:len(x)] wet = wet / (np.max(np.abs(wet)) or 1.0) return (1 - mix_amt) * x + mix_amt * wet # ───────────────────────────────────────────────────────────────────────────── # instruments + drums (for music beds) # ───────────────────────────────────────────────────────────────────────────── def midi_hz(m): return 440.0 * 2 ** ((m - 69) / 12) def synth_note(midi, dur, kind="saw", a=0.005, d=0.08, s=0.6, rel=0.1, cutoff=2500, detune=0.0, vib=0.0): f = midi_hz(midi) if vib: f = f * (1 + vib * 0.006 * np.sin(2 * np.pi * 5 * tarr(dur))) x = osc(f, dur, kind) if detune: x = 0.5 * x + 0.5 * osc(midi_hz(midi) * (1 + detune), dur, kind) x = x * adsr(dur, a, d, s, rel) if cutoff < SR / 2: x = biquad_lp(x, cutoff, 0.8) return x def epiano(midi, dur): f = midi_hz(midi) x = (np.sin(2*np.pi*f*tarr(dur)) + 0.5*np.sin(2*np.pi*2*f*tarr(dur)*1.001) + 0.25*np.sin(2*np.pi*3*f*tarr(dur))) return x * adsr(dur, 0.005, 0.5, 0.25, 0.3) def pad(midi, dur, detune=0.008): x = (osc(midi_hz(midi), dur, "saw") + osc(midi_hz(midi)*(1+detune), dur, "saw") + osc(midi_hz(midi)*(1-detune), dur, "saw")) / 3 x = biquad_lp(x, 1800, 0.7) return x * adsr(dur, 0.4, 0.2, 0.8, 0.5) def organ(midi, dur): # drawbar-organ-ish: stacked harmonic sines (16'/8'/5⅓'/4'/2') + a gentle Leslie tremolo and a # soft percussive attack. Warm and mellow — the RSL covers-band voice. f = midi_hz(midi); t = tarr(dur) drawbars = [(0.5, 1), (1.0, 2), (0.55, 3), (0.4, 4), (0.22, 6)] x = sum(a * np.sin(2 * np.pi * f * mult * t) for a, mult in drawbars) / 2.6 trem = 1 + 0.06 * np.sin(2 * np.pi * 5.2 * t) # gentle Leslie tremolo return x * trem * adsr(dur, 0.012, 0.15, 0.82, 0.12) def kick(dur=0.28): n = int(dur * SR) f = np.linspace(120, 45, n) x = np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-np.linspace(0, 9, n)) click = noise(0.005) * np.exp(-np.linspace(0, 40, int(0.005*SR))) return mix(x, np.concatenate([click, np.zeros(n - len(click))])) def snare(dur=0.2, r=None): n = int(dur * SR) body = np.sin(2*np.pi*190*tarr(dur)) * np.exp(-np.linspace(0, 14, n)) nz = noise(dur, r) * np.exp(-np.linspace(0, 16, n)) return 0.5*body + 0.8*bandpass(nz, 1200, 6000) def hat(dur=0.05, r=None, open_=False): n = int(dur * SR) nz = bandpass(noise(dur, r), 7000, 16000) env = np.exp(-np.linspace(0, 3 if open_ else 30, n)) return nz * env # ───────────────────────────────────────────────────────────────────────────── # sequencer for music beds # ───────────────────────────────────────────────────────────────────────────── def render_line(notes, bpm, instr, gap=0.0): """notes: list of (midi_or_None, beats). instr: fn(midi,dur)->array. Returns concatenated.""" spb = 60.0 / bpm parts = [] for midi, beats in notes: dur = beats * spb if midi is None: parts.append(np.zeros(int(dur * SR))) else: x = instr(midi, max(dur - gap, 0.05)) x = np.concatenate([x, np.zeros(int(dur * SR) - len(x))]) if len(x) < int(dur*SR) else x[:int(dur*SR)] parts.append(x) return np.concatenate(parts) def render_drums(pattern, bpm, bars, r): """pattern: dict 'k'/'s'/'h' -> list of 16th-step indices (0..15) for one bar.""" spb = 60.0 / bpm step = spb / 4 barlen = int(16 * step * SR) out = np.zeros(barlen * bars) voices = {"k": lambda: kick(), "s": lambda: snare(r=r), "h": lambda: hat(r=r), "o": lambda: hat(r=r, open_=True)} for b in range(bars): for v, steps in pattern.items(): for s in steps: pos = int((b * 16 + s) * step * SR) smp = voices[v]() end = min(pos + len(smp), len(out)) out[pos:end] += smp[:end - pos] return out # ───────────────────────────────────────────────────────────────────────────── # AMBIENCE generators (loopable ~40s) # ───────────────────────────────────────────────────────────────────────────── AMB_LEN = 40.0 XF = 1.0 def amb_street_day(name): r = rng(name) dur = AMB_LEN + XF wind = lp1(noise(dur, r, "pink"), 600) * 0.6 # distant traffic: slow-swelling band-passed noise traf = bandpass(noise(dur, r), 120, 900) swell = 0.5 + 0.5 * np.sin(2 * np.pi * 0.05 * tarr(dur) + r.random() * 6) traf = traf * swell * 0.35 # sparse bird chirps birds = np.zeros(int(dur * SR)) for _ in range(int(dur * 0.7)): at = r.uniform(0, dur - 0.4) f0 = r.uniform(2200, 4200) cd = r.uniform(0.06, 0.16) chirp = np.sin(2*np.pi*np.cumsum(np.linspace(f0, f0*r.uniform(0.8,1.4), int(cd*SR)))/SR) chirp *= np.hanning(len(chirp)) * r.uniform(0.05, 0.12) p = int(at * SR); birds[p:p+len(chirp)] += chirp body = mix(pan(wind, -0.2), pan(traf, 0.1), pan(birds, r.uniform(-0.5, 0.5))) return seamless(body, XF) def amb_street_night(name): r = rng(name) dur = AMB_LEN + XF hum = lp1(noise(dur, r, "pink"), 200) * 0.4 + osc(60, dur, "sine") * 0.02 # crickets: rhythmic AM high tone base = osc(4500, dur, "sine") trill = (np.sin(2*np.pi*22*tarr(dur)) > 0.3).astype(float) gate = (0.5 + 0.5*np.sin(2*np.pi*0.7*tarr(dur))) > 0.55 crickets = base * trill * gate * 0.04 crickets = bandpass(crickets, 3500, 6000) body = mix(pan(hum, 0), pan(crickets, 0.3), pan(bandpass(noise(dur, r), 200, 500)*0.1, -0.3)) return seamless(body, XF) def amb_rain(name): r = rng(name) dur = AMB_LEN + XF base = bandpass(noise(dur, r), 800, 9000) * 0.5 # hiss low = lp1(noise(dur, r), 400) * 0.25 # rumble drops = np.zeros(int(dur * SR)) for _ in range(int(dur * 12)): at = r.uniform(0, dur - 0.05); f = r.uniform(1500, 5000) d = np.sin(2*np.pi*f*tarr(0.02)) * np.exp(-np.linspace(0, 30, int(0.02*SR))) * r.uniform(0.05, 0.15) p = int(at*SR); drops[p:p+len(d)] += d body = mix(pan(base, 0), pan(low, 0), pan(drops, r.uniform(-0.4, 0.4))) return seamless(body, XF) def _roomtone(name, hum_f, hiss_lo, hiss_hi, hiss_g, extra=None): r = rng(name) dur = AMB_LEN + XF hum = (osc(hum_f, dur, "sine")*0.5 + osc(hum_f*2, dur, "sine")*0.2) * 0.06 hiss = bandpass(noise(dur, r), hiss_lo, hiss_hi) * hiss_g layers = [pan(hum, 0), pan(hiss, 0)] if extra is not None: layers.append(pan(extra(r, dur), 0.2)) return seamless(mix(*layers), XF) def amb_roomtone_retail(name): return _roomtone(name, 100, 200, 2000, 0.05) def amb_roomtone_milkbar(name): # fridge compressor buzz return _roomtone(name, 120, 300, 1500, 0.05, extra=lambda r, d: softclip(osc(120, d, "tri")*0.4, 2)*0.05) def amb_roomtone_video(name): # faint CRT flyback whine (~15.7 kHz) + hum return _roomtone(name, 60, 400, 3000, 0.03, extra=lambda r, d: osc(15734, d, "sine")*0.012) def _walla_chan(r, dur): base = bandpass(noise(dur, r), 300, 2600) env = np.full(int(dur * SR), 0.5) # crowd "waves" = sum of slow random LFOs for _ in range(4): env = env + 0.22 * np.sin(2 * np.pi * r.uniform(0.1, 0.5) * tarr(dur) + r.random() * 6) walla = base * np.clip(env, 0.1, 1.2) * 0.5 walla = 0.6 * walla + 0.4 * biquad_lp(hp1(walla, 400), 1600, 3.0) # vocal-ish formant colour for _ in range(int(dur * 0.8)): # sparse laughs/calls at = r.uniform(0, dur - 0.3) blip = bandpass(noise(r.uniform(0.1, 0.3), r), 500, 2500) blip *= np.hanning(len(blip)) * r.uniform(0.1, 0.25) p = int(at * SR); walla[p:p+len(blip)] += blip return walla def amb_crowd_walla(name): # pub crowd murmur — decorrelated L/R for width, seamless loop (gig interior layer) r = rng(name); dur = 20.0 + XF body = stereo(_walla_chan(r, dur), _walla_chan(r, dur)) return seamless(peaknorm(body, -6), XF) # ───────────────────────────────────────────────────────────────────────────── # SFX generators (short, dry, peak-normalized) # ───────────────────────────────────────────────────────────────────────────── def _bell(midi, dur, partials, decay): f = midi_hz(midi) x = np.zeros(int(dur * SR)) for mult, g in partials: x[:] += g * np.sin(2*np.pi*f*mult*tarr(dur)) return x * np.exp(-np.linspace(0, decay, len(x))) def sfx_doorbell(name): ding = _bell(76, 0.9, [(1, 1), (2.76, 0.4), (5.4, 0.15)], 5) # E5 dong = _bell(72, 1.1, [(1, 1), (2.76, 0.4), (5.4, 0.15)], 5) # C5 x = np.concatenate([ding[:int(0.45*SR)], dong]) return peaknorm(fade(x, 0.002, 0.05), -3) def sfx_door_open(name): r = rng(name) latch = noise(0.03, r) * np.exp(-np.linspace(0, 30, int(0.03*SR))) latch = bandpass(latch, 800, 5000) creak = bandpass(noise(0.4, r), 300, 1200) creak *= (0.3 + 0.7*np.abs(np.sin(2*np.pi*7*tarr(0.4)))) * np.linspace(1, 0, int(0.4*SR)) * 0.4 x = np.concatenate([latch, np.zeros(int(0.05*SR)), creak]) return peaknorm(fade(x, 0.002, 0.03), -4) def sfx_till(name): # pleasant buy-confirm: two-note bell arpeggio + tiny mechanical "cha" r = rng(name) n1 = _bell(72, 0.5, [(1, 1), (2.0, 0.3), (3.01, 0.2)], 6) n2 = _bell(79, 0.7, [(1, 1), (2.0, 0.3), (3.01, 0.2)], 6) cha = bandpass(noise(0.04, r), 2000, 8000) * np.exp(-np.linspace(0, 25, int(0.04*SR))) * 0.5 x = mix(np.concatenate([cha, np.zeros(int(0.6*SR))]), np.concatenate([n1[:int(0.12*SR)], n2])) return peaknorm(fade(x, 0.002, 0.05), -3) def sfx_riffle(name): # record-bin dig: a run of soft filtered noise "flips" r = rng(name) out = np.zeros(int(0.9 * SR)) tpos = 0.02 while tpos < 0.85: flip = bandpass(noise(0.05, r), 500, 4000) * np.exp(-np.linspace(0, 22, int(0.05*SR))) flip *= r.uniform(0.4, 1.0) p = int(tpos * SR); out[p:p+len(flip)] += flip tpos += r.uniform(0.05, 0.11) return peaknorm(fade(out, 0.002, 0.03), -5) def sfx_toast(name): x = np.concatenate([synth_note(84, 0.06, "sine", 0.002, 0.02, 0.3, 0.03, cutoff=SR//2), synth_note(88, 0.12, "sine", 0.002, 0.03, 0.3, 0.06, cutoff=SR//2)]) return peaknorm(fade(x, 0.002, 0.03), -6) def sfx_tram_bell(name): x = _bell(84, 1.2, [(1, 1), (2.4, 0.5), (3.9, 0.25), (5.2, 0.12)], 4) d2 = np.concatenate([np.zeros(int(0.18*SR)), _bell(84, 1.0, [(1, 1), (2.4, 0.5), (3.9, 0.25)], 4)]) d2 = np.concatenate([d2, np.zeros(len(x))])[:len(x)] # zero-pad/trim to x return peaknorm(fade(x + 0.5*d2, 0.002, 0.08), -3) def sfx_tram_rumble(name): # loopable continuous low rumble (~3 s) — smooth (no discrete clacks that would collide at # the loop fold); the wheel-clatter texture is a modulated mid-band noise instead. r = rng(name); dur = 3.0 + XF low = lp1(noise(dur, r), 120) * 0.6 + osc(45, dur, "sine") * 0.2 clatter = bandpass(noise(dur, r), 200, 1400) lfo = 0.4 + 0.6 * (0.5 + 0.5 * np.sin(2 * np.pi * 6 * tarr(dur))) # 6 Hz wheel wobble body = mix(low, clatter * lfo * 0.18) return seamless(peaknorm(body, -6), XF) def sfx_applause(name): # applause + cheer sting (one-shot): a swell of ~400 filtered-noise claps over a cheer bed r = rng(name); dur = 2.6 out = np.zeros(int(dur * SR)) for _ in range(400): at = min(r.uniform(0, dur) * r.uniform(0.5, 1.0), dur - 0.02) # bias toward a swell clap = bandpass(noise(0.015, r), 900, 5000) * np.exp(-np.linspace(0, 40, int(0.015*SR))) p = int(at * SR); out[p:p+len(clap)] += clap * r.uniform(0.2, 0.6) cheer = bandpass(noise(dur, r), 400, 2000) swell = np.concatenate([np.linspace(0, 1, int(0.4*SR)), np.ones(max(int(dur*SR) - int(1.2*SR), 0)), np.linspace(1, 0, int(0.8*SR))]) cheer = cheer * np.resize(swell, len(cheer)) * 0.3 return peaknorm(fade(mix(out, cheer), 0.005, 0.15), -3) def _footstep(name, bright, res_f): r = rng(name) thump = osc(np.linspace(res_f*1.6, res_f, int(0.05*SR)), 0.05, "sine") thump *= np.exp(-np.linspace(0, 18, int(0.05*SR))) click = bandpass(noise(0.04, r), 1500 if bright else 700, 9000 if bright else 4000) click *= np.exp(-np.linspace(0, 30, int(0.04*SR))) * (0.7 if bright else 0.4) x = mix(thump, click) * r.uniform(0.8, 1.0) return peaknorm(fade(x, 0.001, 0.02), -6) # ───────────────────────────────────────────────────────────────────────────── # MUSIC beds (instrumental, loopable, seeded, parody-safe originals) # ───────────────────────────────────────────────────────────────────────────── def loop_music(body, revmix=0.12, tail=1.2): """Seamless music loop: render the reverb ring-out PAST the loop end into `tail` seconds of silence, then crossfade that genuine ring-out over the head. Result: when the loop restarts, the decay from the 'previous' pass bleeds into the start — no click. (The broken way is to append a head-copy, which makes tail==head and the crossfade a no-op.)""" if body.ndim == 1: body = stereo(body) T = int(tail * SR) wet = schroeder_reverb(np.concatenate([body, np.zeros((T, 2))]), revmix) return seamless(wet, tail) def music_record_shop(name): r = rng(name); bpm = 96; bars = 8 spb = 60/bpm; barlen = int(4*spb*SR) # Am7 - D7 - Gmaj7 - Cmaj7 kind of groove (original voicing), root midis: prog = [45, 50, 43, 48] * 2 # A, D, G, C (one per bar) third = [+3, +4, +4, +4]; seventh = [+10, +10, +11, +11] bass = render_line(sum([[(root, 0.75), (None, 0.25), (root+7, 0.5), (root+12, 0.5), (root, 1.0), (root+7, 1.0)] for root in prog], []), bpm, lambda m, d: synth_note(m-12, d, "saw", 0.005, 0.1, 0.5, 0.05, cutoff=700)) chords = [] for i, root in enumerate(prog): for _ in range(4): # comp on each beat ch = mix(epiano(root+12, spb*0.9), epiano(root+12+third[i % 4], spb*0.9), epiano(root+12+seventh[i % 4], spb*0.9)) chords.append(ch * 0.5) chords = np.concatenate([np.concatenate([c, np.zeros(max(int(spb*SR)-len(c),0))])[:int(spb*SR)] for c in chords]) drums = render_drums({"k": [0, 6, 8], "s": [4, 12], "h": [0, 2, 4, 6, 8, 10, 12, 14]}, bpm, bars, r) body = mix(pan(bass, 0)*0.9, pan(chords, -0.15)*0.7, pan(drums, 0.1)*0.6) return loop_music(body, 0.12) def music_milkbar(name): r = rng(name); bpm = 108; bars = 8 spb = 60/bpm prog = [48, 55, 53, 50] # C G F D-ish bright pop (original) bass = render_line(sum([[(root-12, 1.0), (root-12, 1.0), (root-5, 1.0), (root-12, 1.0)] for root in prog]*2, []), bpm, lambda m, d: synth_note(m, d, "tri", 0.005, 0.1, 0.6, 0.05, cutoff=900)) lead = render_line([(r.choice([root, root+4, root+7, root+12]), 0.5) for root in prog for _ in range(8)], bpm, lambda m, d: synth_note(m+12, d, "square", 0.005, 0.05, 0.4, 0.05, cutoff=3000)) drums = render_drums({"k": [0, 8], "s": [4, 12], "h": [0, 2, 4, 6, 8, 10, 12, 14], "o": [14]}, bpm, bars, r) body = mix(pan(bass, 0)*0.9, pan(lead, 0.2)*0.35, pan(drums, 0)*0.55) return loop_music(body, 0.1) def music_video_synth(name): r = rng(name); bpm = 84; bars = 8 spb = 60/bpm prog = [45, 45, 50, 43] # moody synthwave (original) pads = np.concatenate([mix(pad(root, 4*spb), pad(root+7, 4*spb), pad(root+10, 4*spb))[:int(4*spb*SR)] for root in prog]*2) arp_notes = [] for root in prog*2: for s in [0, 7, 12, 7]: arp_notes.append((root+12+s, 0.5)) arp = render_line(arp_notes, bpm, lambda m, d: synth_note(m, d, "pulse25", 0.002, 0.05, 0.3, 0.05, cutoff=2600)) bass = render_line(sum([[(root-12, 2.0), (root-12, 2.0)] for root in prog]*2, []), bpm, lambda m, d: synth_note(m, d, "saw", 0.01, 0.1, 0.7, 0.1, cutoff=500)) drums = render_drums({"k": [0, 8], "s": [8], "h": [2, 6, 10, 14]}, bpm, bars, r) body = mix(pan(pads, 0)*0.5, pan(arp, 0.3)*0.3, pan(bass, 0)*0.8, pan(drums, 0)*0.4) return loop_music(body, 0.2) def music_arcade(name): r = rng(name); bpm = 132; bars = 8 spb = 60/bpm prog = [48, 48, 53, 55] # upbeat chiptune (original) lead_scale = [0, 2, 4, 7, 9, 12] lead = render_line([(root+12+r.choice(lead_scale), 0.25) for root in prog for _ in range(16)], bpm, lambda m, d: synth_note(m, d, "square", 0.001, 0.02, 0.5, 0.02, cutoff=SR//2)) bass = render_line(sum([[(root-12, 0.5), (root, 0.5)]*4 for root in prog]*2, []), bpm, lambda m, d: synth_note(m, d, "pulse25", 0.002, 0.03, 0.5, 0.02, cutoff=SR//2)) hats = render_drums({"h": list(range(0, 16, 2)), "k": [0, 4, 8, 12], "s": [4, 12]}, bpm, bars, r) body = mix(pan(lead, 0.1)*0.4, pan(bass, -0.1)*0.5, pan(hats, 0)*0.4) return loop_music(body, 0.03) def powerchord(root, dur, drive=3.5): x = osc(midi_hz(root), dur, "saw") + osc(midi_hz(root+7), dur, "saw") + osc(midi_hz(root+12), dur, "saw") x = softclip(x * 0.4, drive) # overdriven pub-rock crunch x = biquad_lp(x, 3200, 0.9) return x * adsr(dur, 0.003, 0.05, 0.7, 0.04) def grungechord(root, dur, drive=5.0): # sludgier, down-tuned cousin of powerchord: root+5th+octave, a square sub-octave for weight, # heavier fuzz and a darker filter. `drive` swings the quiet-loud grunge dynamic. x = (osc(midi_hz(root), dur, "saw") + osc(midi_hz(root+7), dur, "saw") + osc(midi_hz(root+12), dur, "saw") + 0.6 * osc(midi_hz(root-12), dur, "square")) x = softclip(x * 0.5, drive) # thick fuzz x = biquad_lp(x, 2400, 0.9) # darker than pub-rock's 3200 return x * adsr(dur, 0.004, 0.06, 0.75, 0.06) def music_pubrock(name): # the gig live bed — 90s Aussie pub-rock: driving power chords, punchy bass, rock kit. Louder # + rawer than the shop beds. Original I-bVII-IV-I riff in E. r = rng(name); bpm = 144; bars = 8 prog = [40, 38, 45, 40] * 2 # E D A E, ×2 bars = 8 bars guitar = render_line([(root, 0.5) for root in prog for _ in range(8)], # palm-mute 8ths bpm, lambda m, d: powerchord(m, d), gap=0.02) bass = render_line([(root - 12, 0.5) for root in prog for _ in range(8)], bpm, lambda m, d: synth_note(m, d, "saw", 0.003, 0.05, 0.6, 0.03, cutoff=650)) drums = render_drums({"k": [0, 3, 6, 8, 11, 14], "s": [4, 12], "h": [0, 2, 4, 6, 8, 10, 12, 14], "o": [15]}, bpm, bars, r) body = mix(pan(guitar, -0.12)*0.5, pan(bass, 0.1)*0.7, pan(drums, 0)*0.7) return loop_music(body, 0.06) def music_grunge(name): # band-room gig bed — 90s grunge: down-tuned sludge riff with a quiet-loud dynamic (clean-ish # verse → full-fuzz chorus) and a loose, heavy kit. Original i-bVII-bVI riff, low + dark. Slower # and murkier than the pub-rock bed so the two venues never sound the same. r = rng(name); bpm = 112 prog = [35, 35, 42, 40] # 4 bars (B B E D-ish), played verse then chorus verse = render_line([(root, 0.5) for root in prog for _ in range(8)], # palm-mute 8ths, cleanish bpm, lambda m, d: grungechord(m, d, 2.4) * 0.32, gap=0.03) chorus = render_line([(root, 0.5) for root in prog for _ in range(8)], # full sludge bpm, lambda m, d: grungechord(m, d, 5.5) * 0.55, gap=0.0) guitar = np.concatenate([verse, chorus]) bass = render_line([(root - 12, 0.5) for root in prog for _ in range(8)] * 2, bpm, lambda m, d: synth_note(m, d, "saw", 0.004, 0.06, 0.7, 0.04, cutoff=520)) vdr = render_drums({"k": [0, 8], "s": [4, 12], "h": [0, 4, 8, 12]}, bpm, 4, r) # sparse verse cdr = render_drums({"k": [0, 3, 6, 8, 11, 14], "s": [4, 12], "h": [0, 2, 4, 6, 8, 10, 12, 14], "o": [7, 15]}, bpm, 4, r) # driving chorus drums = np.concatenate([vdr, cdr]) body = mix(pan(guitar, -0.1)*0.72, pan(bass, 0.08)*0.62, pan(drums, 0)*0.62) # headroom: dense chorus return loop_music(body, 0.10) def music_covers(name): # RSL gig bed — a covers band at the returned-servicemen's club: mellow, organ-led easy-listening. # Warm I-vi-IV-V turnaround (original voicing), walking bass, soft kit, a sparse organ answer. # Cleaner and warmer than the pub/band-room beds — the crowd-cap stress room reads relaxed. r = rng(name); bpm = 100; bars = 8 spb = 60 / bpm prog = [48, 45, 53, 55] # C Am F G7 (roots), one per bar, ×2 = 8 bars chords = {48: [0, 4, 7], 45: [0, 3, 7], 53: [0, 4, 7], 55: [0, 4, 7, 10]} # G7 on the turnaround org_bars = [] for root in prog * 2: # sustained organ triad per bar ch = mix(*[organ(root + 12 + iv, 4 * spb) for iv in chords[root]]) * 0.5 cell = int(4 * spb * SR) org_bars.append(np.concatenate([ch, np.zeros(max(cell - len(ch), 0))])[:cell]) organ_part = np.concatenate(org_bars) walk = sum([[(root - 12, 1.0), (root - 5, 1.0), (root, 1.0), (root - 3, 1.0)] for root in prog * 2], []) bass = render_line(walk, bpm, lambda m, d: synth_note(m, d, "tri", 0.006, 0.12, 0.6, 0.06, cutoff=800)) drums = render_drums({"k": [0, 8], "s": [4, 12], "h": [0, 2, 4, 6, 8, 10, 12, 14]}, bpm, bars, r) * 0.5 lead_notes = sum([[(root + 12, 1.5), (None, 0.5), (root + 19, 1.0), (None, 1.0)] for root in prog * 2], []) lead = render_line(lead_notes, bpm, # gentle organ-ish answer lambda m, d: synth_note(m, d, "tri", 0.02, 0.12, 0.5, 0.2, cutoff=1600, vib=0.8)) body = mix(pan(organ_part, -0.1)*0.7, pan(bass, 0.05)*0.8, pan(drums, 0)*0.5, pan(lead, 0.25)*0.3) return loop_music(body, 0.18) # warmer reverb than the rock beds # ───────────────────────────────────────────────────────────────────────────── # the pack: name -> (category, generator, {loop, gain, note}) # ───────────────────────────────────────────────────────────────────────────── PACK = { # ambience (loop, quiet beds) "ambience-street-day": ("ambience", amb_street_day, dict(loop=True, gain=0.5)), "ambience-street-night": ("ambience", amb_street_night, dict(loop=True, gain=0.5)), "ambience-rain": ("ambience", amb_rain, dict(loop=True, gain=0.45, note="layer over street beds")), "roomtone-retail": ("ambience", amb_roomtone_retail, dict(loop=True, gain=0.35)), "roomtone-milkbar": ("ambience", amb_roomtone_milkbar, dict(loop=True, gain=0.35)), "roomtone-video": ("ambience", amb_roomtone_video, dict(loop=True, gain=0.3)), # sfx "sfx-doorbell": ("sfx", sfx_doorbell, dict(loop=False, gain=0.7)), "sfx-door-open": ("sfx", sfx_door_open, dict(loop=False, gain=0.6)), "sfx-till": ("sfx", sfx_till, dict(loop=False, gain=0.7)), "sfx-riffle": ("sfx", sfx_riffle, dict(loop=False, gain=0.6)), "sfx-toast": ("sfx", sfx_toast, dict(loop=False, gain=0.5)), "sfx-tram-bell": ("sfx", sfx_tram_bell, dict(loop=False, gain=0.7)), "sfx-tram-rumble":("sfx", sfx_tram_rumble,dict(loop=True, gain=0.5)), # footsteps (2 surfaces x 3 variants) "step-pavement-1": ("sfx", lambda n: _footstep(n, True, 90), dict(loop=False, gain=0.4)), "step-pavement-2": ("sfx", lambda n: _footstep(n, True, 95), dict(loop=False, gain=0.4)), "step-pavement-3": ("sfx", lambda n: _footstep(n, True, 85), dict(loop=False, gain=0.4)), "step-timber-1": ("sfx", lambda n: _footstep(n, False, 70), dict(loop=False, gain=0.4)), "step-timber-2": ("sfx", lambda n: _footstep(n, False, 75), dict(loop=False, gain=0.4)), "step-timber-3": ("sfx", lambda n: _footstep(n, False, 65), dict(loop=False, gain=0.4)), # music beds (loop, instrumental originals) "music-record-shop": ("music", music_record_shop, dict(loop=True, gain=0.55, note="record shop")), "music-milkbar": ("music", music_milkbar, dict(loop=True, gain=0.5, note="milk bar radio")), "music-video-synth": ("music", music_video_synth, dict(loop=True, gain=0.5, note="video store")), "music-arcade": ("music", music_arcade, dict(loop=True, gain=0.5, note="arcade")), # ── gig layer (?gigs=1 consumers: F plays these when a gig is on) — the manifest key IS the # gigKey, ALWAYS `gig-` (R13 debt #1: one key, zero mapping). One bed per venue kind. ── "music-gig-pubrock": ("music", music_pubrock, dict(loop=True, gain=0.6, note="gig live bed — pub-rock (pub); F plays via room.audio gigKey")), "music-gig-grunge": ("music", music_grunge, dict(loop=True, gain=0.6, note="gig live bed — grunge (band_room)")), "music-gig-covers": ("music", music_covers, dict(loop=True, gain=0.5, note="gig live bed — covers (RSL); mellower, organ-led")), "ambience-crowd-walla":("ambience", amb_crowd_walla, dict(loop=True, gain=0.4, note="gig crowd layer (interior)")), "sfx-applause": ("sfx", sfx_applause, dict(loop=False, gain=0.7, note="between-sets cheer")), } def write_wav(path, x): if x.ndim == 1: x = stereo(x) peak = np.max(np.abs(x)) if peak > 0.95: # limiter: never clip (loudnorm sets final loudness) x = x * (0.95 / peak) x = np.clip(x, -1.0, 1.0) pcm = (x * 32767).astype(" OGG/Opus (+ M4A/AAC). Long beds/music get loudnorm; SFX are pre-peak-normalized.""" ogg = os.path.join(OUT, base + ".ogg") m4a = os.path.join(OUT, base + ".m4a") af = ["-af", "loudnorm=I=-16:TP=-1.5:LRA=11"] if category in ("ambience", "music") else [] br = "96k" if category == "music" else ("80k" if category == "ambience" else "64k") subprocess.run([FF, "-y", "-i", wav, *af, "-c:a", "libopus", "-b:a", br, "-ar", "48000", ogg], check=True, capture_output=True) subprocess.run([FF, "-y", "-i", wav, *af, "-c:a", "aac", "-b:a", "128k", "-ar", "44100", m4a], check=True, capture_output=True) return os.path.getsize(ogg), os.path.getsize(m4a) if __name__ == "__main__": only = sys.argv[sys.argv.index("--only") + 1] if "--only" in sys.argv else "" wav_only = "--wav-only" in sys.argv todo = {k: v for k, v in PACK.items() if not only or only in k or only == v[0]} if "--dry-run" in sys.argv: print(f"{len(todo)} audio assets (procedural synth, $0):") by = {} for k, (cat, _, meta) in sorted(todo.items()): by.setdefault(cat, []).append(k) for cat, ks in by.items(): print(f" [{cat}] {len(ks)}: {', '.join(ks)}") sys.exit(0) print(f"rendering {len(todo)} assets @ {SR}Hz (numpy synth -> ffmpeg opus+aac)") total_ogg = total_m4a = 0 for k, (cat, gen, meta) in todo.items(): wav = os.path.join(RAW, k + ".wav") x = gen(k) write_wav(wav, x) dur = (len(x) / SR) if wav_only: print(f" [{cat}] {k} {dur:.1f}s (wav only)") continue og, m4 = encode(wav, k, cat) total_ogg += og; total_m4a += m4 print(f" [{cat}] {k:22s} {dur:5.1f}s ogg={og//1024:>4}KB m4a={m4//1024:>4}KB") if not wav_only: print(f"\ntotal shipped: ogg {total_ogg/1e6:.2f}MB + m4a {total_m4a/1e6:.2f}MB " f"= {(total_ogg+total_m4a)/1e6:.2f}MB (budget 25MB)")