Synchronize realtime data with display frames

This commit is contained in:
Mikei386 committed 2026-08-05 20:10:02 +02:00
1 parent feec2bac56
commit c6a1037611
3 files changed
+55 -75

No files matched your search

+8 -2
View File
@@ -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');
+42 -72
View File
@@ -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);
}
+5 -1
View File
@@ -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) {