From a8e9729b3a0557401ad0c8c124ae9826a64c9617 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:35:09 +0200 Subject: [PATCH] Smooth realtime display frame pacing --- scripts/test_gui_contracts.mjs | 7 +++++++ www/core/audio.js | 31 +++++++++++++++++-------------- www/main.js | 10 ++++++++-- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/scripts/test_gui_contracts.mjs b/scripts/test_gui_contracts.mjs index a9deb03..4c7c322 100644 --- a/scripts/test_gui_contracts.mjs +++ b/scripts/test_gui_contracts.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; 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 audio = fs.readFileSync(new URL('../www/core/audio.js', import.meta.url), 'utf8'); const styleSelect = html.match(/]*\bid="styleSel"[^>]*>/)?.[0] || ''; 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'); assert(/renderRequestGeneration\s*===\s*renderGeneration\)\s*renderDirty\s*=\s*false/.test(main), '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'); diff --git a/www/core/audio.js b/www/core/audio.js index 8d1e0fc..1bfb66a 100644 --- a/www/core/audio.js +++ b/www/core/audio.js @@ -26,7 +26,6 @@ let lifecycleHandlersBound = false; let recoverTimer = null; let lastHardRecoverAt = 0; let pendingPhoenixFrame = null; -let phoenixPacketRaf = 0; let phoenixPacketBusy = false; 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); phoenixVisualsRaf = 0; pendingPhoenixFrame = null; - if (phoenixPacketRaf) cancelAnimationFrame(phoenixPacketRaf); - phoenixPacketRaf = 0; phoenixPacketBusy = false; } @@ -432,7 +429,6 @@ async function applyIncomingAudioPacket(env, packet, CONFIG, sampleTs = performa const previousSeq = Number(env.audio.phoenixMetricsSeq || 0); if (Number.isFinite(seq) && seq > 0 && previousSeq > 0 && seq <= previousSeq) return; if (Number.isFinite(seq) && seq > 0) env.audio.phoenixMetricsSeq = seq; - env.requestRender?.('audio'); env.audio.lastSampleTs = sampleTs; env.audio.alive = true; 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); + // 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) { @@ -627,23 +627,26 @@ function mergePendingPhoenixFrame(previous, next) { } function schedulePhoenixPacketPump(env, CONFIG) { - if (phoenixPacketRaf || phoenixPacketBusy || !pendingPhoenixFrame) return; - phoenixPacketRaf = requestAnimationFrame(async () => { - phoenixPacketRaf = 0; - const frame = pendingPhoenixFrame; - pendingPhoenixFrame = null; - if (!frame) return; - phoenixPacketBusy = true; + if (phoenixPacketBusy || !pendingPhoenixFrame) return; + phoenixPacketBusy = true; + void (async () => { try { - const packet = buildPhoenixMeterPacket(frame); - await applyIncomingAudioPacket(env, packet, CONFIG, performance.now()); + // Consume the newest packet immediately. Incoming packets are still + // 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) { console.warn('Phoenix packet error:', err); } finally { phoenixPacketBusy = false; if (pendingPhoenixFrame) schedulePhoenixPacketPump(env, CONFIG); } - }); + })(); } function enqueuePhoenixFrame(env, CONFIG, frame) { diff --git a/www/main.js b/www/main.js index 35fffa5..8a2d5d7 100644 --- a/www/main.js +++ b/www/main.js @@ -2698,14 +2698,20 @@ async function loop(now){ const currentAudioSeq = Math.max(currentMetricsSeq, currentVisualsSeq, currentXySeq); const audioDirty = currentAudioSeq > lastRenderedAudioSeq; const shouldRenderFrame = + audioOk || renderDirty || audioDirty || !audioOk || style === 'options-panel' || style === 'recorder' || renderStyle === 'clock'; - if (elapsed >= target){ - lastFrameTime = now - (elapsed % targetMs); + // With live audio, requestAnimationFrame itself is the display clock. An + // 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{ updateRect(); meterHitRects.length = 0;