Restore smooth realtime fall ballistics
This commit is contained in:
1 parent
c6a1037611
commit
66110be76d
5 files changed
+55
-30
No files matched your search
@@ -4,6 +4,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 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] || '';
|
const styleSelect = html.match(/<select\b[^>]*\bid="styleSel"[^>]*>/)?.[0] || '';
|
||||||
assert(styleSelect, 'style selector is missing');
|
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');
|
'audio transport must not create requestAnimationFrame loops that compete with the canvas loop');
|
||||||
assert(!/while\s*\(pendingPhoenixFrame\)/.test(audio),
|
assert(!/while\s*\(pendingPhoenixFrame\)/.test(audio),
|
||||||
'metrics transport must coalesce bursts instead of draining every queued state');
|
'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');
|
console.log('GUI contract regression tests passed');
|
||||||
@@ -2,7 +2,12 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import vm from 'node:vm';
|
import vm from 'node:vm';
|
||||||
import { getRtwCenters } from '../www/core/rtw_centers.js';
|
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 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');
|
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');
|
console.log('RTA profile regression tests passed');
|
||||||
@@ -40,6 +40,7 @@ use crate::{
|
|||||||
model::{InputSource, MeterFrame, PhoenixRtaConfig},
|
model::{InputSource, MeterFrame, PhoenixRtaConfig},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
const PLACEHOLDER_UPDATES_PER_SECOND: u32 = 120;
|
const PLACEHOLDER_UPDATES_PER_SECOND: u32 = 120;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default)]
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
//! Timing and sample-selection helpers for the realtime goniometer stream.
|
//! Timing and sample-selection helpers for the realtime goniometer stream.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub struct GoniometerClock {
|
pub struct GoniometerClock {
|
||||||
phase: u64,
|
phase: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
impl GoniometerClock {
|
impl GoniometerClock {
|
||||||
/// Returns true at the first capture boundary after the next display tick.
|
/// Returns true at the first capture boundary after the next display tick.
|
||||||
/// The fractional phase is retained, so the average rate is independent
|
/// The fractional phase is retained, so the average rate is independent
|
||||||
|
|||||||
+10
-29
@@ -200,10 +200,13 @@ export async function render(env, state) {
|
|||||||
} else {
|
} else {
|
||||||
const integrated = baseLevels;
|
const integrated = baseLevels;
|
||||||
const displayBase = applyDisplayHold(state, integrated, CONFIG, range);
|
const displayBase = applyDisplayHold(state, integrated, CONFIG, range);
|
||||||
// Native IIR values already contain the selected power-domain
|
// Detector integration remains authoritative in the native DSP. The
|
||||||
// integration. A second browser attack/hold stage would falsify it.
|
// display-only hold/release stages advance on every VSync so repeated
|
||||||
const display = displayBase;
|
// transport anchors cannot freeze a falling bar or peak hairline.
|
||||||
syncNativePeakHold(state, displayRtaPacket, CONFIG, range, state.mapping.length, 'iir');
|
const display = (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars'
|
||||||
|
? applyRealtimeBarBallistics(state, displayBase, CONFIG, range)
|
||||||
|
: displayBase;
|
||||||
|
applyPeakHold(state, integrated, CONFIG, range);
|
||||||
state.displayLevels = display;
|
state.displayLevels = display;
|
||||||
state.currentRange = range;
|
state.currentRange = range;
|
||||||
if (typeof window !== 'undefined') window.__RTA_STATE__ = state;
|
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'
|
const display = (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars'
|
||||||
? applyRealtimeBarBallistics(state, displayBase, CONFIG, range)
|
? applyRealtimeBarBallistics(state, displayBase, CONFIG, range)
|
||||||
: displayBase;
|
: displayBase;
|
||||||
syncNativePeakHold(state, displayRtaPacket, CONFIG, range, state.mapping.length, 'fft');
|
applyPeakHold(state, integrated, CONFIG, range);
|
||||||
state.displayLevels = display;
|
state.displayLevels = display;
|
||||||
state.currentRange = range;
|
state.currentRange = range;
|
||||||
if (typeof window !== 'undefined') window.__RTA_STATE__ = state;
|
if (typeof window !== 'undefined') window.__RTA_STATE__ = state;
|
||||||
@@ -439,7 +442,7 @@ function applyIntegration(state, levels, CONFIG, range) {
|
|||||||
return buffer;
|
return buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPeakHold(state, levels, CONFIG, range) {
|
export function applyPeakHold(state, levels, CONFIG, range) {
|
||||||
const mode = CONFIG.RTA_PEAK_HOLD_MODE || 'fall';
|
const mode = CONFIG.RTA_PEAK_HOLD_MODE || 'fall';
|
||||||
const len = levels.length;
|
const len = levels.length;
|
||||||
const mapped = PEAK_HOLD_MAP.get(mode);
|
const mapped = PEAK_HOLD_MAP.get(mode);
|
||||||
@@ -482,28 +485,6 @@ function applyPeakHold(state, levels, CONFIG, range) {
|
|||||||
return buffer;
|
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) {
|
function applyDisplayHold(state, levels, CONFIG, range) {
|
||||||
const holdSec = Math.max(0, Number(CONFIG.RTA_DISPLAY_HOLD_SEC) || 0);
|
const holdSec = Math.max(0, Number(CONFIG.RTA_DISPLAY_HOLD_SEC) || 0);
|
||||||
const len = levels.length;
|
const len = levels.length;
|
||||||
@@ -538,7 +519,7 @@ function applyDisplayHold(state, levels, CONFIG, range) {
|
|||||||
return buffer;
|
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 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 decayRate = Math.max(0, Number(CONFIG.REALTIME_BAR_DECAY_DB_PER_S) || 0);
|
||||||
const len = levels.length;
|
const len = levels.length;
|
||||||
|
|||||||
Reference in new issue
Block a user