Fix RTA peak hold and FFT band energy
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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');
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
+54
-21
@@ -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 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 v = Number(src[i]);
|
||||
out[i] = clamp((Number.isFinite(v) ? v : bottom) + gain, bottom, top);
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user