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**
|
- [ ] **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
|
||||||
|
|||||||
@@ -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_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
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user