The umpire is one man with one microphone, and voiceSeq forgot it

"the drill mode immediately goes into some horrible audio loop" — on a phone,
never on desktop, and that is the tell. Three of the four defects can only
happen on a phone, and every one of them is a rule voice() had followed for
months that my new function quietly broke.

- A SUSPENDED AudioContext HAS A FROZEN CLOCK. Scheduling four clips at
  currentTime +0/+0.9/+1.7/+2.4 while the context is suspended puts all of
  them in the past by the time it starts, so they fire on one instant through
  a 1.3s reverb tail. Resume first, re-read the clock after the decode.
- A second call layered instead of replacing. voiceSeq SET voiceUntil and
  never checked it. It stops the outstanding list now.
- ...and cancelling a list has to give back the time it booked, or the
  replacement queues behind a list that will never play. Measured at 6.6s of
  silence before the call the player is waiting on.
- The amplifier was in drill.js: the empty-court net redealt on the spot,
  making the repair rate the frame rate — and every deal calls the umpire. It
  goes through the ordinary between-rounds beat now.

Verified by tagging each decoded buffer with its source file and counting
start() calls: one drill_call, three colours, spaced, zero overlapping voice
lines. The earlier "clean" reading counted fetches, which proves nothing —
VOICE[] caches, so a repeat play never fetches again.

27 tests; the new one empties the court every frame for six seconds and
asserts at most one umpire call per beat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-11 18:29:11 +02:00
1 parent ef8a413031
commit 435a470ec7
5 files changed
+93 -7

No files matched your search

+30 -4
View File
@@ -61,16 +61,42 @@ function loadVoice(name) {
// rule is satisfied by construction rather than fought. Fire-and-forget — the caller's beat clock
// runs at its own pace, and a late decode simply means he starts a moment later. Returns the total
// speaking time (0 until the clips are cached) so a beat CAN be sized to it if one ever needs to be.
// THREE THINGS THIS HAS TO GET RIGHT, and the first cut got all three wrong. It shipped a phone-only
// garble that never once reproduced on desktop, so read this before touching it.
//
// 1. **A SUSPENDED CONTEXT HAS A FROZEN CLOCK.** On a phone the AudioContext starts suspended and
// `currentTime` does not advance until it resumes. Scheduling four clips at currentTime + 0,
// +0.9, +1.7, +2.4 against a frozen clock puts every one of them in the PAST by the time it
// actually runs — so they all fire on the same instant, on top of each other, through a 1.3 s
// reverb tail. Resume FIRST, and re-read the clock after the decode.
// 2. **A SECOND CALL MUST REPLACE THE FIRST, NOT LAYER ON IT.** `voice()` has always enforced one
// man, one microphone; this function SET that clock and never checked it, so anything that called
// it twice in quick succession got two overlapping lists. It stops the outstanding one now.
// 3. **NEVER SCHEDULE INTO THE PAST**, even with a running clock: a slow decode can land after the
// time we picked. Every start is clamped forward.
// 4. **STOPPING A LIST MUST GIVE BACK THE TIME IT RESERVED.** `voiceUntil` is how long the umpire is
// booked for. Cancelling the outstanding list without rewinding it left the booking standing, so
// the REPLACEMENT queued behind a list that is never going to play — measured at 6.6 seconds of
// silence before the call the player is waiting on. Every redeal made it worse.
let seqSources = [], seqUntilBefore = 0;
export function voiceSeq(names, gap = 0.12) {
Promise.all(names.map(loadVoice)).then(bufs => {
const ac = audio();
let at = ac.currentTime + 0.05;
const ac = audio(); // resumes a suspended context (see audio())
if (seqSources.length) { // the new list replaces the old one, booking and all
for (const s of seqSources) { try { s.stop(); } catch (e) {} }
voiceUntil = seqUntilBefore;
}
seqSources = [];
seqUntilBefore = voiceUntil;
const ready = ac.state === 'running' ? Promise.resolve() : ac.resume().catch(() => {});
Promise.all([ready, ...names.map(loadVoice)]).then(([, ...bufs]) => {
let at = Math.max(ac.currentTime + 0.08, voiceUntil); // clock re-read AFTER the decode + resume
for (const buf of bufs) {
const src = ac.createBufferSource(); src.buffer = buf;
src.connect(ac.destination);
const send = ac.createGain(); send.gain.value = 1.8;
src.connect(send).connect(reverb(ac));
src.start(at);
src.start(Math.max(at, ac.currentTime)); // never behind the clock
seqSources.push(src);
at += buf.duration + gap;
}
voiceUntil = at; // he is busy for the whole list — ordinary lines stay out of the way
+8 -2
View File
@@ -127,9 +127,15 @@ export function updateDrill(dt) {
// latches loaded) and could not reproduce it afterwards, which is precisely the kind of bug that
// comes back in front of a player. Unrepaired it is a dead mode: no targets, no timer, nothing to
// tap. Deal a fresh round and leave evidence, same contract as watchdog()'s invariant net.
// It goes through the ordinary between-rounds BEAT rather than dealing on the spot, and that is
// the load-bearing half: dealing here directly makes the repair rate the FRAME RATE, so a court
// that keeps coming up empty redeals sixty times a second — and every deal calls the umpire.
// Through the beat, the worst case is one redeal every drillBeat seconds, which is a mode that
// limps instead of a phone screaming at you.
if (!zombies.some(z => z.bib !== undefined)) {
logSwing({ result: 'INVARIANT', reason: 'drill court emptied from outside the round — redealt' });
return newDrillRound();
logSwing({ result: 'INVARIANT', reason: 'drill court emptied from outside the round — redealing' });
d.beat = TUNE.drillBeat;
return;
}
if (d.callT > 0) { // the walk-on: they take their marks
d.callT -= dt;