diff --git a/scripts/test_gui_contracts.mjs b/scripts/test_gui_contracts.mjs index 4c7c322..14e1353 100644 --- a/scripts/test_gui_contracts.mjs +++ b/scripts/test_gui_contracts.mjs @@ -19,9 +19,15 @@ assert(/renderRequestGeneration\s*===\s*renderGeneration\)\s*renderDirty\s*=\s*f '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 flushPendingPhoenixData\(env, CONFIG\);[^]*?const currentMetricsSeq/.test(main), + 'the render loop must publish buffered Phoenix data before reading frame sequences'); 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'); +assert(/export async function flushPendingPhoenixData\(env, CONFIG\)/.test(audio), + 'Phoenix data paths must expose one display-synchronized presentation pump'); +assert(!/requestAnimationFrame/.test(audio), + 'audio transport must not create requestAnimationFrame loops that compete with the canvas loop'); +assert(!/while\s*\(pendingPhoenixFrame\)/.test(audio), + 'metrics transport must coalesce bursts instead of draining every queued state'); console.log('GUI contract regression tests passed'); diff --git a/www/core/audio.js b/www/core/audio.js index 1bfb66a..9f82243 100644 --- a/www/core/audio.js +++ b/www/core/audio.js @@ -16,17 +16,14 @@ let phoenixSpectroSocket = null; let phoenixSpectroRetryTimer = null; let phoenixSpectroDemanded = false; let pendingSpectroBuffer = null; -let phoenixSpectroRaf = 0; let phoenixVisualsSocket = null; let phoenixVisualsRetryTimer = null; let pendingVisualsBuffer = null; -let phoenixVisualsRaf = 0; let envRef = null; let lifecycleHandlersBound = false; let recoverTimer = null; let lastHardRecoverAt = 0; let pendingPhoenixFrame = null; -let phoenixPacketBusy = false; const RMS_RING = { L: new Float32Array(512), R: new Float32Array(512), i: 0, n: 0 }; const WAVEFORM_RING_SECONDS = 20; @@ -106,8 +103,6 @@ function closePhoenixSocket() { phoenixSpectroSocket = null; phoenixSpectroDemanded = false; pendingSpectroBuffer = null; - if (phoenixSpectroRaf) cancelAnimationFrame(phoenixSpectroRaf); - phoenixSpectroRaf = 0; if (phoenixVisualsRetryTimer) clearTimeout(phoenixVisualsRetryTimer); phoenixVisualsRetryTimer = null; if (phoenixVisualsSocket) { @@ -121,10 +116,7 @@ function closePhoenixSocket() { } phoenixVisualsSocket = null; pendingVisualsBuffer = null; - if (phoenixVisualsRaf) cancelAnimationFrame(phoenixVisualsRaf); - phoenixVisualsRaf = 0; pendingPhoenixFrame = null; - phoenixPacketBusy = false; } async function requestPhoenixRtaConfig(baseUrl, config) { @@ -269,7 +261,6 @@ function openPhoenixSpectroSocket(baseUrl, env) { socket.onmessage = (event) => { if (!(event.data instanceof ArrayBuffer)) return; pendingSpectroBuffer = event.data; - scheduleSpectroBufferPump(env); }; const retry = () => { @@ -306,27 +297,6 @@ function setPhoenixSpectroDemand(baseUrl, env, demanded) { } catch (_) {} } pendingSpectroBuffer = null; - if (phoenixSpectroRaf) cancelAnimationFrame(phoenixSpectroRaf); - phoenixSpectroRaf = 0; -} - -function scheduleSpectroBufferPump(env) { - if (phoenixSpectroRaf || !pendingSpectroBuffer) return; - phoenixSpectroRaf = requestAnimationFrame(() => { - phoenixSpectroRaf = 0; - const buffer = pendingSpectroBuffer; - pendingSpectroBuffer = null; - try { - const decoded = decodePhoenixSpectroBuffer(buffer); - if (!decoded) return; - if (!copyPhoenixSpectroBins(env.audio, decoded)) return; - env.requestRender?.('spectro'); - } catch (err) { - console.warn('Phoenix spectrogram packet error:', err); - } finally { - if (pendingSpectroBuffer) scheduleSpectroBufferPump(env); - } - }); } function openPhoenixVisualsSocket(baseUrl, env) { @@ -340,7 +310,6 @@ function openPhoenixVisualsSocket(baseUrl, env) { socket.onmessage = (event) => { if (!(event.data instanceof ArrayBuffer)) return; pendingVisualsBuffer = event.data; - scheduleVisualsBufferPump(env); }; const retry = () => { if (phoenixVisualsSocket === socket) phoenixVisualsSocket = null; @@ -355,22 +324,6 @@ function openPhoenixVisualsSocket(baseUrl, env) { socket.onclose = retry; } -function scheduleVisualsBufferPump(env) { - if (phoenixVisualsRaf || !pendingVisualsBuffer) return; - phoenixVisualsRaf = requestAnimationFrame(() => { - phoenixVisualsRaf = 0; - const buffer = pendingVisualsBuffer; - pendingVisualsBuffer = null; - try { - applyVisualsBuffer(env, buffer); - } catch (err) { - console.warn('Phoenix visual packet error:', err); - } finally { - if (pendingVisualsBuffer) scheduleVisualsBufferPump(env); - } - }); -} - function applyVisualsBuffer(env, buffer) { const decoded = decodePhoenixVisualsBuffer(buffer); if (!decoded) return; @@ -626,32 +579,49 @@ function mergePendingPhoenixFrame(previous, next) { return merged; } -function schedulePhoenixPacketPump(env, CONFIG) { - if (phoenixPacketBusy || !pendingPhoenixFrame) return; - phoenixPacketBusy = true; - void (async () => { - try { - // 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(frame) { + pendingPhoenixFrame = mergePendingPhoenixFrame(pendingPhoenixFrame, frame); } -function enqueuePhoenixFrame(env, CONFIG, frame) { - pendingPhoenixFrame = mergePendingPhoenixFrame(pendingPhoenixFrame, frame); - schedulePhoenixPacketPump(env, CONFIG); +// WebSocket delivery and the display's VSync are independent clocks. Keep the +// callbacks allocation-light and publish one coherent newest-state snapshot at +// the beginning of the single canvas render loop. This prevents metrics, +// visuals and spectrogram updates from being applied in bursts between frames. +export async function flushPendingPhoenixData(env, CONFIG) { + const metricsFrame = pendingPhoenixFrame; + const visualsBuffer = pendingVisualsBuffer; + const spectroBuffer = pendingSpectroBuffer; + pendingPhoenixFrame = null; + pendingVisualsBuffer = null; + pendingSpectroBuffer = null; + + if (metricsFrame) { + try { + const packet = buildPhoenixMeterPacket(metricsFrame); + await applyIncomingAudioPacket(env, packet, CONFIG, performance.now()); + } catch (err) { + console.warn('Phoenix packet error:', err); + } + } + + if (visualsBuffer) { + try { + applyVisualsBuffer(env, visualsBuffer); + } catch (err) { + console.warn('Phoenix visual packet error:', err); + } + } + + if (spectroBuffer) { + try { + const decoded = decodePhoenixSpectroBuffer(spectroBuffer); + if (decoded && copyPhoenixSpectroBins(env.audio, decoded)) { + env.requestRender?.('spectro'); + } + } catch (err) { + console.warn('Phoenix spectrogram packet error:', err); + } + } } function bindLifecycleHandlers() { @@ -1174,7 +1144,7 @@ async function initPhoenixAudio(env) { socket.onmessage = (event) => { try { const frame = JSON.parse(event.data); - enqueuePhoenixFrame(env, CONFIG, frame); + enqueuePhoenixFrame(frame); } catch (err) { console.warn('Phoenix metrics parse error:', err); } diff --git a/www/main.js b/www/main.js index 8a2d5d7..4badc77 100644 --- a/www/main.js +++ b/www/main.js @@ -23,7 +23,7 @@ try { import { CONFIG, applyRtaBpoSelection, loadConfig, saveConfig, loadLayoutPreset } from './core/config.js'; import * as utils from './core/utils.js'; import { meterFacade, registerMeter } from './core/registry.js'; -import { initAudio, audioLost, reloadAudio } from './core/audio.js'; +import { initAudio, audioLost, reloadAudio, flushPendingPhoenixData } from './core/audio.js'; import { createScreensaver } from './core/screensaver.js'; // Views @@ -2675,6 +2675,10 @@ async function maybeRecoverAudio(reason) { async function loop(now){ const elapsed = now - lastFrameTime; + // Apply all newest transport states once, immediately before this display + // frame reads them. WebSocket callbacks only buffer; this is the sole + // presentation pump for metrics, visuals and spectrogram data. + await flushPendingPhoenixData(env, CONFIG); const audioOk = env.audio.alive && !audioLost(env); const renderStyle = getRenderableStyle(); if (processingProfileDirty || appliedProcessingProfileView !== renderStyle) {