PROCITY/pipeline/gen_audio.py
m3ultra be9f967d19 Lane E R11 (audio): procedural audio pack — 23 assets, free/on-device
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>
2026-07-15 14:50:15 +10:00

650 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

#!/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 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)")