From dba83a2d986ffed28c0592aa19aa1c9be192a7dd Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:53:18 +0200 Subject: [PATCH] Revert "Synchronize realtime data with display frames" This reverts commit c6a103761141339bb236b44e89d48c4c2cb7402c. --- scripts/test_gui_contracts.mjs | 19 ------ www/core/audio.js | 110 +++++++++++++++++++++------------ www/main.js | 6 +- 3 files changed, 71 insertions(+), 64 deletions(-) diff --git a/scripts/test_gui_contracts.mjs b/scripts/test_gui_contracts.mjs index e9b9140..aa3f98e 100644 --- a/scripts/test_gui_contracts.mjs +++ b/scripts/test_gui_contracts.mjs @@ -3,7 +3,6 @@ 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 realtime = fs.readFileSync(new URL('../www/views/realtime.js', import.meta.url), 'utf8'); const styleSelect = html.match(/]*\bid="styleSel"[^>]*>/)?.[0] || ''; @@ -12,24 +11,6 @@ assert(!/\bopt-w-\d+\b/.test(styleSelect), 'style selector must not have a fixed assert(/adjustStyleDropdownWidth\(\)/.test(main), 'dynamic style selector sizing is missing'); assert(/styleSel\.style\.width\s*=/.test(main), 'dynamic style selector width assignment is missing'); -assert(/currentMetricsSeq\s*=\s*Number\.isFinite\(env\.audio\?\.phoenixMetricsSeq\)/.test(main), - 'render loop must track the metrics sequence used by the RTA'); -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 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(/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'); assert(!/syncNativePeakHold/.test(realtime), 'native RTA peak anchors must not be copied directly into the visible hold line'); assert((realtime.match(/applyPeakHold\(state, integrated, CONFIG, range\)/g) || []).length >= 3, diff --git a/www/core/audio.js b/www/core/audio.js index 9f82243..1bfb66a 100644 --- a/www/core/audio.js +++ b/www/core/audio.js @@ -16,14 +16,17 @@ 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; @@ -103,6 +106,8 @@ function closePhoenixSocket() { phoenixSpectroSocket = null; phoenixSpectroDemanded = false; pendingSpectroBuffer = null; + if (phoenixSpectroRaf) cancelAnimationFrame(phoenixSpectroRaf); + phoenixSpectroRaf = 0; if (phoenixVisualsRetryTimer) clearTimeout(phoenixVisualsRetryTimer); phoenixVisualsRetryTimer = null; if (phoenixVisualsSocket) { @@ -116,7 +121,10 @@ function closePhoenixSocket() { } phoenixVisualsSocket = null; pendingVisualsBuffer = null; + if (phoenixVisualsRaf) cancelAnimationFrame(phoenixVisualsRaf); + phoenixVisualsRaf = 0; pendingPhoenixFrame = null; + phoenixPacketBusy = false; } async function requestPhoenixRtaConfig(baseUrl, config) { @@ -261,6 +269,7 @@ function openPhoenixSpectroSocket(baseUrl, env) { socket.onmessage = (event) => { if (!(event.data instanceof ArrayBuffer)) return; pendingSpectroBuffer = event.data; + scheduleSpectroBufferPump(env); }; const retry = () => { @@ -297,6 +306,27 @@ 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) { @@ -310,6 +340,7 @@ 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; @@ -324,6 +355,22 @@ 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; @@ -579,49 +626,32 @@ function mergePendingPhoenixFrame(previous, next) { return merged; } -function enqueuePhoenixFrame(frame) { - pendingPhoenixFrame = mergePendingPhoenixFrame(pendingPhoenixFrame, frame); -} - -// 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) { +function schedulePhoenixPacketPump(env, CONFIG) { + if (phoenixPacketBusy || !pendingPhoenixFrame) return; + phoenixPacketBusy = true; + void (async () => { 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'); + // 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 spectrogram packet error:', err); + console.warn('Phoenix packet error:', err); + } finally { + phoenixPacketBusy = false; + if (pendingPhoenixFrame) schedulePhoenixPacketPump(env, CONFIG); } - } + })(); +} + +function enqueuePhoenixFrame(env, CONFIG, frame) { + pendingPhoenixFrame = mergePendingPhoenixFrame(pendingPhoenixFrame, frame); + schedulePhoenixPacketPump(env, CONFIG); } function bindLifecycleHandlers() { @@ -1144,7 +1174,7 @@ async function initPhoenixAudio(env) { socket.onmessage = (event) => { try { const frame = JSON.parse(event.data); - enqueuePhoenixFrame(frame); + enqueuePhoenixFrame(env, CONFIG, frame); } catch (err) { console.warn('Phoenix metrics parse error:', err); } diff --git a/www/main.js b/www/main.js index 4badc77..8a2d5d7 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, flushPendingPhoenixData } from './core/audio.js'; +import { initAudio, audioLost, reloadAudio } from './core/audio.js'; import { createScreensaver } from './core/screensaver.js'; // Views @@ -2675,10 +2675,6 @@ 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) {