Compare commits

...
4 Commits
Author SHA1 Message Date
Mikei386 cbbeac5b0f Revert "Fix skipped realtime analyzer frames"
This reverts commit afcceec986.
2026-08-05 20:54:55 +02:00
Mikei386 ab19798a2d Revert "Smooth realtime display frame pacing"
This reverts commit a8e9729b3a.
2026-08-05 20:54:33 +02:00
Mikei386 96d323c32d Revert "Decouple realtime snapshots from transport cadence"
This reverts commit feec2bac56.
2026-08-05 20:53:58 +02:00
Mikei386 dba83a2d98 Revert "Synchronize realtime data with display frames"
This reverts commit c6a1037611.
2026-08-05 20:53:18 +02:00
7 changed files with 97 additions and 171 deletions

No files matched your search

-19
View File
@@ -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(/<select\b[^>]*\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,
+1 -1
View File
@@ -37,5 +37,5 @@ metrics.addEventListener('message', (event) => {
await new Promise((resolve) => setTimeout(resolve, 1100));
sockets.forEach((socket) => socket.close());
assert.equal(invalidPayload, false, 'metrics must be ordered and contain no large visual payloads');
assert.ok(count >= 110 && count <= 145, `expected about 120 metrics/s, received ${count}`);
assert.ok(count >= 50 && count <= 75, `expected about 60 metrics/s, received ${count}`);
console.log(`runtime websocket test passed (${count} metrics in 1.1 s; max frame age ${maxFrameAgeMs} ms; all three streams opened)`);
+13 -5
View File
@@ -14,7 +14,7 @@ use tracing::warn;
#[cfg(target_os = "linux")]
use crate::correlation::CorrelationMeter;
#[cfg(target_os = "linux")]
use crate::goniometer::selected_sample_indices;
use crate::goniometer::{selected_sample_indices, GoniometerClock};
#[cfg(target_os = "linux")]
use crate::model::{RtaFrame, SpectroFrame, WaveEnvFrame};
#[cfg(target_os = "linux")]
@@ -40,8 +40,8 @@ use crate::{
model::{InputSource, MeterFrame, PhoenixRtaConfig},
};
#[cfg(not(target_os = "linux"))]
const PLACEHOLDER_UPDATES_PER_SECOND: u32 = 120;
#[cfg(target_os = "linux")]
const METRICS_TARGET_UPDATES_PER_SECOND: u32 = 60;
#[derive(Clone, Copy, Debug, Default)]
struct TransportPeaks {
@@ -415,8 +415,7 @@ pub fn spawn_audio_capture_worker(deps: AudioWorkerDeps) {
pub fn spawn_audio_capture_worker(deps: AudioWorkerDeps) {
tokio::spawn(async move {
warn!("Phoenix ALSA capture is only available on Linux; emitting placeholder frames on this host");
let tick_ns = 1_000_000_000u64 / u64::from(PLACEHOLDER_UPDATES_PER_SECOND);
let mut ticker = tokio::time::interval(Duration::from_nanos(tick_ns));
let mut ticker = tokio::time::interval(Duration::from_millis(16));
deps.actual_sample_rate
.store(deps.config.sample_rate as u64, Ordering::SeqCst);
loop {
@@ -594,6 +593,7 @@ struct PpmState {
lr_delay_y1_r: f32,
true_peak_l: TruePeakDetector,
true_peak_r: TruePeakDetector,
transport_clock: GoniometerClock,
transport_peaks: TransportPeaks,
correlation: CorrelationMeter,
phase_wheel: PhaseWheelAnalyzer,
@@ -626,6 +626,7 @@ impl Default for PpmState {
lr_delay_y1_r: 0.0,
true_peak_l: TruePeakDetector::default(),
true_peak_r: TruePeakDetector::default(),
transport_clock: GoniometerClock::default(),
transport_peaks: TransportPeaks::default(),
correlation: CorrelationMeter::new(48_000, 1.0, -75.0, 0),
phase_wheel: PhaseWheelAnalyzer::new(48_000),
@@ -1251,6 +1252,13 @@ fn process_audio_block(
None
};
if !ppm_state
.transport_clock
.advance(frames, sample_rate, METRICS_TARGET_UPDATES_PER_SECOND)
{
return (None, spectro_frame);
}
let rta = ppm_state.rta_state.as_ref().and_then(|state| match state {
RtaEngineState::Iir(bank) => Some(build_iir_rta_frame(bank, sample_rate, rta_config)),
RtaEngineState::Fft(fft) if fft.ring_fill >= fft.fft_size => {
-2
View File
@@ -1,12 +1,10 @@
//! Timing and sample-selection helpers for the realtime goniometer stream.
#[cfg(test)]
#[derive(Clone, Debug, Default)]
pub struct GoniometerClock {
phase: u64,
}
#[cfg(test)]
impl GoniometerClock {
/// Returns true at the first capture boundary after the next display tick.
/// The fractional phase is retained, so the average rate is independent
+10 -76
View File
@@ -14,7 +14,7 @@ use std::{
process::Stdio,
sync::atomic::{AtomicU64, Ordering},
sync::Arc,
time::{Duration, SystemTime, UNIX_EPOCH},
time::{SystemTime, UNIX_EPOCH},
};
use tokio::{fs, io::AsyncWriteExt, process::Command};
use tracing::warn;
@@ -27,8 +27,6 @@ use crate::{
const ONLINE_UPDATE_ZIP_URL: &str = "https://webshare.casaderoll.de/share/Phoenix.zip";
const DEVICE_BACKUP_KIND: &str = "phoenix-device-backup";
const DEVICE_BACKUP_SCHEMA_VERSION: u32 = 1;
const REALTIME_WS_UPDATES_PER_SECOND: u64 = 120;
const MAX_MERGED_XY_POINTS: usize = 2048;
static JSON_WRITE_TOKEN: AtomicU64 = AtomicU64::new(1);
fn stable_script_command(program: &str) -> Command {
@@ -1198,11 +1196,7 @@ fn normalize_frontend_layout_id(raw: &str) -> String {
async fn metrics_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) {
let mut rx = state.subscribe_metrics();
let tick_ns = 1_000_000_000u64 / REALTIME_WS_UPDATES_PER_SECOND;
let mut ticker = tokio::time::interval(Duration::from_nanos(tick_ns));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
ticker.tick().await;
let Some(mut latest) = recv_latest_meter_frame(&mut rx).await else {
break;
};
@@ -1242,11 +1236,7 @@ fn strip_visual_payloads(frame: &mut MeterFrame) {
async fn visuals_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) {
let mut rx = state.subscribe_metrics();
let tick_ns = 1_000_000_000u64 / REALTIME_WS_UPDATES_PER_SECOND;
let mut ticker = tokio::time::interval(Duration::from_nanos(tick_ns));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
ticker.tick().await;
let Some(mut latest) = recv_latest_meter_frame(&mut rx).await else {
return;
};
@@ -1426,14 +1416,19 @@ fn drain_visual_meter_frames(
latest: &mut Arc<MeterFrame>,
) -> (Option<WaveEnvFrame>, Option<(Vec<f32>, Vec<f32>)>) {
let mut combined_wave_env = latest.wave_env.clone();
let mut combined_xy = None;
append_xy_samples(&mut combined_xy, &latest.xy_l, &latest.xy_r);
let mut latest_xy = if latest.xy_l.is_empty() || latest.xy_r.is_empty() {
None
} else {
Some((latest.xy_l.clone(), latest.xy_r.clone()))
};
loop {
match rx.try_recv() {
Ok(newer) => {
merge_wave_env(&mut combined_wave_env, newer.wave_env.clone());
append_xy_samples(&mut combined_xy, &newer.xy_l, &newer.xy_r);
if !newer.xy_l.is_empty() && !newer.xy_r.is_empty() {
latest_xy = Some((newer.xy_l.clone(), newer.xy_r.clone()));
}
*latest = newer;
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break,
@@ -1444,31 +1439,7 @@ fn drain_visual_meter_frames(
}
}
(combined_wave_env, combined_xy)
}
fn append_xy_samples(
target: &mut Option<(Vec<f32>, Vec<f32>)>,
incoming_l: &[f32],
incoming_r: &[f32],
) {
let count = incoming_l.len().min(incoming_r.len());
if count == 0 {
return;
}
let (left, right) = target.get_or_insert_with(|| {
(
Vec::with_capacity(count.min(MAX_MERGED_XY_POINTS)),
Vec::with_capacity(count.min(MAX_MERGED_XY_POINTS)),
)
});
left.extend_from_slice(&incoming_l[..count]);
right.extend_from_slice(&incoming_r[..count]);
if left.len() > MAX_MERGED_XY_POINTS {
let excess = left.len() - MAX_MERGED_XY_POINTS;
left.drain(..excess);
right.drain(..excess);
}
(combined_wave_env, latest_xy)
}
fn merge_wave_env(target: &mut Option<WaveEnvFrame>, incoming: Option<WaveEnvFrame>) {
@@ -1718,43 +1689,6 @@ mod tests {
assert_eq!(tp_r, -3.0);
}
#[test]
fn visual_drain_keeps_xy_samples_in_chronological_order() {
let (tx, mut rx) = tokio::sync::broadcast::channel(8);
for (seq, left, right) in [
(1, vec![1.0, 2.0], vec![-1.0, -2.0]),
(2, vec![3.0, 4.0], vec![-3.0, -4.0]),
(3, vec![5.0], vec![-5.0]),
] {
let mut frame = meter_frame();
frame.seq = seq;
frame.xy_l = left;
frame.xy_r = right;
tx.send(Arc::new(frame)).unwrap();
}
let mut latest = rx.try_recv().unwrap();
let (_, xy) = drain_visual_meter_frames(&mut rx, &mut latest);
let (left, right) = xy.unwrap();
assert_eq!(latest.seq, 3);
assert_eq!(left, vec![1.0, 2.0, 3.0, 4.0, 5.0]);
assert_eq!(right, vec![-1.0, -2.0, -3.0, -4.0, -5.0]);
}
#[test]
fn merged_xy_keeps_the_newest_bounded_window() {
let mut xy = None;
let first: Vec<f32> = (0..1500).map(|value| value as f32).collect();
let second: Vec<f32> = (1500..2500).map(|value| value as f32).collect();
append_xy_samples(&mut xy, &first, &first);
append_xy_samples(&mut xy, &second, &second);
let (left, right) = xy.unwrap();
assert_eq!(left.len(), MAX_MERGED_XY_POINTS);
assert_eq!(right.len(), MAX_MERGED_XY_POINTS);
assert_eq!(left[0], 452.0);
assert_eq!(left[MAX_MERGED_XY_POINTS - 1], 2499.0);
}
#[test]
fn wave_envelopes_merge_without_losing_columns() {
let mut target = Some(WaveEnvFrame {
+67 -40
View File
@@ -16,14 +16,18 @@ 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 phoenixPacketRaf = 0;
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 +107,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 +122,12 @@ function closePhoenixSocket() {
}
phoenixVisualsSocket = null;
pendingVisualsBuffer = null;
if (phoenixVisualsRaf) cancelAnimationFrame(phoenixVisualsRaf);
phoenixVisualsRaf = 0;
pendingPhoenixFrame = null;
if (phoenixPacketRaf) cancelAnimationFrame(phoenixPacketRaf);
phoenixPacketRaf = 0;
phoenixPacketBusy = false;
}
async function requestPhoenixRtaConfig(baseUrl, config) {
@@ -261,6 +272,7 @@ function openPhoenixSpectroSocket(baseUrl, env) {
socket.onmessage = (event) => {
if (!(event.data instanceof ArrayBuffer)) return;
pendingSpectroBuffer = event.data;
scheduleSpectroBufferPump(env);
};
const retry = () => {
@@ -297,6 +309,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 +343,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 +358,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;
@@ -382,6 +432,7 @@ 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) {
@@ -436,10 +487,6 @@ 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) {
@@ -579,49 +626,29 @@ 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;
function schedulePhoenixPacketPump(env, CONFIG) {
if (phoenixPacketRaf || phoenixPacketBusy || !pendingPhoenixFrame) return;
phoenixPacketRaf = requestAnimationFrame(async () => {
phoenixPacketRaf = 0;
const frame = pendingPhoenixFrame;
pendingPhoenixFrame = null;
pendingVisualsBuffer = null;
pendingSpectroBuffer = null;
if (metricsFrame) {
if (!frame) return;
phoenixPacketBusy = true;
try {
const packet = buildPhoenixMeterPacket(metricsFrame);
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);
}
});
}
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 enqueuePhoenixFrame(env, CONFIG, frame) {
pendingPhoenixFrame = mergePendingPhoenixFrame(pendingPhoenixFrame, frame);
schedulePhoenixPacketPump(env, CONFIG);
}
function bindLifecycleHandlers() {
@@ -1144,7 +1171,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);
}
+6 -28
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, flushPendingPhoenixData } from './core/audio.js';
import { initAudio, audioLost, reloadAudio } from './core/audio.js';
import { createScreensaver } from './core/screensaver.js';
// Views
@@ -2634,7 +2634,6 @@ let prevSaverActive = false;
let audioRecoverInFlight = false;
let lastAudioRecoverAt = 0;
let renderDirty = true;
let renderRequestGeneration = 0;
let lastRenderedAudioSeq = 0;
let processingProfileDirty = true;
let appliedProcessingProfileView = '';
@@ -2643,7 +2642,6 @@ const DATA_ONLY_RENDER_REASONS = new Set(['audio', 'spectro', 'visuals']);
function requestRender(reason = 'ui') {
renderDirty = true;
renderRequestGeneration += 1;
if (!DATA_ONLY_RENDER_REASONS.has(reason)) processingProfileDirty = true;
env.__lastRenderReason = reason;
}
@@ -2675,10 +2673,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) {
@@ -2692,30 +2686,17 @@ async function loop(now){
}
const targetMs = audioOk ? (1000 / desiredFpsForStyle(renderStyle)) : (1000 / 20);
const target = targetMs;
const currentMetricsSeq = Number.isFinite(env.audio?.phoenixMetricsSeq)
? env.audio.phoenixMetricsSeq
: 0;
const currentVisualsSeq = Number.isFinite(env.audio?.phoenixVisualsSeq)
? env.audio.phoenixVisualsSeq
: 0;
const currentXySeq = Number.isFinite(env.audio?.xySeq) ? env.audio.xySeq : 0;
const currentAudioSeq = Math.max(currentMetricsSeq, currentVisualsSeq, currentXySeq);
const currentAudioSeq = Number.isFinite(env.audio?.xySeq) ? env.audio.xySeq : 0;
const audioDirty = currentAudioSeq > lastRenderedAudioSeq;
const shouldRenderFrame =
audioOk ||
renderDirty ||
audioDirty ||
!audioOk ||
style === 'options-panel' ||
style === 'recorder' ||
renderStyle === 'clock';
// 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);
if (elapsed >= target){
lastFrameTime = now - (elapsed % targetMs);
try{
updateRect();
meterHitRects.length = 0;
@@ -2733,17 +2714,14 @@ async function loop(now){
}
prevSaverActive = saverActive;
if (!saverActive && shouldRenderFrame) {
const renderGeneration = renderRequestGeneration;
meterFacade.setFrameStamp?.(++frameCounter);
drawBG();
if (!currentView || currentViewId !== renderStyle) setView(renderStyle);
if (currentView.render) await currentView.render(env, viewState);
drawOptionsPanelBackdrop();
drawAudioLostOverlay();
// A packet or UI event can request another frame while the async view
// render is in progress. Do not erase that newer request here.
if (renderRequestGeneration === renderGeneration) renderDirty = false;
lastRenderedAudioSeq = Math.max(lastRenderedAudioSeq, currentAudioSeq);
renderDirty = false;
lastRenderedAudioSeq = currentAudioSeq;
}
} catch(e){
showErr('Render error: ' + (e?.message || String(e)));