PROCITY/pipeline/gen_audio.py
m3ultra 7795780336 Lane E R12 (v3.0-alpha gig layer): 5 instruments + 4 posters + 3 gig audio
Instruments (MODELBEAST flux_local concept -> trellis_mac -> normalize):
electric_guitar, bass_guitar, drum_kit, guitar_amp, mic_stand -> manifest
fittings + thumbs, published to the tailnet depot. All 5 reconstructed
cleanly (thin mic-boom + cymbals survived); heavy tris (30-96k, amp the
outlier) accepted like the R3 props. gen_props.py gains --batch;
gig_instruments.json + gig_norm_batch.json + _gig_results.json.

Posters (flux_local, gen_skins.py --posters): poster-xerox/screenprint/
grunge/retro -> skins.poster with nameZone for band-name overprint, blank
name-band (no baked text) like blank-signboard facades.

Gig audio (procedural, gen_audio.py): music-pubrock-live (original 90s
pub-rock riff), ambience-crowd-walla, sfx-applause -> manifest audio,
flagged gig:true for F. Pack now 26 assets, 9.1 MB of 25.

build_manifest reads _gig_results.json + emits skins.poster + gig audio;
validate_manifest green (--depot 0 err, all live). Provenance in AUDIT.md
(section Round 12); README + E-progress updated. Touched no other lane's
files. F owns the v3.0-alpha tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:44:48 +10:00

714 lines
32 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)
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 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)
# ─────────────────────────────────────────────────────────────────────────────
# 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")),
# ── round-12 gig layer (?gigs=1 consumers: F plays these when a gig is on) ────────────
"music-pubrock-live": ("music", music_pubrock, dict(loop=True, gain=0.6, note="gig live bed — pub-rock; F plays via room.audio gigKey")),
"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)")