#!/usr/bin/env python3 """GUTS audio pack — 100% procedural synthesis in numpy, on-device, $0 (Lane D). Ported from PROCITY's gen_audio.py (round-11 audio round) — same DSP toolkit, same reasons: * $0, no model download, runs in the mflux venv (numpy only). * Deterministic + seeded — same name, same sound, forever. * Perfect SEAMLESS LOOPS (the tail is crossfaded over the head) — neural gens can't loop. * Parody-safe by construction: every sample is an original oscillator/noise render. * Precise budget control (<=10 MB shipped, TECH.md). Pipeline: synth (numpy float32) -> WAV (stdlib wave, int16) -> ffmpeg -> OGG/Opus + M4A/AAC. Loudness: beds -> ffmpeg loudnorm (EBU R128, -16 LUFS); sfx -> peak-normalized in-synth (loudnorm is unreliable under ~3 s). Evidence: --evidence writes a spectrogram sheet + a NUMERIC loop-seam check to docs/shots/laneD/ (PROCESS.md: audio evidence = spectrogram + loop-seam check). pipeline/gen_audio.py --dry-run # list the pack + budget, no render pipeline/gen_audio.py # render -> web/assets/audio/ pipeline/gen_audio.py --only sfx # one category pipeline/gen_audio.py --wav-only # keep .genaudio/*.wav, skip ffmpeg (debug) = ~/Documents/MODELBEAST/venvs/mflux/bin/python House law: the game runs silent-and-happy with zero assets — none of this blocks anyone. """ import json, math, os, shutil, subprocess, sys, wave, 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 SHOTS = os.path.join(ROOT, "docs", "shots", "laneD") BATCH = os.path.join(ROOT, "pipeline", "batch_audio.json") for d in (RAW, OUT, SHOTS): os.makedirs(d, exist_ok=True) # m3ultra's ffmpeg is homebrew and NOT on the non-interactive ssh PATH — find it properly. FF = (os.environ.get("GUTS_FFMPEG") or shutil.which("ffmpeg") or ("/opt/homebrew/bin/ffmpeg" if os.path.exists("/opt/homebrew/bin/ffmpeg") else "ffmpeg")) XF = 0.75 # loop crossfade seconds # ───────────────────────────────────────────────────────────────────────────── # DSP toolkit (pure numpy) — ported from PROCITY pipeline/gen_audio.py # ───────────────────────────────────────────────────────────────────────────── 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 _phase(f, dur): n = int(dur * SR) f = np.full(n, f, dtype=np.float64) if np.isscalar(f) else 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)) raise ValueError(kind) def noise(dur, r=None, kind="white"): n = int(dur * SR) r = r if r is not None else np.random.default_rng(0) x = r.standard_normal(n) if kind == "pink": # ~-3 dB/oct, vectorised one-pole cascade out = np.zeros(n) for a, g in ((0.99765, 0.0990460), (0.96300, 0.2965164), (0.57000, 1.0526913)): out += lp_pole(x * g, a) return (out + x * 0.1848) / 4 return x def lp_pole(x, a): """y[n] = a*y[n-1] + x[n] — via lfilter-free cumulative trick (scipy is not installed).""" y = np.empty_like(x) acc = 0.0 for i in range(len(x)): # honest loop; pack is small, runs in ~1 s acc = a * acc + x[i] y[i] = acc return y def lp1(x, cutoff): a = math.exp(-2 * math.pi * cutoff / SR) return lp_pole(x * (1 - a), a) def lp(x, cutoff, poles=1): """Cascaded one-poles: 6 dB/oct each. ONE pole is not "muffled" — at cutoff 900 it still leaves 16 kHz only ~25 dB down, which reads as hiss, not as a wet cavern. Anything that is supposed to sound muffled wants 2–3 poles.""" for _ in range(poles): x = lp1(x, cutoff) return x def hp1(x, cutoff): return x - lp1(x, cutoff) def bandpass(x, lo, hi, poles=1): return hp1(lp(x, hi, poles), lo) def adsr(dur, a=0.01, d=0.1, s=0.7, r=0.1): n = int(dur * SR) ai, di = int(a * SR), int(d * SR) ri = min(int(r * SR), max(n - ai - di, 0)) si = max(n - ai - di - ri, 0) return np.concatenate([ np.linspace(0, 1, ai), np.linspace(1, s, di), np.full(si, s), np.linspace(s, 0, ri)])[:n] 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 softclip(x, drive=1.0): return np.tanh(x * drive) def seamless(x, xf=XF): """Loop of length len(x)-xf*SR that plays end->start seamlessly, by crossfading the tail (xf s) over the head. Render xf LONGER than the loop you want.""" k = int(xf * SR) if k <= 0 or len(x) <= k: return x body, tail = x[:-k].copy(), x[-k:] w = np.linspace(0, 1, k) if body.ndim == 2: w = w[:, None] body[:k] = body[:k] * w + tail * (1 - w) return body def stereo(l, r=None): r = l if r is None else r n = max(len(l), len(r)) out = np.zeros((n, 2)) out[:len(l), 0] = l; out[:len(r), 1] = r return out def pan(x, p): 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 = 2 if any(a.ndim == 2 for a in layers) else 1 out = np.zeros((n, 2) if ch == 2 else n) for a in layers: if ch == 2 and a.ndim == 1: a = stereo(a) out[:len(a)] += a return out def schroeder_reverb(x, mix_amt=0.2, decay=0.5): """4 combs + 2 allpasses. The gut is a wet cave; everything gets some of this.""" mono = x.mean(axis=1) if x.ndim == 2 else x out = np.zeros(len(mono)) for ms, g in ((29.7, 0.805), (37.1, 0.827), (41.1, 0.783), (43.7, 0.764)): d = int(ms * SR / 1000) buf = np.zeros(len(mono) + d) buf[:len(mono)] = mono for i in range(d, len(buf)): buf[i] += buf[i - d] * g * decay out += buf[:len(mono)] out /= 4 for ms, g in ((5.0, 0.7), (1.7, 0.7)): d = int(ms * SR / 1000) y = np.zeros(len(out) + d) y[:len(out)] = out for i in range(d, len(y)): y[i] += y[i - d] * -g out = y[:len(out)] wet = out / (np.max(np.abs(out)) or 1) if x.ndim == 2: return x * (1 - mix_amt) + stereo(wet) * mix_amt return x * (1 - mix_amt) + wet * mix_amt # ───────────────────────────────────────────────────────────────────────────── # the GUTS pack (round 1 = proof: 1 bed + 4 sfx; full pack is round 2) # ───────────────────────────────────────────────────────────────────────────── def bed_esophagus(name): """Wet cavernous drone + muffled resting heartbeat + peristalsis whooshes. Loop = 24 s exactly, so the 1.2 s heartbeat (50 bpm) and 4 s whoosh both divide it and survive the crossfade without a stutter.""" r = rng(name) LOOP = 24.0 dur = LOOP + XF # low wet drone — two detuned saws, heavily lowpassed, slow swell d1 = osc(55.0, dur, "saw") + osc(55.0 * 1.006, dur, "saw") * 0.8 drone = lp(d1 * 0.25, 190, poles=2) * (0.75 + 0.25 * np.sin(2 * np.pi * tarr(dur) / 12.0)) # cavern air — pink noise in the 200–900 band, breathing at 0.25 Hz. 3 poles: you are # inside a body, everything above ~1 kHz is behind wet tissue. air = bandpass(noise(dur, r, "pink"), 200, 900, poles=3) * 0.30 air *= 0.6 + 0.4 * np.sin(2 * np.pi * tarr(dur) / 8.0) # muffled heartbeat (lub-dub), 50 bpm hb = np.zeros(int(dur * SR)) period = 1.2 for i in range(int(dur / period) + 1): for off, amp in ((0.0, 1.0), (0.16, 0.62)): # lub, dub st = int((i * period + off) * SR) if st >= len(hb): continue th = 0.22 body = osc(np.linspace(58, 34, int(th * SR)), th, "sine") * adsr(th, 0.004, 0.05, 0.25, 0.14) seg = body * amp n = min(len(seg), len(hb) - st) hb[st:st + n] += seg[:n] hb = lp1(hb, 120) * 0.55 # peristalsis whoosh every 4 s — filtered noise sweeping past wh = np.zeros(int(dur * SR)) for i in range(int(dur / 4.0) + 1): st = int(i * 4.0 * SR) wd = 1.8 seg = noise(wd, np.random.default_rng(seed_of(name) + i)) seg = bandpass(seg, 200, 1400, poles=2) * np.sin(np.pi * np.linspace(0, 1, len(seg))) ** 2 seg *= 0.18 * (0.7 + 0.3 * math.sin(i)) n = min(len(seg), len(wh) - st) if n > 0: wh[st:st + n] += seg[:n] body = mix(pan(drone, -0.15), pan(air, 0.2), pan(hb, 0.0), pan(wh, 0.35)) body = schroeder_reverb(body, mix_amt=0.28, decay=0.55) body = softclip(body * 0.8, 1.1) return seamless(body, XF) def sfx_pellet(name): """Lysozyme cannon — a short wet bright zap, not a laser pew.""" dur = 0.20 r = rng(name) body = osc(np.linspace(1500, 380, int(dur * SR)), dur, "saw") * adsr(dur, 0.001, 0.04, 0.25, 0.12) wet = bandpass(noise(dur, r) * adsr(dur, 0.001, 0.03, 0.1, 0.15), 800, 5200) * 0.5 x = lp1(body * 0.6 + wet, 6000) return peaknorm(fade(x, 0.001, 0.02), -4.0) def sfx_hit_squelch(name): """Wet impact into tissue: a low slap + a burst of high spatter.""" dur = 0.34 r = rng(name) thud = osc(np.linspace(180, 55, int(dur * SR)), dur, "sine") * adsr(dur, 0.002, 0.09, 0.18, 0.2) spat = bandpass(noise(dur, r), 700, 4500) * adsr(dur, 0.001, 0.05, 0.06, 0.25) x = softclip(thud * 0.9 + spat * 0.55, 1.4) x = schroeder_reverb(x, mix_amt=0.16, decay=0.4) return peaknorm(fade(x, 0.001, 0.03), -3.5) def sfx_boost(name): """ENDO-1 boost — rising filtered whoosh with a pressure thump underneath.""" dur = 0.85 r = rng(name) air = noise(dur, r) env = np.linspace(0, 1, int(dur * SR)) ** 1.6 air = bandpass(air, 220, 5200) * env * np.sin(np.pi * np.linspace(0, 1, int(dur * SR))) ** 0.7 sub = osc(np.linspace(38, 120, int(dur * SR)), dur, "sine") * adsr(dur, 0.05, 0.2, 0.6, 0.3) * 0.6 x = softclip(air * 0.7 + sub, 1.2) return peaknorm(fade(x, 0.01, 0.06), -3.5) def sfx_pickup(name): """Pickup chime — soft, green, organic (ART_BIBLE pickups #7dffb0). Two-note bell.""" dur = 0.5 x = np.zeros(int(dur * SR)) for f, amp, delay in ((784.0, 1.0, 0.0), (1174.7, 0.7, 0.07)): st = int(delay * SR) seg = (osc(f, dur - delay, "sine") + 0.35 * osc(f * 2.01, dur - delay, "sine")) \ * adsr(dur - delay, 0.004, 0.18, 0.22, 0.28) * amp x[st:st + len(seg)] += seg x = schroeder_reverb(lp1(x, 7000), mix_amt=0.22, decay=0.45) return peaknorm(fade(x, 0.002, 0.05), -4.0) PACK = { "bed-esophagus": ("bed", bed_esophagus, dict(loop=True, gain=0.5, key="esophagus")), "sfx-pellet": ("sfx", sfx_pellet, dict(loop=False, gain=0.5, key="pellet")), "sfx-hit-squelch": ("sfx", sfx_hit_squelch, dict(loop=False, gain=0.7, key="hit_squelch")), "sfx-boost": ("sfx", sfx_boost, dict(loop=False, gain=0.6, key="boost")), "sfx-pickup": ("sfx", sfx_pickup, dict(loop=False, gain=0.5, key="pickup")), } # ───────────────────────────────────────────────────────────────────────────── # io + evidence # ───────────────────────────────────────────────────────────────────────────── def write_wav(path, x): if x.ndim == 1: x = stereo(x) peak = np.max(np.abs(x)) if peak > 0.95: x = x * (0.95 / peak) pcm = (np.clip(x, -1, 1) * 32767).astype(" first sample) to the RMS step inside the signal. ~1 = the seam is indistinguishable from ordinary motion; >>1 = an audible click. Reported for every looping bed; vibes are not evidence.""" m = x.mean(axis=1) if x.ndim == 2 else x inner = np.sqrt(np.mean(np.diff(m) ** 2)) or 1e-9 wrap = abs(m[0] - m[-1]) return float(wrap / inner) SPEC_TICKS = (50, 100, 500, 1000, 5000, 10000) # Hz gridlines drawn on the sheet SPEC_FMIN, SPEC_FMAX, SPEC_DB = 40.0, 16000.0, 80.0 def _log_rows(nbins, h): """Row -> FFT-bin map for a LOG frequency axis. A linear axis spends 3/4 of the image on 8–16 kHz where these sounds have nothing; log puts the drone, the heartbeat and the squelch where you can actually see them.""" freqs = np.geomspace(SPEC_FMIN, SPEC_FMAX, h) return np.clip((freqs / (SR / 2) * (nbins - 1)).astype(int), 0, nbins - 1) def spectrogram(x, w=420, h=180): """|STFT| -> log-magnitude, log-frequency image. numpy only (no matplotlib on the box). Levels are normalised to EACH clip's own peak over an 80 dB range. (First cut used a fixed `(db+60)/70` and every sheet came out a saturated cream rectangle — evidence you can't read is not evidence.)""" m = x.mean(axis=1) if x.ndim == 2 else x n = 1024 hop = max(1, (len(m) - n) // w) win = np.hanning(n) cols = [] for i in range(w): seg = m[i * hop:i * hop + n] if len(seg) < n: seg = np.pad(seg, (0, n - len(seg))) cols.append(np.abs(np.fft.rfft(seg * win))) S = np.array(cols).T # (bins, frames) db = 20 * np.log10(S + 1e-9) db -= db.max() # 0 dB = this clip's loudest bin img = db[_log_rows(S.shape[0], h)][::-1] # low freqs at the bottom return np.clip((img + SPEC_DB) / SPEC_DB, 0, 1) def evidence(rendered): """Spectrogram sheet + loop-seam table -> docs/shots/laneD/. Import PIL late: the render path itself must not depend on it.""" from PIL import Image, ImageDraw rows = len(rendered) W, H, PADL = 420, 180, 150 sheet = Image.new("RGB", (W + PADL + 56, rows * (H + 16) + 16), (10, 12, 14)) d = ImageDraw.Draw(sheet) freqs = np.geomspace(SPEC_FMIN, SPEC_FMAX, H) for i, (name, x, meta) in enumerate(rendered): y = 8 + i * (H + 16) img = spectrogram(x, W, H) g = (img * 255).astype(np.uint8) rgb = np.stack([g, (g * 0.85).astype(np.uint8), (g * 0.55).astype(np.uint8)], axis=-1) sheet.paste(Image.fromarray(rgb, "RGB"), (PADL, y)) for f in SPEC_TICKS: # log-axis gridlines + Hz labels row = H - 1 - int(np.argmin(np.abs(freqs - f))) d.line([(PADL, y + row), (PADL + W, y + row)], fill=(60, 90, 100)) d.text((PADL + W + 4, y + row - 5), f"{f//1000}k" if f >= 1000 else str(f), fill=(110, 140, 150)) d.text((8, y + 6), name, fill=(90, 255, 210)) d.text((8, y + 24), f"{meta['seconds']:.2f}s {meta['ogg_kb']}KB ogg", fill=(150, 170, 180)) if meta.get("loop"): d.text((8, y + 40), f"loop seam {meta['loop_seam']:.2f}", fill=(150, 170, 180)) d.text((8, y + H - 14), "40Hz–16kHz log · 80dB", fill=(90, 110, 120)) p = os.path.join(SHOTS, "audio_spectrograms.png") sheet.save(p, "PNG") return p 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 numpy synth, $0):") for k, (cat, _, meta) in sorted(todo.items()): print(f" [{cat}] {k:18s} loop={str(meta['loop']):5s} key={meta['key']}") sys.exit(0) print(f"rendering {len(todo)} assets @ {SR}Hz (numpy synth -> {os.path.basename(FF)} opus+aac)") log = json.load(open(BATCH)) if os.path.exists(BATCH) else {} rendered, tot_ogg, tot_m4a = [], 0, 0 for k, (cat, gen, meta) in todo.items(): x = gen(k) wav = os.path.join(RAW, k + ".wav") write_wav(wav, x) secs = len(x) / SR seam = loop_seam(x) if meta["loop"] else None if wav_only: print(f" [{cat}] {k:18s} {secs:5.2f}s (wav only)" + (f" seam={seam:.2f}" if seam is not None else "")) continue og, m4 = encode(wav, k, cat) tot_ogg += og; tot_m4a += m4 info = dict(category=cat, key=meta["key"], loop=meta["loop"], gain=meta["gain"], seconds=round(secs, 2), ogg_kb=og // 1024, m4a_kb=m4 // 1024) if seam is not None: info["loop_seam"] = round(seam, 3) log[k] = info rendered.append((k, x, info)) print(f" [{cat}] {k:18s} {secs:5.2f}s ogg={og//1024:>4}KB m4a={m4//1024:>4}KB" + (f" seam={seam:.2f}" if seam is not None else "")) if wav_only: sys.exit(0) json.dump(log, open(BATCH, "w"), indent=2, sort_keys=True) total = (tot_ogg + tot_m4a) / 1e6 print(f"\ntotal shipped: ogg {tot_ogg/1e6:.2f}MB + m4a {tot_m4a/1e6:.2f}MB = {total:.2f}MB " f"(budget 10MB)") if "--evidence" in sys.argv and rendered: print(f"evidence -> {evidence(rendered)}")