PROCITY/pipeline/gen_audio.py
m3ultra 55f6698fb2 Lane E R14 (v3.0 release): provenance freeze + doc-consistency fixes
Provenance close-out for the v3.0 tag. Verified via a 6-agent inventory+audit workflow
(both adversarial audits PASS, 0 blockers) cross-checked against glb_stat/ffprobe/manifest.

- LANE_E_NOTES: v3.0-FROZEN provenance table + full audio(28)/GLB(38)/skins(96)/packs(3)
  inventories; by-design notes + v3.1 deferrals (street_bin decimation).
- AUDIT.md: add R13 gig-layer finalization section (14k decimation, _hi published, gigKey
  rename + 2 beds, depot publish done) — ledger was stale at R12.
- manifest glb_law string: state the documented <=8k-hero / <=14k-instrument tiers so the
  frozen contract is self-consistent (regenerated; 1-line diff, Gate 3 still 0/0).
- gen_audio: roomtone-video gain 0.3 -> 0.35 to match the shipped manifest.

Gate 3 GREEN; publish.py --verify clean; 0 quarantine assets in manifest; goldens unmoved
(synthetic 0x3fa36874, gig 0x1f636349). No new synthesis (genres final); no poster templates
(no ask from B).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 01:18:40 +10:00

782 lines
36 KiB
Python
Raw Permalink 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.

#!/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 215 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).
<mflux-venv-python> pipeline/gen_audio.py --dry-run # list the pack + budget, no render
<mflux-venv-python> pipeline/gen_audio.py # render everything -> web/assets/audio/
<mflux-venv-python> pipeline/gen_audio.py --only music # just the music beds
<mflux-venv-python> 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.35)),
# 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-<genreKey>` (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("<i2")
with wave.open(path, "wb") as w:
w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR)
w.writeframes(pcm.tobytes())
def encode(wav, base, category):
"""WAV -> 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)")