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:
1 parent
ef8a413031
commit
435a470ec7
5 files changed
+93
-7
No files matched your search
+31
@@ -520,6 +520,37 @@ no penalty because there was no way to earn one.
|
|||||||
- Ten tests now (26 total), including a named regression for the auto-aim being gone and one that
|
- Ten tests now (26 total), including a named regression for the auto-aim being gone and one that
|
||||||
deals 40 rounds and asserts colours, counts and shapes all move.
|
deals 40 rounds and asserts colours, counts and shapes all move.
|
||||||
|
|
||||||
|
**THIRD PASS, same day — "the drill mode immediately goes into some horrible audio loop" (phone).**
|
||||||
|
Never reproduced on desktop, and that is the tell: **three of the four defects behind it can only
|
||||||
|
happen on a phone, and my whole verification had been stepping frames with rAF killed on a machine
|
||||||
|
whose AudioContext was already running.** All four are in `voiceSeq`, which is new code, and every
|
||||||
|
one of them is a rule `voice()` had followed for months and my function quietly broke:
|
||||||
|
|
||||||
|
1. **A SUSPENDED AudioContext HAS A FROZEN CLOCK.** On a phone the context 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 the context
|
||||||
|
actually starts — 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 LAYERED INSTEAD OF REPLACING.** `voice()` has always enforced one man, one
|
||||||
|
microphone; `voiceSeq` SET that clock (`voiceUntil`) and never checked it. Two calls close
|
||||||
|
together = two overlapping lists. It stops the outstanding one now.
|
||||||
|
3. **CANCELLING A LIST HAS TO GIVE BACK THE TIME IT BOOKED.** The first cut of fix 2 stopped the old
|
||||||
|
sources but left `voiceUntil` standing, so the replacement queued behind a list that would never
|
||||||
|
play — measured at **6.6 s of silence** before the call the player is waiting on, and every
|
||||||
|
redeal made it worse. `seqUntilBefore` rewinds the booking.
|
||||||
|
4. **THE AMPLIFIER, in drill.js: the empty-court net redealt ON THE SPOT**, which makes the repair
|
||||||
|
rate the FRAME RATE — and every deal calls the umpire. It goes through the ordinary
|
||||||
|
between-rounds beat now, so the worst case is one redeal every `drillBeat` seconds: a mode that
|
||||||
|
limps instead of a phone screaming at you. Pinned by a test that empties the court every frame
|
||||||
|
for six seconds and asserts at most one call per beat.
|
||||||
|
|
||||||
|
**And a measurement lesson worth more than the fix.** The first "it's clean" reading was taken by
|
||||||
|
counting `fetch`es of `assets/audio/*` — which proved nothing, because `VOICE[]` caches every clip
|
||||||
|
and a repeat play never fetches again. The honest probe tags each decoded buffer with the file it
|
||||||
|
came from (patch `decodeAudioData`, pair it with the pending fetch URL) and counts
|
||||||
|
`AudioBufferSourceNode.start` — then "did two umpire lines overlap" is a real question with a real
|
||||||
|
answer. Counting requests measures loading; counting `start()` measures sound.
|
||||||
|
|
||||||
**Open, and every one of them is Oliver's call after he plays it:**
|
**Open, and every one of them is Oliver's call after he plays it:**
|
||||||
1. **A ball that carries through the lane can complete two steps at once** — measured: one serve
|
1. **A ball that carries through the lane can complete two steps at once** — measured: one serve
|
||||||
killed bib 1 and then bib 2 standing behind it, banking two correct steps in one shot. Had the
|
killed bib 1 and then bib 2 standing behind it, banking two correct steps in one shot. Had the
|
||||||
|
|||||||
+1
-1
@@ -399,6 +399,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="src/main.js?v=153"></script>
|
<script type="module" src="src/main.js?v=154"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+30
-4
@@ -61,16 +61,42 @@ function loadVoice(name) {
|
|||||||
// rule is satisfied by construction rather than fought. Fire-and-forget — the caller's beat clock
|
// 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
|
// 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.
|
// 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) {
|
export function voiceSeq(names, gap = 0.12) {
|
||||||
Promise.all(names.map(loadVoice)).then(bufs => {
|
const ac = audio(); // resumes a suspended context (see audio())
|
||||||
const ac = audio();
|
if (seqSources.length) { // the new list replaces the old one, booking and all
|
||||||
let at = ac.currentTime + 0.05;
|
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) {
|
for (const buf of bufs) {
|
||||||
const src = ac.createBufferSource(); src.buffer = buf;
|
const src = ac.createBufferSource(); src.buffer = buf;
|
||||||
src.connect(ac.destination);
|
src.connect(ac.destination);
|
||||||
const send = ac.createGain(); send.gain.value = 1.8;
|
const send = ac.createGain(); send.gain.value = 1.8;
|
||||||
src.connect(send).connect(reverb(ac));
|
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;
|
at += buf.duration + gap;
|
||||||
}
|
}
|
||||||
voiceUntil = at; // he is busy for the whole list — ordinary lines stay out of the way
|
voiceUntil = at; // he is busy for the whole list — ordinary lines stay out of the way
|
||||||
|
|||||||
+8
-2
@@ -127,9 +127,15 @@ export function updateDrill(dt) {
|
|||||||
// latches loaded) and could not reproduce it afterwards, which is precisely the kind of bug that
|
// 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
|
// 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.
|
// 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)) {
|
if (!zombies.some(z => z.bib !== undefined)) {
|
||||||
logSwing({ result: 'INVARIANT', reason: 'drill court emptied from outside the round — redealt' });
|
logSwing({ result: 'INVARIANT', reason: 'drill court emptied from outside the round — redealing' });
|
||||||
return newDrillRound();
|
d.beat = TUNE.drillBeat;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (d.callT > 0) { // the walk-on: they take their marks
|
if (d.callT > 0) { // the walk-on: they take their marks
|
||||||
d.callT -= dt;
|
d.callT -= dt;
|
||||||
|
|||||||
@@ -402,6 +402,29 @@ test('being caught costs the point, not the run', () => {
|
|||||||
assert.equal(zombies.length, 0, 'and the court is cleared for the next one');
|
assert.equal(zombies.length, 0, 'and the court is cleared for the next one');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an emptied court cannot machine-gun the umpire', () => {
|
||||||
|
// Oliver, on a phone: "the drill mode immediately goes into some horrible audio loop". The trigger
|
||||||
|
// was never reproduced on desktop (a suspended AudioContext has a frozen clock, so a scheduled
|
||||||
|
// sequence all fires at once on resume — see voiceSeq), but the AMPLIFIER was here: the
|
||||||
|
// empty-court net used to redeal on the spot, which makes the repair rate the frame rate, and
|
||||||
|
// every deal calls the umpire. Whatever empties the court, it must never announce more than once
|
||||||
|
// per beat.
|
||||||
|
drillPlaying();
|
||||||
|
let deals = 0;
|
||||||
|
const seq0 = G.drill.seq;
|
||||||
|
for (let i = 0; i < 60 * 6; i++) { // six seconds with the court emptied every single frame
|
||||||
|
zombies.length = 0;
|
||||||
|
update(DT);
|
||||||
|
if (G.drill.seq !== seq0 && G.drill.callT > 0 && zombies.length) { deals++; }
|
||||||
|
if (zombies.length) zombies.length = 0; // ...and emptied again, so it can never settle
|
||||||
|
}
|
||||||
|
const repairs = G.missLog.filter(m => m.result === 'INVARIANT').length;
|
||||||
|
assert.ok(repairs > 0, 'the net notices and leaves evidence');
|
||||||
|
assert.ok(repairs <= 6 / TUNE.drillBeat + 2,
|
||||||
|
'at most one repair per drillBeat, not one per frame — got ' + repairs);
|
||||||
|
assert.ok(deals <= 6 / TUNE.drillBeat + 2, 'and at most one deal (one umpire call) per beat — got ' + deals);
|
||||||
|
});
|
||||||
|
|
||||||
test('endDrill hands the court back', () => {
|
test('endDrill hands the court back', () => {
|
||||||
drillPlaying();
|
drillPlaying();
|
||||||
endDrill();
|
endDrill();
|
||||||
|
|||||||
Reference in new issue
Block a user