A bed that finishes loading wakes the context it was started for

Oliver's hunch was right: the tap unlocks audio and starts a 768 KB fetch, but
the source starts seconds later inside a promise with no gesture near it. iOS
suspends an idle context in that gap, and a source started into a suspended
context is silent for good — nothing on the splash ever asks for audio again.
Resume once more when the buffer lands, which the first tap has already
authorised, and again when the app comes back to the foreground.

Intermittent because it only bites when the decode outlasts the browser's
patience: a phone, a cold cache, a slow radio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-15 15:16:41 +02:00
1 parent 3b639e743b
commit 1daa3e9d6a
4 files changed
+35 -4

No files matched your search

+13
View File
@@ -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
+1 -1
View File
@@ -564,6 +564,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div>
</div>
</div>
<script type="module" src="src/main.js?v=263"></script>
<script type="module" src="src/main.js?v=264"></script>
</body>
</html>
+20 -2
View File
@@ -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
+1 -1
View File
@@ -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 <img> 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".