mirror of
https://github.com/YusufB5/ASCILINE.git
synced 2026-06-17 22:35:13 +02:00
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.
59 lines
2.7 KiB
JavaScript
59 lines
2.7 KiB
JavaScript
/*
|
|
* freeze_repro2.js — Model the COLD-START freeze (issue #7): audio starts late,
|
|
* so the wall-clock fallback renders ahead, then the master clock snaps back to
|
|
* audioEl.currentTime (~0) and playback freezes until audio catches up.
|
|
*
|
|
* Faithfully simulates: a server feeding frames at fps, a capped jitter buffer,
|
|
* a 60fps render loop, and the app.js master-clock + frame-gate logic — under
|
|
* three clock policies.
|
|
*/
|
|
function sim({ policy, audioDelayMs }) {
|
|
const fps = 24, rAF = 1000 / 60, DURATION = 6000;
|
|
const buf = [];
|
|
let lastServerFrame = -1, audioStart = null;
|
|
const renderTimes = [];
|
|
|
|
for (let t = 0; t <= DURATION; t += rAF) {
|
|
// server feeds frames in real time; jitter buffer caps at 20 (drops oldest)
|
|
const want = Math.floor(t / 1000 * fps);
|
|
while (lastServerFrame < want) buf.push({ time: ++lastServerFrame / fps });
|
|
while (buf.length > 20) buf.shift();
|
|
|
|
const audioPlaying = t >= audioDelayMs;
|
|
if (audioPlaying && audioStart === null) audioStart = t;
|
|
const audioCurrent = audioPlaying ? (t - audioStart) / 1000 : 0;
|
|
const wall = t / 1000;
|
|
|
|
// ── master clock policy ──
|
|
let master;
|
|
if (policy === 'broken') master = audioPlaying ? audioCurrent : wall;
|
|
else if (policy === 'guard') master = (audioPlaying && audioCurrent > 0) ? audioCurrent : wall;
|
|
else if (policy === 'monotonic') {
|
|
// fix: anchor audio so the clock never jumps backward
|
|
if (audioPlaying) {
|
|
if (sim._off === undefined) sim._off = wall - audioCurrent; // capture once
|
|
master = audioCurrent + sim._off;
|
|
} else master = wall;
|
|
}
|
|
|
|
// ── frame gate (verbatim from app.js) ──
|
|
if (buf.length) {
|
|
while (buf.length > 1 && buf[0].time < master - 0.1) buf.shift();
|
|
if (buf[0].time <= master + 0.05) { buf.shift(); renderTimes.push(t); }
|
|
}
|
|
}
|
|
delete sim._off;
|
|
const last = renderTimes[renderTimes.length - 1] ?? 0;
|
|
const afterAudio = renderTimes.filter(t => t > audioDelayMs).length;
|
|
const trailingStallMs = Math.round(DURATION - last); // froze until the end?
|
|
return { rendered: renderTimes.length, afterAudio, trailingStallMs };
|
|
}
|
|
|
|
const EXPECTED_AFTER = Math.round((6000 - 2000) / 1000 * 24); // ~96 frames in the 2-6s window
|
|
console.log(`audio starts 2s late (cold start). Expect ~${EXPECTED_AFTER} frames AFTER 2s if smooth:\n`);
|
|
for (const policy of ['broken', 'guard', 'monotonic']) {
|
|
const r = sim({ policy, audioDelayMs: 2000 });
|
|
const frozen = r.afterAudio < 5;
|
|
console.log(` ${policy.padEnd(10)} total ${String(r.rendered).padStart(3)} | after-audio ${String(r.afterAudio).padStart(3)}/${EXPECTED_AFTER} | `
|
|
+ (frozen ? `FROZE (stuck ${r.trailingStallMs}ms to end)` : `smooth`));
|
|
}
|