From 66110be76d24ed3506eb8fd9dbe71766f6b6913e Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:39:19 +0200 Subject: [PATCH] Restore smooth realtime fall ballistics --- scripts/test_gui_contracts.mjs | 7 ++++++ scripts/test_rta_profile.mjs | 36 ++++++++++++++++++++++++++++++- src/audio.rs | 1 + src/goniometer.rs | 2 ++ www/views/realtime.js | 39 +++++++++------------------------- 5 files changed, 55 insertions(+), 30 deletions(-) diff --git a/scripts/test_gui_contracts.mjs b/scripts/test_gui_contracts.mjs index 14e1353..e9b9140 100644 --- a/scripts/test_gui_contracts.mjs +++ b/scripts/test_gui_contracts.mjs @@ -4,6 +4,7 @@ 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] || ''; assert(styleSelect, 'style selector is missing'); @@ -29,5 +30,11 @@ 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, + 'every RTA engine must advance peak hold on the display clock'); +assert(/useIirEngine[^]*?applyRealtimeBarBallistics\(state, displayBase, CONFIG, range\)/.test(realtime), + 'native IIR bars must advance their visible release on the display clock'); console.log('GUI contract regression tests passed'); diff --git a/scripts/test_rta_profile.mjs b/scripts/test_rta_profile.mjs index d8a6d03..5959388 100644 --- a/scripts/test_rta_profile.mjs +++ b/scripts/test_rta_profile.mjs @@ -2,7 +2,12 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import vm from 'node:vm'; import { getRtwCenters } from '../www/core/rtw_centers.js'; -import { buildRtwTickPositions, selectLocalRtwPacket } from '../www/views/realtime.js'; +import { + applyPeakHold, + applyRealtimeBarBallistics, + buildRtwTickPositions, + selectLocalRtwPacket, +} from '../www/views/realtime.js'; const source = fs.readFileSync(new URL('../www/core/audio.js', import.meta.url), 'utf8'); const configSource = fs.readFileSync(new URL('../www/core/config.js', import.meta.url), 'utf8'); @@ -118,4 +123,33 @@ for (const mode of ['1_3', '1_6', '1_12']) { } } +const range = { bottom: -36, top: 9 }; +const peakNow = performance.now(); +const peakState = { + peakHold: new Float32Array([8]), + lastPeakTime: [peakNow - 3000], + holdSampleTs: peakNow - (1000 / 60), +}; +const repeatedPeakAnchor = new Float32Array([-36]); +const peakAfterOneFrame = applyPeakHold(peakState, repeatedPeakAnchor, { + RTA_PEAK_HOLD_MODE: 'fall', + RTA_PEAK_HOLD_SEC: 2.5, + RTA_PEAK_DECAY_DB_PER_S: 20, +}, range)[0]; +assert.ok(peakAfterOneFrame < 7.8 && peakAfterOneFrame > 7.5, + `a repeated native peak anchor must still fall once per display frame, got ${peakAfterOneFrame}`); + +const barNow = performance.now(); +const barState = { + rtBarLevels: new Float32Array([8]), + rtBarPeakTimes: [barNow - 1000], + lastRtBarTs: barNow - (1000 / 60), +}; +const barAfterOneFrame = applyRealtimeBarBallistics(barState, new Float32Array([-36]), { + REALTIME_BAR_HOLD_MS: 0, + REALTIME_BAR_DECAY_DB_PER_S: 20, +}, range)[0]; +assert.ok(barAfterOneFrame < 7.8 && barAfterOneFrame > 7.5, + `a repeated native bar anchor must still fall once per display frame, got ${barAfterOneFrame}`); + console.log('RTA profile regression tests passed'); diff --git a/src/audio.rs b/src/audio.rs index 47db58a..ceb26f7 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -40,6 +40,7 @@ use crate::{ model::{InputSource, MeterFrame, PhoenixRtaConfig}, }; +#[cfg(not(target_os = "linux"))] const PLACEHOLDER_UPDATES_PER_SECOND: u32 = 120; #[derive(Clone, Copy, Debug, Default)] diff --git a/src/goniometer.rs b/src/goniometer.rs index de27292..efbea66 100644 --- a/src/goniometer.rs +++ b/src/goniometer.rs @@ -1,10 +1,12 @@ //! 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 diff --git a/www/views/realtime.js b/www/views/realtime.js index fcbb441..46c2adb 100644 --- a/www/views/realtime.js +++ b/www/views/realtime.js @@ -200,10 +200,13 @@ export async function render(env, state) { } else { const integrated = baseLevels; const displayBase = applyDisplayHold(state, integrated, CONFIG, range); - // Native IIR values already contain the selected power-domain - // integration. A second browser attack/hold stage would falsify it. - const display = displayBase; - syncNativePeakHold(state, displayRtaPacket, CONFIG, range, state.mapping.length, 'iir'); + // Detector integration remains authoritative in the native DSP. The + // display-only hold/release stages advance on every VSync so repeated + // transport anchors cannot freeze a falling bar or peak hairline. + const display = (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars' + ? applyRealtimeBarBallistics(state, displayBase, CONFIG, range) + : displayBase; + applyPeakHold(state, integrated, CONFIG, range); state.displayLevels = display; state.currentRange = range; if (typeof window !== 'undefined') window.__RTA_STATE__ = state; @@ -237,7 +240,7 @@ export async function render(env, state) { const display = (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars' ? applyRealtimeBarBallistics(state, displayBase, CONFIG, range) : displayBase; - syncNativePeakHold(state, displayRtaPacket, CONFIG, range, state.mapping.length, 'fft'); + applyPeakHold(state, integrated, CONFIG, range); state.displayLevels = display; state.currentRange = range; if (typeof window !== 'undefined') window.__RTA_STATE__ = state; @@ -439,7 +442,7 @@ function applyIntegration(state, levels, CONFIG, range) { return buffer; } -function applyPeakHold(state, levels, CONFIG, range) { +export function applyPeakHold(state, levels, CONFIG, range) { const mode = CONFIG.RTA_PEAK_HOLD_MODE || 'fall'; const len = levels.length; const mapped = PEAK_HOLD_MAP.get(mode); @@ -482,28 +485,6 @@ function applyPeakHold(state, levels, CONFIG, range) { return buffer; } -function syncNativePeakHold(state, packet, CONFIG, range, expectedLen, engine) { - const src = isVectorLike(packet?.bands_peak) && packet.bands_peak.length === expectedLen - ? packet.bands_peak - : null; - if (!src) return applyPeakHold(state, state.displayLevels, CONFIG, range); - const gain = engine === 'iir' - ? (Number(CONFIG.RTA_DISPLAY_GAIN_IIR_DB ?? 0) || 0) - : (Number(CONFIG.RTA_DISPLAY_GAIN_FFT_DB ?? 0) || 0); - if (!state.peakHold || state.peakHold.length !== expectedLen) { - state.peakHold = new Float32Array(expectedLen); - } - for (let i = 0; i < expectedLen; i++) { - const value = Number(src[i]); - state.peakHold[i] = clamp( - (Number.isFinite(value) ? value : range.bottom) + gain, - range.bottom, - range.top, - ); - } - return state.peakHold; -} - function applyDisplayHold(state, levels, CONFIG, range) { const holdSec = Math.max(0, Number(CONFIG.RTA_DISPLAY_HOLD_SEC) || 0); const len = levels.length; @@ -538,7 +519,7 @@ function applyDisplayHold(state, levels, CONFIG, range) { return buffer; } -function applyRealtimeBarBallistics(state, levels, CONFIG, range) { +export function applyRealtimeBarBallistics(state, levels, CONFIG, range) { const holdMs = Math.max(0, Number(CONFIG.REALTIME_BAR_HOLD_MS) || 0); const decayRate = Math.max(0, Number(CONFIG.REALTIME_BAR_DECAY_DB_PER_S) || 0); const len = levels.length;