From f7dd44f55aa5f28561b89c1c435063be6f5caa8a Mon Sep 17 00:00:00 2001 From: m3ultra Date: Wed, 15 Jul 2026 16:00:31 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20B=20(Streetscape):=20round-11=20audio=20?= =?UTF-8?q?=E2=80=94=20WebAudio=20engine=20(street=20side)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- B-progress.md | 19 +++ docs/LANES/LANE_B_NOTES.md | 34 +++++ web/index.html | 7 + web/js/world/audio.js | 280 +++++++++++++++++++++++++++++++++++++ 4 files changed, 340 insertions(+) create mode 100644 web/js/world/audio.js diff --git a/B-progress.md b/B-progress.md index 748f244..0c9db14 100644 --- a/B-progress.md +++ b/B-progress.md @@ -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 diff --git a/docs/LANES/LANE_B_NOTES.md b/docs/LANES/LANE_B_NOTES.md index eee9376..81558f1 100644 --- a/docs/LANES/LANE_B_NOTES.md +++ b/docs/LANES/LANE_B_NOTES.md @@ -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. diff --git a/web/index.html b/web/index.html index 0da89f3..7016115 100644 --- a/web/index.html +++ b/web/index.html @@ -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')) { diff --git a/web/js/world/audio.js b/web/js/world/audio.js new file mode 100644 index 0000000..b0e7e46 --- /dev/null +++ b/web/js/world/audio.js @@ -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, + }; +}