PROCITY/web/js/world/audio.js
m3ultra eee60e05a5 Lane B (Streetscape) round 14: v3.0 release — alpha-alias retirement + tour audit + queue_night hero (?gigs=1)
Release round: no new systems, no rng, goldens frozen (0x3fa36874 / gig 0x1f636349 both unmoved).
Files: audio.js, venue.js, dbg.js (+ LANE_B_NOTES). Verified fresh Chromium, seed 20261990.

- Alpha-alias readers dropped (debt #1, B first): audio.js venueGigState() loses the alias hop
  (g.venueShopId===id -> g.state); byVenue + clock fallback only. venue.js stateGetter() loses the
  bare-string arm (F passes gigState.byVenue since R13, confirmed index.html:379); seed = update(null).
  Verified live: byVenue {116:on,404:on,490:quiet} at NIGHT drives the marquee with no string arm;
  audio resolves gig-<genreKey> per venue with no alias hop. F is UNBLOCKED to delete the getters.
- Bookmark/tour audit under ?gigs=1: all 19 bookmarks resolve, frame un-letterboxed, 0 page errors,
  <=300 draws. District venue-conversions hijacked none (night_neon->Video Regal, dig_real->Retro
  Groove). No pose fixes needed. (Orphan-chunk teleport inflates tris across sequential shots -- fresh
  counts <=153k; tram_stop's real ~293k is close-range Lane D GLB ped rigs, framing fine, not a venue block.)
- queue_night bookmark added (dbg.js): John's release hero -- close 3/4 on the pub door at seg 5
  ('on'), biased to the queue side; new poseForVenueQueue() recomputes venue.js frontage geometry.
  Frames marquee + window glow + streetlamp pools + the queue (spawned live by the shell loop; F's
  gig_shot.py drives it live -- verified by manually spawning D's VenueQueue: 2 punters at the door).
- Poster-repetition call (debt #4): FINE, no ask to E -- 2 gigs tonight => 2 distinct designs
  (screenprint/Screaming Utes x7, xerox/The Dead Ringers x7); variety scales with venues playing.
- Town-wide streetlamp pools: v3.1 candidate per Fable ruling (venue-scoped for v3.0; no code).

-> Lane F: cross-lane alias sweep is CLEAN (no A/C/D/E reads it); only your own 20 tool lines in
   flags_check.py + gig_shot.py remain -- migrate to *Of(id)/venueShopIds[0] when you delete the
   gig_state.js:141-151 getters + the CITY_SPEC alias line (with A).
-> Lane A (now gates John's hero-shot poster): buildPosters item 1 still seats each venue's own
   frontage poster on local -Z (back wall, 15.9m from the +Z street facade). Flip to +sin,+cos and it
   seats by the door -- in the existing queue_night frame, no pose change. Per "don't touch world
   code," B did not work around it. Re-flagged from R13.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:43:22 +10:00

337 lines
17 KiB
JavaScript

// 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.
import { gigKeyFor } from '../citygen/gigs.js'; // debt #1: the ONE genre→bed mapping (gig-<genreKey>)
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),
};
// gig spill (round 12): the venue's live bed leaking THROUGH THE WALL — low-pass filtered (muffled
// bass/thump through the bricks) so it reads distinct from an open shop-door spill, and reaching
// further down the street. B owns the street side of the gig audio seam.
const gigLowpass = ctx.createBiquadFilter();
gigLowpass.type = 'lowpass'; gigLowpass.frequency.value = 470; gigLowpass.Q.value = 0.6;
gigLowpass.connect(buses.music);
L.gigSpill = layer(gigLowpass);
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, GIG_SPILL_R = 26;
// the district's venues (round 13), if the gig layer is present (?gigs); each lot carries the coords
// and each shop its genreKey ⇒ gigKeyFor(genreKey) is the bed. Empty flags-off ⇒ no gig spill.
const venues = (PROCITY.plan?.shops || [])
.filter((s) => s.venue)
.map((s) => ({ shop: s, lot: (PROCITY.plan?.lots || []).find((l) => l.id === s.lot), genreKey: s.genreKey }))
.filter((v) => v.lot);
if (!venues.length) { // R12 fallback: the pub is the sole venue if A didn't flag venues
const pub = (PROCITY.plan?.shops || []).find((s) => s.type === 'pub');
const lot = pub && (PROCITY.plan?.lots || []).find((l) => l.id === pub.lot);
if (lot) venues.push({ shop: pub, lot, genreKey: pub.genreKey || 'pubrock' });
}
// Per-venue gig state for the muffled spill (R14: the R12 alpha alias hop is retired):
// 1. F's per-venue map window.PROCITY.gigs.byVenue[id] (the canonical surface since R13)
// 2. clock fallback plan.gigs (night 0) vs the current segment (works before F wires / headless)
// Returns null only when there is no gig layer at all (flags-off ⇒ byte-identical, no spill).
function venueGigState(shopId) {
const g = PROCITY.gigs;
if (g && g.byVenue) { const e = g.byVenue[shopId]; if (e) return typeof e === 'string' ? e : (e.state || 'quiet'); }
const gigs = PROCITY.plan?.gigs; if (!gigs || !gigs.length) return null;
const ton = gigs.find((x) => x.night === 0 && x.venueShopId === shopId);
if (!ton) return 'quiet'; // dark tonight — reads true
const clk = PROCITY.lighting?.getClock?.(); if (!clk) return 'quiet';
const s = ton.startSeg ?? 5, e = ton.endSeg ?? 5;
if (clk.seg >= s && clk.seg <= e) return 'on';
if (clk.seg === (s - 1 + 6) % 6) return 'doors';
return 'quiet';
}
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);
}
// ── muffled gig spill through the nearest active venue's wall (round 13, the district) — a low-pass
// bed PER GENRE (gig-<genreKey>), reaching further than a door spill; the loudest (nearest, at
// doors/on) venue wins, so walking pub→band_room→RSL crossfades the genre. Byte-identical off. ──
if (street && venues.length && P.plan?.gigs?.length) {
let bestGain = 0, bestKey = null, bestState = null;
for (let i = 0; i < venues.length; i++) {
const vn = venues[i];
const gs = venueGigState(vn.shop.id);
if (gs !== 'on' && gs !== 'doors') continue;
const dist = Math.hypot(vn.lot.x - _cam.x, vn.lot.z - _cam.z);
if (dist >= GIG_SPILL_R) continue;
const key = gigKeyFor(vn.genreKey || 'pubrock');
const gg = (AUDIO?.music?.[key]?.gain ?? 0.6) * 0.55 * ((GIG_SPILL_R - dist) / GIG_SPILL_R);
if (gg > bestGain) { bestGain = gg; bestKey = key; bestState = gs; }
}
state.gig = bestState;
swapBed(L.gigSpill, bestGain > 0 ? bestKey : null, bestGain > 0 ? AUDIO?.music?.[bestKey] : null, bestGain, 0.9);
} else if (L.gigSpill.key) { ramp(L.gigSpill.g.gain, 0, 0.6); }
// ── 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,
};
}