Fix RTA peak hold and FFT band energy

This commit is contained in:
Mikei386
2026-07-22 08:42:32 +02:00
parent caec1edfcf
commit 3a0bdbb5ad
13 changed files with 394 additions and 87 deletions
+6 -7
View File
@@ -72,9 +72,9 @@ Diese vorhandenen Funktionen sind nicht automatisch messtechnisch korrekt. Die f
- [ ] **7. Peak Hold und Bandspeicher wie beim PortaMonitor ergänzen**
- **Soll:** Peak Hold 2,5 s, 4 s oder manuell; Speicher für acht Bänder plus Hold.
- **Ist:** Die Anzeige unterstützt jetzt Peak Hold 2,5 s, 4 s und manuell. Der ältere kontinuierliche Backend-Peak sowie der achtbandige RTW-Speicher sind noch nicht vollständig ersetzt beziehungsweise ergänzt.
- **Falsch/unvollständig:** Ein kontinuierlich fallender Peak ist funktional nicht dasselbe wie Peak Hold.
- **Aufgabe:** Hold-Zeit, manuellen Hold, Reset, Rücklauf nach Hold-Ende und acht auswählbare Speicherbänder implementieren. Aktuellwert und Holdwert visuell eindeutig trennen.
- **Ist:** 2,5 s, 4 s und manueller Hold werden samplezeitbasiert im Backend geführt. Nach Zeitablauf springt der Hold eindeutig auf den aktuellen Detektorwert; ein kontinuierlicher Rücklauf findet nicht mehr statt. Der Reset erreicht über ein Token sowohl Backend als auch Anzeige. FFT und IIR verwenden dieselbe Hold-Semantik, ohne eine zweite Browser-Holdstufe.
- **Noch offen:** Der achtbandige RTW-Speicher ist noch nicht ergänzt.
- **Aufgabe:** Acht auswählbare Speicherbänder implementieren und Aktuell-, Hold- und Speicherwert visuell eindeutig trennen.
- **Abnahme:** Hold-Zeiten und Reset-Verhalten stimmen zeitlich und visuell mit der Referenz überein.
- [ ] **8. RTA-Anzeigeoptionen am PortaMonitor-Profil ausrichten**
@@ -114,11 +114,10 @@ Diese vorhandenen Funktionen sind nicht automatisch messtechnisch korrekt. Die f
- **Noch extern zu prüfen:** Pegelkalibrierung und die optische Übereinstimmung mit dem konkreten RTW-PortaMonitor am analogen Eingang.
- **Abnahme:** Softwaretests bestehen; endgültige Geräteübereinstimmung folgt mit der analogen Referenzmessung.
- [ ] **13. FFT-Modus als optionale Spektrumsansicht fachlich korrigieren**
- [x] **13. FFT-Modus als optionale Spektrumsansicht fachlich korrigieren**
- **Soll:** FFT ist eine korrekte Zusatzansicht, aber nicht die RTW-IIR-Referenz.
- **Ist:** FFT-RTA und Spektrogramm sind vorhanden.
- **Falsch:** FFT-Bins werden innerhalb eines Bandes normiert gemittelt statt zur Bandenergie summiert. Rauschsignale und unterschiedlich breite Bänder werden dadurch falsch bewertet.
- **Aufgabe:** Binleistungen energetisch integrieren, Fensterleistung korrekt kompensieren und FFT-Ergebnisse gegen die IIR-Referenz testen.
- **Ist:** Das FFT-RTA summiert einseitige Binenergien mit Hann-Fensterleistungskompensation. Rand-Bins werden entsprechend ihrem tatsächlichen Bandüberlappungsanteil berücksichtigt; es findet keine Normierung auf die Anzahl oder Gesamtgewichtung der Bins mehr statt. Die native FFT-Integration wird im Browser nicht erneut ausgeführt. Der getrennte Spektrogramm-Amplitudenmaßstab bleibt unverändert.
- **Geprüft:** Sinusenergie bleibt bei 2048, 4096, 8192 und 16384 Punkten innerhalb der Testtoleranz konstant. Ein zusätzlicher Test prüft die geometrische Bandenergie bei allen FFT-Größen.
- **Abnahme:** Konsistente Pegel bei FFT-Größenwechseln und eindeutig getrennte Kennzeichnung im UI.
## Priorität 3 - Stereoanzeigen und visuelles RTW-Verhalten
+26
View File
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import { buildBandBinMapping, computeBandLevels } from '../www/core/utils.js';
const band = [{ center: 1000, fLo: 875, fHi: 1125 }];
for (const binCount of [2048, 4096, 8192, 16384]) {
const nyquist = 24000;
const binWidth = nyquist / binCount;
const mapping = buildBandBinMapping(band, nyquist, binCount);
const totalWeight = mapping[0].bins.reduce((sum, bin) => sum + bin.weight, 0);
assert.ok(
Math.abs(totalWeight * binWidth - 250) < 1e-6,
`bin overlaps must cover the complete band at FFT size ${binCount * 2}`,
);
// Constant power density: every bin contains density × bin width. The
// integrated band level must therefore be independent of FFT resolution.
const binPower = binWidth * 1e-6;
const binsDb = new Float32Array(binCount);
binsDb.fill(10 * Math.log10(binPower));
const level = computeBandLevels(mapping, binsDb)[0];
const expected = 10 * Math.log10(250e-6);
assert.ok(Math.abs(level - expected) < 1e-5, `FFT size ${binCount * 2}: ${level} vs ${expected}`);
}
console.log('FFT band-energy regression tests passed');
+6
View File
@@ -22,6 +22,9 @@ const forced = context.buildRtaRuntimeConfig({
RTA_FREQ_RANGE: 'lf',
RTA_IIR_ORDER: 2,
RTA_DETECTOR: 'peak',
RTA_PEAK_HOLD_MODE: 'manual',
RTA_PEAK_HOLD_SEC: 4,
RTA_PEAK_RESET_TOKEN: 9,
CORR_RESPONSE_S: 2.5,
CORR_RESET_TOKEN: 7,
XY_POINTS: 256,
@@ -31,6 +34,9 @@ assert.equal(forced.bpo, '1_12');
assert.equal(forced.freqRange, 'norm');
assert.equal(forced.order, 6);
assert.equal(forced.detector, 'peak');
assert.equal(forced.rtaPeakHoldMode, 'manual');
assert.equal(forced.rtaPeakHoldSeconds, 4);
assert.equal(forced.rtaPeakResetToken, 9);
assert.equal(forced.tauFast, 0.125);
assert.equal(forced.rtwCenters.length, 121);
assert.equal(forced.correlationResponseS, 2.5);
+230 -42
View File
@@ -40,9 +40,6 @@ use crate::{
// 128-sample periods at 48 kHz -> 62.5 visual updates/s. The DSP history is
// still updated sample-by-sample; only transport snapshots are rate-limited.
const XY_TARGET_UPDATES_PER_SECOND: u64 = 60;
#[cfg(target_os = "linux")]
const RTA_PEAK_DECAY_DB_PER_S: f32 = 10.0;
#[cfg(target_os = "linux")]
const RTA_PEAK_FLOOR_DB: f32 = -150.0;
#[cfg(target_os = "linux")]
const WAVE_ENV_COLUMNS_PER_SEC: f32 = 9600.0;
@@ -133,7 +130,7 @@ struct RtaBank {
peak_windows: Option<Vec<SlidingPeak>>,
energies: Vec<f64>,
levels: Vec<f32>,
peaks: Vec<f32>,
peak_hold: RtaPeakHold,
}
#[cfg(target_os = "linux")]
@@ -173,7 +170,89 @@ struct FftRtaState {
power_bins: Vec<f32>,
energies: Vec<f64>,
levels: Vec<f32>,
peaks: Vec<f32>,
peak_hold: RtaPeakHold,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum FftPowerScale {
BinAmplitude,
IntegratedEnergy,
}
struct RtaPeakHold {
values: Vec<f32>,
expires_at_sample: Vec<u64>,
sample_clock: u64,
mode: String,
hold_samples: u64,
reset_token: u64,
}
impl RtaPeakHold {
fn new(len: usize) -> Self {
Self {
values: vec![RTA_PEAK_FLOOR_DB; len],
expires_at_sample: vec![0; len],
sample_clock: 0,
mode: String::new(),
hold_samples: 0,
reset_token: 0,
}
}
fn update(
&mut self,
current: &[f32],
advanced_samples: usize,
sample_rate: u32,
config: &PhoenixRtaConfig,
) {
self.sample_clock = self.sample_clock.saturating_add(advanced_samples as u64);
let mode = normalize_rta_peak_hold_mode(&config.rta_peak_hold_mode);
let hold_samples = (f64::from(sample_rate.max(1))
* f64::from(config.rta_peak_hold_seconds.max(0.0)))
.round()
.clamp(0.0, u64::MAX as f64) as u64;
let reset = self.mode != mode
|| self.hold_samples != hold_samples
|| self.reset_token != config.rta_peak_reset_token;
if self.values.len() != current.len() {
self.values.resize(current.len(), RTA_PEAK_FLOOR_DB);
self.expires_at_sample.resize(current.len(), 0);
}
if reset {
self.mode = mode.to_string();
self.hold_samples = hold_samples;
self.reset_token = config.rta_peak_reset_token;
self.values.copy_from_slice(current);
self.expires_at_sample.fill(0);
}
if mode == "off" {
self.values.copy_from_slice(current);
self.expires_at_sample.fill(0);
return;
}
for (index, &incoming) in current.iter().enumerate() {
let incoming = if incoming.is_finite() {
incoming.max(RTA_PEAK_FLOOR_DB)
} else {
RTA_PEAK_FLOOR_DB
};
if incoming >= self.values[index] {
self.values[index] = incoming;
if mode == "auto" {
self.expires_at_sample[index] = self.sample_clock.saturating_add(hold_samples);
}
} else if mode == "auto" && self.sample_clock >= self.expires_at_sample[index] {
// A timed hold ends with a hard release to the current detector
// value. There is deliberately no second, continuous peak decay.
self.values[index] = incoming;
self.expires_at_sample[index] = self.sample_clock.saturating_add(hold_samples);
}
}
}
}
#[cfg(target_os = "linux")]
@@ -1164,7 +1243,7 @@ fn build_meter_frame(
if let Some(state) = ppm_state.rta_state.as_mut() {
match state {
RtaEngineState::Iir(bank) => {
finalize_rta_bank(bank, sample_rate, frames);
finalize_rta_bank(bank, sample_rate, frames, rta_config);
ppm_state.last_rta = Some(build_iir_rta_frame(bank, sample_rate, rta_config));
}
RtaEngineState::Fft(fft) => {
@@ -1440,7 +1519,7 @@ fn create_rta_bank(sample_rate: u32, config: &PhoenixRtaConfig) -> RtaBank {
peak_windows,
energies: vec![0.0; len],
levels: vec![-120.0; len],
peaks: vec![-120.0; len],
peak_hold: RtaPeakHold::new(len),
}
}
@@ -1478,7 +1557,7 @@ fn create_fft_state(sample_rate: u32, config: &PhoenixRtaConfig) -> FftRtaState
power_bins: vec![0.0; fft_size / 2],
energies: vec![0.0; len],
levels: vec![-120.0; len],
peaks: vec![-120.0; len],
peak_hold: RtaPeakHold::new(len),
}
}
@@ -1568,23 +1647,19 @@ fn push_spectro_sample(state: &mut SpectroState, l: f32, r: f32) {
}
#[cfg(target_os = "linux")]
fn finalize_rta_bank(bank: &mut RtaBank, sample_rate: u32, block_size: usize) {
let dt = block_size as f32 / sample_rate as f32;
let release_step = RTA_PEAK_DECAY_DB_PER_S * dt;
fn finalize_rta_bank(
bank: &mut RtaBank,
sample_rate: u32,
block_size: usize,
config: &PhoenixRtaConfig,
) {
for i in 0..bank.energies.len() {
let corrected_energy = bank.energies[i] * bank.weighting_corrections[i];
let level_db = (10.0 * corrected_energy.max(1.0e-12).log10()) as f32;
bank.levels[i] = level_db;
let prev_peak = bank.peaks[i];
if !prev_peak.is_finite() || level_db >= prev_peak {
bank.peaks[i] = level_db;
} else {
bank.peaks[i] = (prev_peak - release_step)
.max(level_db)
.max(RTA_PEAK_FLOOR_DB);
}
}
bank.peak_hold
.update(&bank.levels, block_size, sample_rate, config);
}
#[cfg(target_os = "linux")]
@@ -1607,10 +1682,9 @@ fn finalize_fft_state(
&mut state.fft_re_r,
&mut state.fft_im_r,
&mut state.power_bins,
FftPowerScale::IntegratedEnergy,
);
let dt = state.fft_step_samples as f32 / sample_rate as f32;
let release_step = RTA_PEAK_DECAY_DB_PER_S * dt;
let weighting = normalize_weighting(&config.weighting);
for (i, band) in state.mapping.iter().enumerate() {
let mut band_power = 0.0f32;
@@ -1652,15 +1726,10 @@ fn finalize_fft_state(
.max(1.0e-12);
let db = (10.0 * state.energies[i].log10()) as f32;
state.levels[i] = db;
let prev_peak = state.peaks[i];
if !prev_peak.is_finite() || state.levels[i] >= prev_peak {
state.peaks[i] = state.levels[i];
} else {
state.peaks[i] = (prev_peak - release_step)
.max(state.levels[i])
.max(RTA_PEAK_FLOOR_DB);
}
}
state
.peak_hold
.update(&state.levels, state.fft_step_samples, sample_rate, config);
true
}
@@ -1680,6 +1749,7 @@ fn finalize_spectro_state(state: &mut SpectroState, sample_rate: u32) -> bool {
&mut state.fft_re_r,
&mut state.fft_im_r,
&mut state.bins,
FftPowerScale::BinAmplitude,
);
let floor = -160.0f32;
@@ -1702,7 +1772,7 @@ fn build_iir_rta_frame(bank: &RtaBank, sample_rate: u32, config: &PhoenixRtaConf
detector: normalize_rta_detector(&config.detector).to_string(),
response: normalize_rta_integration(&config.integration).to_string(),
bands_avg: bank.levels.clone(),
bands_peak: bank.peaks.clone(),
bands_peak: bank.peak_hold.values.clone(),
centers: bank.centers.clone(),
freq_min: bank.freq_min,
freq_max: bank.freq_max,
@@ -1725,7 +1795,7 @@ fn build_fft_rta_frame(
detector: normalize_rta_detector(&config.detector).to_string(),
response: normalize_rta_integration(&config.integration).to_string(),
bands_avg: state.levels.clone(),
bands_peak: state.peaks.clone(),
bands_peak: state.peak_hold.values.clone(),
centers: state.centers.clone(),
freq_min: state.freq_min,
freq_max: state.freq_max,
@@ -1871,6 +1941,14 @@ fn normalize_rta_integration(value: &str) -> &'static str {
}
}
fn normalize_rta_peak_hold_mode(value: &str) -> &'static str {
match value.trim().to_ascii_lowercase().as_str() {
"off" => "off",
"manual" => "manual",
_ => "auto",
}
}
#[cfg(target_os = "linux")]
fn build_rta_bands(
sample_rate: u32,
@@ -1962,7 +2040,6 @@ fn build_fft_band_mapping(bands: &[RtaBandDef], nyq: f32, bin_count: usize) -> V
.ceil()
.min((bin_count.saturating_sub(1)) as f32) as usize;
let mut bins = Vec::new();
let mut weight_sum = 0.0f32;
for index in start..=end {
let bin_start = index as f32 * bin_width;
let bin_end = bin_start + bin_width;
@@ -1970,9 +2047,8 @@ fn build_fft_band_mapping(bands: &[RtaBandDef], nyq: f32, bin_count: usize) -> V
if overlap > 0.0 {
bins.push(FftBandSeg {
index,
weight: overlap,
weight: (overlap / bin_width).clamp(0.0, 1.0),
});
weight_sum += overlap;
}
}
if bins.is_empty() {
@@ -1983,10 +2059,6 @@ fn build_fft_band_mapping(bands: &[RtaBandDef], nyq: f32, bin_count: usize) -> V
index: idx,
weight: 1.0,
});
weight_sum = 1.0;
}
for seg in &mut bins {
seg.weight /= weight_sum.max(1.0e-12);
}
result.push(FftBandMap {
center: band.center,
@@ -1998,7 +2070,6 @@ fn build_fft_band_mapping(bands: &[RtaBandDef], nyq: f32, bin_count: usize) -> V
result
}
#[cfg(target_os = "linux")]
fn compute_fft_power_bins(
ring_l: &[f32],
ring_r: &[f32],
@@ -2008,14 +2079,17 @@ fn compute_fft_power_bins(
fft_re_r: &mut [f32],
fft_im_r: &mut [f32],
out_power_bins: &mut [f32],
scale: FftPowerScale,
) {
let fft_size = fft_re_l.len();
let mut window_sum = 0.0f32;
let mut window_power_sum = 0.0f32;
for i in 0..fft_size {
let src_idx = (ring_pos + i) % fft_size;
let phase = (2.0 * std::f32::consts::PI * i as f32) / (fft_size.saturating_sub(1) as f32);
let win = 0.5 - 0.5 * phase.cos();
window_sum += win;
window_power_sum += win * win;
fft_re_l[i] = ring_l[src_idx] * win;
fft_im_l[i] = 0.0;
fft_re_r[i] = ring_r[src_idx] * win;
@@ -2026,10 +2100,18 @@ fn compute_fft_power_bins(
fft_in_place(fft_re_r, fft_im_r);
let fft_norm = ((window_sum * 0.5).max(1.0)).powi(2);
let energy_norm = (fft_size as f32 * window_power_sum).max(1.0);
for bin in 0..out_power_bins.len() {
let power_l = fft_re_l[bin] * fft_re_l[bin] + fft_im_l[bin] * fft_im_l[bin];
let power_r = fft_re_r[bin] * fft_re_r[bin] + fft_im_r[bin] * fft_im_r[bin];
out_power_bins[bin] = 0.5 * (power_l + power_r) / fft_norm;
let stereo_power = 0.5 * (power_l + power_r);
out_power_bins[bin] = match scale {
FftPowerScale::BinAmplitude => stereo_power / fft_norm,
FftPowerScale::IntegratedEnergy => {
let one_sided = if bin == 0 { 1.0 } else { 2.0 };
stereo_power * one_sided / energy_norm
}
};
}
}
@@ -2047,7 +2129,6 @@ fn db_gain(db: f32) -> f32 {
10.0f32.powf(db / 20.0)
}
#[cfg(target_os = "linux")]
fn fft_in_place(re: &mut [f32], im: &mut [f32]) {
let n = re.len();
if n == 0 || n != im.len() || !n.is_power_of_two() {
@@ -2134,6 +2215,59 @@ fn band_weighting_gain(f_lo: f32, center: f32, f_hi: f32, mode: &str) -> f32 {
mod tests {
use super::*;
fn integrated_fft_power(fft_size: usize) -> f32 {
let cycles = 64.0f32;
let signal: Vec<f32> = (0..fft_size)
.map(|index| {
(2.0 * std::f32::consts::PI * cycles * index as f32 / fft_size as f32).sin()
})
.collect();
let mut re_l = vec![0.0; fft_size];
let mut im_l = vec![0.0; fft_size];
let mut re_r = vec![0.0; fft_size];
let mut im_r = vec![0.0; fft_size];
let mut bins = vec![0.0; fft_size / 2];
compute_fft_power_bins(
&signal,
&signal,
0,
&mut re_l,
&mut im_l,
&mut re_r,
&mut im_r,
&mut bins,
FftPowerScale::IntegratedEnergy,
);
bins.iter().sum()
}
fn fft_tone_bin_amplitude(fft_size: usize) -> f32 {
let tone_bin = 64usize;
let signal: Vec<f32> = (0..fft_size)
.map(|index| {
(2.0 * std::f32::consts::PI * tone_bin as f32 * index as f32 / fft_size as f32)
.sin()
})
.collect();
let mut re_l = vec![0.0; fft_size];
let mut im_l = vec![0.0; fft_size];
let mut re_r = vec![0.0; fft_size];
let mut im_r = vec![0.0; fft_size];
let mut bins = vec![0.0; fft_size / 2];
compute_fft_power_bins(
&signal,
&signal,
0,
&mut re_l,
&mut im_l,
&mut re_r,
&mut im_r,
&mut bins,
FftPowerScale::BinAmplitude,
);
bins[tone_bin]
}
fn run_rms(period: usize) -> f32 {
let sample_rate = 48_000;
let mut window = create_moving_average_window(sample_rate, 300.0);
@@ -2159,4 +2293,58 @@ mod tests {
assert!((run_rms(period) - reference).abs() < 1.0e-7);
}
}
#[test]
fn fft_integrated_energy_compensates_hann_window_and_fft_size() {
let expected = 0.5f32;
for fft_size in [2048, 4096, 8192, 16384] {
let measured = integrated_fft_power(fft_size);
assert!(
(measured - expected).abs() < 2.0e-4,
"FFT {fft_size}: expected {expected}, got {measured}"
);
}
}
#[test]
fn spectrogram_bin_amplitude_scale_remains_unchanged() {
for fft_size in [2048, 4096, 8192] {
let measured = fft_tone_bin_amplitude(fft_size);
assert!((measured - 1.0).abs() < 2.0e-4);
}
}
#[test]
fn rta_peak_hold_has_timed_manual_off_and_reset_semantics() {
let mut config = PhoenixRtaConfig::default();
config.rta_peak_hold_seconds = 0.1;
let mut hold = RtaPeakHold::new(1);
hold.update(&[-10.0], 0, 1_000, &config);
hold.update(&[-30.0], 99, 1_000, &config);
assert_eq!(hold.values[0], -10.0);
hold.update(&[-30.0], 1, 1_000, &config);
assert_eq!(
hold.values[0], -30.0,
"timed hold must release directly to current"
);
config.rta_peak_hold_mode = "manual".to_string();
hold.update(&[-20.0], 1, 1_000, &config);
hold.update(&[-5.0], 1, 1_000, &config);
hold.update(&[-40.0], 1_000, 1_000, &config);
assert_eq!(hold.values[0], -5.0, "manual hold must not decay");
config.rta_peak_reset_token += 1;
hold.update(&[-40.0], 1, 1_000, &config);
assert_eq!(hold.values[0], -40.0, "reset must release manual hold");
config.rta_peak_hold_mode = "off".to_string();
hold.update(&[-12.0], 1, 1_000, &config);
hold.update(&[-35.0], 1, 1_000, &config);
assert_eq!(
hold.values[0], -35.0,
"off must always follow current value"
);
}
}
+6
View File
@@ -68,6 +68,9 @@ pub struct PhoenixRtaConfig {
pub tau_slow: f32,
pub integration: String,
pub layout: String,
pub rta_peak_hold_mode: String,
pub rta_peak_hold_seconds: f32,
pub rta_peak_reset_token: u64,
pub input_offset_db_l: f32,
pub input_offset_db_r: f32,
pub ppm_din_attack_ms: f32,
@@ -100,6 +103,9 @@ impl Default for PhoenixRtaConfig {
tau_slow: 1.0,
integration: "fast".to_string(),
layout: "rtw".to_string(),
rta_peak_hold_mode: "auto".to_string(),
rta_peak_hold_seconds: 2.5,
rta_peak_reset_token: 0,
input_offset_db_l: -5.0,
input_offset_db_r: -5.0,
ppm_din_attack_ms: 10.0,
+47
View File
@@ -407,6 +407,30 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig {
// Keep the serialized fields for old clients, but normalize their values.
config.tau_fast = 0.125;
config.tau_slow = 1.0;
config.rta_peak_hold_mode = match config
.rta_peak_hold_mode
.trim()
.to_ascii_lowercase()
.as_str()
{
"off" => "off".to_string(),
"manual" => "manual".to_string(),
_ => "auto".to_string(),
};
let requested_hold = if config.rta_peak_hold_seconds.is_finite() {
config.rta_peak_hold_seconds
} else {
2.5
};
config.rta_peak_hold_seconds = if config.layout == "rtw" {
if requested_hold >= 3.25 {
4.0
} else {
2.5
}
} else {
requested_hold.clamp(0.0, 30.0)
};
let offset_l = if config.input_offset_db_l.is_finite() {
config.input_offset_db_l
} else {
@@ -767,6 +791,29 @@ mod tests {
assert_eq!(config.integration, "medium");
}
#[test]
fn rta_peak_hold_config_is_normalized_without_decay_parameter() {
let rtw = normalize_rta_config(PhoenixRtaConfig {
rta_peak_hold_mode: "MANUAL".to_string(),
rta_peak_hold_seconds: 3.7,
rta_peak_reset_token: 12,
layout: "rtw".to_string(),
..PhoenixRtaConfig::default()
});
assert_eq!(rtw.rta_peak_hold_mode, "manual");
assert_eq!(rtw.rta_peak_hold_seconds, 4.0);
assert_eq!(rtw.rta_peak_reset_token, 12);
let extension = normalize_rta_config(PhoenixRtaConfig {
rta_peak_hold_mode: "invalid".to_string(),
rta_peak_hold_seconds: 45.0,
layout: "iec".to_string(),
..PhoenixRtaConfig::default()
});
assert_eq!(extension.rta_peak_hold_mode, "auto");
assert_eq!(extension.rta_peak_hold_seconds, 30.0);
}
#[test]
fn legacy_peak_mode_migrates_to_peak_detector_with_fast_response() {
let config = normalize_rta_config(PhoenixRtaConfig {
+5
View File
@@ -916,6 +916,11 @@ export function buildRtaRuntimeConfig(CONFIG = {}) {
tauSlow: CONFIG.RTA_IIR_TAU_SLOW || 1.0,
integration: CONFIG.RTA_INTEGRATION || 'fast',
layout,
rtaPeakHoldMode: ['off', 'manual'].includes(CONFIG.RTA_PEAK_HOLD_MODE)
? CONFIG.RTA_PEAK_HOLD_MODE
: 'auto',
rtaPeakHoldSeconds: Number.isFinite(CONFIG.RTA_PEAK_HOLD_SEC) ? CONFIG.RTA_PEAK_HOLD_SEC : 2.5,
rtaPeakResetToken: Math.max(0, Math.floor(Number(CONFIG.RTA_PEAK_RESET_TOKEN) || 0)),
inputOffsetDbL: Number.isFinite(CONFIG.INPUT_OFFSET_DB_L) ? CONFIG.INPUT_OFFSET_DB_L : -5,
inputOffsetDbR: Number.isFinite(CONFIG.INPUT_OFFSET_DB_R) ? CONFIG.INPUT_OFFSET_DB_R : -5,
ppmDinAttackMs: 10,
+5 -1
View File
@@ -64,7 +64,7 @@ const CONFIG = {
RTA_BALLISTICS_MODE: 'average', // 'average' | 'peak' | 'both'
RTA_PEAK_HOLD_MODE: 'auto', // 'off' | 'auto' | 'manual'
RTA_PEAK_HOLD_SEC: 2.5,
RTA_PEAK_DECAY_DB_PER_S: 20,
RTA_PEAK_RESET_TOKEN: 0,
RTA_DISPLAY_HOLD_SEC: 0,
RTA_IIR_ORDER: 6,
RTA_IIR_TAU_FAST: 0.125,
@@ -810,6 +810,10 @@ function loadConfig(opts = {}) {
const hold = Number(CONFIG.RTA_PEAK_HOLD_SEC);
CONFIG.RTA_PEAK_HOLD_SEC = hold >= 3.25 ? 4.0 : 2.5;
}
CONFIG.RTA_PEAK_HOLD_MODE = ['off', 'auto', 'manual'].includes(CONFIG.RTA_PEAK_HOLD_MODE)
? CONFIG.RTA_PEAK_HOLD_MODE
: 'auto';
CONFIG.RTA_PEAK_RESET_TOKEN = Math.max(0, Math.floor(Number(CONFIG.RTA_PEAK_RESET_TOKEN) || 0));
if (!Number.isFinite(CONFIG.DBFS_TOP)) CONFIG.DBFS_TOP = CONFIG_DEFAULTS.DBFS_TOP;
if (!Number.isFinite(CONFIG.DBFS_BOTTOM)) CONFIG.DBFS_BOTTOM = CONFIG_DEFAULTS.DBFS_BOTTOM;
// Clamp to sensible bounds while keeping the user-selected display range.
+1 -5
View File
@@ -138,22 +138,18 @@ export function buildBandBinMapping(bands, nyq, binCount) {
const bins = [];
const start = Math.max(0, Math.floor((band.fLo / nyq) * binCount));
const end = Math.min(binCount - 1, Math.ceil((band.fHi / nyq) * binCount));
let weightSum = 0;
for (let i = start; i <= end; i++) {
const binStart = i * binWidth;
const binEnd = binStart + binWidth;
const overlap = Math.max(0, Math.min(binEnd, band.fHi) - Math.max(binStart, band.fLo));
if (overlap > 0) {
bins.push({ index: i, weight: overlap });
weightSum += overlap;
bins.push({ index: i, weight: Math.min(1, overlap / binWidth) });
}
}
if (!bins.length) {
const idx = Math.max(0, Math.min(binCount - 1, Math.round((band.center / nyq) * binCount)));
bins.push({ index: idx, weight: 1 });
weightSum = 1;
}
bins.forEach((b) => { b.weight /= weightSum || 1; });
result.push({
center: band.center,
fLo: band.fLo,
+1 -5
View File
@@ -389,16 +389,12 @@
<option value="auto" selected>Auto</option>
<option value="manual">Manuell</option>
</select>
<small>Peak-Hold Verhalten</small>
<small>Auto gibt den Peak nach 2,5 oder 4 Sekunden direkt auf den aktuellen Messwert frei; Manuell hält bis zum Reset.</small>
</div>
<div class="opt"><label>Hold Time (s)</label>
<input id="opt_rtaHoldTime" type="number" min="0" max="30" step="0.5" style="width:110px">
<small>Dauer der Hold-Anzeige</small>
</div>
<div class="opt"><label>Hold Decay (dB/s)</label>
<input id="opt_rtaDecay" type="number" min="1" max="60" step="1" style="width:110px">
<small>Abfall des Hold-Peaks</small>
</div>
<div class="opt"><label>Display Hold (s)</label>
<input id="opt_rtaDisplayHold" type="number" min="0" max="5" step="0.1" style="width:110px">
<small>Zusatzhalt für Anzeige</small>
+3
View File
@@ -2065,6 +2065,9 @@ if (peakHistScrollSel) {
}
if (resetPeakBtn) {
resetPeakBtn.addEventListener('click', () => {
CONFIG.RTA_PEAK_RESET_TOKEN = Math.max(0, Math.floor(Number(CONFIG.RTA_PEAK_RESET_TOKEN) || 0)) + 1;
saveConfig();
try { env.audio.updateRtaConfig?.(); } catch (_) {}
try {
if (typeof window.resetRealTimeAnalyzerPeakHold === 'function') {
window.resetRealTimeAnalyzerPeakHold();
-2
View File
@@ -293,7 +293,6 @@ function syncUI() {
['opt_rtaBallistics', CONFIG.RTA_BALLISTICS_MODE || 'average'],
['opt_rtaHoldMode', CONFIG.RTA_PEAK_HOLD_MODE || 'auto'],
['opt_rtaHoldTime', CONFIG.RTA_PEAK_HOLD_SEC ?? 2.5],
['opt_rtaDecay', CONFIG.RTA_PEAK_DECAY_DB_PER_S ?? 20],
['opt_rtaDisplayHold', CONFIG.RTA_DISPLAY_HOLD_SEC ?? 0],
['opt_spectroGamma', CONFIG.SPECTRO_GAMMA ?? 0.9, 'val_spectroGamma', (v)=>Number(v).toFixed(2)],
['opt_spectroScroll', CONFIG.SPECTRO_SCROLL_MODE ?? 1],
@@ -1175,7 +1174,6 @@ function wireHandlers(env) {
: requested;
return CONFIG.RTA_PEAK_HOLD_SEC;
});
h('opt_rtaDecay', v => { CONFIG.RTA_PEAK_DECAY_DB_PER_S = clamp(+v, 1, 60); });
h('opt_rtaDisplayHold', v => { CONFIG.RTA_DISPLAY_HOLD_SEC = clamp(+v, 0, 5); });
h('opt_spectroGamma', v => {
const num = clamp(parseFloat(v), 0.3, 1.2);
+58 -25
View File
@@ -180,7 +180,7 @@ export async function render(env, state) {
// Native IIR values already contain the selected power-domain
// integration. A second browser attack/hold stage would falsify it.
const display = displayBase;
applyPeakHold(state, integrated, CONFIG, range);
syncNativePeakHold(state, displayRtaPacket, CONFIG, range, state.mapping.length, 'iir');
state.displayLevels = display;
state.currentRange = range;
if (typeof window !== 'undefined') window.__RTA_STATE__ = state;
@@ -200,16 +200,20 @@ export async function render(env, state) {
);
}
} else if (useNativeFftEngine) {
const nativeLevels = mapNativeFftLevels(displayRtaPacket, CONFIG, range, state.mapping.length);
const ballisticsData = mapNativeFftLevels(displayRtaPacket, CONFIG, range, state.mapping.length);
const nativeLevels = ballisticsData?.primary;
if (!nativeLevels || !nativeLevels.length) {
drawWaiting(g, plotX, plotY);
} else {
const integrated = applyIntegration(state, nativeLevels, CONFIG, range);
// Native FFT packets already contain the selected power-domain
// integration. Integrating them again in the browser adds lag and
// changes the measured value.
const integrated = nativeLevels;
const displayBase = applyDisplayHold(state, integrated, CONFIG, range);
const display = (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars'
? applyRealtimeBarBallistics(state, displayBase, CONFIG, range)
: displayBase;
applyPeakHold(state, integrated, CONFIG, range);
syncNativePeakHold(state, displayRtaPacket, CONFIG, range, state.mapping.length, 'fft');
state.displayLevels = display;
state.currentRange = range;
if (typeof window !== 'undefined') window.__RTA_STATE__ = state;
@@ -224,8 +228,8 @@ export async function render(env, state) {
CONFIG,
range,
freqBounds,
null,
CONFIG.RTA_BALLISTICS_MODE || 'average'
ballisticsData?.overlay || null,
ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average'
);
}
} else if (!analyser || !buf) {
@@ -286,7 +290,7 @@ function buildConfigSignature(CONFIG, nyq, binCount) {
CONFIG.RTA_INTEGRATION,
CONFIG.RTA_PEAK_HOLD_MODE,
CONFIG.RTA_PEAK_HOLD_SEC,
CONFIG.RTA_PEAK_DECAY_DB_PER_S,
CONFIG.RTA_PEAK_RESET_TOKEN,
CONFIG.RTA_DISPLAY_HOLD_SEC,
CONFIG.REALTIME_RENDER_STYLE,
CONFIG.REALTIME_BAR_HOLD_MS,
@@ -415,9 +419,7 @@ function applyPeakHold(state, levels, CONFIG, range) {
const holdSec = Number.isFinite(mapped)
? Math.max(0, mapped)
: Math.max(0, Number(CONFIG.RTA_PEAK_HOLD_SEC) || 0);
const decayRate = Math.max(0, Number(CONFIG.RTA_PEAK_DECAY_DB_PER_S) || 0);
const now = performance.now();
const dt = Math.max(0, (now - (state.holdSampleTs || now)) / 1000);
state.holdSampleTs = now;
if (!state.peakHold || state.peakHold.length !== len) {
@@ -440,18 +442,37 @@ function applyPeakHold(state, levels, CONFIG, range) {
continue;
}
if (mapped === Infinity || mode === 'manual') continue;
let allowDecay = mode === 'off';
if (mode === 'auto') {
allowDecay = (now - (state.lastPeakTime[i] || 0)) >= holdSec * 1000;
}
if (allowDecay && decayRate > 0) {
buffer[i] = Math.max(current, buffer[i] - decayRate * dt);
if (mode === 'auto' && (now - (state.lastPeakTime[i] || 0)) >= holdSec * 1000) {
buffer[i] = current;
state.lastPeakTime[i] = now;
}
buffer[i] = clamp(buffer[i], range.bottom, range.top);
}
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;
@@ -912,16 +933,28 @@ function mapNativeFftLevels(packet, CONFIG, range, expectedLen) {
const bottom = (range && Number.isFinite(range.bottom)) ? range.bottom : FLOOR_DB;
const top = (range && Number.isFinite(range.top)) ? range.top : 9;
const gain = Number(CONFIG.RTA_DISPLAY_GAIN_FFT_DB ?? 0) || 0;
const src = isVectorLike(packet.bands_avg || packet.bands)
? (packet.bands_avg || packet.bands)
: null;
if (!src || (expectedLen && src.length !== expectedLen)) return null;
const out = new Float32Array(src.length);
for (let i = 0; i < src.length; i++) {
const v = Number(src[i]);
out[i] = clamp((Number.isFinite(v) ? v : bottom) + gain, bottom, top);
}
return out;
const ensureLen = (arr) => isVectorLike(arr) && (!expectedLen || arr.length === expectedLen) ? arr : null;
const avgRaw = ensureLen(packet.bands_avg || packet.bands);
const peakRaw = ensureLen(packet.bands_peak);
const requested = ['average', 'peak', 'both'].includes(CONFIG.RTA_BALLISTICS_MODE)
? CONFIG.RTA_BALLISTICS_MODE
: 'average';
const convert = (src) => {
if (!src) return null;
const out = new Float32Array(src.length);
for (let i = 0; i < src.length; i++) {
const value = Number(src[i]);
out[i] = clamp((Number.isFinite(value) ? value : bottom) + gain, bottom, top);
}
return out;
};
const average = convert(avgRaw);
const peak = convert(peakRaw);
if (requested === 'peak' && peak) return { primary: peak, overlay: null, mode: 'peak' };
if (requested === 'both' && average && peak) return { primary: average, overlay: peak, mode: 'both' };
if (average) return { primary: average, overlay: null, mode: 'average' };
if (peak) return { primary: peak, overlay: null, mode: 'peak' };
return null;
}
function isVectorLike(v) {