diff --git a/PROJECT.md b/PROJECT.md index 10aa77f..88cc577 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -452,6 +452,19 @@ followed you onto the silent menu. it, and ducks both court beds. `ambient()` and `zombieMusic()` un-duck themselves for the same reason — the SECOND run would otherwise have been played in the silence the menu left them in. +**A BED MUST RESUME THE CONTEXT WHEN IT FINISHES LOADING, NOT ONLY WHEN IT IS ASKED FOR.** The +second half of the report — *"sometimes, when initially entering the game and tapping somewhere, +the background music does not start; I suspect the sound is not yet loaded"* — and he had it +exactly right. The tap unlocks the context and starts a 768 KB fetch; the `src.start()` that +follows happens seconds later inside a promise, with no gesture anywhere near it. iOS suspends an +idle context in that gap, and **a source started into a suspended context is silent forever** — +nothing on the splash calls `audio()` again, so the theme simply never arrived. `loopBed` now calls +`audio()` a second time in its `.then`, which is allowed because the first tap already unlocked the +context. That is why it was intermittent: it only bites when the decode outlasts the browser's +patience, i.e. a phone on a cold cache. A `visibilitychange` resume covers the same trap from the +other end (a phone that locks ON THE MENU came back silent; during play every pock resumes it for +free, but a title screen makes no sound of its own). + **...and each of those un-ducks ITS OWN BED ONLY.** The first cut had `ambient()` un-duck both court beds together, which put the splatter music back on in classic tennis for the rest of the session — Oliver caught it on the retest: *"after playing the splatter gore, the zombie background diff --git a/index.html b/index.html index 1187c84..68ed74a 100644 --- a/index.html +++ b/index.html @@ -564,6 +564,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/audio.js b/src/audio.js index 59353b6..17b6a52 100644 --- a/src/audio.js +++ b/src/audio.js @@ -145,6 +145,16 @@ function loopBed(file, lowcut, vol, loopEnd = 0) { .then(r => { if (!r.ok) throw 0; return r.arrayBuffer(); }) .then(ab => ac.decodeAudioData(ab)) .then(buf => { + // RESUME AGAIN, HERE. The gesture that unlocked the context is long gone by now: the menu + // theme is 768 KB of AAC and a phone can spend seconds fetching and decoding it, and iOS + // suspends an idle context in the meantime. A source started into a suspended context is + // silent FOREVER — nothing on the splash ever calls audio() again, so the theme simply never + // arrived (Oliver, 2026-08-15: "sometimes, when initially entering the game and tapping + // somewhere, the background music does not start — I suspect the sound is not yet loaded"). + // It is allowed: the context was already unlocked by that first tap, so a later resume() is + // no longer gated. This is why it was intermittent — it only bites when the decode outlasts + // the browser's patience, which is a phone, a cold cache and a slow radio. + audio(); const src = ac.createBufferSource(); src.buffer = buf; src.loop = true; // loopEnd = the source's TRUE length, for a bed that is compressed. AAC pads the last packet, // and Chrome hands back a buffer ~17 ms longer than the master — silence spliced in at every @@ -217,10 +227,18 @@ export const menuDuck = out => fadeBed('background-music.m4a', out); // BOTH court beds as one gesture, because "leaving the court" is one event and the two of them // have to move together — the menu was silent AND noisy at the same time when only one did. const courtDuck = out => { fadeBed('tennis-ambient-loop.wav', out); zombieDuck(out); }; +// THE SAME TRAP FROM THE OTHER END: iOS suspends the context while the app is in the +// background, so a phone that locks on the menu comes back silent. During PLAY this fixes itself — +// every pock and every umpire line goes through audio(), which resumes — but a title screen makes +// no sound of its own and nothing wakes it until you tap something. Guarded on AC already existing, +// so this can never CREATE a context outside a gesture and leave a suspended one lying around. +if (typeof document !== 'undefined') + document.addEventListener('visibilitychange', () => { if (!document.hidden && AC) audio(); }); + // Test surface (test.js), same idea as net.js's netTest. `ducked` is the INTENT — the level a bed // is at, or will start at if it is still loading — and it is the whole of what the menu-silence bug -// was: a flag nothing ever cleared. Nothing in the game reads this. -export const bedTest = { ducked }; +// was: a flag nothing ever cleared. Nothing in the game reads any of this. +export const bedTest = { ducked, beds, ac: () => AC }; let sysVoice = null; function say(text, delay = 0) { // fallback when a recorded umpire line is missing diff --git a/src/render.js b/src/render.js index 540185d..3070cf9 100644 --- a/src/render.js +++ b/src/render.js @@ -221,7 +221,7 @@ function farB(z) { // Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the // menu screens hang tags on the same files, and two copies of this number is one copy too // many: the one that does not get bumped is the one that serves a cached 404. -export const SPRV = 131; +export const SPRV = 132; // The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was // already requested at import — it just makes the wait visible and holds the menu back until the // last sheet has landed. See the onerror note below for the other half of "sometimes missing".