gen_audio.py: a numpy synthesis engine (osc / ADSR / filters / reverb /
seamless-loop crossfade + note sequencer + drum synth). Renders 6 ambience
beds (street day/night, rain, 3 interior roomtones) + 13 sfx (doorbell,
door, till, record-riffle, toast, tram bell/rumble, 6 footsteps) + 4
instrumental 90s music loops (record-shop, milkbar, video-synth, arcade)
-> web/assets/audio/ as OGG/Opus + M4A/AAC. 8.1 MB of the 25 MB budget.
Tool = 100% procedural synthesis (not a neural model): $0, deterministic/
seeded, perfectly loopable, parody-safe by construction. Documented in
README (§ audio) + AUDIT.md provenance.
manifest audio:{ambience,sfx,music} — file + m4a fallback + loop + gain;
interior roomtone/music carry types[] (seed-pick per shop, like facades);
footstep is {surface:[variants]}. build_manifest.py builds it,
validate_manifest.py checks it. qa.sh --strict green (audio 23, 0 err).
Verified technically (peaks <= -0.4 dBFS, all loops seamless, durations)
via spectrogram sheet + numeric — subjective ear-check is John's/F's.
E leads the audio round; F owns the v2.1 tag + push. Not pushed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
650 lines
28 KiB
Python
650 lines
28 KiB
Python
#!/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).
|
||
|
||
<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 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)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 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 _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)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 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")),
|
||
}
|
||
|
||
|
||
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)")
|