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
+230 -42
View File
@@ -40,9 +40,6 @@ use crate::{
// 128-sample periods at 48 kHz -> 62.5 visual updates/s. The DSP history is
// still updated sample-by-sample; only transport snapshots are rate-limited.
const XY_TARGET_UPDATES_PER_SECOND: u64 = 60;
#[cfg(target_os = "linux")]
const RTA_PEAK_DECAY_DB_PER_S: f32 = 10.0;
#[cfg(target_os = "linux")]
const RTA_PEAK_FLOOR_DB: f32 = -150.0;
#[cfg(target_os = "linux")]
const WAVE_ENV_COLUMNS_PER_SEC: f32 = 9600.0;
@@ -133,7 +130,7 @@ struct RtaBank {
peak_windows: Option<Vec<SlidingPeak>>,
energies: Vec<f64>,
levels: Vec<f32>,
peaks: Vec<f32>,
peak_hold: RtaPeakHold,
}
#[cfg(target_os = "linux")]
@@ -173,7 +170,89 @@ struct FftRtaState {
power_bins: Vec<f32>,
energies: Vec<f64>,
levels: Vec<f32>,
peaks: Vec<f32>,
peak_hold: RtaPeakHold,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum FftPowerScale {
BinAmplitude,
IntegratedEnergy,
}
struct RtaPeakHold {
values: Vec<f32>,
expires_at_sample: Vec<u64>,
sample_clock: u64,
mode: String,
hold_samples: u64,
reset_token: u64,
}
impl RtaPeakHold {
fn new(len: usize) -> Self {
Self {
values: vec![RTA_PEAK_FLOOR_DB; len],
expires_at_sample: vec![0; len],
sample_clock: 0,
mode: String::new(),
hold_samples: 0,
reset_token: 0,
}
}
fn update(
&mut self,
current: &[f32],
advanced_samples: usize,
sample_rate: u32,
config: &PhoenixRtaConfig,
) {
self.sample_clock = self.sample_clock.saturating_add(advanced_samples as u64);
let mode = normalize_rta_peak_hold_mode(&config.rta_peak_hold_mode);
let hold_samples = (f64::from(sample_rate.max(1))
* f64::from(config.rta_peak_hold_seconds.max(0.0)))
.round()
.clamp(0.0, u64::MAX as f64) as u64;
let reset = self.mode != mode
|| self.hold_samples != hold_samples
|| self.reset_token != config.rta_peak_reset_token;
if self.values.len() != current.len() {
self.values.resize(current.len(), RTA_PEAK_FLOOR_DB);
self.expires_at_sample.resize(current.len(), 0);
}
if reset {
self.mode = mode.to_string();
self.hold_samples = hold_samples;
self.reset_token = config.rta_peak_reset_token;
self.values.copy_from_slice(current);
self.expires_at_sample.fill(0);
}
if mode == "off" {
self.values.copy_from_slice(current);
self.expires_at_sample.fill(0);
return;
}
for (index, &incoming) in current.iter().enumerate() {
let incoming = if incoming.is_finite() {
incoming.max(RTA_PEAK_FLOOR_DB)
} else {
RTA_PEAK_FLOOR_DB
};
if incoming >= self.values[index] {
self.values[index] = incoming;
if mode == "auto" {
self.expires_at_sample[index] = self.sample_clock.saturating_add(hold_samples);
}
} else if mode == "auto" && self.sample_clock >= self.expires_at_sample[index] {
// A timed hold ends with a hard release to the current detector
// value. There is deliberately no second, continuous peak decay.
self.values[index] = incoming;
self.expires_at_sample[index] = self.sample_clock.saturating_add(hold_samples);
}
}
}
}
#[cfg(target_os = "linux")]
@@ -1164,7 +1243,7 @@ fn build_meter_frame(
if let Some(state) = ppm_state.rta_state.as_mut() {
match state {
RtaEngineState::Iir(bank) => {
finalize_rta_bank(bank, sample_rate, frames);
finalize_rta_bank(bank, sample_rate, frames, rta_config);
ppm_state.last_rta = Some(build_iir_rta_frame(bank, sample_rate, rta_config));
}
RtaEngineState::Fft(fft) => {
@@ -1440,7 +1519,7 @@ fn create_rta_bank(sample_rate: u32, config: &PhoenixRtaConfig) -> RtaBank {
peak_windows,
energies: vec![0.0; len],
levels: vec![-120.0; len],
peaks: vec![-120.0; len],
peak_hold: RtaPeakHold::new(len),
}
}
@@ -1478,7 +1557,7 @@ fn create_fft_state(sample_rate: u32, config: &PhoenixRtaConfig) -> FftRtaState
power_bins: vec![0.0; fft_size / 2],
energies: vec![0.0; len],
levels: vec![-120.0; len],
peaks: vec![-120.0; len],
peak_hold: RtaPeakHold::new(len),
}
}
@@ -1568,23 +1647,19 @@ fn push_spectro_sample(state: &mut SpectroState, l: f32, r: f32) {
}
#[cfg(target_os = "linux")]
fn finalize_rta_bank(bank: &mut RtaBank, sample_rate: u32, block_size: usize) {
let dt = block_size as f32 / sample_rate as f32;
let release_step = RTA_PEAK_DECAY_DB_PER_S * dt;
fn finalize_rta_bank(
bank: &mut RtaBank,
sample_rate: u32,
block_size: usize,
config: &PhoenixRtaConfig,
) {
for i in 0..bank.energies.len() {
let corrected_energy = bank.energies[i] * bank.weighting_corrections[i];
let level_db = (10.0 * corrected_energy.max(1.0e-12).log10()) as f32;
bank.levels[i] = level_db;
let prev_peak = bank.peaks[i];
if !prev_peak.is_finite() || level_db >= prev_peak {
bank.peaks[i] = level_db;
} else {
bank.peaks[i] = (prev_peak - release_step)
.max(level_db)
.max(RTA_PEAK_FLOOR_DB);
}
}
bank.peak_hold
.update(&bank.levels, block_size, sample_rate, config);
}
#[cfg(target_os = "linux")]
@@ -1607,10 +1682,9 @@ fn finalize_fft_state(
&mut state.fft_re_r,
&mut state.fft_im_r,
&mut state.power_bins,
FftPowerScale::IntegratedEnergy,
);
let dt = state.fft_step_samples as f32 / sample_rate as f32;
let release_step = RTA_PEAK_DECAY_DB_PER_S * dt;
let weighting = normalize_weighting(&config.weighting);
for (i, band) in state.mapping.iter().enumerate() {
let mut band_power = 0.0f32;
@@ -1652,15 +1726,10 @@ fn finalize_fft_state(
.max(1.0e-12);
let db = (10.0 * state.energies[i].log10()) as f32;
state.levels[i] = db;
let prev_peak = state.peaks[i];
if !prev_peak.is_finite() || state.levels[i] >= prev_peak {
state.peaks[i] = state.levels[i];
} else {
state.peaks[i] = (prev_peak - release_step)
.max(state.levels[i])
.max(RTA_PEAK_FLOOR_DB);
}
}
state
.peak_hold
.update(&state.levels, state.fft_step_samples, sample_rate, config);
true
}
@@ -1680,6 +1749,7 @@ fn finalize_spectro_state(state: &mut SpectroState, sample_rate: u32) -> bool {
&mut state.fft_re_r,
&mut state.fft_im_r,
&mut state.bins,
FftPowerScale::BinAmplitude,
);
let floor = -160.0f32;
@@ -1702,7 +1772,7 @@ fn build_iir_rta_frame(bank: &RtaBank, sample_rate: u32, config: &PhoenixRtaConf
detector: normalize_rta_detector(&config.detector).to_string(),
response: normalize_rta_integration(&config.integration).to_string(),
bands_avg: bank.levels.clone(),
bands_peak: bank.peaks.clone(),
bands_peak: bank.peak_hold.values.clone(),
centers: bank.centers.clone(),
freq_min: bank.freq_min,
freq_max: bank.freq_max,
@@ -1725,7 +1795,7 @@ fn build_fft_rta_frame(
detector: normalize_rta_detector(&config.detector).to_string(),
response: normalize_rta_integration(&config.integration).to_string(),
bands_avg: state.levels.clone(),
bands_peak: state.peaks.clone(),
bands_peak: state.peak_hold.values.clone(),
centers: state.centers.clone(),
freq_min: state.freq_min,
freq_max: state.freq_max,
@@ -1871,6 +1941,14 @@ fn normalize_rta_integration(value: &str) -> &'static str {
}
}
fn normalize_rta_peak_hold_mode(value: &str) -> &'static str {
match value.trim().to_ascii_lowercase().as_str() {
"off" => "off",
"manual" => "manual",
_ => "auto",
}
}
#[cfg(target_os = "linux")]
fn build_rta_bands(
sample_rate: u32,
@@ -1962,7 +2040,6 @@ fn build_fft_band_mapping(bands: &[RtaBandDef], nyq: f32, bin_count: usize) -> V
.ceil()
.min((bin_count.saturating_sub(1)) as f32) as usize;
let mut bins = Vec::new();
let mut weight_sum = 0.0f32;
for index in start..=end {
let bin_start = index as f32 * bin_width;
let bin_end = bin_start + bin_width;
@@ -1970,9 +2047,8 @@ fn build_fft_band_mapping(bands: &[RtaBandDef], nyq: f32, bin_count: usize) -> V
if overlap > 0.0 {
bins.push(FftBandSeg {
index,
weight: overlap,
weight: (overlap / bin_width).clamp(0.0, 1.0),
});
weight_sum += overlap;
}
}
if bins.is_empty() {
@@ -1983,10 +2059,6 @@ fn build_fft_band_mapping(bands: &[RtaBandDef], nyq: f32, bin_count: usize) -> V
index: idx,
weight: 1.0,
});
weight_sum = 1.0;
}
for seg in &mut bins {
seg.weight /= weight_sum.max(1.0e-12);
}
result.push(FftBandMap {
center: band.center,
@@ -1998,7 +2070,6 @@ fn build_fft_band_mapping(bands: &[RtaBandDef], nyq: f32, bin_count: usize) -> V
result
}
#[cfg(target_os = "linux")]
fn compute_fft_power_bins(
ring_l: &[f32],
ring_r: &[f32],
@@ -2008,14 +2079,17 @@ fn compute_fft_power_bins(
fft_re_r: &mut [f32],
fft_im_r: &mut [f32],
out_power_bins: &mut [f32],
scale: FftPowerScale,
) {
let fft_size = fft_re_l.len();
let mut window_sum = 0.0f32;
let mut window_power_sum = 0.0f32;
for i in 0..fft_size {
let src_idx = (ring_pos + i) % fft_size;
let phase = (2.0 * std::f32::consts::PI * i as f32) / (fft_size.saturating_sub(1) as f32);
let win = 0.5 - 0.5 * phase.cos();
window_sum += win;
window_power_sum += win * win;
fft_re_l[i] = ring_l[src_idx] * win;
fft_im_l[i] = 0.0;
fft_re_r[i] = ring_r[src_idx] * win;
@@ -2026,10 +2100,18 @@ fn compute_fft_power_bins(
fft_in_place(fft_re_r, fft_im_r);
let fft_norm = ((window_sum * 0.5).max(1.0)).powi(2);
let energy_norm = (fft_size as f32 * window_power_sum).max(1.0);
for bin in 0..out_power_bins.len() {
let power_l = fft_re_l[bin] * fft_re_l[bin] + fft_im_l[bin] * fft_im_l[bin];
let power_r = fft_re_r[bin] * fft_re_r[bin] + fft_im_r[bin] * fft_im_r[bin];
out_power_bins[bin] = 0.5 * (power_l + power_r) / fft_norm;
let stereo_power = 0.5 * (power_l + power_r);
out_power_bins[bin] = match scale {
FftPowerScale::BinAmplitude => stereo_power / fft_norm,
FftPowerScale::IntegratedEnergy => {
let one_sided = if bin == 0 { 1.0 } else { 2.0 };
stereo_power * one_sided / energy_norm
}
};
}
}
@@ -2047,7 +2129,6 @@ fn db_gain(db: f32) -> f32 {
10.0f32.powf(db / 20.0)
}
#[cfg(target_os = "linux")]
fn fft_in_place(re: &mut [f32], im: &mut [f32]) {
let n = re.len();
if n == 0 || n != im.len() || !n.is_power_of_two() {
@@ -2134,6 +2215,59 @@ fn band_weighting_gain(f_lo: f32, center: f32, f_hi: f32, mode: &str) -> f32 {
mod tests {
use super::*;
fn integrated_fft_power(fft_size: usize) -> f32 {
let cycles = 64.0f32;
let signal: Vec<f32> = (0..fft_size)
.map(|index| {
(2.0 * std::f32::consts::PI * cycles * index as f32 / fft_size as f32).sin()
})
.collect();
let mut re_l = vec![0.0; fft_size];
let mut im_l = vec![0.0; fft_size];
let mut re_r = vec![0.0; fft_size];
let mut im_r = vec![0.0; fft_size];
let mut bins = vec![0.0; fft_size / 2];
compute_fft_power_bins(
&signal,
&signal,
0,
&mut re_l,
&mut im_l,
&mut re_r,
&mut im_r,
&mut bins,
FftPowerScale::IntegratedEnergy,
);
bins.iter().sum()
}
fn fft_tone_bin_amplitude(fft_size: usize) -> f32 {
let tone_bin = 64usize;
let signal: Vec<f32> = (0..fft_size)
.map(|index| {
(2.0 * std::f32::consts::PI * tone_bin as f32 * index as f32 / fft_size as f32)
.sin()
})
.collect();
let mut re_l = vec![0.0; fft_size];
let mut im_l = vec![0.0; fft_size];
let mut re_r = vec![0.0; fft_size];
let mut im_r = vec![0.0; fft_size];
let mut bins = vec![0.0; fft_size / 2];
compute_fft_power_bins(
&signal,
&signal,
0,
&mut re_l,
&mut im_l,
&mut re_r,
&mut im_r,
&mut bins,
FftPowerScale::BinAmplitude,
);
bins[tone_bin]
}
fn run_rms(period: usize) -> f32 {
let sample_rate = 48_000;
let mut window = create_moving_average_window(sample_rate, 300.0);
@@ -2159,4 +2293,58 @@ mod tests {
assert!((run_rms(period) - reference).abs() < 1.0e-7);
}
}
#[test]
fn fft_integrated_energy_compensates_hann_window_and_fft_size() {
let expected = 0.5f32;
for fft_size in [2048, 4096, 8192, 16384] {
let measured = integrated_fft_power(fft_size);
assert!(
(measured - expected).abs() < 2.0e-4,
"FFT {fft_size}: expected {expected}, got {measured}"
);
}
}
#[test]
fn spectrogram_bin_amplitude_scale_remains_unchanged() {
for fft_size in [2048, 4096, 8192] {
let measured = fft_tone_bin_amplitude(fft_size);
assert!((measured - 1.0).abs() < 2.0e-4);
}
}
#[test]
fn rta_peak_hold_has_timed_manual_off_and_reset_semantics() {
let mut config = PhoenixRtaConfig::default();
config.rta_peak_hold_seconds = 0.1;
let mut hold = RtaPeakHold::new(1);
hold.update(&[-10.0], 0, 1_000, &config);
hold.update(&[-30.0], 99, 1_000, &config);
assert_eq!(hold.values[0], -10.0);
hold.update(&[-30.0], 1, 1_000, &config);
assert_eq!(
hold.values[0], -30.0,
"timed hold must release directly to current"
);
config.rta_peak_hold_mode = "manual".to_string();
hold.update(&[-20.0], 1, 1_000, &config);
hold.update(&[-5.0], 1, 1_000, &config);
hold.update(&[-40.0], 1_000, 1_000, &config);
assert_eq!(hold.values[0], -5.0, "manual hold must not decay");
config.rta_peak_reset_token += 1;
hold.update(&[-40.0], 1, 1_000, &config);
assert_eq!(hold.values[0], -40.0, "reset must release manual hold");
config.rta_peak_hold_mode = "off".to_string();
hold.update(&[-12.0], 1, 1_000, &config);
hold.update(&[-35.0], 1, 1_000, &config);
assert_eq!(
hold.values[0], -35.0,
"off must always follow current value"
);
}
}
+6
View File
@@ -68,6 +68,9 @@ pub struct PhoenixRtaConfig {
pub tau_slow: f32,
pub integration: String,
pub layout: String,
pub rta_peak_hold_mode: String,
pub rta_peak_hold_seconds: f32,
pub rta_peak_reset_token: u64,
pub input_offset_db_l: f32,
pub input_offset_db_r: f32,
pub ppm_din_attack_ms: f32,
@@ -100,6 +103,9 @@ impl Default for PhoenixRtaConfig {
tau_slow: 1.0,
integration: "fast".to_string(),
layout: "rtw".to_string(),
rta_peak_hold_mode: "auto".to_string(),
rta_peak_hold_seconds: 2.5,
rta_peak_reset_token: 0,
input_offset_db_l: -5.0,
input_offset_db_r: -5.0,
ppm_din_attack_ms: 10.0,
+47
View File
@@ -407,6 +407,30 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig {
// Keep the serialized fields for old clients, but normalize their values.
config.tau_fast = 0.125;
config.tau_slow = 1.0;
config.rta_peak_hold_mode = match config
.rta_peak_hold_mode
.trim()
.to_ascii_lowercase()
.as_str()
{
"off" => "off".to_string(),
"manual" => "manual".to_string(),
_ => "auto".to_string(),
};
let requested_hold = if config.rta_peak_hold_seconds.is_finite() {
config.rta_peak_hold_seconds
} else {
2.5
};
config.rta_peak_hold_seconds = if config.layout == "rtw" {
if requested_hold >= 3.25 {
4.0
} else {
2.5
}
} else {
requested_hold.clamp(0.0, 30.0)
};
let offset_l = if config.input_offset_db_l.is_finite() {
config.input_offset_db_l
} else {
@@ -767,6 +791,29 @@ mod tests {
assert_eq!(config.integration, "medium");
}
#[test]
fn rta_peak_hold_config_is_normalized_without_decay_parameter() {
let rtw = normalize_rta_config(PhoenixRtaConfig {
rta_peak_hold_mode: "MANUAL".to_string(),
rta_peak_hold_seconds: 3.7,
rta_peak_reset_token: 12,
layout: "rtw".to_string(),
..PhoenixRtaConfig::default()
});
assert_eq!(rtw.rta_peak_hold_mode, "manual");
assert_eq!(rtw.rta_peak_hold_seconds, 4.0);
assert_eq!(rtw.rta_peak_reset_token, 12);
let extension = normalize_rta_config(PhoenixRtaConfig {
rta_peak_hold_mode: "invalid".to_string(),
rta_peak_hold_seconds: 45.0,
layout: "iec".to_string(),
..PhoenixRtaConfig::default()
});
assert_eq!(extension.rta_peak_hold_mode, "auto");
assert_eq!(extension.rta_peak_hold_seconds, 30.0);
}
#[test]
fn legacy_peak_mode_migrates_to_peak_detector_with_fast_response() {
let config = normalize_rta_config(PhoenixRtaConfig {