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>
This commit is contained in:
parent
f7dd44f55a
commit
8c275c711b
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user