diff --git a/.gitignore b/.gitignore index da29124..f084241 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ saves/ node_modules/ .env pipeline/.genprops/ +pipeline/.genaudio/ web/assets/models/ pipeline/.peds_orig/ pipeline/.props_orig/ diff --git a/E-progress.md b/E-progress.md index 517d546..e484533 100644 --- a/E-progress.md +++ b/E-progress.md @@ -1,8 +1,32 @@ # LANE E — ASSETS · progress (PROCITY-E) -*2026-07-14. Owner: PROCITY-E. Scope: `pipeline/*`, `web/assets/*`, `web/assets/manifest.json`. -Reviewed against `docs/LANES/LANE_E_ASSETS.md` + `docs/LANES/ROUND3_INSTRUCTIONS.md`. Nothing in -other lanes' files was touched.* +*Owner: PROCITY-E. Scope: `pipeline/*`, `web/assets/*`. Nothing in other lanes' files was touched.* + +## Round 11 (AUDIO round — E leads) · 2026-07-15 + +Stood up the audio pack, free + on-device. **Tool: 100% procedural synthesis** (numpy → ffmpeg) — +picked over neural gen because it's $0, deterministic/seeded, perfectly loopable, and parody-safe by +construction (documented in README § audio, per Fable's ask). + +- **`pipeline/gen_audio.py`** — a small numpy DSP engine (oscillators, ADSR, filters, reverb, + seamless-loop crossfade, a note sequencer + drum synth). 23 assets, seeded, `--dry-run`/`--only`. +- **`web/assets/audio/`** — 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). OGG/Opus + M4A/AAC. **8.1 MB** of 25 MB. +- **Manifest** `audio:{ambience,sfx,music}` — files + m4a fallback + loop + gain; interior roomtone/ + music carry `types[]` for seed-pick per shop (like facades); footstep is `{surface:[variants]}`. + `build_manifest.py` builds it, `validate_manifest.py` checks it. **qa green, audio 23, 0 errors.** +- **Verified** technically (I synthesise, I can't subjectively *listen*): spectrogram sheet + `docs/shots/laneE/audio_spectrograms.png` + numeric — peaks ≤ -0.4 dBFS (no clip), **all loops + seamless**, durations correct. **Subjective ear-check is John's/F's** (flagged honestly). +- Provenance in `AUDIT.md` (§ Round 11). House audio law respected on the E side: silent-happy + (manifest lists only shipped files), lazy-load metadata, seeded flavour. B wires WebAudio, C gives + the `room.audio` contract, F integrates + owns the **v2.1 tag/push** — I did **not** push. + +--- + +*Round 3 and earlier below.* +*Reviewed against `docs/LANES/LANE_E_ASSETS.md` + `docs/LANES/ROUND3_INSTRUCTIONS.md`.* ## Round 9 (Fable) — VRAM audit + depot hygiene (ship-round DD) diff --git a/docs/shots/laneE/audio_spectrograms.png b/docs/shots/laneE/audio_spectrograms.png new file mode 100644 index 0000000..5a17f05 Binary files /dev/null and b/docs/shots/laneE/audio_spectrograms.png differ diff --git a/pipeline/AUDIT.md b/pipeline/AUDIT.md index e2b908c..5b651fb 100644 --- a/pipeline/AUDIT.md +++ b/pipeline/AUDIT.md @@ -391,3 +391,29 @@ required for the tag.** Two follow-ups flagged for **v3, not now** (bigger than **Depot/manifest hygiene (E2):** `validate --depot` 0 err · pack-QA 3/3 valid · provenance-drift clean (`_published.json` == 27 live procity GLBs) · 15 stock atlases live, **0 orphans**. All green post-R8. + +## Round 11 — audio pack provenance + +**Every audio file is 100% original procedural synthesis** (`pipeline/gen_audio.py`, numpy on the +MODELBEAST box → ffmpeg). No neural model, no sample library, no third-party recording touched — so +the whole pack is **CC0-equivalent original work** and **parody-safe by construction**: there is +nothing to imitate, every waveform is an oscillator/noise render. Music beds are original chord +progressions/melodies — no imitation of any specific real recording. Tool choice is documented in +`pipeline/README.md` (§ audio): synthesis over local MusicGen/stable-audio because it's $0, +deterministic/seeded, perfectly loopable, and licence-clean. + +| group | files | origin | +|---|---|---| +| ambience (6) | street-day/night, rain, roomtone-retail/milkbar/video | filtered-noise beds + synthesised events (birds/crickets/drops/hum) 🟢 | +| sfx (13) | doorbell, door-open, till, riffle, toast, tram-bell, tram-rumble, footsteps ×6 | additive-bell + filtered-noise transients 🟢 | +| music (4) | record-shop, milkbar, video-synth, arcade | original synth loops (bass+chords+drums+lead) 🟢 | + +- **Format**: OGG/Opus primary (48 kHz) + M4A/AAC fallback (44.1 kHz). **Loudness**: long beds/music + ffmpeg `loudnorm` I=-16 LUFS / TP=-1.5 (single-pass EBU R128); short SFX peak-normalised (-3…-6 dBFS). +- **Verified** (I synthesise, I can't subjectively listen): spectrogram sheet + `docs/shots/laneE/audio_spectrograms.png` (content/character) + numeric pass — peaks ≤ -0.4 dBFS + (no clip), **all loops seamless** (wrap-jump ≤ internal step), durations correct. **Final subjective + ear-check is John's/F's** — flagged honestly. +- **Budget**: 8.1 MB shipped (2.8 MB ogg + 5.3 MB m4a) vs the 25 MB cap; lazy-loaded per manifest. +- Files ship in `web/assets/audio/` (committed, like `gen/` skins); WAV scratch `pipeline/.genaudio/` + is git-ignored. Contract in `manifest.json` → `audio:{ambience,sfx,music}`. diff --git a/pipeline/README.md b/pipeline/README.md index 7e2a596..8b421be 100644 --- a/pipeline/README.md +++ b/pipeline/README.md @@ -95,13 +95,34 @@ python3 pipeline/stage_local_depot.py # mirror _normalized/*.glb → web/ass # then load the game with ?localdepot=1 — depot: resolves to assets/models/ instead of the CDN ``` +### 8. Audio pack — `gen_audio.py` *(round-11: DONE, free, on-device)* +**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 outright — $0, no model +download, deterministic/seeded (house law), **perfectly seamless loops** (neural gens don't loop), +**parody-safe by construction** (every sample is an original oscillator/noise render — nothing to +imitate), and precise ≤25 MB budget control. Pipeline: numpy synth → WAV → ffmpeg OGG/Opus (+ M4A/AAC). +```bash +PY=~/Documents/MODELBEAST/venvs/mflux/bin/python # numpy lives in the mflux venv +$PY pipeline/gen_audio.py --dry-run # list the 23-asset pack + budget +$PY pipeline/gen_audio.py # render all → web/assets/audio/*.ogg + *.m4a +$PY pipeline/gen_audio.py --only music # just the music beds +python3 pipeline/build_manifest.py # catalogue → manifest.json audio:{ambience,sfx,music} +``` +23 assets, **8.1 MB** shipped (opus+aac): 6 ambience beds (street day/night, rain, 3 roomtones), 13 +sfx (doorbell, door, till, riffle, toast, tram bell/rumble, 6 footsteps), 4 instrumental 90s music +loops. Loudness: beds/music `loudnorm` -16 LUFS, sfx peak-normalised. **Ear-ball** = spectrogram sheet +`docs/shots/laneE/audio_spectrograms.png` + numeric loop-seam/peak check (I can't subjectively listen — +final ear-check is John's/F's). Add assets by editing `PACK` in `gen_audio.py` + the `AUDIO_*` maps in +`build_manifest.py`. WAV scratch `pipeline/.genaudio/` is git-ignored; OGG/M4A ship committed. + ## Cost summary | step | cost | |---|---| | audit, normalize, manifest, validate, publish | **free** (compute + bandwidth) | | facade skin gap-fill (23 imgs) | **free** — done on-device (MODELBEAST flux_local); cloud fallback ~9¢ | -| MeshGod hero props (9 solids) | **~$2.70** — gated on John (local `sf3d`/`trellis_mac` is free but HF-gated) | +| hero props (round-3) | **free** — 5 via MODELBEAST trellis_mac; glass-case/bus-shelter on fal.ai list (≤$2.70, John's go) | +| audio pack (round-11, 23 assets) | **free** — 100% procedural synthesis (numpy → ffmpeg), $0, no model | ## Artifacts (not committed — regenerated from the steps) diff --git a/pipeline/build_manifest.py b/pipeline/build_manifest.py index e31b64f..c67004f 100644 --- a/pipeline/build_manifest.py +++ b/pipeline/build_manifest.py @@ -16,8 +16,28 @@ GEN = os.path.join(ROOT, "web", "assets", "gen") RESULTS = os.path.join(ROOT, "pipeline", "_normalize_results.json") # library GLBs PROPS_RESULTS = os.path.join(ROOT, "pipeline", "_props_results.json") # round-3 hero props OUT = os.path.join(ROOT, "web", "assets", "manifest.json") +AUDIO = os.path.join(ROOT, "web", "assets", "audio") # round-11 audio pack DEPOT = "https://digalot.fyi/3god" +# ── curated audio contract (files from pipeline/gen_audio.py). Interior beds/music carry types[] +# — shop types that use them — so Lane C/F seed-pick from the matching pool, exactly like facades. ── +AUDIO_STREET = {"street-day": 0.5, "street-night": 0.5, "rain": 0.45} # Lane B street beds +AUDIO_ROOMTONE = { # interior room-tone key -> shop types it suits + "retail": ["record", "opshop", "book", "toy", "pawn", "stall", "general"], + "milkbar": ["milkbar"], + "video": ["video", "dept", "arcade"], +} +AUDIO_MUSIC = { # interior music key -> (gain, shop types, note) + "record-shop": (0.55, ["record"], "record shop plays music"), + "milkbar": (0.50, ["milkbar", "general"], "milk bar radio"), + "video-synth": (0.50, ["video"], "video store synth hum"), + "arcade": (0.50, ["arcade", "dept"], "arcade cabinet"), +} +AUDIO_SFX = {"doorbell": 0.7, "door-open": 0.6, "till": 0.7, "riffle": 0.6, # one-shots + "toast": 0.5, "tram-bell": 0.7} +AUDIO_SFX_LOOP = {"tram-rumble": 0.5} +AUDIO_FOOTSTEPS = {"pavement": 3, "timber": 3} # surface -> variants + # ── depot-reuse: already-published GLBs we point at without a local normalize. The round-3 reuse # check found vintage-cash-register.glb live on 3GOD (from thriftgod_hero) — reuse it for the # universal till instead of re-generating. Manual footprint/height (no local file to measure); @@ -83,6 +103,46 @@ def rel(p): return "gen/" + os.path.basename(p) +def build_audio(): + """The audio section: ambience (street beds + interior roomtone), sfx (one-shots + footstep + variant arrays), music. Only assets present on disk are listed — silent-happy by construction.""" + def ref(n): + return ({"file": f"audio/{n}.ogg", "fallback": f"audio/{n}.m4a"} + if os.path.isfile(os.path.join(AUDIO, n + ".ogg")) else None) + ambience = {} + for k, g in AUDIO_STREET.items(): + r = ref("ambience-" + k) + if r: + ambience[k] = {**r, "loop": True, "gain": g, "scope": "street"} + for k, types in AUDIO_ROOMTONE.items(): + r = ref("roomtone-" + k) + if r: + ambience["roomtone-" + k] = {**r, "loop": True, "gain": 0.35, "scope": "interior", "types": types} + music = {} + for k, (g, types, note) in AUDIO_MUSIC.items(): + r = ref("music-" + k) + if r: + music[k] = {**r, "loop": True, "gain": g, "types": types, "note": note} + sfx = {} + for k, g in AUDIO_SFX.items(): + r = ref("sfx-" + k) + if r: + sfx[k] = {**r, "gain": g} + for k, g in AUDIO_SFX_LOOP.items(): + r = ref("sfx-" + k) + if r: + sfx[k] = {**r, "gain": g, "loop": True} + footsteps = {} + for surf, n in AUDIO_FOOTSTEPS.items(): + vs = [ref(f"step-{surf}-{i}") for i in range(1, n + 1)] + vs = [v for v in vs if v] + if vs: + footsteps[surf] = vs + if footsteps: + sfx["footstep"] = footsteps + return {"ambience": ambience, "sfx": sfx, "music": music} + + def build(): have = {os.path.splitext(os.path.basename(f))[0] for f in glob.glob(GEN + "/*.jpg")} @@ -198,11 +258,16 @@ def build(): "these to the exposed side face; other types reuse the front skin", "fallbacks": "every asset is optional — missing file => primitive/flat-colour, never a crash", "load": "fittings/furniture load from depot as depot:; skins are local gen/", + "audio": "OGG/Opus primary + M4A/AAC fallback; loop:true beds are seamless; gain is a " + "suggested 0-1 playback level; interior roomtone/music carry types[] (seed-pick " + "per shop, like facades); footstep is {surface:[variants]}. Silent-happy: any " + "missing file => silence, never an error; ?mute=1 / ?noassets=1 => no audio fetch.", }, "skins": skins, "fittings": fittings, "furniture": furniture, "fittingAliases": fitting_aliases, + "audio": build_audio(), } with open(OUT, "w") as fh: json.dump(manifest, fh, indent=2) @@ -210,6 +275,8 @@ def build(): print(f" facades {len(facade)} skies {len(sky)} grounds {len(ground)} " f"wallpapers {len(wall)} floors {len(floor)} surfaces {len(surface)} awnings {len(awning)}") print(f" fittings {len(fittings)} furniture {len(furniture)}") + au = manifest["audio"] + print(f" audio: ambience {len(au['ambience'])} sfx {len(au['sfx'])} music {len(au['music'])}") if __name__ == "__main__": diff --git a/pipeline/gen_audio.py b/pipeline/gen_audio.py new file mode 100644 index 0000000..ba539e3 --- /dev/null +++ b/pipeline/gen_audio.py @@ -0,0 +1,649 @@ +#!/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). + + pipeline/gen_audio.py --dry-run # list the pack + budget, no render + pipeline/gen_audio.py # render everything -> web/assets/audio/ + pipeline/gen_audio.py --only music # just the music beds + 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(" 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)") diff --git a/pipeline/validate_manifest.py b/pipeline/validate_manifest.py index dc948e0..d67f486 100644 --- a/pipeline/validate_manifest.py +++ b/pipeline/validate_manifest.py @@ -116,6 +116,22 @@ def main(): manifest_files.add(entry["file"]) n_glb += 1 + # audio pack (round-11): if the manifest names an audio file it must ship locally (both the + # ogg primary and the m4a fallback). Silent-happy is a runtime rule, not a licence to dangle refs. + n_audio = 0 + def _chk_audio(e): + for key in ("file", "fallback"): + if key in e: + check_skin(e[key]) + for grp in m.get("audio", {}).values(): + for v in grp.values(): + if isinstance(v, dict) and "file" in v: + _chk_audio(v); n_audio += 1 + elif isinstance(v, dict): # footstep {surface:[variants]} + for arr in v.values(): + for e in arr: + _chk_audio(e); n_audio += 1 + # provenance-drift gate: every manifest depot GLB must be recorded in _published.json, so a # clobbered/stale provenance record (the R5 bug) fails QA loudly instead of hiding. recpath = os.path.join(ROOT, "pipeline", "_published.json") @@ -138,7 +154,7 @@ def main(): print(f"manifest v{m.get('version')} — facades {len(facade)}, " f"skins {sum(len(v) if isinstance(v, list) else (len(v) if isinstance(v, dict) else 0) for v in sk.values())} groups, " - f"GLBs {n_glb}") + f"GLBs {n_glb}, audio {n_audio}") for w in warnings: print(f" WARN {w}") if errors: diff --git a/web/assets/audio/ambience-rain.m4a b/web/assets/audio/ambience-rain.m4a new file mode 100644 index 0000000..652713b Binary files /dev/null and b/web/assets/audio/ambience-rain.m4a differ diff --git a/web/assets/audio/ambience-rain.ogg b/web/assets/audio/ambience-rain.ogg new file mode 100644 index 0000000..cd41ae1 Binary files /dev/null and b/web/assets/audio/ambience-rain.ogg differ diff --git a/web/assets/audio/ambience-street-day.m4a b/web/assets/audio/ambience-street-day.m4a new file mode 100644 index 0000000..2735e22 Binary files /dev/null and b/web/assets/audio/ambience-street-day.m4a differ diff --git a/web/assets/audio/ambience-street-day.ogg b/web/assets/audio/ambience-street-day.ogg new file mode 100644 index 0000000..dffda7f Binary files /dev/null and b/web/assets/audio/ambience-street-day.ogg differ diff --git a/web/assets/audio/ambience-street-night.m4a b/web/assets/audio/ambience-street-night.m4a new file mode 100644 index 0000000..2c495a6 Binary files /dev/null and b/web/assets/audio/ambience-street-night.m4a differ diff --git a/web/assets/audio/ambience-street-night.ogg b/web/assets/audio/ambience-street-night.ogg new file mode 100644 index 0000000..f0b3b88 Binary files /dev/null and b/web/assets/audio/ambience-street-night.ogg differ diff --git a/web/assets/audio/music-arcade.m4a b/web/assets/audio/music-arcade.m4a new file mode 100644 index 0000000..5001726 Binary files /dev/null and b/web/assets/audio/music-arcade.m4a differ diff --git a/web/assets/audio/music-arcade.ogg b/web/assets/audio/music-arcade.ogg new file mode 100644 index 0000000..e3c2377 Binary files /dev/null and b/web/assets/audio/music-arcade.ogg differ diff --git a/web/assets/audio/music-milkbar.m4a b/web/assets/audio/music-milkbar.m4a new file mode 100644 index 0000000..ea765ec Binary files /dev/null and b/web/assets/audio/music-milkbar.m4a differ diff --git a/web/assets/audio/music-milkbar.ogg b/web/assets/audio/music-milkbar.ogg new file mode 100644 index 0000000..9a6c948 Binary files /dev/null and b/web/assets/audio/music-milkbar.ogg differ diff --git a/web/assets/audio/music-record-shop.m4a b/web/assets/audio/music-record-shop.m4a new file mode 100644 index 0000000..7061656 Binary files /dev/null and b/web/assets/audio/music-record-shop.m4a differ diff --git a/web/assets/audio/music-record-shop.ogg b/web/assets/audio/music-record-shop.ogg new file mode 100644 index 0000000..0032ce7 Binary files /dev/null and b/web/assets/audio/music-record-shop.ogg differ diff --git a/web/assets/audio/music-video-synth.m4a b/web/assets/audio/music-video-synth.m4a new file mode 100644 index 0000000..61bf6bf Binary files /dev/null and b/web/assets/audio/music-video-synth.m4a differ diff --git a/web/assets/audio/music-video-synth.ogg b/web/assets/audio/music-video-synth.ogg new file mode 100644 index 0000000..ee6a3fe Binary files /dev/null and b/web/assets/audio/music-video-synth.ogg differ diff --git a/web/assets/audio/roomtone-milkbar.m4a b/web/assets/audio/roomtone-milkbar.m4a new file mode 100644 index 0000000..60fda3a Binary files /dev/null and b/web/assets/audio/roomtone-milkbar.m4a differ diff --git a/web/assets/audio/roomtone-milkbar.ogg b/web/assets/audio/roomtone-milkbar.ogg new file mode 100644 index 0000000..8e16e73 Binary files /dev/null and b/web/assets/audio/roomtone-milkbar.ogg differ diff --git a/web/assets/audio/roomtone-retail.m4a b/web/assets/audio/roomtone-retail.m4a new file mode 100644 index 0000000..88e67f6 Binary files /dev/null and b/web/assets/audio/roomtone-retail.m4a differ diff --git a/web/assets/audio/roomtone-retail.ogg b/web/assets/audio/roomtone-retail.ogg new file mode 100644 index 0000000..5f5ded5 Binary files /dev/null and b/web/assets/audio/roomtone-retail.ogg differ diff --git a/web/assets/audio/roomtone-video.m4a b/web/assets/audio/roomtone-video.m4a new file mode 100644 index 0000000..00f31ca Binary files /dev/null and b/web/assets/audio/roomtone-video.m4a differ diff --git a/web/assets/audio/roomtone-video.ogg b/web/assets/audio/roomtone-video.ogg new file mode 100644 index 0000000..3779ed7 Binary files /dev/null and b/web/assets/audio/roomtone-video.ogg differ diff --git a/web/assets/audio/sfx-door-open.m4a b/web/assets/audio/sfx-door-open.m4a new file mode 100644 index 0000000..6b03bce Binary files /dev/null and b/web/assets/audio/sfx-door-open.m4a differ diff --git a/web/assets/audio/sfx-door-open.ogg b/web/assets/audio/sfx-door-open.ogg new file mode 100644 index 0000000..91cf81e Binary files /dev/null and b/web/assets/audio/sfx-door-open.ogg differ diff --git a/web/assets/audio/sfx-doorbell.m4a b/web/assets/audio/sfx-doorbell.m4a new file mode 100644 index 0000000..05b5666 Binary files /dev/null and b/web/assets/audio/sfx-doorbell.m4a differ diff --git a/web/assets/audio/sfx-doorbell.ogg b/web/assets/audio/sfx-doorbell.ogg new file mode 100644 index 0000000..585be96 Binary files /dev/null and b/web/assets/audio/sfx-doorbell.ogg differ diff --git a/web/assets/audio/sfx-riffle.m4a b/web/assets/audio/sfx-riffle.m4a new file mode 100644 index 0000000..a5417b1 Binary files /dev/null and b/web/assets/audio/sfx-riffle.m4a differ diff --git a/web/assets/audio/sfx-riffle.ogg b/web/assets/audio/sfx-riffle.ogg new file mode 100644 index 0000000..c70de7e Binary files /dev/null and b/web/assets/audio/sfx-riffle.ogg differ diff --git a/web/assets/audio/sfx-till.m4a b/web/assets/audio/sfx-till.m4a new file mode 100644 index 0000000..52079a3 Binary files /dev/null and b/web/assets/audio/sfx-till.m4a differ diff --git a/web/assets/audio/sfx-till.ogg b/web/assets/audio/sfx-till.ogg new file mode 100644 index 0000000..0174559 Binary files /dev/null and b/web/assets/audio/sfx-till.ogg differ diff --git a/web/assets/audio/sfx-toast.m4a b/web/assets/audio/sfx-toast.m4a new file mode 100644 index 0000000..40217fc Binary files /dev/null and b/web/assets/audio/sfx-toast.m4a differ diff --git a/web/assets/audio/sfx-toast.ogg b/web/assets/audio/sfx-toast.ogg new file mode 100644 index 0000000..3746a20 Binary files /dev/null and b/web/assets/audio/sfx-toast.ogg differ diff --git a/web/assets/audio/sfx-tram-bell.m4a b/web/assets/audio/sfx-tram-bell.m4a new file mode 100644 index 0000000..78c3ffd Binary files /dev/null and b/web/assets/audio/sfx-tram-bell.m4a differ diff --git a/web/assets/audio/sfx-tram-bell.ogg b/web/assets/audio/sfx-tram-bell.ogg new file mode 100644 index 0000000..c4a2492 Binary files /dev/null and b/web/assets/audio/sfx-tram-bell.ogg differ diff --git a/web/assets/audio/sfx-tram-rumble.m4a b/web/assets/audio/sfx-tram-rumble.m4a new file mode 100644 index 0000000..5ef8897 Binary files /dev/null and b/web/assets/audio/sfx-tram-rumble.m4a differ diff --git a/web/assets/audio/sfx-tram-rumble.ogg b/web/assets/audio/sfx-tram-rumble.ogg new file mode 100644 index 0000000..0e70580 Binary files /dev/null and b/web/assets/audio/sfx-tram-rumble.ogg differ diff --git a/web/assets/audio/step-pavement-1.m4a b/web/assets/audio/step-pavement-1.m4a new file mode 100644 index 0000000..4df8ff2 Binary files /dev/null and b/web/assets/audio/step-pavement-1.m4a differ diff --git a/web/assets/audio/step-pavement-1.ogg b/web/assets/audio/step-pavement-1.ogg new file mode 100644 index 0000000..e647fc4 Binary files /dev/null and b/web/assets/audio/step-pavement-1.ogg differ diff --git a/web/assets/audio/step-pavement-2.m4a b/web/assets/audio/step-pavement-2.m4a new file mode 100644 index 0000000..5cdcf9f Binary files /dev/null and b/web/assets/audio/step-pavement-2.m4a differ diff --git a/web/assets/audio/step-pavement-2.ogg b/web/assets/audio/step-pavement-2.ogg new file mode 100644 index 0000000..e772cdc Binary files /dev/null and b/web/assets/audio/step-pavement-2.ogg differ diff --git a/web/assets/audio/step-pavement-3.m4a b/web/assets/audio/step-pavement-3.m4a new file mode 100644 index 0000000..9a4f33d Binary files /dev/null and b/web/assets/audio/step-pavement-3.m4a differ diff --git a/web/assets/audio/step-pavement-3.ogg b/web/assets/audio/step-pavement-3.ogg new file mode 100644 index 0000000..6059af6 Binary files /dev/null and b/web/assets/audio/step-pavement-3.ogg differ diff --git a/web/assets/audio/step-timber-1.m4a b/web/assets/audio/step-timber-1.m4a new file mode 100644 index 0000000..75bb0c4 Binary files /dev/null and b/web/assets/audio/step-timber-1.m4a differ diff --git a/web/assets/audio/step-timber-1.ogg b/web/assets/audio/step-timber-1.ogg new file mode 100644 index 0000000..c6c58d6 Binary files /dev/null and b/web/assets/audio/step-timber-1.ogg differ diff --git a/web/assets/audio/step-timber-2.m4a b/web/assets/audio/step-timber-2.m4a new file mode 100644 index 0000000..402b0a3 Binary files /dev/null and b/web/assets/audio/step-timber-2.m4a differ diff --git a/web/assets/audio/step-timber-2.ogg b/web/assets/audio/step-timber-2.ogg new file mode 100644 index 0000000..d633c13 Binary files /dev/null and b/web/assets/audio/step-timber-2.ogg differ diff --git a/web/assets/audio/step-timber-3.m4a b/web/assets/audio/step-timber-3.m4a new file mode 100644 index 0000000..86e8ed6 Binary files /dev/null and b/web/assets/audio/step-timber-3.m4a differ diff --git a/web/assets/audio/step-timber-3.ogg b/web/assets/audio/step-timber-3.ogg new file mode 100644 index 0000000..9486981 Binary files /dev/null and b/web/assets/audio/step-timber-3.ogg differ diff --git a/web/assets/manifest.json b/web/assets/manifest.json index f31d90b..241a98a 100644 --- a/web/assets/manifest.json +++ b/web/assets/manifest.json @@ -8,7 +8,8 @@ "facade_face": "facade entries without a 'face' key are front elevations (shop or house fronts); face:'side' entries are windowless corner-lot side walls with empty types \u2014 never select one as a shop front", "corner_lots": "corner lots expose a side wall: dedicated facade--side.jpg skins now exist for brick/render/weatherboard (face:'side') \u2014 Lane B maps these to the exposed side face; other types reuse the front skin", "fallbacks": "every asset is optional \u2014 missing file => primitive/flat-colour, never a crash", - "load": "fittings/furniture load from depot as depot:; skins are local gen/" + "load": "fittings/furniture load from depot as depot:; skins are local gen/", + "audio": "OGG/Opus primary + M4A/AAC fallback; loop:true beds are seamless; gain is a suggested 0-1 playback level; interior roomtone/music carry types[] (seed-pick per shop, like facades); footstep is {surface:[variants]}. Silent-happy: any missing file => silence, never an error; ?mute=1 / ?noassets=1 => no audio fetch." }, "skins": { "facade": { @@ -838,5 +839,180 @@ "magazine_rack": "magazine_rack", "fridge": "drinks_fridge", "listening_corner": "listening_booth" + }, + "audio": { + "ambience": { + "street-day": { + "file": "audio/ambience-street-day.ogg", + "fallback": "audio/ambience-street-day.m4a", + "loop": true, + "gain": 0.5, + "scope": "street" + }, + "street-night": { + "file": "audio/ambience-street-night.ogg", + "fallback": "audio/ambience-street-night.m4a", + "loop": true, + "gain": 0.5, + "scope": "street" + }, + "rain": { + "file": "audio/ambience-rain.ogg", + "fallback": "audio/ambience-rain.m4a", + "loop": true, + "gain": 0.45, + "scope": "street" + }, + "roomtone-retail": { + "file": "audio/roomtone-retail.ogg", + "fallback": "audio/roomtone-retail.m4a", + "loop": true, + "gain": 0.35, + "scope": "interior", + "types": [ + "record", + "opshop", + "book", + "toy", + "pawn", + "stall", + "general" + ] + }, + "roomtone-milkbar": { + "file": "audio/roomtone-milkbar.ogg", + "fallback": "audio/roomtone-milkbar.m4a", + "loop": true, + "gain": 0.35, + "scope": "interior", + "types": [ + "milkbar" + ] + }, + "roomtone-video": { + "file": "audio/roomtone-video.ogg", + "fallback": "audio/roomtone-video.m4a", + "loop": true, + "gain": 0.35, + "scope": "interior", + "types": [ + "video", + "dept", + "arcade" + ] + } + }, + "sfx": { + "doorbell": { + "file": "audio/sfx-doorbell.ogg", + "fallback": "audio/sfx-doorbell.m4a", + "gain": 0.7 + }, + "door-open": { + "file": "audio/sfx-door-open.ogg", + "fallback": "audio/sfx-door-open.m4a", + "gain": 0.6 + }, + "till": { + "file": "audio/sfx-till.ogg", + "fallback": "audio/sfx-till.m4a", + "gain": 0.7 + }, + "riffle": { + "file": "audio/sfx-riffle.ogg", + "fallback": "audio/sfx-riffle.m4a", + "gain": 0.6 + }, + "toast": { + "file": "audio/sfx-toast.ogg", + "fallback": "audio/sfx-toast.m4a", + "gain": 0.5 + }, + "tram-bell": { + "file": "audio/sfx-tram-bell.ogg", + "fallback": "audio/sfx-tram-bell.m4a", + "gain": 0.7 + }, + "tram-rumble": { + "file": "audio/sfx-tram-rumble.ogg", + "fallback": "audio/sfx-tram-rumble.m4a", + "gain": 0.5, + "loop": true + }, + "footstep": { + "pavement": [ + { + "file": "audio/step-pavement-1.ogg", + "fallback": "audio/step-pavement-1.m4a" + }, + { + "file": "audio/step-pavement-2.ogg", + "fallback": "audio/step-pavement-2.m4a" + }, + { + "file": "audio/step-pavement-3.ogg", + "fallback": "audio/step-pavement-3.m4a" + } + ], + "timber": [ + { + "file": "audio/step-timber-1.ogg", + "fallback": "audio/step-timber-1.m4a" + }, + { + "file": "audio/step-timber-2.ogg", + "fallback": "audio/step-timber-2.m4a" + }, + { + "file": "audio/step-timber-3.ogg", + "fallback": "audio/step-timber-3.m4a" + } + ] + } + }, + "music": { + "record-shop": { + "file": "audio/music-record-shop.ogg", + "fallback": "audio/music-record-shop.m4a", + "loop": true, + "gain": 0.55, + "types": [ + "record" + ], + "note": "record shop plays music" + }, + "milkbar": { + "file": "audio/music-milkbar.ogg", + "fallback": "audio/music-milkbar.m4a", + "loop": true, + "gain": 0.5, + "types": [ + "milkbar", + "general" + ], + "note": "milk bar radio" + }, + "video-synth": { + "file": "audio/music-video-synth.ogg", + "fallback": "audio/music-video-synth.m4a", + "loop": true, + "gain": 0.5, + "types": [ + "video" + ], + "note": "video store synth hum" + }, + "arcade": { + "file": "audio/music-arcade.ogg", + "fallback": "audio/music-arcade.m4a", + "loop": true, + "gain": 0.5, + "types": [ + "arcade", + "dept" + ], + "note": "arcade cabinet" + } + } } } \ No newline at end of file