fix(#7): anchor the audio master clock so it never jumps backward (startup freeze)

Cold-start root cause: on a slow first load the <audio> element starts late, so
the audio-ready gate's wall-clock fallback has already advanced playback a second
or two by the time audio finally begins at currentTime≈0. The master clock then
snapped back toward 0, every buffered frame read as "in the future", and
renderFrame() deadlocked until audio caught up — the freeze. A refresh warms the
cache, audio starts immediately, the gap (and the freeze) vanish — which is why
reloading "fixes" it.

Fix: the first time audio is genuinely playing, capture the offset between the
wall clock and the audio clock and add it back, so the master clock follows
audio's *rate* without ever moving backward. When audio starts promptly the
offset is ~0, so normal playback is unchanged.

experiments/freeze_repro.js models a 2s-late audio start with a realistic jitter
buffer + 60fps render loop: the original code and a naive `currentTime > 0` guard
render 0-1 of ~96 frames after audio starts (frozen ~4s); the anchored clock
renders 96/96 smooth. Real-browser regression (muted Chrome): normal playback
unaffected at ~29 fps.
This commit is contained in:
Nate 2026-06-13 23:30:22 -04:00
parent e130b0cc2f
commit 58626d7602
2 changed files with 78 additions and 3 deletions

22
app.js
View file

@ -44,6 +44,7 @@ let selectionBuffer = null;
let lastRenderTime = 0;
let frameCount = 0, currentFps = 0, lastFpsUpdate = 0;
let streamStartTime = 0;
let audioClockOffset = null; // anchors the audio clock so it never jumps backward (issue #7)
const CHAR_LUT = new Array(128);
for (let i = 0; i < 128; i++) CHAR_LUT[i] = String.fromCharCode(i);
@ -185,6 +186,7 @@ function connectWebSocket() {
streamStartTime = performance.now();
lastRenderTime = performance.now();
lastFpsUpdate = lastRenderTime;
audioClockOffset = null; // re-anchor for this stream
requestAnimationFrame(renderFrame);
};
@ -267,11 +269,25 @@ function renderFrame(now) {
requestAnimationFrame(renderFrame);
// ── MASTER CLOCK LOGIC ──
// The audio track is the master clock, BUT it must never jump backward
// (issue #7, the cold-start freeze). On a cold load the <audio> element can
// start late: the wall-clock fallback has already advanced playback a second
// or two, and when audio finally begins, audioEl.currentTime is back near 0.
// Snapping the master clock back to ~0 makes every buffered frame read as
// "in the future", so renderFrame() returns forever and the stream freezes
// until audio catches up. The fix: the first time audio is genuinely playing,
// capture the offset between where the wall clock already is and the audio
// clock, and add it back — so the clock follows audio's *rate* without ever
// moving backward. (When audio starts promptly the offset is ~0.)
const wallClock = (now - streamStartTime) / 1000.0;
let masterClock;
if (audioEl && audioEl.readyState >= 1 && !audioEl.paused) {
masterClock = audioEl.currentTime;
if (audioEl && audioEl.readyState >= 1 && !audioEl.paused && audioEl.currentTime > 0) {
if (audioClockOffset === null) {
audioClockOffset = Math.max(0, wallClock - audioEl.currentTime);
}
masterClock = audioEl.currentTime + audioClockOffset;
} else {
masterClock = (now - streamStartTime) / 1000.0;
masterClock = wallClock;
}
if (frameBuffer.length === 0) return;