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** - [ ] **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. - **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. - **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.
- **Falsch/unvollständig:** Ein kontinuierlich fallender Peak ist funktional nicht dasselbe wie Peak Hold. - **Noch offen:** Der achtbandige RTW-Speicher ist noch nicht ergänzt.
- **Aufgabe:** Hold-Zeit, manuellen Hold, Reset, Rücklauf nach Hold-Ende und acht auswählbare Speicherbänder implementieren. Aktuellwert und Holdwert visuell eindeutig trennen. - **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. - **Abnahme:** Hold-Zeiten und Reset-Verhalten stimmen zeitlich und visuell mit der Referenz überein.
- [ ] **8. RTA-Anzeigeoptionen am PortaMonitor-Profil ausrichten** - [ ] **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. - **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. - **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. - **Soll:** FFT ist eine korrekte Zusatzansicht, aber nicht die RTW-IIR-Referenz.
- **Ist:** FFT-RTA und Spektrogramm sind vorhanden. - **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.
- **Falsch:** FFT-Bins werden innerhalb eines Bandes normiert gemittelt statt zur Bandenergie summiert. Rauschsignale und unterschiedlich breite Bänder werden dadurch falsch bewertet. - **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.
- **Aufgabe:** Binleistungen energetisch integrieren, Fensterleistung korrekt kompensieren und FFT-Ergebnisse gegen die IIR-Referenz testen.
- **Abnahme:** Konsistente Pegel bei FFT-Größenwechseln und eindeutig getrennte Kennzeichnung im UI. - **Abnahme:** Konsistente Pegel bei FFT-Größenwechseln und eindeutig getrennte Kennzeichnung im UI.
## Priorität 3 - Stereoanzeigen und visuelles RTW-Verhalten ## 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_FREQ_RANGE: 'lf',
RTA_IIR_ORDER: 2, RTA_IIR_ORDER: 2,
RTA_DETECTOR: 'peak', RTA_DETECTOR: 'peak',
RTA_PEAK_HOLD_MODE: 'manual',
RTA_PEAK_HOLD_SEC: 4,
RTA_PEAK_RESET_TOKEN: 9,
CORR_RESPONSE_S: 2.5, CORR_RESPONSE_S: 2.5,
CORR_RESET_TOKEN: 7, CORR_RESET_TOKEN: 7,
XY_POINTS: 256, XY_POINTS: 256,
@@ -31,6 +34,9 @@ assert.equal(forced.bpo, '1_12');
assert.equal(forced.freqRange, 'norm'); assert.equal(forced.freqRange, 'norm');
assert.equal(forced.order, 6); assert.equal(forced.order, 6);
assert.equal(forced.detector, 'peak'); 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.tauFast, 0.125);
assert.equal(forced.rtwCenters.length, 121); assert.equal(forced.rtwCenters.length, 121);
assert.equal(forced.correlationResponseS, 2.5); 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 // 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. // still updated sample-by-sample; only transport snapshots are rate-limited.
const XY_TARGET_UPDATES_PER_SECOND: u64 = 60; 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; const RTA_PEAK_FLOOR_DB: f32 = -150.0;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
const WAVE_ENV_COLUMNS_PER_SEC: f32 = 9600.0; const WAVE_ENV_COLUMNS_PER_SEC: f32 = 9600.0;
@@ -133,7 +130,7 @@ struct RtaBank {
peak_windows: Option<Vec<SlidingPeak>>, peak_windows: Option<Vec<SlidingPeak>>,
energies: Vec<f64>, energies: Vec<f64>,
levels: Vec<f32>, levels: Vec<f32>,
peaks: Vec<f32>, peak_hold: RtaPeakHold,
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -173,7 +170,89 @@ struct FftRtaState {
power_bins: Vec<f32>, power_bins: Vec<f32>,
energies: Vec<f64>, energies: Vec<f64>,
levels: Vec<f32>, 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")] #[cfg(target_os = "linux")]
@@ -1164,7 +1243,7 @@ fn build_meter_frame(
if let Some(state) = ppm_state.rta_state.as_mut() { if let Some(state) = ppm_state.rta_state.as_mut() {
match state { match state {
RtaEngineState::Iir(bank) => { 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)); ppm_state.last_rta = Some(build_iir_rta_frame(bank, sample_rate, rta_config));
} }
RtaEngineState::Fft(fft) => { RtaEngineState::Fft(fft) => {
@@ -1440,7 +1519,7 @@ fn create_rta_bank(sample_rate: u32, config: &PhoenixRtaConfig) -> RtaBank {
peak_windows, peak_windows,
energies: vec![0.0; len], energies: vec![0.0; len],
levels: vec![-120.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], power_bins: vec![0.0; fft_size / 2],
energies: vec![0.0; len], energies: vec![0.0; len],
levels: vec![-120.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")] #[cfg(target_os = "linux")]
fn finalize_rta_bank(bank: &mut RtaBank, sample_rate: u32, block_size: usize) { fn finalize_rta_bank(
let dt = block_size as f32 / sample_rate as f32; bank: &mut RtaBank,
let release_step = RTA_PEAK_DECAY_DB_PER_S * dt; sample_rate: u32,
block_size: usize,
config: &PhoenixRtaConfig,
) {
for i in 0..bank.energies.len() { for i in 0..bank.energies.len() {
let corrected_energy = bank.energies[i] * bank.weighting_corrections[i]; let corrected_energy = bank.energies[i] * bank.weighting_corrections[i];
let level_db = (10.0 * corrected_energy.max(1.0e-12).log10()) as f32; let level_db = (10.0 * corrected_energy.max(1.0e-12).log10()) as f32;
bank.levels[i] = level_db; 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")] #[cfg(target_os = "linux")]
@@ -1607,10 +1682,9 @@ fn finalize_fft_state(
&mut state.fft_re_r, &mut state.fft_re_r,
&mut state.fft_im_r, &mut state.fft_im_r,
&mut state.power_bins, &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); let weighting = normalize_weighting(&config.weighting);
for (i, band) in state.mapping.iter().enumerate() { for (i, band) in state.mapping.iter().enumerate() {
let mut band_power = 0.0f32; let mut band_power = 0.0f32;
@@ -1652,15 +1726,10 @@ fn finalize_fft_state(
.max(1.0e-12); .max(1.0e-12);
let db = (10.0 * state.energies[i].log10()) as f32; let db = (10.0 * state.energies[i].log10()) as f32;
state.levels[i] = db; 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 true
} }
@@ -1680,6 +1749,7 @@ fn finalize_spectro_state(state: &mut SpectroState, sample_rate: u32) -> bool {
&mut state.fft_re_r, &mut state.fft_re_r,
&mut state.fft_im_r, &mut state.fft_im_r,
&mut state.bins, &mut state.bins,
FftPowerScale::BinAmplitude,
); );
let floor = -160.0f32; 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(), detector: normalize_rta_detector(&config.detector).to_string(),
response: normalize_rta_integration(&config.integration).to_string(), response: normalize_rta_integration(&config.integration).to_string(),
bands_avg: bank.levels.clone(), bands_avg: bank.levels.clone(),
bands_peak: bank.peaks.clone(), bands_peak: bank.peak_hold.values.clone(),
centers: bank.centers.clone(), centers: bank.centers.clone(),
freq_min: bank.freq_min, freq_min: bank.freq_min,
freq_max: bank.freq_max, freq_max: bank.freq_max,
@@ -1725,7 +1795,7 @@ fn build_fft_rta_frame(
detector: normalize_rta_detector(&config.detector).to_string(), detector: normalize_rta_detector(&config.detector).to_string(),
response: normalize_rta_integration(&config.integration).to_string(), response: normalize_rta_integration(&config.integration).to_string(),
bands_avg: state.levels.clone(), bands_avg: state.levels.clone(),
bands_peak: state.peaks.clone(), bands_peak: state.peak_hold.values.clone(),
centers: state.centers.clone(), centers: state.centers.clone(),
freq_min: state.freq_min, freq_min: state.freq_min,
freq_max: state.freq_max, 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")] #[cfg(target_os = "linux")]
fn build_rta_bands( fn build_rta_bands(
sample_rate: u32, sample_rate: u32,
@@ -1962,7 +2040,6 @@ fn build_fft_band_mapping(bands: &[RtaBandDef], nyq: f32, bin_count: usize) -> V
.ceil() .ceil()
.min((bin_count.saturating_sub(1)) as f32) as usize; .min((bin_count.saturating_sub(1)) as f32) as usize;
let mut bins = Vec::new(); let mut bins = Vec::new();
let mut weight_sum = 0.0f32;
for index in start..=end { for index in start..=end {
let bin_start = index as f32 * bin_width; let bin_start = index as f32 * bin_width;
let bin_end = bin_start + 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 { if overlap > 0.0 {
bins.push(FftBandSeg { bins.push(FftBandSeg {
index, index,
weight: overlap, weight: (overlap / bin_width).clamp(0.0, 1.0),
}); });
weight_sum += overlap;
} }
} }
if bins.is_empty() { if bins.is_empty() {
@@ -1983,10 +2059,6 @@ fn build_fft_band_mapping(bands: &[RtaBandDef], nyq: f32, bin_count: usize) -> V
index: idx, index: idx,
weight: 1.0, weight: 1.0,
}); });
weight_sum = 1.0;
}
for seg in &mut bins {
seg.weight /= weight_sum.max(1.0e-12);
} }
result.push(FftBandMap { result.push(FftBandMap {
center: band.center, center: band.center,
@@ -1998,7 +2070,6 @@ fn build_fft_band_mapping(bands: &[RtaBandDef], nyq: f32, bin_count: usize) -> V
result result
} }
#[cfg(target_os = "linux")]
fn compute_fft_power_bins( fn compute_fft_power_bins(
ring_l: &[f32], ring_l: &[f32],
ring_r: &[f32], ring_r: &[f32],
@@ -2008,14 +2079,17 @@ fn compute_fft_power_bins(
fft_re_r: &mut [f32], fft_re_r: &mut [f32],
fft_im_r: &mut [f32], fft_im_r: &mut [f32],
out_power_bins: &mut [f32], out_power_bins: &mut [f32],
scale: FftPowerScale,
) { ) {
let fft_size = fft_re_l.len(); let fft_size = fft_re_l.len();
let mut window_sum = 0.0f32; let mut window_sum = 0.0f32;
let mut window_power_sum = 0.0f32;
for i in 0..fft_size { for i in 0..fft_size {
let src_idx = (ring_pos + i) % 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 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(); let win = 0.5 - 0.5 * phase.cos();
window_sum += win; window_sum += win;
window_power_sum += win * win;
fft_re_l[i] = ring_l[src_idx] * win; fft_re_l[i] = ring_l[src_idx] * win;
fft_im_l[i] = 0.0; fft_im_l[i] = 0.0;
fft_re_r[i] = ring_r[src_idx] * win; 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); fft_in_place(fft_re_r, fft_im_r);
let fft_norm = ((window_sum * 0.5).max(1.0)).powi(2); 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() { 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_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]; 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) 10.0f32.powf(db / 20.0)
} }
#[cfg(target_os = "linux")]
fn fft_in_place(re: &mut [f32], im: &mut [f32]) { fn fft_in_place(re: &mut [f32], im: &mut [f32]) {
let n = re.len(); let n = re.len();
if n == 0 || n != im.len() || !n.is_power_of_two() { 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 { mod tests {
use super::*; 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 { fn run_rms(period: usize) -> f32 {
let sample_rate = 48_000; let sample_rate = 48_000;
let mut window = create_moving_average_window(sample_rate, 300.0); 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); 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 tau_slow: f32,
pub integration: String, pub integration: String,
pub layout: 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_l: f32,
pub input_offset_db_r: f32, pub input_offset_db_r: f32,
pub ppm_din_attack_ms: f32, pub ppm_din_attack_ms: f32,
@@ -100,6 +103,9 @@ impl Default for PhoenixRtaConfig {
tau_slow: 1.0, tau_slow: 1.0,
integration: "fast".to_string(), integration: "fast".to_string(),
layout: "rtw".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_l: -5.0,
input_offset_db_r: -5.0, input_offset_db_r: -5.0,
ppm_din_attack_ms: 10.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. // Keep the serialized fields for old clients, but normalize their values.
config.tau_fast = 0.125; config.tau_fast = 0.125;
config.tau_slow = 1.0; 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() { let offset_l = if config.input_offset_db_l.is_finite() {
config.input_offset_db_l config.input_offset_db_l
} else { } else {
@@ -767,6 +791,29 @@ mod tests {
assert_eq!(config.integration, "medium"); 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] #[test]
fn legacy_peak_mode_migrates_to_peak_detector_with_fast_response() { fn legacy_peak_mode_migrates_to_peak_detector_with_fast_response() {
let config = normalize_rta_config(PhoenixRtaConfig { 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, tauSlow: CONFIG.RTA_IIR_TAU_SLOW || 1.0,
integration: CONFIG.RTA_INTEGRATION || 'fast', integration: CONFIG.RTA_INTEGRATION || 'fast',
layout, 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, 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, inputOffsetDbR: Number.isFinite(CONFIG.INPUT_OFFSET_DB_R) ? CONFIG.INPUT_OFFSET_DB_R : -5,
ppmDinAttackMs: 10, ppmDinAttackMs: 10,
+5 -1
View File
@@ -64,7 +64,7 @@ const CONFIG = {
RTA_BALLISTICS_MODE: 'average', // 'average' | 'peak' | 'both' RTA_BALLISTICS_MODE: 'average', // 'average' | 'peak' | 'both'
RTA_PEAK_HOLD_MODE: 'auto', // 'off' | 'auto' | 'manual' RTA_PEAK_HOLD_MODE: 'auto', // 'off' | 'auto' | 'manual'
RTA_PEAK_HOLD_SEC: 2.5, RTA_PEAK_HOLD_SEC: 2.5,
RTA_PEAK_DECAY_DB_PER_S: 20, RTA_PEAK_RESET_TOKEN: 0,
RTA_DISPLAY_HOLD_SEC: 0, RTA_DISPLAY_HOLD_SEC: 0,
RTA_IIR_ORDER: 6, RTA_IIR_ORDER: 6,
RTA_IIR_TAU_FAST: 0.125, RTA_IIR_TAU_FAST: 0.125,
@@ -810,6 +810,10 @@ function loadConfig(opts = {}) {
const hold = Number(CONFIG.RTA_PEAK_HOLD_SEC); const hold = Number(CONFIG.RTA_PEAK_HOLD_SEC);
CONFIG.RTA_PEAK_HOLD_SEC = hold >= 3.25 ? 4.0 : 2.5; 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_TOP)) CONFIG.DBFS_TOP = CONFIG_DEFAULTS.DBFS_TOP;
if (!Number.isFinite(CONFIG.DBFS_BOTTOM)) CONFIG.DBFS_BOTTOM = CONFIG_DEFAULTS.DBFS_BOTTOM; if (!Number.isFinite(CONFIG.DBFS_BOTTOM)) CONFIG.DBFS_BOTTOM = CONFIG_DEFAULTS.DBFS_BOTTOM;
// Clamp to sensible bounds while keeping the user-selected display range. // 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 bins = [];
const start = Math.max(0, Math.floor((band.fLo / nyq) * binCount)); const start = Math.max(0, Math.floor((band.fLo / nyq) * binCount));
const end = Math.min(binCount - 1, Math.ceil((band.fHi / nyq) * binCount)); const end = Math.min(binCount - 1, Math.ceil((band.fHi / nyq) * binCount));
let weightSum = 0;
for (let i = start; i <= end; i++) { for (let i = start; i <= end; i++) {
const binStart = i * binWidth; const binStart = i * binWidth;
const binEnd = binStart + binWidth; const binEnd = binStart + binWidth;
const overlap = Math.max(0, Math.min(binEnd, band.fHi) - Math.max(binStart, band.fLo)); const overlap = Math.max(0, Math.min(binEnd, band.fHi) - Math.max(binStart, band.fLo));
if (overlap > 0) { if (overlap > 0) {
bins.push({ index: i, weight: overlap }); bins.push({ index: i, weight: Math.min(1, overlap / binWidth) });
weightSum += overlap;
} }
} }
if (!bins.length) { if (!bins.length) {
const idx = Math.max(0, Math.min(binCount - 1, Math.round((band.center / nyq) * binCount))); const idx = Math.max(0, Math.min(binCount - 1, Math.round((band.center / nyq) * binCount)));
bins.push({ index: idx, weight: 1 }); bins.push({ index: idx, weight: 1 });
weightSum = 1;
} }
bins.forEach((b) => { b.weight /= weightSum || 1; });
result.push({ result.push({
center: band.center, center: band.center,
fLo: band.fLo, fLo: band.fLo,
+1 -5
View File
@@ -389,16 +389,12 @@
<option value="auto" selected>Auto</option> <option value="auto" selected>Auto</option>
<option value="manual">Manuell</option> <option value="manual">Manuell</option>
</select> </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>
<div class="opt"><label>Hold Time (s)</label> <div class="opt"><label>Hold Time (s)</label>
<input id="opt_rtaHoldTime" type="number" min="0" max="30" step="0.5" style="width:110px"> <input id="opt_rtaHoldTime" type="number" min="0" max="30" step="0.5" style="width:110px">
<small>Dauer der Hold-Anzeige</small> <small>Dauer der Hold-Anzeige</small>
</div> </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> <div class="opt"><label>Display Hold (s)</label>
<input id="opt_rtaDisplayHold" type="number" min="0" max="5" step="0.1" style="width:110px"> <input id="opt_rtaDisplayHold" type="number" min="0" max="5" step="0.1" style="width:110px">
<small>Zusatzhalt für Anzeige</small> <small>Zusatzhalt für Anzeige</small>
+3
View File
@@ -2065,6 +2065,9 @@ if (peakHistScrollSel) {
} }
if (resetPeakBtn) { if (resetPeakBtn) {
resetPeakBtn.addEventListener('click', () => { 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 { try {
if (typeof window.resetRealTimeAnalyzerPeakHold === 'function') { if (typeof window.resetRealTimeAnalyzerPeakHold === 'function') {
window.resetRealTimeAnalyzerPeakHold(); window.resetRealTimeAnalyzerPeakHold();
-2
View File
@@ -293,7 +293,6 @@ function syncUI() {
['opt_rtaBallistics', CONFIG.RTA_BALLISTICS_MODE || 'average'], ['opt_rtaBallistics', CONFIG.RTA_BALLISTICS_MODE || 'average'],
['opt_rtaHoldMode', CONFIG.RTA_PEAK_HOLD_MODE || 'auto'], ['opt_rtaHoldMode', CONFIG.RTA_PEAK_HOLD_MODE || 'auto'],
['opt_rtaHoldTime', CONFIG.RTA_PEAK_HOLD_SEC ?? 2.5], ['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_rtaDisplayHold', CONFIG.RTA_DISPLAY_HOLD_SEC ?? 0],
['opt_spectroGamma', CONFIG.SPECTRO_GAMMA ?? 0.9, 'val_spectroGamma', (v)=>Number(v).toFixed(2)], ['opt_spectroGamma', CONFIG.SPECTRO_GAMMA ?? 0.9, 'val_spectroGamma', (v)=>Number(v).toFixed(2)],
['opt_spectroScroll', CONFIG.SPECTRO_SCROLL_MODE ?? 1], ['opt_spectroScroll', CONFIG.SPECTRO_SCROLL_MODE ?? 1],
@@ -1175,7 +1174,6 @@ function wireHandlers(env) {
: requested; : requested;
return CONFIG.RTA_PEAK_HOLD_SEC; 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_rtaDisplayHold', v => { CONFIG.RTA_DISPLAY_HOLD_SEC = clamp(+v, 0, 5); });
h('opt_spectroGamma', v => { h('opt_spectroGamma', v => {
const num = clamp(parseFloat(v), 0.3, 1.2); const num = clamp(parseFloat(v), 0.3, 1.2);
+54 -21
View File
@@ -180,7 +180,7 @@ export async function render(env, state) {
// Native IIR values already contain the selected power-domain // Native IIR values already contain the selected power-domain
// integration. A second browser attack/hold stage would falsify it. // integration. A second browser attack/hold stage would falsify it.
const display = displayBase; const display = displayBase;
applyPeakHold(state, integrated, CONFIG, range); syncNativePeakHold(state, displayRtaPacket, CONFIG, range, state.mapping.length, 'iir');
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;
@@ -200,16 +200,20 @@ export async function render(env, state) {
); );
} }
} else if (useNativeFftEngine) { } 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) { if (!nativeLevels || !nativeLevels.length) {
drawWaiting(g, plotX, plotY); drawWaiting(g, plotX, plotY);
} else { } 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 displayBase = applyDisplayHold(state, integrated, CONFIG, range);
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;
applyPeakHold(state, integrated, CONFIG, range); syncNativePeakHold(state, displayRtaPacket, CONFIG, range, state.mapping.length, 'fft');
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;
@@ -224,8 +228,8 @@ export async function render(env, state) {
CONFIG, CONFIG,
range, range,
freqBounds, freqBounds,
null, ballisticsData?.overlay || null,
CONFIG.RTA_BALLISTICS_MODE || 'average' ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average'
); );
} }
} else if (!analyser || !buf) { } else if (!analyser || !buf) {
@@ -286,7 +290,7 @@ function buildConfigSignature(CONFIG, nyq, binCount) {
CONFIG.RTA_INTEGRATION, CONFIG.RTA_INTEGRATION,
CONFIG.RTA_PEAK_HOLD_MODE, CONFIG.RTA_PEAK_HOLD_MODE,
CONFIG.RTA_PEAK_HOLD_SEC, CONFIG.RTA_PEAK_HOLD_SEC,
CONFIG.RTA_PEAK_DECAY_DB_PER_S, CONFIG.RTA_PEAK_RESET_TOKEN,
CONFIG.RTA_DISPLAY_HOLD_SEC, CONFIG.RTA_DISPLAY_HOLD_SEC,
CONFIG.REALTIME_RENDER_STYLE, CONFIG.REALTIME_RENDER_STYLE,
CONFIG.REALTIME_BAR_HOLD_MS, CONFIG.REALTIME_BAR_HOLD_MS,
@@ -415,9 +419,7 @@ function applyPeakHold(state, levels, CONFIG, range) {
const holdSec = Number.isFinite(mapped) const holdSec = Number.isFinite(mapped)
? Math.max(0, mapped) ? Math.max(0, mapped)
: Math.max(0, Number(CONFIG.RTA_PEAK_HOLD_SEC) || 0); : 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 now = performance.now();
const dt = Math.max(0, (now - (state.holdSampleTs || now)) / 1000);
state.holdSampleTs = now; state.holdSampleTs = now;
if (!state.peakHold || state.peakHold.length !== len) { if (!state.peakHold || state.peakHold.length !== len) {
@@ -440,18 +442,37 @@ function applyPeakHold(state, levels, CONFIG, range) {
continue; continue;
} }
if (mapped === Infinity || mode === 'manual') continue; if (mapped === Infinity || mode === 'manual') continue;
let allowDecay = mode === 'off'; if (mode === 'auto' && (now - (state.lastPeakTime[i] || 0)) >= holdSec * 1000) {
if (mode === 'auto') { buffer[i] = current;
allowDecay = (now - (state.lastPeakTime[i] || 0)) >= holdSec * 1000; state.lastPeakTime[i] = now;
}
if (allowDecay && decayRate > 0) {
buffer[i] = Math.max(current, buffer[i] - decayRate * dt);
} }
buffer[i] = clamp(buffer[i], range.bottom, range.top); buffer[i] = clamp(buffer[i], range.bottom, range.top);
} }
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;
@@ -912,16 +933,28 @@ function mapNativeFftLevels(packet, CONFIG, range, expectedLen) {
const bottom = (range && Number.isFinite(range.bottom)) ? range.bottom : FLOOR_DB; const bottom = (range && Number.isFinite(range.bottom)) ? range.bottom : FLOOR_DB;
const top = (range && Number.isFinite(range.top)) ? range.top : 9; const top = (range && Number.isFinite(range.top)) ? range.top : 9;
const gain = Number(CONFIG.RTA_DISPLAY_GAIN_FFT_DB ?? 0) || 0; const gain = Number(CONFIG.RTA_DISPLAY_GAIN_FFT_DB ?? 0) || 0;
const src = isVectorLike(packet.bands_avg || packet.bands) const ensureLen = (arr) => isVectorLike(arr) && (!expectedLen || arr.length === expectedLen) ? arr : null;
? (packet.bands_avg || packet.bands) const avgRaw = ensureLen(packet.bands_avg || packet.bands);
: null; const peakRaw = ensureLen(packet.bands_peak);
if (!src || (expectedLen && src.length !== expectedLen)) return null; 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); const out = new Float32Array(src.length);
for (let i = 0; i < src.length; i++) { for (let i = 0; i < src.length; i++) {
const v = Number(src[i]); const value = Number(src[i]);
out[i] = clamp((Number.isFinite(v) ? v : bottom) + gain, bottom, top); out[i] = clamp((Number.isFinite(value) ? value : bottom) + gain, bottom, top);
} }
return out; 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) { function isVectorLike(v) {