Smooth realtime display frame pacing

This commit is contained in:
Mikei386
2026-08-05 16:35:09 +02:00
parent afcceec986
commit a8e9729b3a
3 changed files with 32 additions and 16 deletions
+7
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
const html = fs.readFileSync(new URL('../www/index.html', import.meta.url), 'utf8'); const html = fs.readFileSync(new URL('../www/index.html', import.meta.url), 'utf8');
const main = fs.readFileSync(new URL('../www/main.js', import.meta.url), 'utf8'); const main = fs.readFileSync(new URL('../www/main.js', import.meta.url), 'utf8');
const audio = fs.readFileSync(new URL('../www/core/audio.js', import.meta.url), 'utf8');
const styleSelect = html.match(/<select\b[^>]*\bid="styleSel"[^>]*>/)?.[0] || ''; const styleSelect = html.match(/<select\b[^>]*\bid="styleSel"[^>]*>/)?.[0] || '';
assert(styleSelect, 'style selector is missing'); assert(styleSelect, 'style selector is missing');
@@ -16,5 +17,11 @@ assert(/renderRequestGeneration\s*\+=\s*1/.test(main),
'render requests must carry a generation that survives asynchronous rendering'); 'render requests must carry a generation that survives asynchronous rendering');
assert(/renderRequestGeneration\s*===\s*renderGeneration\)\s*renderDirty\s*=\s*false/.test(main), assert(/renderRequestGeneration\s*===\s*renderGeneration\)\s*renderDirty\s*=\s*false/.test(main),
'an in-flight render must not clear a newer render request'); 'an in-flight render must not clear a newer render request');
assert(/const frameDue\s*=\s*audioOk\s*\|\|\s*elapsed\s*>=\s*target/.test(main),
'live displays must use requestAnimationFrame directly instead of a second 60 Hz gate');
assert(/await updateActiveMeters\(env, d, CONFIG\);\s*[^]*?env\.requestRender\?\.\('audio'\)/.test(audio),
'audio rendering must be requested after active meters finish updating');
assert(!/phoenixPacketRaf\s*=\s*requestAnimationFrame/.test(audio),
'metrics packets must not wait for a competing requestAnimationFrame pump');
console.log('GUI contract regression tests passed'); console.log('GUI contract regression tests passed');
+17 -14
View File
@@ -26,7 +26,6 @@ let lifecycleHandlersBound = false;
let recoverTimer = null; let recoverTimer = null;
let lastHardRecoverAt = 0; let lastHardRecoverAt = 0;
let pendingPhoenixFrame = null; let pendingPhoenixFrame = null;
let phoenixPacketRaf = 0;
let phoenixPacketBusy = false; let phoenixPacketBusy = false;
const RMS_RING = { L: new Float32Array(512), R: new Float32Array(512), i: 0, n: 0 }; const RMS_RING = { L: new Float32Array(512), R: new Float32Array(512), i: 0, n: 0 };
@@ -125,8 +124,6 @@ function closePhoenixSocket() {
if (phoenixVisualsRaf) cancelAnimationFrame(phoenixVisualsRaf); if (phoenixVisualsRaf) cancelAnimationFrame(phoenixVisualsRaf);
phoenixVisualsRaf = 0; phoenixVisualsRaf = 0;
pendingPhoenixFrame = null; pendingPhoenixFrame = null;
if (phoenixPacketRaf) cancelAnimationFrame(phoenixPacketRaf);
phoenixPacketRaf = 0;
phoenixPacketBusy = false; phoenixPacketBusy = false;
} }
@@ -432,7 +429,6 @@ async function applyIncomingAudioPacket(env, packet, CONFIG, sampleTs = performa
const previousSeq = Number(env.audio.phoenixMetricsSeq || 0); const previousSeq = Number(env.audio.phoenixMetricsSeq || 0);
if (Number.isFinite(seq) && seq > 0 && previousSeq > 0 && seq <= previousSeq) return; if (Number.isFinite(seq) && seq > 0 && previousSeq > 0 && seq <= previousSeq) return;
if (Number.isFinite(seq) && seq > 0) env.audio.phoenixMetricsSeq = seq; if (Number.isFinite(seq) && seq > 0) env.audio.phoenixMetricsSeq = seq;
env.requestRender?.('audio');
env.audio.lastSampleTs = sampleTs; env.audio.lastSampleTs = sampleTs;
env.audio.alive = true; env.audio.alive = true;
if (Number.isFinite(Number(d.sampleRate)) && Number(d.sampleRate) > 0) { if (Number.isFinite(Number(d.sampleRate)) && Number(d.sampleRate) > 0) {
@@ -487,6 +483,10 @@ async function applyIncomingAudioPacket(env, packet, CONFIG, sampleTs = performa
} }
await updateActiveMeters(env, d, CONFIG); await updateActiveMeters(env, d, CONFIG);
// Publish the frame only after every active meter has consumed the packet.
// Otherwise the canvas loop can draw between the data update and the meter
// update, producing an old frame followed by a visible catch-up step.
env.requestRender?.('audio');
} }
function buildPhoenixMeterPacket(frame) { function buildPhoenixMeterPacket(frame) {
@@ -627,23 +627,26 @@ function mergePendingPhoenixFrame(previous, next) {
} }
function schedulePhoenixPacketPump(env, CONFIG) { function schedulePhoenixPacketPump(env, CONFIG) {
if (phoenixPacketRaf || phoenixPacketBusy || !pendingPhoenixFrame) return; if (phoenixPacketBusy || !pendingPhoenixFrame) return;
phoenixPacketRaf = requestAnimationFrame(async () => { phoenixPacketBusy = true;
phoenixPacketRaf = 0; void (async () => {
const frame = pendingPhoenixFrame;
pendingPhoenixFrame = null;
if (!frame) return;
phoenixPacketBusy = true;
try { try {
const packet = buildPhoenixMeterPacket(frame); // Consume the newest packet immediately. Incoming packets are still
await applyIncomingAudioPacket(env, packet, CONFIG, performance.now()); // coalesced while an asynchronous meter/config update is in progress,
// but they no longer wait for a second requestAnimationFrame loop.
while (pendingPhoenixFrame) {
const frame = pendingPhoenixFrame;
pendingPhoenixFrame = null;
const packet = buildPhoenixMeterPacket(frame);
await applyIncomingAudioPacket(env, packet, CONFIG, performance.now());
}
} catch (err) { } catch (err) {
console.warn('Phoenix packet error:', err); console.warn('Phoenix packet error:', err);
} finally { } finally {
phoenixPacketBusy = false; phoenixPacketBusy = false;
if (pendingPhoenixFrame) schedulePhoenixPacketPump(env, CONFIG); if (pendingPhoenixFrame) schedulePhoenixPacketPump(env, CONFIG);
} }
}); })();
} }
function enqueuePhoenixFrame(env, CONFIG, frame) { function enqueuePhoenixFrame(env, CONFIG, frame) {
+8 -2
View File
@@ -2698,14 +2698,20 @@ async function loop(now){
const currentAudioSeq = Math.max(currentMetricsSeq, currentVisualsSeq, currentXySeq); const currentAudioSeq = Math.max(currentMetricsSeq, currentVisualsSeq, currentXySeq);
const audioDirty = currentAudioSeq > lastRenderedAudioSeq; const audioDirty = currentAudioSeq > lastRenderedAudioSeq;
const shouldRenderFrame = const shouldRenderFrame =
audioOk ||
renderDirty || renderDirty ||
audioDirty || audioDirty ||
!audioOk || !audioOk ||
style === 'options-panel' || style === 'options-panel' ||
style === 'recorder' || style === 'recorder' ||
renderStyle === 'clock'; renderStyle === 'clock';
if (elapsed >= target){ // With live audio, requestAnimationFrame itself is the display clock. An
lastFrameTime = now - (elapsed % targetMs); // additional exact 60 Hz comparison occasionally rejected a real 60 Hz
// frame because of fractional timestamp jitter, creating 33 ms pauses in
// otherwise smooth peak decay. Only throttle the disconnected state.
const frameDue = audioOk || elapsed >= target;
if (frameDue){
lastFrameTime = audioOk ? now : now - (elapsed % targetMs);
try{ try{
updateRect(); updateRect();
meterHitRects.length = 0; meterHitRects.length = 0;