Smooth realtime display frame pacing
This commit is contained in:
1 parent
afcceec986
commit
a8e9729b3a
3 files changed
+29
-13
No files matched your search
@@ -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');
|
||||||
+14
-11
@@ -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 () => {
|
||||||
|
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;
|
const frame = pendingPhoenixFrame;
|
||||||
pendingPhoenixFrame = null;
|
pendingPhoenixFrame = null;
|
||||||
if (!frame) return;
|
|
||||||
phoenixPacketBusy = true;
|
|
||||||
try {
|
|
||||||
const packet = buildPhoenixMeterPacket(frame);
|
const packet = buildPhoenixMeterPacket(frame);
|
||||||
await applyIncomingAudioPacket(env, packet, CONFIG, performance.now());
|
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
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user