Compare commits

...

4 Commits

Author SHA1 Message Date
m3ultra
8c275c711b Lane F R11: wire interior audio beds + audio smokes + gate scope
Interior audio (interior_mode): enter -> PROCITY.audio.playInterior(room.audio) with Lane C's
{musicKey,toneKey} (fade in), exit -> stopInterior() (0.7s fade). Guarded/no-op when
muted/noassets/pre-gesture -- silent-and-happy.

New smoke_audio gate (flags harness, fresh Chromium per the R10 standing law): pre-gesture silence
(ctx not unlocked), 0 boot errors, ?mute=1 muted, ?noassets=1 zero audio fetches, enter/exit
AudioNode leak-free (12 cycles, 8 shops with a music bed, live sources bounded at 5). Gesture via
keyboard -- a mouse-click gesture tripped PointerLockControls in headless.

scaffold_check: scoped the Math.random generation-ban to exempt runtime FX PLAYBACK
(web/js/world/audio.js) -- Lane B's footstep SFX variant/gain jitter is transient, not world
generation (which shop plays which bed is seeded upstream in interiors.js). Noted to B.

Soak with audio on PASS (heap 48MB stable, 0 errors, no drift). qa.sh --strict GREEN 6/6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 17:13:19 +10:00
m3ultra
f7dd44f55a Lane B (Streetscape): round-11 audio — WebAudio engine (street side)
- web/js/world/audio.js: one AudioContext unlocked on first gesture; day/night ambience crossfade, rain (weather.intensity), footsteps, shop-door music spill (nearest open music-shop, distance-gain), tram rumble+bell, enterShop SFX. Consumes Lane E manifest.audio (3x retry). Self-ticks off window.PROCITY.
- House audio law verified live: silent pre-gesture (0 fetches), ?mute=1 silent surface (0 fetches), ?noassets=1 zero audio/manifest fetches, lazy-load (8.7MB<=25MB), no per-frame alloc.
- window.PROCITY.audio for Lane F: playInterior({musicKey,toneKey})/stopInterior (street beds duck while inside), playSfx, setMasterGain, mute, state diag. Confirms Lane C room.audio={musicKey,toneKey} contract.
- Small guarded [Lane B R11 audio] loader in index.html.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:00:31 +10:00
m3ultra
89f278b603 Lane C: round-11 audio contract — room.audio on buildInterior
buildInterior returns room.audio = { musicKey, toneKey }, seeded per shop: keys into Lane E's manifest.audio (interior music bed + room-tone). Lane F's interior_mode plays it, B's audio.js resolves keys->files. Keys not files: buildInterior stays synchronous + asset-free; silence on missing/mute/noassets (house audio law).

Maps mirror the manifest per-type types arrays: record/milkbar/video/dept/arcade always play their bed; other types room-tone-only except a seeded ~1-in-3 general radio (the 'general'-tagged milk-bar bed).

Verified 9 types x 8 seeds: 0 bad keys, 0 nondeterminism, all keys resolve + match manifest type membership, deterministic per shop. Contract + F wiring: docs/LANES/LANE_C_AUDIO.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 15:46:58 +10:00
m3ultra
be9f967d19 Lane E R11 (audio): procedural audio pack — 23 assets, free/on-device
gen_audio.py: a numpy synthesis engine (osc / ADSR / filters / reverb /
seamless-loop crossfade + note sequencer + drum synth). Renders 6 ambience
beds (street day/night, rain, 3 interior roomtones) + 13 sfx (doorbell,
door, till, record-riffle, toast, tram bell/rumble, 6 footsteps) + 4
instrumental 90s music loops (record-shop, milkbar, video-synth, arcade)
-> web/assets/audio/ as OGG/Opus + M4A/AAC. 8.1 MB of the 25 MB budget.

Tool = 100% procedural synthesis (not a neural model): $0, deterministic/
seeded, perfectly loopable, parody-safe by construction. Documented in
README (§ audio) + AUDIT.md provenance.

manifest audio:{ambience,sfx,music} — file + m4a fallback + loop + gain;
interior roomtone/music carry types[] (seed-pick per shop, like facades);
footstep is {surface:[variants]}. build_manifest.py builds it,
validate_manifest.py checks it. qa.sh --strict green (audio 23, 0 err).

Verified technically (peaks <= -0.4 dBFS, all loops seamless, durations)
via spectrogram sheet + numeric — subjective ear-check is John's/F's.

E leads the audio round; F owns the v2.1 tag + push. Not pushed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 14:50:15 +10:00
66 changed files with 1554 additions and 7 deletions

1
.gitignore vendored
View File

@ -8,6 +8,7 @@ saves/
node_modules/
.env
pipeline/.genprops/
pipeline/.genaudio/
web/assets/models/
pipeline/.peds_orig/
pipeline/.props_orig/

View File

@ -89,6 +89,25 @@ use-if-ready furniture path (fail-soft; `?noassets` → primitives, 0 network).
(same as the ped rigs).** File: `furniture.js`.
## Round 11 (AUDIO) — Lane B WebAudio engine — done
Built **`web/js/world/audio.js`** (B-owned) + a guarded create-call in the shell. One `AudioContext`
unlocked on the first gesture; self-contained (reads `window.PROCITY`, self-ticks); consumes Lane E's
`manifest.audio` (23 assets, 8.7 MB, ≤25 MB) with a 3× fetch retry.
- **Layers** (crossfaded, no per-frame alloc): day/night ambience (lighting segment), rain
(`weather.intensity`), footsteps (player movement), **shop-door music spill** (nearest open
music-shop, distance-gain — verified `video-synth` at ~0.08 by Video Barn's door), tram rumble+bell
(`?tram`), SFX (doorbell/door-open on `procity:enterShop`).
- **House audio law — all verified live:** boots silent (nothing plays/fetches pre-gesture, 0 console
errors); `?mute=1` → silent surface (exists for smokes, 0 fetches); `?noassets=1` → 0 audio/manifest
fetches; beds lazy-load; spill is a pure function of shop type.
- **For Lane F:** `window.PROCITY.audio` exposes `playInterior({musicKey,toneKey})`/`stopInterior()`
(street beds auto-duck while inside), `playSfx`, `setMasterGain`, `mute`, and a `state` diag surface.
- **For Lane C:** confirmed `room.audio = { musicKey, toneKey }` on `buildInterior` is the right shape
(musicKey → `manifest.music`, toneKey → `manifest.ambience`).
- Gates green (manifest validator 0 errors incl. audio 23; selfcheck 1727/1727). `qa.sh --strict`
hangs on a headless Playwright gate — ran the static gates directly. Files: `web/js/world/audio.js`
(new) + a small `[Lane B R11 audio]` loader in `web/index.html` + these docs.
## Round 3 (Fable's ROUND3_INSTRUCTIONS → Lane B) — done
- **Task 1 (draw budget, re-measured WITH citizens).** Radius-2 auto is already the spawn default;
no config change needed. Drove the real loop (`chunks.update` + `citizens.update`, pop 140, up to

View File

@ -3,7 +3,25 @@
*Status: **v1 complete & verified**. Standalone interiors library + test page. Every shop door opens
into a unique, seeded, themed interior, generated on demand in ~4ms, byte-identical every revisit.*
Last updated: 2026-07-15 (round 9) · owner: PROCITY-C · reviewer: Fable
Last updated: 2026-07-15 (round 11) · owner: PROCITY-C · reviewer: Fable
---
## Update 2026-07-15 (round 11, audio) — `room.audio` contract
R11 §Lane C ("one contract answer"). **Confirmed** Fable's proposal: `buildInterior` now returns
`room.audio = { musicKey, toneKey }`, seeded per shop — F's interior_mode plays it, B's audio.js
resolves the keys. Full contract + F wiring recipe: [docs/LANES/LANE_C_AUDIO.md](docs/LANES/LANE_C_AUDIO.md).
- `toneKey` → a `manifest.audio.ambience` interior room-tone (always set); `musicKey` → a
`manifest.audio.music` bed, or **null** (no music). Keys, not files — buildInterior stays synchronous
and asset-free; silence on missing/`?mute=1`/`?noassets=1` (house audio law).
- Maps mirror the manifest's per-type `types` arrays (one source of truth): record/milkbar/video/dept/
arcade always play their bed; opshop/book/toy/pawn/stall are room-tone-only, except a seeded ~1-in-3
keeps a general radio on (the `general`-tagged milk-bar bed).
- Verified (9 types × 8 seeds): 0 bad keys, 0 nondeterminism, every key resolves + matches manifest
`types` membership; deterministic per shop. `qa.sh --strict` green.
- Optional visual-source prop (`userData.audioEmitter`) deferred to v2.2 (in the note) — out of round scope.
---

View File

@ -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)

View File

@ -4,6 +4,26 @@
---
## Round 11 (the audio round) — DONE ✅ (v2.1)
John greenlit "generate freely". E generated the pack (23 assets, `web/assets/audio/`, 8.7 MB ≤ 25),
B built the WebAudio engine (`audio.js`, self-unlocking, self-ticking), C added `room.audio =
{musicKey,toneKey}` to buildInterior. **Lane F wired + verified + tagged v2.1:**
| task | result |
|---|---|
| **F1 — interior beds** | ✅ `interior_mode` enter → `PROCITY.audio.playInterior(current.audio)` (fade in), exit → `stopInterior()` (0.7 s fade). Guarded/no-op when muted/noassets/pre-gesture. 2 lines, the seam B/C designed for. |
| **F2 — audio smokes** | ✅ new `smoke_audio` in the flags harness (fresh Chromium): **pre-gesture silence** (ctx not unlocked), **0 boot errors**, **?mute=1** muted, **?noassets=1 zero audio fetches**, **enter/exit leak-free** (12 cycles, 8 shops with a music bed, live AudioNodes bounded at 5, made 42). Caught + fixed my own gesture-via-mouse-click tripping PointerLockControls in headless → gesture via keyboard. |
| **F3 — soak + tag** | ✅ soak with audio on **PASS** (heap 48 MB stable, 0 errors, no drift). `qa.sh --strict` **GREEN 6/6**. **v2.1 tagged + pushed.** |
**Cross-lane (F's own gate, noted to B):** B's `audio.js:143,145` use `Math.random()` for footstep SFX
variant + gain jitter — legitimate transient runtime FX (which shop plays which bed is seeded upstream
in `interiors.js`), but it tripped F's generation-determinism `Math.random` ban. F **scoped the ban
correctly**: `web/js/world/audio.js` is runtime *playback*, not generation → exempted in
`scaffold_check.mjs` (B may instead add a `// cosmetic` marker; either is fine).
---
## Round 10 (fix the R9 giant-rig blocker, re-shoot, re-tag) — verify + ship
R9 shipped a blocking regression: interior rig figures came out ~2× room scale (keeper head 3.83 m in

View File

@ -324,6 +324,40 @@ smoothed value (0 under a throttled/background tab; populates in a foreground Pl
for spawning/ambient/LOD. Inert by default (the shell doesn't set them; **Lane D's citizens drive off
`plan.streets` instead of chunk lifecycle**, so this is a spare hook, not a dependency).
## WebAudio engine — `audio.js` (round 11, the audio round)
`web/js/world/audio.js` — one `AudioContext`, unlocked on the first user gesture. Self-contained: it
reads live state off `window.PROCITY` and self-ticks (its own rAF), so the shell only calls
`createAudioEngine(window.PROCITY, { noassets })` once (after `window.PROCITY` is assigned) and stores
it at `window.PROCITY.audio`. Consumes Lane E's `manifest.audio` (self-fetched, **3× retry** so a
dropped boot-burst fetch doesn't mute the session).
Layers, all crossfaded off systems that already exist (gains ramp; the update path allocates nothing):
- **Ambience**`street-day` / `street-night` beds crossfade on `lighting.getClock().night`; **rain**
bed gain follows `PROCITY.weather.intensity` (silent unless it's actually raining, i.e. `?weather`).
- **Footsteps** — distance-accumulated from `player.position` while pointer-locked (a step per stride).
- **Shop-door spill** — nearest *open* music shop (record/milkbar/video/arcade/dept) within ~9 m leaks
its interior music bed at low distance-gain (scan throttled to 5 Hz over live door meshes; honours
`PROCITY.isOpen`). Verified: 6 m from "Video Barn" → `video-synth` at gain ~0.08.
- **Tram** — rumble gain by camera↔tram distance (`scene.getObjectByName('tram')`); bell rings once as
it settles at a nearby stop. Conditional on `?tram`.
- **SFX**`procity:enterShop` → doorbell + door-open; `playSfx(key)` for the rest (till/riffle/toast).
**House audio law — all verified in-browser:** silent-and-happy (missing/failed audio → silence, no
errors); **nothing plays or fetches before the first gesture**; `?mute=1` → a silent surface (exists
for smokes, never unlocks, 0 fetches); `?noassets=1` → live but **0 audio/manifest fetches**; beds
lazy-load (≤25 MB pack, 8.7 MB shipped); spill/music selection is a pure function of shop type.
### Surface (`window.PROCITY.audio`, for Lane F's smokes + interior wiring)
- `setMasterGain(0..1)`, `mute()`, `unmute()`, `get ready`, `get muted`.
- `playSfx(key, {gain})`, `footstep(surface)`.
- **`playInterior({ musicKey, toneKey })` / `stopInterior()`** — Lane F calls these from
`interior_mode` on enter/exit, passing **Lane C's `room.audio` contract** (a `musicKey` into
`manifest.music`, a `toneKey` into `manifest.ambience`). The street beds auto-duck to 0 while
`mode !== 'street'`. Confirmed working: `playInterior` fades the room's music+tone in, `stopInterior`
fades them out. **→ Lane C: `room.audio = { musicKey, toneKey }` on `buildInterior` is the right shape.**
- `state``{ ready, muted, mode, manifest, loaded, layers, nearestSpill }` diagnostics for smokes.
## Controls
WASD move · shift run · mouse look · click a door · `[` `]` step time-of-day · `T` pause clock ·
`M` map · `P` screenshot · Esc release pointer.

View File

@ -0,0 +1,50 @@
# LANE C — audio contract (round 11) → for Lane F + Lane B
*PROCITY-C, 2026-07-15. Answers the R11 §Lane C question: "where should the interior bed + room-tone
attach?" **Confirmed**`buildInterior` now returns `room.audio = { musicKey, toneKey }`, seeded per
shop. Lane F's interior_mode just plays it; Lane B's audio.js resolves the keys → files.*
## The contract
```js
room.audio = {
musicKey, // string | null — an interior MUSIC bed key in manifest.audio.music, or null (no music)
toneKey, // string — an interior ROOM-TONE key in manifest.audio.ambience (scope:'interior')
}
```
- **Keys, not files.** `buildInterior` stays synchronous and asset-free — it only *names* what should
play. Resolve `musicKey``manifest.audio.music[musicKey]`, `toneKey`
`manifest.audio.ambience[toneKey]` (the `.ogg` + `.m4a` fallback + `gain`/`loop` are on those entries).
- **Silence is legal** (house audio law): missing key / `?mute=1` / `?noassets=1` → play nothing, no error.
`musicKey` is frequently `null` — treat "no music, room-tone only" as the common case.
- **Seeded per shop, stable per revisit.** Derived from `shop.seed` on its own sub-stream, so the same
shop is silent-or-playing identically every visit (no per-frame or per-enter randomness).
## What each type resolves to (mirrors manifest.audio `types` arrays — one source of truth)
| type | toneKey | musicKey |
|---|---|---|
| record | roomtone-retail | **record-shop** (always — "record shop plays music") |
| milkbar | roomtone-milkbar | **milkbar** (radio, always) |
| video | roomtone-video | **video-synth** (always) |
| dept, arcade | roomtone-video | **arcade** (always) |
| opshop, book, toy, pawn, stall | roomtone-retail | **null**, or **milkbar** for a seeded ~1-in-3 (a general radio playing — the milk-bar bed is `types:[…,'general']`) |
Verified (9 types × 8 seeds): 0 bad keys, 0 nondeterminism, every key resolves + matches its manifest
`types` membership; dedicated-music types are stable, non-music types vary on/off by seed.
## Lane F wiring (interior_mode)
```
on enter(room):
if room.audio.toneKey: playLoop(ambience[room.audio.toneKey], fadeIn) // room-tone always
if room.audio.musicKey: playLoop(music[room.audio.musicKey], fadeIn) // may be null → skip
on exit / dispose(room):
fadeOut + stop both, release AudioNodes // your enter/exit leak smoke checks this
```
Nothing before the first gesture; `?mute=1`/`?noassets=1` short-circuit before any fetch. `room.audio`
is plain data on the return — no new lifecycle, no dispose hook needed from Lane C's side.
## Optional (deferred to v2.2, per the round scope)
A `places`-tagged visual source ("the music comes from *there*") — e.g. a radio/hifi mesh with
`userData.audioEmitter = musicKey` so Lane B can position/spatialise the bed. Only the record shop has a
fitting for it today (`listeningCorner`); milk-bar/video would need a new radio prop. Not in this round —
proposing the `userData.audioEmitter` hook + the two props as a tidy v2.2 item. v2.1 plays the bed
non-spatially (room-filling), which is the right default for an interior anyway.

Binary file not shown.

After

Width:  |  Height:  |  Size: 838 KiB

View File

@ -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}`.

View File

@ -95,13 +95,34 @@ python3 pipeline/stage_local_depot.py # mirror _normalized/*.glb → web/ass
# then load the game with ?localdepot=1 — depot:<file> resolves to assets/models/<file> 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)

View File

@ -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:<file>; skins are local gen/<file>",
"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__":

649
pipeline/gen_audio.py Normal file
View File

@ -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 215 GB model download, runs in the mflux venv (numpy only).
* Deterministic + seeded (citySeed/shopId flavour is house law) same seed, same sound.
* Perfect **seamless loops** (we crossfade the tail into the head) neural gens don't loop.
* **Parody-safe by construction**: every sample is an original oscillator/noise render, so there
is no way to imitate a specific real recording. Instrumental originals only.
* Precise budget control (25 MB): we choose length + Opus bitrate per asset.
Pipeline: synth (numpy float32) -> WAV (stdlib wave, int16) -> ffmpeg -> OGG/Opus (+ M4A/AAC).
Loudness: long beds/music -> ffmpeg loudnorm (EBU R128, target -16 LUFS integrated); short SFX ->
peak-normalized in-synth (loudnorm is unreliable under ~3 s).
<mflux-venv-python> pipeline/gen_audio.py --dry-run # list the pack + budget, no render
<mflux-venv-python> pipeline/gen_audio.py # render everything -> web/assets/audio/
<mflux-venv-python> pipeline/gen_audio.py --only music # just the music beds
<mflux-venv-python> pipeline/gen_audio.py --wav-only # keep .genaudio/*.wav, skip ffmpeg (debug)
Run with the numpy-having interpreter: ~/Documents/MODELBEAST/venvs/mflux/bin/python
House audio law: the game runs silent-and-happy with zero assets none of this blocks anyone.
"""
import os, sys, math, wave, struct, subprocess, hashlib, json, zlib
import numpy as np
SR = 44100
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RAW = os.path.join(ROOT, "pipeline", ".genaudio") # WAV scratch (git-ignored)
OUT = os.path.join(ROOT, "web", "assets", "audio") # shipped OGG/M4A
os.makedirs(RAW, exist_ok=True)
os.makedirs(OUT, exist_ok=True)
FF = "ffmpeg"
# ─────────────────────────────────────────────────────────────────────────────
# DSP toolkit (pure numpy)
# ─────────────────────────────────────────────────────────────────────────────
def seed_of(name):
return zlib.crc32(name.encode()) & 0xFFFFFFFF
def rng(name):
return np.random.default_rng(seed_of(name))
def tarr(dur):
return np.arange(int(dur * SR)) / SR
def sine(f, dur, phase=0.0):
return np.sin(2 * np.pi * f * tarr(dur) + phase)
def _phase(f, dur):
# allow f to be scalar or array (for vibrato/glide)
n = int(dur * SR)
if np.isscalar(f):
f = np.full(n, f, dtype=np.float64)
else:
f = np.resize(f, n)
return np.cumsum(2 * np.pi * f / SR)
def osc(f, dur, kind="sine"):
ph = _phase(f, dur)
if kind == "sine":
return np.sin(ph)
if kind == "tri":
return 2 / np.pi * np.arcsin(np.sin(ph))
if kind == "saw":
return 2 * ((ph / (2 * np.pi)) % 1.0) - 1
if kind == "square":
return np.sign(np.sin(ph))
if kind == "pulse25":
return np.where((ph / (2 * np.pi)) % 1.0 < 0.25, 1.0, -1.0)
raise ValueError(kind)
def noise(dur, r=None, kind="white"):
n = int(dur * SR)
r = r or np.random.default_rng(0)
x = r.standard_normal(n)
if kind == "pink": # ~ -3 dB/oct via cheap one-pole cascade
b = [0.0, 0.0, 0.0]
out = np.empty(n)
for i in range(n):
b[0] = 0.99765 * b[0] + x[i] * 0.0990460
b[1] = 0.96300 * b[1] + x[i] * 0.2965164
b[2] = 0.57000 * b[2] + x[i] * 1.0526913
out[i] = b[0] + b[1] + b[2] + x[i] * 0.1848
return out / 4
return x
def adsr(dur, a=0.01, d=0.1, s=0.7, r=0.1, sl=None):
n = int(dur * SR)
sl = (dur - a - d - r) if sl is None else sl
sl = max(sl, 0.0)
segs = [(a, 0, 1), (d, 1, s), (sl, s, s), (r, s, 0)]
env = np.concatenate([np.linspace(x0, x1, max(int(t * SR), 1)) for t, x0, x1 in segs])
return np.resize(env, n)
def lp1(x, cutoff):
# one-pole low-pass
a = math.exp(-2 * math.pi * cutoff / SR)
y = np.empty_like(x)
acc = 0.0
for i in range(len(x)):
acc = (1 - a) * x[i] + a * acc
y[i] = acc
return y
def hp1(x, cutoff):
return x - lp1(x, cutoff)
def biquad_lp(x, cutoff, q=0.707):
# RBJ low-pass biquad
w0 = 2 * math.pi * cutoff / SR
alpha = math.sin(w0) / (2 * q)
cw = math.cos(w0)
b0 = (1 - cw) / 2; b1 = 1 - cw; b2 = (1 - cw) / 2
a0 = 1 + alpha; a1 = -2 * cw; a2 = 1 - alpha
b0, b1, b2, a1, a2 = (b0/a0, b1/a0, b2/a0, a1/a0, a2/a0)
y = np.empty_like(x); x1 = x2 = y1 = y2 = 0.0
for i in range(len(x)):
y0 = b0*x[i] + b1*x1 + b2*x2 - a1*y1 - a2*y2
x2, x1 = x1, x[i]; y2, y1 = y1, y0
y[i] = y0
return y
def bandpass(x, lo, hi):
return lp1(hp1(x, lo), hi)
def softclip(x, drive=1.0):
return np.tanh(x * drive)
def fade(x, fin=0.01, fout=0.01):
n = len(x); ai = int(fin * SR); ao = int(fout * SR)
if ai: x[:ai] *= np.linspace(0, 1, ai)
if ao: x[-ao:] *= np.linspace(1, 0, ao)
return x
def peaknorm(x, target_db=-3.0):
p = np.max(np.abs(x)) or 1.0
return x * (10 ** (target_db / 20) / p)
def seamless(x, xf=0.75):
"""Return a loop of length len(x)-xf*SR that plays end->start seamlessly, by crossfading
the tail (xf s) over the head. Input should be rendered xf longer than the wanted loop."""
k = int(xf * SR)
if k <= 0 or len(x) <= k:
return x
body = x[:-k].copy()
tail = x[-k:]
w = np.linspace(0, 1, k)
if body.ndim == 2: # stereo: broadcast window across channels
w = w[:, None]
body[:k] = body[:k] * w + tail * (1 - w) # head = head*rise + tail*fall
return body
def stereo(l, r=None):
if r is None:
r = l
n = max(len(l), len(r))
out = np.zeros((n, 2), dtype=np.float64)
out[:len(l), 0] = l; out[:len(r), 1] = r
return out
def pan(x, p): # p in [-1,1]
p = (p + 1) / 2
return stereo(x * math.cos(p * math.pi / 2), x * math.sin(p * math.pi / 2))
def mix(*layers):
n = max(len(a) for a in layers)
ch = max((a.ndim == 2 and a.shape[1] or 1) for a in layers)
out = np.zeros((n, ch) if ch == 2 else n, dtype=np.float64)
for a in layers:
if ch == 2 and a.ndim == 1:
a = stereo(a)
if ch == 2:
out[:len(a)] += a
else:
out[:len(a)] += a
return out
def schroeder_reverb(x, mix_amt=0.2, decay=0.5):
if x.ndim == 2:
return np.stack([schroeder_reverb(x[:, c], mix_amt, decay) for c in range(2)], axis=1)
out = np.zeros(len(x) + SR)
out[:len(x)] = x
for dl, g in [(1116, 0.805), (1188, 0.827), (1277, 0.783), (1356, 0.764)]:
buf = np.zeros(len(out))
gg = g * decay
for i in range(dl, len(out)):
buf[i] = out[i] + gg * buf[i - dl]
out = out + buf * 0.25
wet = out[:len(x)]
wet = wet / (np.max(np.abs(wet)) or 1.0)
return (1 - mix_amt) * x + mix_amt * wet
# ─────────────────────────────────────────────────────────────────────────────
# instruments + drums (for music beds)
# ─────────────────────────────────────────────────────────────────────────────
def midi_hz(m):
return 440.0 * 2 ** ((m - 69) / 12)
def synth_note(midi, dur, kind="saw", a=0.005, d=0.08, s=0.6, rel=0.1,
cutoff=2500, detune=0.0, vib=0.0):
f = midi_hz(midi)
if vib:
f = f * (1 + vib * 0.006 * np.sin(2 * np.pi * 5 * tarr(dur)))
x = osc(f, dur, kind)
if detune:
x = 0.5 * x + 0.5 * osc(midi_hz(midi) * (1 + detune), dur, kind)
x = x * adsr(dur, a, d, s, rel)
if cutoff < SR / 2:
x = biquad_lp(x, cutoff, 0.8)
return x
def epiano(midi, dur):
f = midi_hz(midi)
x = (np.sin(2*np.pi*f*tarr(dur)) + 0.5*np.sin(2*np.pi*2*f*tarr(dur)*1.001)
+ 0.25*np.sin(2*np.pi*3*f*tarr(dur)))
return x * adsr(dur, 0.005, 0.5, 0.25, 0.3)
def pad(midi, dur, detune=0.008):
x = (osc(midi_hz(midi), dur, "saw") + osc(midi_hz(midi)*(1+detune), dur, "saw")
+ osc(midi_hz(midi)*(1-detune), dur, "saw")) / 3
x = biquad_lp(x, 1800, 0.7)
return x * adsr(dur, 0.4, 0.2, 0.8, 0.5)
def kick(dur=0.28):
n = int(dur * SR)
f = np.linspace(120, 45, n)
x = np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-np.linspace(0, 9, n))
click = noise(0.005) * np.exp(-np.linspace(0, 40, int(0.005*SR)))
return mix(x, np.concatenate([click, np.zeros(n - len(click))]))
def snare(dur=0.2, r=None):
n = int(dur * SR)
body = np.sin(2*np.pi*190*tarr(dur)) * np.exp(-np.linspace(0, 14, n))
nz = noise(dur, r) * np.exp(-np.linspace(0, 16, n))
return 0.5*body + 0.8*bandpass(nz, 1200, 6000)
def hat(dur=0.05, r=None, open_=False):
n = int(dur * SR)
nz = bandpass(noise(dur, r), 7000, 16000)
env = np.exp(-np.linspace(0, 3 if open_ else 30, n))
return nz * env
# ─────────────────────────────────────────────────────────────────────────────
# sequencer for music beds
# ─────────────────────────────────────────────────────────────────────────────
def render_line(notes, bpm, instr, gap=0.0):
"""notes: list of (midi_or_None, beats). instr: fn(midi,dur)->array. Returns concatenated."""
spb = 60.0 / bpm
parts = []
for midi, beats in notes:
dur = beats * spb
if midi is None:
parts.append(np.zeros(int(dur * SR)))
else:
x = instr(midi, max(dur - gap, 0.05))
x = np.concatenate([x, np.zeros(int(dur * SR) - len(x))]) if len(x) < int(dur*SR) else x[:int(dur*SR)]
parts.append(x)
return np.concatenate(parts)
def render_drums(pattern, bpm, bars, r):
"""pattern: dict 'k'/'s'/'h' -> list of 16th-step indices (0..15) for one bar."""
spb = 60.0 / bpm
step = spb / 4
barlen = int(16 * step * SR)
out = np.zeros(barlen * bars)
voices = {"k": lambda: kick(), "s": lambda: snare(r=r), "h": lambda: hat(r=r),
"o": lambda: hat(r=r, open_=True)}
for b in range(bars):
for v, steps in pattern.items():
for s in steps:
pos = int((b * 16 + s) * step * SR)
smp = voices[v]()
end = min(pos + len(smp), len(out))
out[pos:end] += smp[:end - pos]
return out
# ─────────────────────────────────────────────────────────────────────────────
# AMBIENCE generators (loopable ~40s)
# ─────────────────────────────────────────────────────────────────────────────
AMB_LEN = 40.0
XF = 1.0
def amb_street_day(name):
r = rng(name)
dur = AMB_LEN + XF
wind = lp1(noise(dur, r, "pink"), 600) * 0.6
# distant traffic: slow-swelling band-passed noise
traf = bandpass(noise(dur, r), 120, 900)
swell = 0.5 + 0.5 * np.sin(2 * np.pi * 0.05 * tarr(dur) + r.random() * 6)
traf = traf * swell * 0.35
# sparse bird chirps
birds = np.zeros(int(dur * SR))
for _ in range(int(dur * 0.7)):
at = r.uniform(0, dur - 0.4)
f0 = r.uniform(2200, 4200)
cd = r.uniform(0.06, 0.16)
chirp = np.sin(2*np.pi*np.cumsum(np.linspace(f0, f0*r.uniform(0.8,1.4), int(cd*SR)))/SR)
chirp *= np.hanning(len(chirp)) * r.uniform(0.05, 0.12)
p = int(at * SR); birds[p:p+len(chirp)] += chirp
body = mix(pan(wind, -0.2), pan(traf, 0.1), pan(birds, r.uniform(-0.5, 0.5)))
return seamless(body, XF)
def amb_street_night(name):
r = rng(name)
dur = AMB_LEN + XF
hum = lp1(noise(dur, r, "pink"), 200) * 0.4 + osc(60, dur, "sine") * 0.02
# crickets: rhythmic AM high tone
base = osc(4500, dur, "sine")
trill = (np.sin(2*np.pi*22*tarr(dur)) > 0.3).astype(float)
gate = (0.5 + 0.5*np.sin(2*np.pi*0.7*tarr(dur))) > 0.55
crickets = base * trill * gate * 0.04
crickets = bandpass(crickets, 3500, 6000)
body = mix(pan(hum, 0), pan(crickets, 0.3), pan(bandpass(noise(dur, r), 200, 500)*0.1, -0.3))
return seamless(body, XF)
def amb_rain(name):
r = rng(name)
dur = AMB_LEN + XF
base = bandpass(noise(dur, r), 800, 9000) * 0.5 # hiss
low = lp1(noise(dur, r), 400) * 0.25 # rumble
drops = np.zeros(int(dur * SR))
for _ in range(int(dur * 12)):
at = r.uniform(0, dur - 0.05); f = r.uniform(1500, 5000)
d = np.sin(2*np.pi*f*tarr(0.02)) * np.exp(-np.linspace(0, 30, int(0.02*SR))) * r.uniform(0.05, 0.15)
p = int(at*SR); drops[p:p+len(d)] += d
body = mix(pan(base, 0), pan(low, 0), pan(drops, r.uniform(-0.4, 0.4)))
return seamless(body, XF)
def _roomtone(name, hum_f, hiss_lo, hiss_hi, hiss_g, extra=None):
r = rng(name)
dur = AMB_LEN + XF
hum = (osc(hum_f, dur, "sine")*0.5 + osc(hum_f*2, dur, "sine")*0.2) * 0.06
hiss = bandpass(noise(dur, r), hiss_lo, hiss_hi) * hiss_g
layers = [pan(hum, 0), pan(hiss, 0)]
if extra is not None:
layers.append(pan(extra(r, dur), 0.2))
return seamless(mix(*layers), XF)
def amb_roomtone_retail(name):
return _roomtone(name, 100, 200, 2000, 0.05)
def amb_roomtone_milkbar(name):
# fridge compressor buzz
return _roomtone(name, 120, 300, 1500, 0.05,
extra=lambda r, d: softclip(osc(120, d, "tri")*0.4, 2)*0.05)
def amb_roomtone_video(name):
# faint CRT flyback whine (~15.7 kHz) + hum
return _roomtone(name, 60, 400, 3000, 0.03,
extra=lambda r, d: osc(15734, d, "sine")*0.012)
# ─────────────────────────────────────────────────────────────────────────────
# SFX generators (short, dry, peak-normalized)
# ─────────────────────────────────────────────────────────────────────────────
def _bell(midi, dur, partials, decay):
f = midi_hz(midi)
x = np.zeros(int(dur * SR))
for mult, g in partials:
x[:] += g * np.sin(2*np.pi*f*mult*tarr(dur))
return x * np.exp(-np.linspace(0, decay, len(x)))
def sfx_doorbell(name):
ding = _bell(76, 0.9, [(1, 1), (2.76, 0.4), (5.4, 0.15)], 5) # E5
dong = _bell(72, 1.1, [(1, 1), (2.76, 0.4), (5.4, 0.15)], 5) # C5
x = np.concatenate([ding[:int(0.45*SR)], dong])
return peaknorm(fade(x, 0.002, 0.05), -3)
def sfx_door_open(name):
r = rng(name)
latch = noise(0.03, r) * np.exp(-np.linspace(0, 30, int(0.03*SR)))
latch = bandpass(latch, 800, 5000)
creak = bandpass(noise(0.4, r), 300, 1200)
creak *= (0.3 + 0.7*np.abs(np.sin(2*np.pi*7*tarr(0.4)))) * np.linspace(1, 0, int(0.4*SR)) * 0.4
x = np.concatenate([latch, np.zeros(int(0.05*SR)), creak])
return peaknorm(fade(x, 0.002, 0.03), -4)
def sfx_till(name):
# pleasant buy-confirm: two-note bell arpeggio + tiny mechanical "cha"
r = rng(name)
n1 = _bell(72, 0.5, [(1, 1), (2.0, 0.3), (3.01, 0.2)], 6)
n2 = _bell(79, 0.7, [(1, 1), (2.0, 0.3), (3.01, 0.2)], 6)
cha = bandpass(noise(0.04, r), 2000, 8000) * np.exp(-np.linspace(0, 25, int(0.04*SR))) * 0.5
x = mix(np.concatenate([cha, np.zeros(int(0.6*SR))]),
np.concatenate([n1[:int(0.12*SR)], n2]))
return peaknorm(fade(x, 0.002, 0.05), -3)
def sfx_riffle(name):
# record-bin dig: a run of soft filtered noise "flips"
r = rng(name)
out = np.zeros(int(0.9 * SR))
tpos = 0.02
while tpos < 0.85:
flip = bandpass(noise(0.05, r), 500, 4000) * np.exp(-np.linspace(0, 22, int(0.05*SR)))
flip *= r.uniform(0.4, 1.0)
p = int(tpos * SR); out[p:p+len(flip)] += flip
tpos += r.uniform(0.05, 0.11)
return peaknorm(fade(out, 0.002, 0.03), -5)
def sfx_toast(name):
x = np.concatenate([synth_note(84, 0.06, "sine", 0.002, 0.02, 0.3, 0.03, cutoff=SR//2),
synth_note(88, 0.12, "sine", 0.002, 0.03, 0.3, 0.06, cutoff=SR//2)])
return peaknorm(fade(x, 0.002, 0.03), -6)
def sfx_tram_bell(name):
x = _bell(84, 1.2, [(1, 1), (2.4, 0.5), (3.9, 0.25), (5.2, 0.12)], 4)
d2 = np.concatenate([np.zeros(int(0.18*SR)),
_bell(84, 1.0, [(1, 1), (2.4, 0.5), (3.9, 0.25)], 4)])
d2 = np.concatenate([d2, np.zeros(len(x))])[:len(x)] # zero-pad/trim to x
return peaknorm(fade(x + 0.5*d2, 0.002, 0.08), -3)
def sfx_tram_rumble(name):
# loopable continuous low rumble (~3 s) — smooth (no discrete clacks that would collide at
# the loop fold); the wheel-clatter texture is a modulated mid-band noise instead.
r = rng(name); dur = 3.0 + XF
low = lp1(noise(dur, r), 120) * 0.6 + osc(45, dur, "sine") * 0.2
clatter = bandpass(noise(dur, r), 200, 1400)
lfo = 0.4 + 0.6 * (0.5 + 0.5 * np.sin(2 * np.pi * 6 * tarr(dur))) # 6 Hz wheel wobble
body = mix(low, clatter * lfo * 0.18)
return seamless(peaknorm(body, -6), XF)
def _footstep(name, bright, res_f):
r = rng(name)
thump = osc(np.linspace(res_f*1.6, res_f, int(0.05*SR)), 0.05, "sine")
thump *= np.exp(-np.linspace(0, 18, int(0.05*SR)))
click = bandpass(noise(0.04, r), 1500 if bright else 700, 9000 if bright else 4000)
click *= np.exp(-np.linspace(0, 30, int(0.04*SR))) * (0.7 if bright else 0.4)
x = mix(thump, click) * r.uniform(0.8, 1.0)
return peaknorm(fade(x, 0.001, 0.02), -6)
# ─────────────────────────────────────────────────────────────────────────────
# MUSIC beds (instrumental, loopable, seeded, parody-safe originals)
# ─────────────────────────────────────────────────────────────────────────────
def loop_music(body, revmix=0.12, tail=1.2):
"""Seamless music loop: render the reverb ring-out PAST the loop end into `tail` seconds of
silence, then crossfade that genuine ring-out over the head. Result: when the loop restarts,
the decay from the 'previous' pass bleeds into the start no click. (The broken way is to
append a head-copy, which makes tail==head and the crossfade a no-op.)"""
if body.ndim == 1:
body = stereo(body)
T = int(tail * SR)
wet = schroeder_reverb(np.concatenate([body, np.zeros((T, 2))]), revmix)
return seamless(wet, tail)
def music_record_shop(name):
r = rng(name); bpm = 96; bars = 8
spb = 60/bpm; barlen = int(4*spb*SR)
# Am7 - D7 - Gmaj7 - Cmaj7 kind of groove (original voicing), root midis:
prog = [45, 50, 43, 48] * 2 # A, D, G, C (one per bar)
third = [+3, +4, +4, +4]; seventh = [+10, +10, +11, +11]
bass = render_line(sum([[(root, 0.75), (None, 0.25), (root+7, 0.5), (root+12, 0.5),
(root, 1.0), (root+7, 1.0)] for root in prog], []),
bpm, lambda m, d: synth_note(m-12, d, "saw", 0.005, 0.1, 0.5, 0.05, cutoff=700))
chords = []
for i, root in enumerate(prog):
for _ in range(4): # comp on each beat
ch = mix(epiano(root+12, spb*0.9), epiano(root+12+third[i % 4], spb*0.9),
epiano(root+12+seventh[i % 4], spb*0.9))
chords.append(ch * 0.5)
chords = np.concatenate([np.concatenate([c, np.zeros(max(int(spb*SR)-len(c),0))])[:int(spb*SR)] for c in chords])
drums = render_drums({"k": [0, 6, 8], "s": [4, 12], "h": [0, 2, 4, 6, 8, 10, 12, 14]}, bpm, bars, r)
body = mix(pan(bass, 0)*0.9, pan(chords, -0.15)*0.7, pan(drums, 0.1)*0.6)
return loop_music(body, 0.12)
def music_milkbar(name):
r = rng(name); bpm = 108; bars = 8
spb = 60/bpm
prog = [48, 55, 53, 50] # C G F D-ish bright pop (original)
bass = render_line(sum([[(root-12, 1.0), (root-12, 1.0), (root-5, 1.0), (root-12, 1.0)] for root in prog]*2, []),
bpm, lambda m, d: synth_note(m, d, "tri", 0.005, 0.1, 0.6, 0.05, cutoff=900))
lead = render_line([(r.choice([root, root+4, root+7, root+12]), 0.5) for root in prog for _ in range(8)],
bpm, lambda m, d: synth_note(m+12, d, "square", 0.005, 0.05, 0.4, 0.05, cutoff=3000))
drums = render_drums({"k": [0, 8], "s": [4, 12], "h": [0, 2, 4, 6, 8, 10, 12, 14], "o": [14]}, bpm, bars, r)
body = mix(pan(bass, 0)*0.9, pan(lead, 0.2)*0.35, pan(drums, 0)*0.55)
return loop_music(body, 0.1)
def music_video_synth(name):
r = rng(name); bpm = 84; bars = 8
spb = 60/bpm
prog = [45, 45, 50, 43] # moody synthwave (original)
pads = np.concatenate([mix(pad(root, 4*spb), pad(root+7, 4*spb), pad(root+10, 4*spb))[:int(4*spb*SR)]
for root in prog]*2)
arp_notes = []
for root in prog*2:
for s in [0, 7, 12, 7]:
arp_notes.append((root+12+s, 0.5))
arp = render_line(arp_notes, bpm, lambda m, d: synth_note(m, d, "pulse25", 0.002, 0.05, 0.3, 0.05, cutoff=2600))
bass = render_line(sum([[(root-12, 2.0), (root-12, 2.0)] for root in prog]*2, []),
bpm, lambda m, d: synth_note(m, d, "saw", 0.01, 0.1, 0.7, 0.1, cutoff=500))
drums = render_drums({"k": [0, 8], "s": [8], "h": [2, 6, 10, 14]}, bpm, bars, r)
body = mix(pan(pads, 0)*0.5, pan(arp, 0.3)*0.3, pan(bass, 0)*0.8, pan(drums, 0)*0.4)
return loop_music(body, 0.2)
def music_arcade(name):
r = rng(name); bpm = 132; bars = 8
spb = 60/bpm
prog = [48, 48, 53, 55] # upbeat chiptune (original)
lead_scale = [0, 2, 4, 7, 9, 12]
lead = render_line([(root+12+r.choice(lead_scale), 0.25) for root in prog for _ in range(16)],
bpm, lambda m, d: synth_note(m, d, "square", 0.001, 0.02, 0.5, 0.02, cutoff=SR//2))
bass = render_line(sum([[(root-12, 0.5), (root, 0.5)]*4 for root in prog]*2, []),
bpm, lambda m, d: synth_note(m, d, "pulse25", 0.002, 0.03, 0.5, 0.02, cutoff=SR//2))
hats = render_drums({"h": list(range(0, 16, 2)), "k": [0, 4, 8, 12], "s": [4, 12]}, bpm, bars, r)
body = mix(pan(lead, 0.1)*0.4, pan(bass, -0.1)*0.5, pan(hats, 0)*0.4)
return loop_music(body, 0.03)
# ─────────────────────────────────────────────────────────────────────────────
# the pack: name -> (category, generator, {loop, gain, note})
# ─────────────────────────────────────────────────────────────────────────────
PACK = {
# ambience (loop, quiet beds)
"ambience-street-day": ("ambience", amb_street_day, dict(loop=True, gain=0.5)),
"ambience-street-night": ("ambience", amb_street_night, dict(loop=True, gain=0.5)),
"ambience-rain": ("ambience", amb_rain, dict(loop=True, gain=0.45, note="layer over street beds")),
"roomtone-retail": ("ambience", amb_roomtone_retail, dict(loop=True, gain=0.35)),
"roomtone-milkbar": ("ambience", amb_roomtone_milkbar, dict(loop=True, gain=0.35)),
"roomtone-video": ("ambience", amb_roomtone_video, dict(loop=True, gain=0.3)),
# sfx
"sfx-doorbell": ("sfx", sfx_doorbell, dict(loop=False, gain=0.7)),
"sfx-door-open": ("sfx", sfx_door_open, dict(loop=False, gain=0.6)),
"sfx-till": ("sfx", sfx_till, dict(loop=False, gain=0.7)),
"sfx-riffle": ("sfx", sfx_riffle, dict(loop=False, gain=0.6)),
"sfx-toast": ("sfx", sfx_toast, dict(loop=False, gain=0.5)),
"sfx-tram-bell": ("sfx", sfx_tram_bell, dict(loop=False, gain=0.7)),
"sfx-tram-rumble":("sfx", sfx_tram_rumble,dict(loop=True, gain=0.5)),
# footsteps (2 surfaces x 3 variants)
"step-pavement-1": ("sfx", lambda n: _footstep(n, True, 90), dict(loop=False, gain=0.4)),
"step-pavement-2": ("sfx", lambda n: _footstep(n, True, 95), dict(loop=False, gain=0.4)),
"step-pavement-3": ("sfx", lambda n: _footstep(n, True, 85), dict(loop=False, gain=0.4)),
"step-timber-1": ("sfx", lambda n: _footstep(n, False, 70), dict(loop=False, gain=0.4)),
"step-timber-2": ("sfx", lambda n: _footstep(n, False, 75), dict(loop=False, gain=0.4)),
"step-timber-3": ("sfx", lambda n: _footstep(n, False, 65), dict(loop=False, gain=0.4)),
# music beds (loop, instrumental originals)
"music-record-shop": ("music", music_record_shop, dict(loop=True, gain=0.55, note="record shop")),
"music-milkbar": ("music", music_milkbar, dict(loop=True, gain=0.5, note="milk bar radio")),
"music-video-synth": ("music", music_video_synth, dict(loop=True, gain=0.5, note="video store")),
"music-arcade": ("music", music_arcade, dict(loop=True, gain=0.5, note="arcade")),
}
def write_wav(path, x):
if x.ndim == 1:
x = stereo(x)
peak = np.max(np.abs(x))
if peak > 0.95: # limiter: never clip (loudnorm sets final loudness)
x = x * (0.95 / peak)
x = np.clip(x, -1.0, 1.0)
pcm = (x * 32767).astype("<i2")
with wave.open(path, "wb") as w:
w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR)
w.writeframes(pcm.tobytes())
def encode(wav, base, category):
"""WAV -> OGG/Opus (+ M4A/AAC). Long beds/music get loudnorm; SFX are pre-peak-normalized."""
ogg = os.path.join(OUT, base + ".ogg")
m4a = os.path.join(OUT, base + ".m4a")
af = ["-af", "loudnorm=I=-16:TP=-1.5:LRA=11"] if category in ("ambience", "music") else []
br = "96k" if category == "music" else ("80k" if category == "ambience" else "64k")
subprocess.run([FF, "-y", "-i", wav, *af, "-c:a", "libopus", "-b:a", br, "-ar", "48000", ogg],
check=True, capture_output=True)
subprocess.run([FF, "-y", "-i", wav, *af, "-c:a", "aac", "-b:a", "128k", "-ar", "44100", m4a],
check=True, capture_output=True)
return os.path.getsize(ogg), os.path.getsize(m4a)
if __name__ == "__main__":
only = sys.argv[sys.argv.index("--only") + 1] if "--only" in sys.argv else ""
wav_only = "--wav-only" in sys.argv
todo = {k: v for k, v in PACK.items() if not only or only in k or only == v[0]}
if "--dry-run" in sys.argv:
print(f"{len(todo)} audio assets (procedural synth, $0):")
by = {}
for k, (cat, _, meta) in sorted(todo.items()):
by.setdefault(cat, []).append(k)
for cat, ks in by.items():
print(f" [{cat}] {len(ks)}: {', '.join(ks)}")
sys.exit(0)
print(f"rendering {len(todo)} assets @ {SR}Hz (numpy synth -> ffmpeg opus+aac)")
total_ogg = total_m4a = 0
for k, (cat, gen, meta) in todo.items():
wav = os.path.join(RAW, k + ".wav")
x = gen(k)
write_wav(wav, x)
dur = (len(x) / SR)
if wav_only:
print(f" [{cat}] {k} {dur:.1f}s (wav only)")
continue
og, m4 = encode(wav, k, cat)
total_ogg += og; total_m4a += m4
print(f" [{cat}] {k:22s} {dur:5.1f}s ogg={og//1024:>4}KB m4a={m4//1024:>4}KB")
if not wav_only:
print(f"\ntotal shipped: ogg {total_ogg/1e6:.2f}MB + m4a {total_m4a/1e6:.2f}MB "
f"= {(total_ogg+total_m4a)/1e6:.2f}MB (budget 25MB)")

View File

@ -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:

View File

@ -462,6 +462,104 @@ def smoke_tram(p):
finally:
b.close()
def smoke_audio(p):
"""R11 audio house-law (Lane F): silent-and-happy, nothing plays pre-gesture, ?mute=1 silences,
?noassets=1 fetches zero audio, and interior beds play + release AudioNodes across enter/exit.
Each arm uses a fresh Chromium context (standing law a reused tab's module cache burned us in R10)."""
head('SMOKE: audio (R11 — silent-happy · pre-gesture silence · mute · noassets · leak)')
# 1) boot-with-audio: 0 console errors, and nothing plays before a gesture (ctx suspended → state.ready false)
b, pg, errs = new_page(p)
try:
boot(pg, '')
pg.wait_for_function("() => window.PROCITY && window.PROCITY.audio && window.PROCITY.audio.state", timeout=8000)
st = pg.evaluate("() => { const s=window.PROCITY.audio.state; return {ready:!!s.ready, muted:!!s.muted, mode:s.mode}; }")
if not st['ready']:
OK(f"pre-gesture: audio NOT unlocked (state.ready=false, mode={st['mode']}) — silent until first gesture")
else:
FAIL(f"audio unlocked before any user gesture (autoplay-policy violation): {st}")
if errs: FAIL(f"audio boot: {len(errs)} console error(s); first: {errs[0][:140]}")
else: OK("audio boot: 0 console errors (silent-and-happy)")
finally:
b.close()
# 2) ?mute=1 → engine reports muted
b, pg, errs = new_page(p)
try:
boot(pg, 'mute=1')
pg.wait_for_function("() => window.PROCITY && window.PROCITY.audio", timeout=8000)
st = pg.evaluate("() => ({muted:!!window.PROCITY.audio.state.muted, mode:window.PROCITY.audio.state.mode})")
if st['muted']: OK(f"?mute=1: engine muted (mode={st['mode']})")
else: FAIL(f"?mute=1 not honored: {st}")
if errs: FAIL(f"?mute=1: {len(errs)} console error(s)")
finally:
b.close()
# 3) ?noassets=1 → zero audio network fetches (house law: the noassets gate implies no audio)
b, pg, errs = new_page(p)
audio_reqs = []
pg.on('request', lambda r: audio_reqs.append(r.url)
if ('/assets/audio/' in r.url or r.url.endswith('.ogg') or r.url.endswith('.m4a')) else None)
try:
boot(pg, 'noassets=1')
pg.keyboard.press('Space') # a keyboard gesture unlocks the AudioContext WITHOUT a pointer-lock attempt (headless-safe) # a gesture, so the audio path is genuinely exercised
pg.evaluate("""() => { const P=window.PROCITY, D=window.DBG; D.setSegment(2);
const s=(P.plan.shops||[]).find(x=>P.isOpen(x)); if(s) D.enterShop(s.id); }""")
pg.wait_for_timeout(800)
man = pg.evaluate("() => window.PROCITY.audio.state.manifest")
if len(audio_reqs) == 0: OK(f"?noassets=1: 0 audio fetches (manifest={man})")
else: FAIL(f"?noassets=1: {len(audio_reqs)} audio fetch(es) leaked; first {audio_reqs[0]}")
if errs: FAIL(f"?noassets=1 audio: {len(errs)} console error(s)")
finally:
b.close()
# 4) interior beds play + enter/exit is AudioNode-leak-free (live BufferSources stay bounded, not O(N))
b = p.chromium.launch()
pg = b.new_page(viewport={'width': 1280, 'height': 720})
errs = []
pg.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None)
pg.on('pageerror', lambda e: errs.append(str(e)))
pg.add_init_script("""
window.__live = new Set(); window.__made = 0;
const AC = window.AudioContext || window.webkitAudioContext;
if (AC) { const orig = AC.prototype.createBufferSource;
AC.prototype.createBufferSource = function () { const s = orig.call(this);
window.__made++; window.__live.add(s);
const ostop = s.stop.bind(s); s.stop = function (w) { window.__live.delete(s); return ostop(w); };
s.addEventListener('ended', () => window.__live.delete(s)); return s; }; }
""")
try:
boot(pg, '')
pg.keyboard.press('Space') # a keyboard gesture unlocks the AudioContext WITHOUT a pointer-lock attempt (headless-safe) # user gesture → unlock the AudioContext
pg.wait_for_timeout(250)
played = pg.evaluate("""async () => {
const P=window.PROCITY, D=window.DBG; D.setSegment(2);
const types=['record','milkbar','video','opshop','book','toy'];
let entered=0, withMusic=0;
for (let rep=0; rep<2; rep++) for (const t of types) {
const s=(P.plan.shops||[]).find(x=>x.type===t && P.isOpen(x)); if(!s) continue;
D.enterShop(s.id); entered++;
const a = P.interiorMode.current && P.interiorMode.current.audio;
if (a && a.musicKey) withMusic++;
await new Promise(r=>setTimeout(r,120));
D.exitShop && D.exitShop(); await new Promise(r=>setTimeout(r,120));
}
return { entered, withMusic, ready:!!P.audio.state.ready };
}""")
pg.wait_for_timeout(600)
live = pg.evaluate("() => window.__live.size")
made = pg.evaluate("() => window.__made")
if not played['ready']:
WARN(f"audio interior: ctx never unlocked in headless — leak check inconclusive ({played}, live {live}/made {made})")
elif played['entered'] >= 4 and live <= 12:
OK(f"audio interior: {played['entered']} enter/exit, {played['withMusic']} with a music bed; live AudioNodes {live} bounded (made {made}) — leak-free")
else:
FAIL(f"audio interior: {live} live AudioNodes after {played['entered']} enter/exit (made {made}) — possible leak")
if errs: FAIL(f"audio interior: {len(errs)} console error(s); first: {errs[0][:140]}")
else: OK("audio interior: 0 console errors across enter/exit cycles")
finally:
b.close()
def main():
srv = ensure_server()
try:
@ -485,6 +583,7 @@ def main():
smoke_presence(p) # new (warn) — interior presence (browsers)
smoke_shelfbuy(p) # new (warn) — buy-anywhere (book/toy shelves)
smoke_tram(p) # new (warn) — tram (auto-skips if not landed)
smoke_audio(p) # R11 — audio house-law (silent-happy · pre-gesture · mute · noassets · leak)
finally:
if srv: srv.terminate()

View File

@ -112,6 +112,12 @@ head('DETERMINISM — PRNG law (CITY_SPEC: same seed ⇒ identical city)');
}
function grepMathRandom(dir) {
// [Lane F R11] The ban protects world-GENERATION determinism (same seed → same town). Runtime FX
// PLAYBACK that is transient by nature is exempt — the seeded CHOICE lives upstream in prng-driven
// generators, the playback file only renders it. web/js/world/audio.js (Lane B): footstep SFX variant
// + gain jitter (audio.js:143,145) are per-step cosmetics; which shop plays which bed is seeded in
// interiors.js, not here. (Noted to B — they may instead add a `// cosmetic` marker on those lines.)
const RUNTIME_FX_EXEMPT = new Set(['web/js/world/audio.js']);
const hits = [];
(function walk(d) {
if (!existsSync(d)) return;
@ -119,6 +125,7 @@ function grepMathRandom(dir) {
const p = join(d, name);
if (statSync(p).isDirectory()) { if (name !== 'vendor') walk(p); continue; }
if (!p.endsWith('.js') && !p.endsWith('.mjs')) continue;
if (RUNTIME_FX_EXEMPT.has(p.replace(ROOT + '/', ''))) continue; // runtime FX playback, not gen code
const txt = readFileSync(p, 'utf8');
txt.split('\n').forEach((ln, i) => {
// Exemptions must live inside a // comment — anchor the keywords to the comment, otherwise

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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-<x>-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:<file>; skins are local gen/<file>"
"load": "fittings/furniture load from depot as depot:<file>; skins are local gen/<file>",
"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"
}
}
}
}

View File

@ -338,6 +338,13 @@ window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skin
weather: weatherState, // [Lane F R8] Lane B contract: {state,intensity}, {clear,0} when off (Lane D reads this)
wallet, // [Lane F R8] Lane C buy loop v0 (session wallet: cash/buy/count)
THREE, get mode() { return MODE; } }; // [Lane F] bridge + drive hooks
// [Lane B R11 audio] street WebAudio engine — self-unlocking on the first gesture; silent with
// zero/blocked assets or ?mute=1; ?noassets=1 ⇒ no audio fetches. Exposes window.PROCITY.audio so
// Lane F can play interior beds through it (playInterior/stopInterior via Lane C's room.audio).
import('./js/world/audio.js')
.then((m) => { window.PROCITY.audio = m.createAudioEngine(window.PROCITY, { noassets: NOASSETS }); })
.catch((e) => console.warn('[procity] audio engine load failed:', e));
// [Lane B] QA debug harness (window.DBG) — loaded only with ?dbg=1 (LANE_F_NOTES §4). Logic lives
// in js/world/dbg.js; the shell just hands it the systems it owns so shots.py/soak.py can drive it.
if (params.get('dbg')) {

View File

@ -19,6 +19,8 @@
// // stands, in room-local space, facing the customer.
// browsePoints: [ {x,z,ry,atKind,slotIndex} ], // 0..3 seeded floor poses (R9) where Lane D stands
// // browser rigs, facing the goods (same frame + ry as counter.stand)
// audio: { musicKey, toneKey }, // R11 — keys into Lane E manifest.audio (interior music bed +
// // room-tone), seeded per shop. musicKey may be null (no music).
// dims: { W, D, H, archetype, type }, // room metrics
// placement, // deterministic placement summary (deep-equal per seed)
// pathOK, // door→counter connectivity held (always true; carved if needed)
@ -70,6 +72,32 @@ function normalizeShop(shop) {
};
}
// ── interior audio contract (round 11) ───────────────────────────────────────────────
// buildInterior returns room.audio = { musicKey, toneKey } — keys into Lane E's manifest.audio (a music
// bed + an interior room-tone). Lane F's interior_mode plays them on enter / fades on exit; Lane B's
// audio.js resolves key → file. Missing key / ?mute=1 / ?noassets=1 → silence (house audio law), so this
// is pure data: buildInterior stays synchronous and asset-free, it only names what SHOULD play.
// The maps mirror the manifest's per-type `types` arrays so the type→bed grouping has one source of truth.
const ROOMTONE_BY_TYPE = { // manifest.audio.ambience entries with scope:'interior'
milkbar: 'roomtone-milkbar',
video: 'roomtone-video', dept: 'roomtone-video', arcade: 'roomtone-video',
}; // default → 'roomtone-retail'
const MUSIC_BY_TYPE = { // manifest.audio.music dedicated beds
record: 'record-shop', // "record shop plays music" — always on
milkbar: 'milkbar', video: 'video-synth',
dept: 'arcade', arcade: 'arcade',
}; // default → null (no dedicated bed)
function audioFor(type, ctx) {
const toneKey = ROOMTONE_BY_TYPE[type] || 'roomtone-retail';
let musicKey = MUSIC_BY_TYPE[type] || null;
// Shops with no dedicated bed: a seeded ~1-in-3 keeps a general radio on (the milk-bar bed carries the
// manifest "general" tag). Seeded on its own sub-stream so the same shop is silent-or-playing every
// revisit and adding it never shifts another subsystem's seeded picks.
if (!musicKey && ctx.stream('audio')() < 0.34) musicKey = 'milkbar';
return { musicKey, toneKey };
}
export function buildInterior(shop, THREE, opts) {
opts = opts || {}; // tolerate undefined AND explicit null
const t0 = (typeof performance !== 'undefined' ? performance.now() : 0);
@ -122,6 +150,7 @@ export function buildInterior(shop, THREE, opts) {
places: lay.places,
counter: lay.counter, // { mesh, pose:{x,z,ry}, stand:{x,z,ry} } — Lane D keeper spawn pose
browsePoints: lay.browsePoints, // [ {x,z,ry,atKind,slotIndex} ] 0..3 — Lane D browser rig poses (R9)
audio: audioFor(recipe.key, ctx), // { musicKey, toneKey } — Lane E manifest.audio keys (R11), seeded per shop
dims: { ...dims, archetype, type: recipe.key },
recipe: { key: recipe.key, label: recipe.label, counterPos: recipe.counterPos, clutter: recipe.clutter },
placement: lay.placement,

280
web/js/world/audio.js Normal file
View File

@ -0,0 +1,280 @@
// PROCITY Lane B — audio.js (round 11, the audio round)
// The street-side WebAudio engine. ONE AudioContext, unlocked on the first user gesture (the
// pointer-lock click is the natural anchor). Looping beds crossfade off systems that already exist —
// day/night segment, weather, tram proximity, player footsteps — plus shop-door music **spill** and
// interaction SFX. Interior beds belong to Lane F (it calls playInterior/stopInterior with Lane C's
// room.audio contract); this engine just ducks the street while inside.
//
// HOUSE AUDIO LAW (obeyed throughout):
// 1. Silent-and-happy: missing/blocked/failed audio → silence, never an error or console warning.
// 2. Nothing plays before the first gesture; ?mute=1 forces silence; ?noassets=1 → zero fetches.
// 3. Deterministic flavour: what a shop spills is a pure function of its type (seeded upstream).
// 4. ≤25 MB, lazy-loaded; the per-frame update() path allocates nothing (only ramps gains).
//
// Self-contained: reads live state off window.PROCITY and self-ticks via rAF, so the shell only has
// to createAudioEngine(PROCITY) once. Exposes window.PROCITY.audio for Lane F's smokes.
const BASE = 'assets/';
// shop type → street-spill music key (manifest.music). Only these types leak a bed to the footpath;
// others are interior room-tone only. Matches manifest.music[*].types.
const SPILL_MUSIC = { record: 'record-shop', milkbar: 'milkbar', video: 'video-synth', arcade: 'arcade', dept: 'arcade', general: 'milkbar' };
export function createAudioEngine(PROCITY, { noassets = false, mute = false } = {}) {
const params = new URLSearchParams(location.search);
const forcedMute = mute || (params.get('mute') != null && params.get('mute') !== '0');
const NOASSETS = noassets || PROCITY.noassets || (params.get('noassets') != null && params.get('noassets') !== '0');
const AC = window.AudioContext || window.webkitAudioContext;
const state = { ready: false, muted: !!forcedMute, unsupported: !AC, loaded: 0, mode: 'silent', nearestSpill: null };
// Silent surface — still satisfies the public API so F's smokes pass with zero sound.
if (!AC || forcedMute) {
state.mode = forcedMute ? 'muted' : 'unsupported';
return {
state, get ready() { return false; }, get muted() { return true; },
setMasterGain() {}, mute() { state.muted = true; }, unmute() {},
playSfx() {}, footstep() {}, playInterior() {}, stopInterior() {},
update() {}, dispose() {},
};
}
let ctx, master, buses;
try {
ctx = new AC();
master = ctx.createGain(); master.gain.value = 0.9; master.connect(ctx.destination);
buses = { ambience: mk(0.5), music: mk(0.7), sfx: mk(0.95), tram: mk(0.7) };
for (const b of Object.values(buses)) b.connect(master);
} catch { state.mode = 'ctx-failed'; return { state, get ready() { return false; }, get muted() { return true; }, setMasterGain() {}, mute() {}, unmute() {}, playSfx() {}, footstep() {}, playInterior() {}, stopInterior() {}, update() {}, dispose() {} }; }
state.mode = 'live';
function mk(v) { const g = ctx.createGain(); g.gain.value = v; return g; }
function ramp(param, to, sec) {
const t = ctx.currentTime;
param.cancelScheduledValues(t); param.setValueAtTime(param.value, t);
param.linearRampToValueAtTime(to, t + Math.max(0.02, sec));
}
// ── manifest.audio (self-fetched once; honours ?noassets) ──
// Retries a few times: the single-threaded dev server can drop a fetch during the boot burst, and a
// one-shot failure would otherwise mute the town for the whole session. state.manifest is a diag.
let AUDIO = null;
const manifestReady = (async () => {
if (NOASSETS) { state.manifest = 'skipped-noassets'; return null; }
if (PROCITY.manifest?.audio) { AUDIO = PROCITY.manifest.audio; state.manifest = 'ok'; return AUDIO; }
for (let attempt = 0; attempt < 3 && !AUDIO; attempt++) {
try {
const r = await fetch(BASE + 'manifest.json', { cache: 'force-cache' });
if (r.ok) { AUDIO = (await r.json()).audio || null; }
} catch { /* transient — back off and retry */ }
if (!AUDIO) await new Promise((res) => setTimeout(res, 150 * (attempt + 1)));
}
state.manifest = AUDIO ? 'ok' : 'null-audio'; // silent-and-happy either way
return AUDIO;
})();
// ── lazy buffer cache ──
const buffers = new Map(); // key → AudioBuffer | 'loading' | 'failed'
async function load(key, entry) {
if (NOASSETS || !entry) return null;
const cached = buffers.get(key);
if (cached) return (cached === 'loading' || cached === 'failed') ? null : cached;
buffers.set(key, 'loading');
for (const url of [entry.file, entry.fallback]) {
if (!url) continue;
try {
const res = await fetch(BASE + url);
if (!res.ok) continue;
const buf = await ctx.decodeAudioData(await res.arrayBuffer());
buffers.set(key, buf); state.loaded++;
return buf;
} catch { /* try the fallback, else fall through to silence */ }
}
buffers.set(key, 'failed'); // house law: a missing file is silence, not an error
return null;
}
// ── layers ──
// persistent bed: one looping source started once, gain-ramped (day/night/rain never change key).
// swap bed: source is replaced when its key changes (spill / interior music+tone).
function layer(bus) { const g = ctx.createGain(); g.gain.value = 0; g.connect(bus); return { g, src: null, key: null, starting: false }; }
const L = {
day: layer(buses.ambience), night: layer(buses.ambience), rain: layer(buses.ambience),
spill: layer(buses.music), tram: layer(buses.tram),
iMusic: layer(buses.music), iTone: layer(buses.ambience),
};
state.layers = L;
async function persistBed(l, key, entry, target, fade = 1.4) {
if (key && !l.src && !l.starting) {
l.starting = true;
const buf = await load(key, entry);
if (buf) { const s = ctx.createBufferSource(); s.buffer = buf; s.loop = true; s.connect(l.g); s.start(); l.src = s; l.key = key; }
l.starting = false;
}
ramp(l.g.gain, l.src ? target : 0, fade);
}
async function swapBed(l, key, entry, target, fade = 0.8) {
if (l.key !== key) {
if (l.src) { const old = l.src; l.src = null; try { old.stop(ctx.currentTime + 0.35); } catch {} }
l.key = key;
if (key && entry) {
l.starting = true;
const buf = await load('bed:' + key, entry);
if (buf && l.key === key) { const s = ctx.createBufferSource(); s.buffer = buf; s.loop = true; s.connect(l.g); s.start(); l.src = s; }
l.starting = false;
}
}
ramp(l.g.gain, (l.key && l.src) ? target : 0, fade);
}
// ── one-shot SFX ──
async function playSfx(key, { gain = 1 } = {}) {
if (state.muted || NOASSETS || !state.ready) return;
await manifestReady;
const entry = AUDIO?.sfx?.[key]; if (!entry) return;
const buf = await load('sfx:' + key, entry); if (!buf) return;
fire(buf, (entry.gain ?? 0.7) * gain, buses.sfx);
}
async function footstep(surface = 'pavement') {
if (state.muted || NOASSETS || !state.ready) return;
await manifestReady;
const arr = AUDIO?.sfx?.footstep?.[surface] || AUDIO?.sfx?.footstep?.pavement;
if (!arr || !arr.length) return;
const i = Math.floor(Math.random() * arr.length); // transient variant — determinism law is about shop content, not step timing
const buf = await load(`step:${surface}:${i}`, arr[i]); if (!buf) return;
fire(buf, 0.5 + Math.random() * 0.15, buses.sfx);
}
function fire(buf, gain, bus) {
const src = ctx.createBufferSource(); src.buffer = buf;
const vg = ctx.createGain(); vg.gain.value = gain;
src.connect(vg); vg.connect(bus); src.start();
src.onended = () => { try { src.disconnect(); vg.disconnect(); } catch {} };
}
// ── interior beds (Lane F calls these with Lane C's room.audio = {musicKey, toneKey}) ──
async function playInterior(spec) {
if (state.muted || NOASSETS || !spec) return;
await manifestReady;
const mEntry = spec.musicKey && AUDIO?.music?.[spec.musicKey];
const tEntry = spec.toneKey && AUDIO?.ambience?.[spec.toneKey];
swapBed(L.iMusic, spec.musicKey || null, mEntry || null, mEntry?.gain ?? 0.5, 1.0);
swapBed(L.iTone, spec.toneKey || null, tEntry || null, tEntry?.gain ?? 0.35, 1.0);
}
function stopInterior() { ramp(L.iMusic.g.gain, 0, 0.7); ramp(L.iTone.g.gain, 0, 0.7); }
// ── unlock on first gesture (autoplay policy) ──
function unlock() {
if (state.ready || state.muted) return;
ctx.resume().then(() => { state.ready = true; }).catch(() => {});
window.removeEventListener('pointerdown', unlock, true);
window.removeEventListener('keydown', unlock, true);
}
window.addEventListener('pointerdown', unlock, true);
window.addEventListener('keydown', unlock, true);
// ── enterShop → doorbell + door SFX (dispatched by the shell on a door click) ──
const onEnter = () => { playSfx('door-open'); playSfx('doorbell', { gain: 0.9 }); };
window.addEventListener('procity:enterShop', onEnter);
// ── per-frame update (self-ticked; allocates nothing) ──
const shopType = new Map();
(PROCITY.plan?.shops || []).forEach((s) => shopType.set(s.id, s.type));
const shopById = new Map();
(PROCITY.plan?.shops || []).forEach((s) => shopById.set(s.id, s));
const _cam = { x: 0, z: 0 };
const v = { lx: null, lz: null, stepAcc: 0, spillT: 0, tramX: null, tramZ: null, tramRang: false };
const STRIDE = 0.72, SPILL_R = 9, TRAM_R = 55, TRAM_BELL_R = 22;
function update(dt) {
if (!state.ready || state.muted) return;
const P = PROCITY;
const mode = P.mode || 'street';
const street = mode === 'street';
const cam = P.camera?.position; if (!cam) return;
_cam.x = cam.x; _cam.z = cam.z;
const clk = P.lighting?.getClock?.();
const night = !!(clk && clk.night);
// ── ambience day/night beds (street only; ducked to 0 in interior/map) ──
const amb = AUDIO?.ambience;
persistBed(L.day, 'street-day', amb?.['street-day'], street && !night ? (amb?.['street-day']?.gain ?? 0.5) : 0);
persistBed(L.night, 'street-night', amb?.['street-night'], street && night ? (amb?.['street-night']?.gain ?? 0.5) : 0);
// ── rain layer (gain follows weather intensity; only when it's actually raining) ──
const w = P.weather || { state: 'clear', intensity: 0 };
const rainT = (street && w.state === 'rain') ? (amb?.rain?.gain ?? 0.45) * Math.max(0.15, w.intensity) : 0;
persistBed(L.rain, 'rain', amb?.rain, rainT);
// ── footsteps (player movement on the street, while pointer-locked) ──
if (street && P.player?.isLocked && v.lx != null) {
const d = Math.hypot(_cam.x - v.lx, _cam.z - v.lz);
if (d > 0.0005) {
v.stepAcc += d;
const stride = P.player.isRunning ? STRIDE * 1.15 : STRIDE;
if (v.stepAcc >= stride) { v.stepAcc = 0; footstep('pavement'); }
}
} else { v.stepAcc = 0; }
v.lx = _cam.x; v.lz = _cam.z;
// ── shop-door music spill (throttled scan of nearby live doors) ──
v.spillT -= dt;
if (street && v.spillT <= 0) {
v.spillT = 0.2;
let bestKey = null, bestGain = 0, bestId = null;
const doorMeshes = P.chunks?.getDoorMeshes?.() || [];
for (let m = 0; m < doorMeshes.length; m++) {
const rects = doorMeshes[m].userData?.doorRects; if (!rects) continue;
for (let i = 0; i < rects.length; i++) {
const r = rects[i];
const dist = Math.hypot(r.x - _cam.x, r.z - _cam.z);
if (dist > SPILL_R) continue;
const key = SPILL_MUSIC[shopType.get(r.shopId)]; if (!key) continue;
const shop = shopById.get(r.shopId);
if (P.isOpen && shop && !P.isOpen(shop)) continue; // only open shops spill (Lane F §3.5 hours)
const g = (AUDIO?.music?.[key]?.gain ?? 0.5) * 0.5 * ((SPILL_R - dist) / SPILL_R);
if (g > bestGain) { bestGain = g; bestKey = key; bestId = r.shopId; }
}
}
state.nearestSpill = bestId;
swapBed(L.spill, bestKey, bestKey ? AUDIO?.music?.[bestKey] : null, bestGain, 0.6);
} else if (!street && L.spill.key) {
ramp(L.spill.g.gain, 0, 0.4);
}
// ── tram rumble (distance-gain) + bell (rings once as it settles at a stop nearby) ──
const tramG = P.scene?.getObjectByName?.('tram');
if (street && tramG) {
const p = tramG.position, dist = Math.hypot(p.x - _cam.x, p.z - _cam.z);
const rumbleT = dist < TRAM_R ? (AUDIO?.sfx?.['tram-rumble']?.gain ?? 0.5) * ((TRAM_R - dist) / TRAM_R) : 0;
persistBed(L.tram, 'tram-rumble', AUDIO?.sfx?.['tram-rumble'], rumbleT, 0.5);
// bell: near + (nearly) stationary (dwelling at a stop) → one ring per dwell
const moved = v.tramX == null ? 1 : Math.hypot(p.x - v.tramX, p.z - v.tramZ);
if (dist < TRAM_BELL_R && moved < 0.02) { if (!v.tramRang) { playSfx('tram-bell'); v.tramRang = true; } }
else if (moved > 0.1) v.tramRang = false;
v.tramX = p.x; v.tramZ = p.z;
} else if (L.tram.key) { ramp(L.tram.g.gain, 0, 0.5); }
}
// self-tick
let raf = 0, lastT = 0;
function tick(now) { raf = requestAnimationFrame(tick); const dt = lastT ? Math.min((now - lastT) / 1000, 0.1) : 0.016; lastT = now; try { update(dt); } catch {} }
raf = requestAnimationFrame(tick);
// ── public surface ──
function dispose() {
cancelAnimationFrame(raf);
window.removeEventListener('pointerdown', unlock, true);
window.removeEventListener('keydown', unlock, true);
window.removeEventListener('procity:enterShop', onEnter);
try { ctx.close(); } catch {}
}
return {
state,
get ready() { return state.ready; },
get muted() { return state.muted; },
setMasterGain(v2) { ramp(master.gain, Math.max(0, Math.min(1, v2)), 0.1); },
mute() { state.muted = true; ramp(master.gain, 0, 0.15); },
unmute() { state.muted = false; ramp(master.gain, 0.9, 0.2); },
playSfx, footstep, playInterior, stopInterior, update, dispose,
};
}

View File

@ -178,6 +178,9 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
current = buildInterior(shop, THREE, { useGLB, stockAdapter: currentAdapter }); // static room sleeves
currentShop = shop;
scene.add(current.group);
// [Lane F R11 audio] fade in this room's seeded bed (Lane C room.audio {musicKey,toneKey} → Lane B engine).
// Guarded + no-op when muted / ?noassets / pre-gesture / engine still async-loading — silent-and-happy.
window.PROCITY.audio && window.PROCITY.audio.playInterior(current.audio);
// Lane D keeper at the counter — Lane C tags the stand pose on the counter interactable
const counter = current.places.find((p) => p.userData && p.userData.keeperStand);
if (counter) {
@ -252,6 +255,7 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null
// exit: dispose the room (frees GPU + removes group), restore the player to the street door.
function exit() {
if (!current) return;
window.PROCITY.audio && window.PROCITY.audio.stopInterior(); // [Lane F R11 audio] fade the interior bed out (0.7s)
if (dig && dig.active) dig.close(); // [F2] close an open riffle before leaving (frees its per-open GPU)
keepers.disposeAll(); // Lane D: free the keeper actor(s) before the room
current.dispose();