Recover analyzer DSP and realtime pipeline corrections
This commit is contained in:
+374
-325
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -80,7 +80,12 @@ impl PhoenixConfig {
|
||||
.unwrap_or(2 * 1024 * 1024 * 1024),
|
||||
lr_fractional_delay_enabled: std::env::var("PHOENIX_LR_FRAC_DELAY_ENABLED")
|
||||
.ok()
|
||||
.map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
||||
.map(|v| {
|
||||
matches!(
|
||||
v.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
})
|
||||
.unwrap_or(true),
|
||||
lr_fractional_delay_samples: std::env::var("PHOENIX_LR_FRAC_DELAY_SAMPLES")
|
||||
.ok()
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Continuous stereo correlation meter.
|
||||
//!
|
||||
//! The detector integrates L², R² and L·R with the same time constant and
|
||||
//! derives the normalized correlation only afterwards. Its timing therefore
|
||||
//! does not depend on ALSA period size or browser frame rate.
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CorrelationMeter {
|
||||
sample_rate: u32,
|
||||
response_seconds: f32,
|
||||
alpha: f64,
|
||||
power_l: f64,
|
||||
power_r: f64,
|
||||
cross_power: f64,
|
||||
value: f32,
|
||||
negative_peak: f32,
|
||||
reset_token: u64,
|
||||
}
|
||||
|
||||
impl CorrelationMeter {
|
||||
pub fn new(sample_rate: u32, response_seconds: f32, reset_token: u64) -> Self {
|
||||
let mut meter = Self {
|
||||
sample_rate: 0,
|
||||
response_seconds: 0.0,
|
||||
alpha: 1.0,
|
||||
power_l: 0.0,
|
||||
power_r: 0.0,
|
||||
cross_power: 0.0,
|
||||
value: 0.0,
|
||||
negative_peak: 1.0,
|
||||
reset_token,
|
||||
};
|
||||
meter.configure(sample_rate, response_seconds, reset_token);
|
||||
meter
|
||||
}
|
||||
|
||||
pub fn configure(&mut self, sample_rate: u32, response_seconds: f32, reset_token: u64) {
|
||||
let sample_rate = sample_rate.max(8_000);
|
||||
let response_seconds = normalize_response_seconds(response_seconds);
|
||||
if self.sample_rate != sample_rate || self.response_seconds != response_seconds {
|
||||
self.sample_rate = sample_rate;
|
||||
self.response_seconds = response_seconds;
|
||||
self.alpha = 1.0
|
||||
- (-1.0 / (f64::from(sample_rate) * f64::from(response_seconds))).exp();
|
||||
}
|
||||
if self.reset_token != reset_token {
|
||||
self.reset_token = reset_token;
|
||||
self.negative_peak = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process(&mut self, left: f32, right: f32) {
|
||||
let l = f64::from(left);
|
||||
let r = f64::from(right);
|
||||
self.power_l += self.alpha * (l * l - self.power_l);
|
||||
self.power_r += self.alpha * (r * r - self.power_r);
|
||||
self.cross_power += self.alpha * (l * r - self.cross_power);
|
||||
|
||||
// Below roughly -100 dBFS RMS per channel the quotient is no longer a
|
||||
// useful phase measurement and should settle at the neutral position.
|
||||
const MIN_POWER: f64 = 1.0e-10;
|
||||
let denominator = (self.power_l * self.power_r).sqrt();
|
||||
self.value = if self.power_l > MIN_POWER && self.power_r > MIN_POWER && denominator > 0.0 {
|
||||
(self.cross_power / denominator).clamp(-1.0, 1.0) as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if self.value < self.negative_peak {
|
||||
self.negative_peak = self.value;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn value(&self) -> f32 {
|
||||
self.value
|
||||
}
|
||||
|
||||
pub fn negative_peak(&self) -> f32 {
|
||||
self.negative_peak
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_response_seconds(value: f32) -> f32 {
|
||||
if value.is_finite() && value >= 1.75 {
|
||||
2.5
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run_signal<F>(meter: &mut CorrelationMeter, seconds: usize, mut signal: F)
|
||||
where
|
||||
F: FnMut(usize) -> (f32, f32),
|
||||
{
|
||||
let count = meter.sample_rate as usize * seconds;
|
||||
for index in 0..count {
|
||||
let (left, right) = signal(index);
|
||||
meter.process(left, right);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_positive_negative_and_quadrature_signals() {
|
||||
let sample_rate = 48_000;
|
||||
let phase_step = 2.0 * std::f32::consts::PI * 1_000.0 / sample_rate as f32;
|
||||
for (phase, expected) in [(0.0, 1.0), (std::f32::consts::PI, -1.0), (std::f32::consts::FRAC_PI_2, 0.0)] {
|
||||
let mut meter = CorrelationMeter::new(sample_rate, 1.0, 0);
|
||||
run_signal(&mut meter, 5, |index| {
|
||||
let angle = phase_step * index as f32;
|
||||
(angle.sin(), (angle + phase).sin())
|
||||
});
|
||||
assert!((meter.value() - expected).abs() < 0.002, "phase {phase}: {}", meter.value());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_time_and_peak_reset_are_deterministic() {
|
||||
let sample_rate = 48_000;
|
||||
let mut fast = CorrelationMeter::new(sample_rate, 1.0, 0);
|
||||
let mut slow = CorrelationMeter::new(sample_rate, 2.5, 0);
|
||||
for index in 0..sample_rate as usize {
|
||||
let sample = if index & 1 == 0 { 0.5 } else { -0.5 };
|
||||
fast.process(sample, sample);
|
||||
slow.process(sample, sample);
|
||||
}
|
||||
assert!(fast.value() > 0.999);
|
||||
assert!(slow.value() > 0.999);
|
||||
run_signal(&mut fast, 2, |index| {
|
||||
let sample = if index & 1 == 0 { 0.5 } else { -0.5 };
|
||||
(sample, -sample)
|
||||
});
|
||||
assert!(fast.negative_peak() < -0.7);
|
||||
fast.configure(sample_rate, 1.0, 1);
|
||||
assert_eq!(fast.negative_peak(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_and_single_channel_are_neutral() {
|
||||
let mut meter = CorrelationMeter::new(48_000, 1.0, 0);
|
||||
run_signal(&mut meter, 2, |_| (0.0, 0.0));
|
||||
assert_eq!(meter.value(), 0.0);
|
||||
run_signal(&mut meter, 2, |index| {
|
||||
let sample = if index & 1 == 0 { 0.5 } else { -0.5 };
|
||||
(sample, 0.0)
|
||||
});
|
||||
assert_eq!(meter.value(), 0.0);
|
||||
}
|
||||
}
|
||||
+49
-11
@@ -1,13 +1,19 @@
|
||||
mod audio;
|
||||
mod config;
|
||||
mod correlation;
|
||||
mod model;
|
||||
mod ppm;
|
||||
mod routes;
|
||||
mod rta;
|
||||
mod state;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::{routing::{get, post}, Router};
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use config::PhoenixConfig;
|
||||
use state::AppState;
|
||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
||||
@@ -30,18 +36,50 @@ async fn main() -> anyhow::Result<()> {
|
||||
let app = Router::new()
|
||||
.route("/health", get(routes::health))
|
||||
.route("/api/v1/status", get(routes::status))
|
||||
.route("/api/v1/global-config", get(routes::get_global_config).post(routes::set_global_config))
|
||||
.route("/api/v1/frontend-presets", get(routes::get_frontend_presets).post(routes::set_frontend_preset))
|
||||
.route("/api/v1/frontend-layouts", get(routes::get_frontend_layouts).post(routes::set_frontend_layout))
|
||||
.route("/api/v1/update/download", post(routes::download_online_update))
|
||||
.route(
|
||||
"/api/v1/global-config",
|
||||
get(routes::get_global_config).post(routes::set_global_config),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/frontend-presets",
|
||||
get(routes::get_frontend_presets).post(routes::set_frontend_preset),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/frontend-layouts",
|
||||
get(routes::get_frontend_layouts).post(routes::set_frontend_layout),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/update/download",
|
||||
post(routes::download_online_update),
|
||||
)
|
||||
.route("/api/v1/update/log", get(routes::get_online_update_log))
|
||||
.route("/api/v1/update/restart", post(routes::restart_after_online_update))
|
||||
.route("/api/v1/rta-config", get(routes::get_rta_config).post(routes::set_rta_config))
|
||||
.route("/api/v1/recordings/wav/start/:session_id", post(routes::start_wav_recording))
|
||||
.route("/api/v1/recordings/wav/stop/:session_id", post(routes::stop_wav_recording))
|
||||
.route("/api/v1/recordings/stop/:session_id/:format", post(routes::stop_recording_with_format))
|
||||
.route("/api/v1/recordings/save/:target/:filename", post(routes::save_recording_file))
|
||||
.route(
|
||||
"/api/v1/update/restart",
|
||||
post(routes::restart_after_online_update),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/rta-config",
|
||||
get(routes::get_rta_config).post(routes::set_rta_config),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/recordings/wav/start/:session_id",
|
||||
post(routes::start_wav_recording),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/recordings/wav/stop/:session_id",
|
||||
post(routes::stop_wav_recording),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/recordings/stop/:session_id/:format",
|
||||
post(routes::stop_recording_with_format),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/recordings/save/:target/:filename",
|
||||
post(routes::save_recording_file),
|
||||
)
|
||||
.route("/api/v1/metrics/ws", get(routes::metrics_ws))
|
||||
.route("/api/v1/spectro/ws", get(routes::spectro_ws))
|
||||
.route("/api/v1/visuals/ws", get(routes::visuals_ws))
|
||||
.layer(CorsLayer::permissive())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
+24
-10
@@ -21,7 +21,6 @@ pub struct RtaFrame {
|
||||
pub engine: String,
|
||||
pub bands_avg: Vec<f32>,
|
||||
pub bands_peak: Vec<f32>,
|
||||
pub bands: Vec<f32>,
|
||||
pub centers: Vec<f32>,
|
||||
pub freq_min: f32,
|
||||
pub freq_max: f32,
|
||||
@@ -33,6 +32,7 @@ pub struct RtaFrame {
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct SpectroFrame {
|
||||
pub seq: u64,
|
||||
pub bins: Vec<f32>,
|
||||
pub sample_rate: u32,
|
||||
pub fft_size: u32,
|
||||
@@ -72,6 +72,9 @@ pub struct PhoenixRtaConfig {
|
||||
pub ppm_ebu_decay_db_per_s: f32,
|
||||
pub lufs_i_window_min: u32,
|
||||
pub lufs_i_norm_enabled: bool,
|
||||
pub correlation_response_s: f32,
|
||||
pub correlation_reset_token: u64,
|
||||
pub xy_points: u32,
|
||||
}
|
||||
|
||||
impl Default for PhoenixRtaConfig {
|
||||
@@ -82,23 +85,26 @@ impl Default for PhoenixRtaConfig {
|
||||
mono_input: false,
|
||||
lr_fractional_delay_enabled: true,
|
||||
lr_fractional_delay_samples: 0.05,
|
||||
bpo: "1_6".to_string(),
|
||||
bpo: "1_3".to_string(),
|
||||
freq_range: "norm".to_string(),
|
||||
weighting: "z".to_string(),
|
||||
order: 4,
|
||||
order: 6,
|
||||
tau_fast: 0.12,
|
||||
tau_slow: 1.0,
|
||||
integration: "fast".to_string(),
|
||||
layout: "rtw".to_string(),
|
||||
input_offset_db_l: -5.0,
|
||||
input_offset_db_r: -5.0,
|
||||
ppm_din_attack_ms: 5.0,
|
||||
ppm_din_decay_db_per_s: 11.8,
|
||||
ppm_din_attack_ms: 10.0,
|
||||
ppm_din_decay_db_per_s: 20.0 / 1.5,
|
||||
ppm_din_fast_attack: false,
|
||||
ppm_ebu_attack_ms: 10.0,
|
||||
ppm_ebu_decay_db_per_s: 8.6,
|
||||
ppm_ebu_decay_db_per_s: 24.0 / 2.8,
|
||||
lufs_i_window_min: 4,
|
||||
lufs_i_norm_enabled: false,
|
||||
correlation_response_s: 1.0,
|
||||
correlation_reset_token: 0,
|
||||
xy_points: 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,6 +142,8 @@ pub struct PhoenixGlobalConfig {
|
||||
pub clock_led_color: String,
|
||||
pub header_text_color: String,
|
||||
pub rta_bar_base_color: String,
|
||||
pub spectro_gamma: f32,
|
||||
pub spectro_scroll_mode: f32,
|
||||
pub peak_history_scroll_mode: f32,
|
||||
pub peak_history_fill_enabled: bool,
|
||||
pub peak_history_fill_invert: bool,
|
||||
@@ -175,7 +183,7 @@ impl Default for PhoenixGlobalConfig {
|
||||
Self {
|
||||
fft_size: 8192,
|
||||
input_source: InputSource::Line,
|
||||
rta_bpo_mode: "1_6".to_string(),
|
||||
rta_bpo_mode: "1_3".to_string(),
|
||||
input_offset_db_l: -5.0,
|
||||
input_offset_db_r: -5.0,
|
||||
mono_input: false,
|
||||
@@ -184,11 +192,11 @@ impl Default for PhoenixGlobalConfig {
|
||||
al_markers_enabled: false,
|
||||
meter_bar_thin: 0.55,
|
||||
panel_dividers_enabled: true,
|
||||
ppm_din_attack_ms: 5.0,
|
||||
ppm_din_decay_db_per_s: 11.8,
|
||||
ppm_din_attack_ms: 10.0,
|
||||
ppm_din_decay_db_per_s: 20.0 / 1.5,
|
||||
ppm_din_fast_attack: false,
|
||||
ppm_ebu_attack_ms: 10.0,
|
||||
ppm_ebu_decay_db_per_s: 8.6,
|
||||
ppm_ebu_decay_db_per_s: 24.0 / 2.8,
|
||||
lufs_i_window_min: 4,
|
||||
lufs_i_norm_enabled: false,
|
||||
ppm_din_loudness_boxes: true,
|
||||
@@ -203,6 +211,8 @@ impl Default for PhoenixGlobalConfig {
|
||||
clock_led_color: "#ff0000".to_string(),
|
||||
header_text_color: "#ffe066".to_string(),
|
||||
rta_bar_base_color: "#ffe066".to_string(),
|
||||
spectro_gamma: 0.9,
|
||||
spectro_scroll_mode: 1.0,
|
||||
peak_history_scroll_mode: 1.0,
|
||||
peak_history_fill_enabled: false,
|
||||
peak_history_fill_invert: false,
|
||||
@@ -250,6 +260,10 @@ pub struct PhoenixGlobalConfigEnvelope {
|
||||
pub struct MeterFrame {
|
||||
pub seq: u64,
|
||||
pub timestamp_ms: u128,
|
||||
pub sample_rate: u32,
|
||||
pub period_size: u32,
|
||||
pub correlation: f32,
|
||||
pub correlation_negative_peak: f32,
|
||||
pub rms_l: f32,
|
||||
pub rms_r: f32,
|
||||
pub vu_l: f32,
|
||||
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
//! Continuous quasi-peak detectors for the Phoenix DIN/EBU programme meters.
|
||||
//!
|
||||
//! The detector is deliberately independent of ALSA block boundaries. Its two
|
||||
//! attack branches are calibrated against the 5 kHz tone-burst response in EBU
|
||||
//! Tech 3205-E. DIN uses the RTW PortaMonitor/Peakmeter norm profile (10 ms
|
||||
//! integration, 20 dB return in 1.5 s). The optional DIN sample mode is kept
|
||||
//! separate and must never be labelled as a standards-compliant DIN reading.
|
||||
|
||||
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
|
||||
const ATTACK_FAST_TAU_S: f32 = 0.001_616_75;
|
||||
const ATTACK_SLOW_TAU_S: f32 = 0.011_369_98;
|
||||
const ATTACK_FAST_MIX: f32 = 0.818_535_6;
|
||||
const STATIC_CALIBRATION_GAIN: f32 = 1.024_6;
|
||||
const OVERSAMPLE: usize = 8;
|
||||
const INTERP_RADIUS: usize = 8;
|
||||
const INTERP_TAPS: usize = INTERP_RADIUS * 2 + 1;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PpmStandard {
|
||||
Din,
|
||||
DinSample,
|
||||
EbuTypeIib,
|
||||
}
|
||||
|
||||
impl PpmStandard {
|
||||
fn return_db_per_second(self) -> f32 {
|
||||
match self {
|
||||
Self::Din | Self::DinSample => 20.0 / 1.5,
|
||||
Self::EbuTypeIib => 24.0 / 2.8,
|
||||
}
|
||||
}
|
||||
|
||||
fn uses_sample_attack(self) -> bool {
|
||||
matches!(self, Self::DinSample)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct PpmChannel {
|
||||
fast: f32,
|
||||
slow: f32,
|
||||
output: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PpmDetector {
|
||||
sample_rate: u32,
|
||||
standard: PpmStandard,
|
||||
fast_coeff: f32,
|
||||
slow_coeff: f32,
|
||||
release_coeff: f32,
|
||||
interp_weights: [[f32; INTERP_TAPS]; OVERSAMPLE],
|
||||
raw_l: [f32; INTERP_TAPS],
|
||||
raw_r: [f32; INTERP_TAPS],
|
||||
raw_pos: usize,
|
||||
left: PpmChannel,
|
||||
right: PpmChannel,
|
||||
}
|
||||
|
||||
impl PpmDetector {
|
||||
pub fn new(sample_rate: u32, standard: PpmStandard) -> Self {
|
||||
let sr = sample_rate.max(8_000);
|
||||
let detector_rate = if standard.uses_sample_attack() {
|
||||
sr as f32
|
||||
} else {
|
||||
(sr * OVERSAMPLE as u32) as f32
|
||||
};
|
||||
let coeff = |tau_s: f32| (-1.0 / (detector_rate * tau_s)).exp();
|
||||
let release_coeff = 10.0f32.powf(-standard.return_db_per_second() / (20.0 * detector_rate));
|
||||
Self {
|
||||
sample_rate: sr,
|
||||
standard,
|
||||
fast_coeff: coeff(ATTACK_FAST_TAU_S),
|
||||
slow_coeff: coeff(ATTACK_SLOW_TAU_S),
|
||||
release_coeff,
|
||||
interp_weights: Self::create_interp_weights(),
|
||||
raw_l: [0.0; INTERP_TAPS],
|
||||
raw_r: [0.0; INTERP_TAPS],
|
||||
raw_pos: 0,
|
||||
left: PpmChannel::default(),
|
||||
right: PpmChannel::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_profile(&mut self, sample_rate: u32, standard: PpmStandard) {
|
||||
let sr = sample_rate.max(8_000);
|
||||
if self.sample_rate != sr || self.standard != standard {
|
||||
*self = Self::new(sr, standard);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process(&mut self, left: f32, right: f32) -> (f32, f32) {
|
||||
if self.standard.uses_sample_attack() {
|
||||
let l = Self::process_channel(
|
||||
&mut self.left,
|
||||
left.abs(),
|
||||
self.standard,
|
||||
self.fast_coeff,
|
||||
self.slow_coeff,
|
||||
self.release_coeff,
|
||||
);
|
||||
let r = Self::process_channel(
|
||||
&mut self.right,
|
||||
right.abs(),
|
||||
self.standard,
|
||||
self.fast_coeff,
|
||||
self.slow_coeff,
|
||||
self.release_coeff,
|
||||
);
|
||||
return (l, r);
|
||||
}
|
||||
|
||||
self.raw_l[self.raw_pos] = left;
|
||||
self.raw_r[self.raw_pos] = right;
|
||||
self.raw_pos = (self.raw_pos + 1) % INTERP_TAPS;
|
||||
|
||||
let standard = self.standard;
|
||||
let fast_coeff = self.fast_coeff;
|
||||
let slow_coeff = self.slow_coeff;
|
||||
let release_coeff = self.release_coeff;
|
||||
for phase in 0..OVERSAMPLE {
|
||||
let mut l = 0.0f32;
|
||||
let mut r = 0.0f32;
|
||||
for tap in 0..INTERP_TAPS {
|
||||
let ring_index = (self.raw_pos + tap) % INTERP_TAPS;
|
||||
let weight = self.interp_weights[phase][tap];
|
||||
l += self.raw_l[ring_index] * weight;
|
||||
r += self.raw_r[ring_index] * weight;
|
||||
}
|
||||
Self::process_channel(
|
||||
&mut self.left,
|
||||
l.abs(),
|
||||
standard,
|
||||
fast_coeff,
|
||||
slow_coeff,
|
||||
release_coeff,
|
||||
);
|
||||
Self::process_channel(
|
||||
&mut self.right,
|
||||
r.abs(),
|
||||
standard,
|
||||
fast_coeff,
|
||||
slow_coeff,
|
||||
release_coeff,
|
||||
);
|
||||
}
|
||||
self.levels()
|
||||
}
|
||||
|
||||
pub fn levels(&self) -> (f32, f32) {
|
||||
(self.left.output, self.right.output)
|
||||
}
|
||||
|
||||
fn create_interp_weights() -> [[f32; INTERP_TAPS]; OVERSAMPLE] {
|
||||
let mut weights = [[0.0; INTERP_TAPS]; OVERSAMPLE];
|
||||
for (phase, phase_weights) in weights.iter_mut().enumerate() {
|
||||
let position = INTERP_RADIUS as f32 + phase as f32 / OVERSAMPLE as f32;
|
||||
let mut sum = 0.0f32;
|
||||
for (tap, weight) in phase_weights.iter_mut().enumerate() {
|
||||
let distance = position - tap as f32;
|
||||
*weight = Self::sinc(distance) * Self::sinc(distance / INTERP_RADIUS as f32);
|
||||
sum += *weight;
|
||||
}
|
||||
if sum.abs() > 1.0e-9 {
|
||||
for weight in phase_weights {
|
||||
*weight /= sum;
|
||||
}
|
||||
}
|
||||
}
|
||||
weights
|
||||
}
|
||||
|
||||
fn sinc(value: f32) -> f32 {
|
||||
if value.abs() < 1.0e-7 {
|
||||
1.0
|
||||
} else {
|
||||
let x = std::f32::consts::PI * value;
|
||||
x.sin() / x
|
||||
}
|
||||
}
|
||||
|
||||
fn process_channel(
|
||||
state: &mut PpmChannel,
|
||||
input: f32,
|
||||
standard: PpmStandard,
|
||||
fast_coeff: f32,
|
||||
slow_coeff: f32,
|
||||
release_coeff: f32,
|
||||
) -> f32 {
|
||||
if standard.uses_sample_attack() {
|
||||
state.output = input.max(state.output * release_coeff);
|
||||
state.fast = state.output;
|
||||
state.slow = state.output;
|
||||
return state.output;
|
||||
}
|
||||
|
||||
state.fast = if input > state.fast {
|
||||
fast_coeff * state.fast + (1.0 - fast_coeff) * input
|
||||
} else {
|
||||
state.fast * release_coeff
|
||||
};
|
||||
state.slow = if input > state.slow {
|
||||
slow_coeff * state.slow + (1.0 - slow_coeff) * input
|
||||
} else {
|
||||
state.slow * release_coeff
|
||||
};
|
||||
|
||||
let attack = (ATTACK_FAST_MIX * state.fast + (1.0 - ATTACK_FAST_MIX) * state.slow)
|
||||
* STATIC_CALIBRATION_GAIN;
|
||||
state.output = attack.max(state.output * release_coeff);
|
||||
state.output
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SR: u32 = 48_000;
|
||||
|
||||
fn sine_sample(index: usize, frequency: f32, amplitude: f32) -> f32 {
|
||||
let phase = 2.0 * std::f32::consts::PI * frequency * index as f32 / SR as f32;
|
||||
phase.sin() * amplitude
|
||||
}
|
||||
|
||||
fn run_tone(
|
||||
detector: &mut PpmDetector,
|
||||
frequency: f32,
|
||||
amplitude: f32,
|
||||
duration_ms: f32,
|
||||
) -> f32 {
|
||||
let samples = (duration_ms * SR as f32 / 1000.0).round() as usize;
|
||||
let mut peak = 0.0f32;
|
||||
for index in 0..samples {
|
||||
let value = sine_sample(index, frequency, amplitude);
|
||||
peak = peak.max(detector.process(value, value).0);
|
||||
}
|
||||
// The band-limited interpolator uses eight samples of look-ahead.
|
||||
// Silence advances its final interval without materially changing PPM return.
|
||||
for _ in 0..INTERP_RADIUS + 1 {
|
||||
peak = peak.max(detector.process(0.0, 0.0).0);
|
||||
}
|
||||
peak
|
||||
}
|
||||
|
||||
fn db(value: f32) -> f32 {
|
||||
20.0 * value.max(1.0e-12).log10()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ebu_type_iib_matches_official_tone_burst_table() {
|
||||
// EBU Tech 3205-E table 2, expressed relative to the continuous-tone
|
||||
// indication: 100/10/5/1.5/0.5 ms -> 0/-2/-4/-9/-17 dB.
|
||||
let cases = [
|
||||
(100.0, 0.0, 0.5),
|
||||
(10.0, -2.0, 0.5),
|
||||
(5.0, -4.0, 0.75),
|
||||
(1.5, -9.0, 1.0),
|
||||
(0.5, -17.0, 2.0),
|
||||
];
|
||||
let mut continuous = PpmDetector::new(SR, PpmStandard::EbuTypeIib);
|
||||
let reference = run_tone(&mut continuous, 5_000.0, 0.5, 500.0);
|
||||
|
||||
for (duration_ms, expected_db, tolerance_db) in cases {
|
||||
let mut detector = PpmDetector::new(SR, PpmStandard::EbuTypeIib);
|
||||
let measured = run_tone(&mut detector, 5_000.0, 0.5, duration_ms);
|
||||
let relative_db = db(measured / reference);
|
||||
assert!(
|
||||
(relative_db - expected_db).abs() <= tolerance_db,
|
||||
"{duration_ms} ms: measured {relative_db:.3} dB, expected {expected_db:.3} +/- {tolerance_db:.3} dB"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ebu_return_time_is_24_db_in_2_8_seconds() {
|
||||
assert_return_time(PpmStandard::EbuTypeIib, 24.0, 2.8, 0.02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn din_return_time_is_20_db_in_1_5_seconds() {
|
||||
assert_return_time(PpmStandard::Din, 20.0, 1.5, 0.02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_detector_does_not_bypass_attack_integration() {
|
||||
let mut detector = PpmDetector::new(SR, PpmStandard::Din);
|
||||
let first = detector.process(1.0, 1.0).0;
|
||||
assert!(first < 0.02, "first sample unexpectedly reached {first}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polarity_is_reversible() {
|
||||
let mut positive = PpmDetector::new(SR, PpmStandard::EbuTypeIib);
|
||||
let mut negative = PpmDetector::new(SR, PpmStandard::EbuTypeIib);
|
||||
for index in 0..SR as usize {
|
||||
let value = sine_sample(index, 1_000.0, 0.5);
|
||||
positive.process(value, value);
|
||||
negative.process(-value, -value);
|
||||
}
|
||||
assert!((positive.levels().0 - negative.levels().0).abs() < 1.0e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ebu_frequency_response_is_flat_through_16_khz() {
|
||||
let frequencies = [31.5, 1_000.0, 5_000.0, 10_000.0, 12_500.0, 16_000.0];
|
||||
let mut reference_detector = PpmDetector::new(SR, PpmStandard::EbuTypeIib);
|
||||
let reference = run_tone(&mut reference_detector, 1_000.0, 0.5, 1_000.0);
|
||||
for frequency in frequencies {
|
||||
let mut detector = PpmDetector::new(SR, PpmStandard::EbuTypeIib);
|
||||
let measured = run_tone(&mut detector, frequency, 0.5, 1_000.0);
|
||||
let relative_db = db(measured / reference);
|
||||
assert!(
|
||||
relative_db.abs() <= 0.3,
|
||||
"{frequency} Hz: measured {relative_db:.3} dB relative to 1 kHz"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_return_time(
|
||||
standard: PpmStandard,
|
||||
drop_db: f32,
|
||||
expected_seconds: f32,
|
||||
tolerance_seconds: f32,
|
||||
) {
|
||||
let mut detector = PpmDetector::new(SR, standard);
|
||||
run_tone(&mut detector, 1_000.0, 0.5, 1_000.0);
|
||||
let start = detector.levels().0;
|
||||
let target = start * 10.0f32.powf(-drop_db / 20.0);
|
||||
let mut elapsed = 0usize;
|
||||
while detector.levels().0 > target && elapsed < SR as usize * 10 {
|
||||
detector.process(0.0, 0.0);
|
||||
elapsed += 1;
|
||||
}
|
||||
let seconds = elapsed as f32 / SR as f32;
|
||||
assert!(
|
||||
(seconds - expected_seconds).abs() <= tolerance_seconds,
|
||||
"return took {seconds:.4} s, expected {expected_seconds:.4} +/- {tolerance_seconds:.4} s"
|
||||
);
|
||||
}
|
||||
}
|
||||
+491
-56
@@ -1,3 +1,4 @@
|
||||
use anyhow::Context;
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, Query, Request, State, WebSocketUpgrade},
|
||||
@@ -5,19 +6,19 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use http_body_util::BodyExt;
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
process::Stdio,
|
||||
sync::Arc,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tokio::{fs, io::AsyncWriteExt, process::Command};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{
|
||||
model::{PhoenixGlobalConfig, PhoenixRtaConfig},
|
||||
model::{MeterFrame, PhoenixGlobalConfig, PhoenixRtaConfig, WaveEnvFrame},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -56,7 +57,9 @@ pub async fn get_rta_config(State(state): State<AppState>) -> Json<PhoenixRtaCon
|
||||
Json(state.rta_config().await)
|
||||
}
|
||||
|
||||
pub async fn get_global_config(State(state): State<AppState>) -> Json<crate::model::PhoenixGlobalConfigEnvelope> {
|
||||
pub async fn get_global_config(
|
||||
State(state): State<AppState>,
|
||||
) -> Json<crate::model::PhoenixGlobalConfigEnvelope> {
|
||||
Json(state.global_config_envelope().await)
|
||||
}
|
||||
|
||||
@@ -209,7 +212,9 @@ pub async fn download_online_update() -> Response {
|
||||
}
|
||||
};
|
||||
let log_path = build_online_update_log_path(&phoenix_root);
|
||||
let script = phoenix_root.join("scripts").join("update_phoenix_from_zip.sh");
|
||||
let script = phoenix_root
|
||||
.join("scripts")
|
||||
.join("update_phoenix_from_zip.sh");
|
||||
if !script.is_file() {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -303,7 +308,9 @@ pub async fn restart_after_online_update() -> Response {
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let script = phoenix_root.join("scripts").join("restart_phoenix_services.sh");
|
||||
let script = phoenix_root
|
||||
.join("scripts")
|
||||
.join("restart_phoenix_services.sh");
|
||||
if !script.is_file() {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -317,9 +324,7 @@ pub async fn restart_after_online_update() -> Response {
|
||||
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(750)).await;
|
||||
let _ = stable_script_command("bash")
|
||||
.arg(script)
|
||||
.spawn();
|
||||
let _ = stable_script_command("bash").arg(script).spawn();
|
||||
});
|
||||
|
||||
(
|
||||
@@ -419,6 +424,14 @@ pub async fn metrics_ws(ws: WebSocketUpgrade, State(state): State<AppState>) ->
|
||||
ws.on_upgrade(move |socket| metrics_ws_inner(socket, state))
|
||||
}
|
||||
|
||||
pub async fn spectro_ws(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
|
||||
ws.on_upgrade(move |socket| spectro_ws_inner(socket, state))
|
||||
}
|
||||
|
||||
pub async fn visuals_ws(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
|
||||
ws.on_upgrade(move |socket| visuals_ws_inner(socket, state))
|
||||
}
|
||||
|
||||
pub async fn start_wav_recording(
|
||||
State(state): State<AppState>,
|
||||
Path(session_id): Path<u64>,
|
||||
@@ -458,7 +471,12 @@ pub async fn stop_recording_with_format(
|
||||
stop_recording_inner(state, session_id, &format, query.bitrate_kbps).await
|
||||
}
|
||||
|
||||
async fn stop_recording_inner(state: AppState, session_id: u64, format: &str, bitrate_kbps: Option<u32>) -> Response {
|
||||
async fn stop_recording_inner(
|
||||
state: AppState,
|
||||
session_id: u64,
|
||||
format: &str,
|
||||
bitrate_kbps: Option<u32>,
|
||||
) -> Response {
|
||||
let normalized_format = normalize_recording_format(format);
|
||||
if normalized_format.is_empty() {
|
||||
return (
|
||||
@@ -474,10 +492,7 @@ async fn stop_recording_inner(state: AppState, session_id: u64, format: &str, bi
|
||||
Ok(capture) => match build_capture_bytes(normalized_format, &capture, bitrate_kbps).await {
|
||||
Ok((mime_type, payload)) => (
|
||||
StatusCode::OK,
|
||||
[
|
||||
("content-type", mime_type),
|
||||
("cache-control", "no-store"),
|
||||
],
|
||||
[("content-type", mime_type), ("cache-control", "no-store")],
|
||||
Body::from(payload),
|
||||
)
|
||||
.into_response(),
|
||||
@@ -514,7 +529,8 @@ async fn build_capture_bytes(
|
||||
capture.channels,
|
||||
&capture.pcm_bytes,
|
||||
normalize_mp3_bitrate_kbps(bitrate_kbps),
|
||||
).await?,
|
||||
)
|
||||
.await?,
|
||||
)),
|
||||
"webm" => Ok((
|
||||
"audio/webm",
|
||||
@@ -527,7 +543,12 @@ async fn build_capture_bytes(
|
||||
}
|
||||
}
|
||||
|
||||
async fn encode_mp3_bytes(sample_rate: u32, channels: u16, pcm_bytes: &[u8], bitrate_kbps: u32) -> anyhow::Result<Vec<u8>> {
|
||||
async fn encode_mp3_bytes(
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
pcm_bytes: &[u8],
|
||||
bitrate_kbps: u32,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
let wav = build_wav_bytes(sample_rate, channels, pcm_bytes);
|
||||
let token = format!(
|
||||
"{}-{}-{}",
|
||||
@@ -557,7 +578,11 @@ async fn encode_mp3_bytes(sample_rate: u32, channels: u16, pcm_bytes: &[u8], bit
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
async fn try_encode_mp3_with_commands(input_path: &PathBuf, output_path: &PathBuf, bitrate_kbps: u32) -> anyhow::Result<()> {
|
||||
async fn try_encode_mp3_with_commands(
|
||||
input_path: &PathBuf,
|
||||
output_path: &PathBuf,
|
||||
bitrate_kbps: u32,
|
||||
) -> anyhow::Result<()> {
|
||||
let ffmpeg = Command::new("ffmpeg")
|
||||
.arg("-y")
|
||||
.arg("-hide_banner")
|
||||
@@ -602,7 +627,11 @@ async fn try_encode_mp3_with_commands(input_path: &PathBuf, output_path: &PathBu
|
||||
}
|
||||
}
|
||||
|
||||
async fn encode_webm_bytes(sample_rate: u32, channels: u16, pcm_bytes: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||
async fn encode_webm_bytes(
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
pcm_bytes: &[u8],
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
let wav = build_wav_bytes(sample_rate, channels, pcm_bytes);
|
||||
let token = format!(
|
||||
"{}-{}-{}",
|
||||
@@ -735,27 +764,53 @@ pub async fn save_recording_file(
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_frontend_presets(config: &crate::config::PhoenixConfig) -> anyhow::Result<serde_json::Map<String, serde_json::Value>> {
|
||||
async fn read_frontend_presets(
|
||||
config: &crate::config::PhoenixConfig,
|
||||
) -> anyhow::Result<serde_json::Map<String, serde_json::Value>> {
|
||||
let path = config.frontend_presets_path.clone();
|
||||
let raw = match fs::read_to_string(&path).await {
|
||||
Ok(raw) => raw,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(serde_json::Map::new()),
|
||||
Err(err) => return Err(anyhow::anyhow!("failed to read frontend presets {} ({})", path.display(), err)),
|
||||
Err(err) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"failed to read frontend presets {} ({})",
|
||||
path.display(),
|
||||
err
|
||||
))
|
||||
}
|
||||
};
|
||||
let parsed = serde_json::from_str::<serde_json::Value>(&raw)
|
||||
.map_err(|err| anyhow::anyhow!("failed to parse frontend presets {} ({})", path.display(), err))?;
|
||||
let parsed = serde_json::from_str::<serde_json::Value>(&raw).map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"failed to parse frontend presets {} ({})",
|
||||
path.display(),
|
||||
err
|
||||
)
|
||||
})?;
|
||||
Ok(parsed.as_object().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn read_frontend_layouts(config: &crate::config::PhoenixConfig) -> anyhow::Result<serde_json::Map<String, serde_json::Value>> {
|
||||
async fn read_frontend_layouts(
|
||||
config: &crate::config::PhoenixConfig,
|
||||
) -> anyhow::Result<serde_json::Map<String, serde_json::Value>> {
|
||||
let path = config.frontend_layouts_path.clone();
|
||||
let raw = match fs::read_to_string(&path).await {
|
||||
Ok(raw) => raw,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(serde_json::Map::new()),
|
||||
Err(err) => return Err(anyhow::anyhow!("failed to read frontend layouts {} ({})", path.display(), err)),
|
||||
Err(err) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"failed to read frontend layouts {} ({})",
|
||||
path.display(),
|
||||
err
|
||||
))
|
||||
}
|
||||
};
|
||||
let parsed = serde_json::from_str::<serde_json::Value>(&raw)
|
||||
.map_err(|err| anyhow::anyhow!("failed to parse frontend layouts {} ({})", path.display(), err))?;
|
||||
let parsed = serde_json::from_str::<serde_json::Value>(&raw).map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"failed to parse frontend layouts {} ({})",
|
||||
path.display(),
|
||||
err
|
||||
)
|
||||
})?;
|
||||
Ok(parsed.as_object().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
@@ -765,9 +820,9 @@ async fn persist_frontend_presets(
|
||||
) -> anyhow::Result<()> {
|
||||
let path = config.frontend_presets_path.clone();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("failed to create frontend preset dir {}", parent.display()))?;
|
||||
fs::create_dir_all(parent).await.with_context(|| {
|
||||
format!("failed to create frontend preset dir {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(&serde_json::Value::Object(presets.clone()))?;
|
||||
fs::write(&path, json)
|
||||
@@ -782,9 +837,9 @@ async fn persist_frontend_layouts(
|
||||
) -> anyhow::Result<()> {
|
||||
let path = config.frontend_layouts_path.clone();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("failed to create frontend layout dir {}", parent.display()))?;
|
||||
fs::create_dir_all(parent).await.with_context(|| {
|
||||
format!("failed to create frontend layout dir {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(&serde_json::Value::Object(layouts.clone()))?;
|
||||
fs::write(&path, json)
|
||||
@@ -835,37 +890,269 @@ fn normalize_frontend_layout_id(raw: &str) -> String {
|
||||
|
||||
async fn metrics_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) {
|
||||
let mut rx = state.subscribe_metrics();
|
||||
let mut ticker = tokio::time::interval(std::time::Duration::from_millis(16));
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(frame) => {
|
||||
let payload = match serde_json::to_string(&frame) {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => {
|
||||
warn!("failed to encode metrics frame: {}", err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if socket
|
||||
.send(axum::extract::ws::Message::Text(payload))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
ticker.tick().await;
|
||||
let Some(mut latest) = recv_latest_meter_frame(&mut rx).await else {
|
||||
break;
|
||||
};
|
||||
drain_latest_meter_frame(&mut rx, &mut latest);
|
||||
let mut frame = (*latest).clone();
|
||||
strip_visual_payloads(&mut frame);
|
||||
|
||||
let payload = match serde_json::to_string(&frame) {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => {
|
||||
match err {
|
||||
tokio::sync::broadcast::error::RecvError::Lagged(skipped) => {
|
||||
warn!("metrics channel lagged by {}; dropping stale frames", skipped);
|
||||
continue;
|
||||
}
|
||||
tokio::sync::broadcast::error::RecvError::Closed => {
|
||||
warn!("metrics channel closed");
|
||||
break;
|
||||
warn!("failed to encode metrics frame: {}", err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if socket
|
||||
.send(axum::extract::ws::Message::Text(payload))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_visual_payloads(frame: &mut MeterFrame) {
|
||||
// Large visual payloads have bounded binary streams of their own.
|
||||
frame.spectro = None;
|
||||
frame.wave_l.clear();
|
||||
frame.wave_r.clear();
|
||||
frame.wave_channels = 0;
|
||||
frame.xy_l.clear();
|
||||
frame.xy_r.clear();
|
||||
frame.wave_env = None;
|
||||
}
|
||||
|
||||
async fn visuals_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) {
|
||||
let mut rx = state.subscribe_metrics();
|
||||
let mut ticker = tokio::time::interval(std::time::Duration::from_millis(16));
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
let Some(mut latest) = recv_latest_meter_frame(&mut rx).await else {
|
||||
return;
|
||||
};
|
||||
let (wave_env, xy) = drain_visual_meter_frames(&mut rx, &mut latest);
|
||||
let mut frame = (*latest).clone();
|
||||
frame.wave_env = wave_env;
|
||||
if let Some((left, right)) = xy {
|
||||
frame.xy_l = left;
|
||||
frame.xy_r = right;
|
||||
}
|
||||
let Some(payload) = encode_visual_frame(&frame) else {
|
||||
continue;
|
||||
};
|
||||
if socket
|
||||
.send(axum::extract::ws::Message::Binary(payload))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn spectro_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) {
|
||||
let mut rx = state.subscribe_metrics();
|
||||
loop {
|
||||
let mut latest = loop {
|
||||
match rx.recv().await {
|
||||
Ok(frame) => {
|
||||
if frame.spectro.is_some() {
|
||||
break frame;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(frame) => {
|
||||
if frame.spectro.is_some() {
|
||||
latest = frame;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break,
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => return,
|
||||
}
|
||||
}
|
||||
|
||||
let payload = encode_spectro_frame(latest.spectro.as_ref().expect("checked above"));
|
||||
if socket
|
||||
.send(axum::extract::ws::Message::Binary(payload))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_spectro_frame(frame: &crate::model::SpectroFrame) -> Vec<u8> {
|
||||
const MAGIC: u32 = 0x5058_5350; // "PXSP"
|
||||
let count = frame.bins.len().min(u32::MAX as usize) as u32;
|
||||
let mut output = Vec::with_capacity(20 + count as usize * 4);
|
||||
output.extend_from_slice(&MAGIC.to_le_bytes());
|
||||
output.extend_from_slice(&(frame.seq as u32).to_le_bytes());
|
||||
output.extend_from_slice(&frame.sample_rate.to_le_bytes());
|
||||
output.extend_from_slice(&frame.fft_size.to_le_bytes());
|
||||
output.extend_from_slice(&count.to_le_bytes());
|
||||
for value in frame.bins.iter().take(count as usize) {
|
||||
output.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn encode_visual_frame(frame: &MeterFrame) -> Option<Vec<u8>> {
|
||||
const MAGIC: u32 = 0x5058_5653; // Phoenix visual stream
|
||||
const FLAG_XY: u16 = 1;
|
||||
const FLAG_WAVE_ENV: u16 = 2;
|
||||
|
||||
let xy_count = frame
|
||||
.xy_l
|
||||
.len()
|
||||
.min(frame.xy_r.len())
|
||||
.min(u32::MAX as usize);
|
||||
let wave = frame.wave_env.as_ref().filter(|value| {
|
||||
matches!(value.channels, 1 | 2)
|
||||
&& value.columns > 0
|
||||
&& value.data.len()
|
||||
== value
|
||||
.columns
|
||||
.saturating_mul(value.channels as usize)
|
||||
.saturating_mul(2)
|
||||
});
|
||||
let wave_values = wave.map(|value| value.data.len()).unwrap_or(0);
|
||||
let mut flags = 0u16;
|
||||
if xy_count > 0 {
|
||||
flags |= FLAG_XY;
|
||||
}
|
||||
if wave_values > 0 {
|
||||
flags |= FLAG_WAVE_ENV;
|
||||
}
|
||||
if flags == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let wave_columns = wave
|
||||
.map(|value| value.columns)
|
||||
.unwrap_or(0)
|
||||
.min(u32::MAX as usize) as u32;
|
||||
let wave_channels = wave.map(|value| value.channels).unwrap_or(0) as u16;
|
||||
let wave_column_samples = wave
|
||||
.map(|value| value.column_samples)
|
||||
.unwrap_or(0)
|
||||
.min(u32::MAX as usize) as u32;
|
||||
let wave_sample_rate = wave.map(|value| value.sample_rate).unwrap_or(0);
|
||||
let mut output = Vec::with_capacity(32 + xy_count * 8 + wave_values * 4);
|
||||
output.extend_from_slice(&MAGIC.to_le_bytes());
|
||||
output.extend_from_slice(&1u16.to_le_bytes());
|
||||
output.extend_from_slice(&flags.to_le_bytes());
|
||||
output.extend_from_slice(&(frame.seq as u32).to_le_bytes());
|
||||
output.extend_from_slice(&(xy_count as u32).to_le_bytes());
|
||||
output.extend_from_slice(&wave_columns.to_le_bytes());
|
||||
output.extend_from_slice(&wave_channels.to_le_bytes());
|
||||
output.extend_from_slice(&0u16.to_le_bytes());
|
||||
output.extend_from_slice(&wave_column_samples.to_le_bytes());
|
||||
output.extend_from_slice(&wave_sample_rate.to_le_bytes());
|
||||
for index in 0..xy_count {
|
||||
output.extend_from_slice(&frame.xy_l[index].to_le_bytes());
|
||||
output.extend_from_slice(&frame.xy_r[index].to_le_bytes());
|
||||
}
|
||||
if let Some(wave) = wave {
|
||||
for value in &wave.data {
|
||||
output.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
}
|
||||
Some(output)
|
||||
}
|
||||
|
||||
async fn recv_latest_meter_frame(
|
||||
rx: &mut tokio::sync::broadcast::Receiver<Arc<MeterFrame>>,
|
||||
) -> Option<Arc<MeterFrame>> {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(frame) => return Some(frame),
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
warn!("metrics channel skipped {} stale frames", skipped);
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_latest_meter_frame(
|
||||
rx: &mut tokio::sync::broadcast::Receiver<Arc<MeterFrame>>,
|
||||
latest: &mut Arc<MeterFrame>,
|
||||
) {
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(newer) => *latest = newer,
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break,
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(skipped)) => {
|
||||
warn!("metrics drain skipped {} stale frames", skipped);
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_visual_meter_frames(
|
||||
rx: &mut tokio::sync::broadcast::Receiver<Arc<MeterFrame>>,
|
||||
latest: &mut Arc<MeterFrame>,
|
||||
) -> (Option<WaveEnvFrame>, Option<(Vec<f32>, Vec<f32>)>) {
|
||||
let mut combined_wave_env = latest.wave_env.clone();
|
||||
let mut latest_xy = if latest.xy_l.is_empty() || latest.xy_r.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((latest.xy_l.clone(), latest.xy_r.clone()))
|
||||
};
|
||||
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(newer) => {
|
||||
merge_wave_env(&mut combined_wave_env, newer.wave_env.clone());
|
||||
if !newer.xy_l.is_empty() && !newer.xy_r.is_empty() {
|
||||
latest_xy = Some((newer.xy_l.clone(), newer.xy_r.clone()));
|
||||
}
|
||||
*latest = newer;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break,
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(skipped)) => {
|
||||
warn!("metrics drain skipped {} stale frames", skipped);
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
|
||||
(combined_wave_env, latest_xy)
|
||||
}
|
||||
|
||||
fn merge_wave_env(target: &mut Option<WaveEnvFrame>, incoming: Option<WaveEnvFrame>) {
|
||||
let Some(mut incoming) = incoming else {
|
||||
return;
|
||||
};
|
||||
let Some(current) = target.as_mut() else {
|
||||
*target = Some(incoming);
|
||||
return;
|
||||
};
|
||||
let compatible = current.channels == incoming.channels
|
||||
&& current.column_samples == incoming.column_samples
|
||||
&& current.sample_rate == incoming.sample_rate;
|
||||
if compatible {
|
||||
current.columns = current.columns.saturating_add(incoming.columns);
|
||||
current.data.append(&mut incoming.data);
|
||||
} else {
|
||||
*current = incoming;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -982,3 +1269,151 @@ async fn write_recording_body(
|
||||
tokio::fs::rename(&tmp_path, &path).await?;
|
||||
Ok(path.display().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn meter_frame() -> MeterFrame {
|
||||
MeterFrame {
|
||||
seq: 7,
|
||||
timestamp_ms: 123,
|
||||
sample_rate: 48_000,
|
||||
period_size: 128,
|
||||
correlation: 0.25,
|
||||
correlation_negative_peak: -0.5,
|
||||
rms_l: -20.0,
|
||||
rms_r: -21.0,
|
||||
vu_l: -20.0,
|
||||
vu_r: -21.0,
|
||||
tp_l: -18.0,
|
||||
tp_r: -19.0,
|
||||
ppm_din_l: -18.5,
|
||||
ppm_din_r: -19.5,
|
||||
ppm_ebu_l: -18.5,
|
||||
ppm_ebu_r: -19.5,
|
||||
lufs_m: None,
|
||||
lufs_s: None,
|
||||
lufs_i: None,
|
||||
lra: None,
|
||||
lufs_ml: None,
|
||||
lufs_mr: None,
|
||||
lufs_sl: None,
|
||||
lufs_sr: None,
|
||||
ppm_box_l: None,
|
||||
ppm_box_r: None,
|
||||
wave_l: vec![0.1, 0.2],
|
||||
wave_r: vec![0.3, 0.4],
|
||||
wave_channels: 2,
|
||||
xy_l: vec![-0.5, 0.5],
|
||||
xy_r: vec![0.25, -0.25],
|
||||
rta: None,
|
||||
spectro: None,
|
||||
wave_env: Some(WaveEnvFrame {
|
||||
data: vec![-0.5, 0.5, -0.25, 0.25],
|
||||
columns: 2,
|
||||
channels: 1,
|
||||
column_samples: 5,
|
||||
sample_rate: 48_000,
|
||||
}),
|
||||
global_config_rev: 0,
|
||||
input: crate::model::InputSource::Line,
|
||||
source: "test",
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spectro_binary_frame_has_stable_header_and_samples() {
|
||||
let frame = crate::model::SpectroFrame {
|
||||
seq: 42,
|
||||
bins: vec![-80.0, -12.5, 0.0],
|
||||
sample_rate: 48_000,
|
||||
fft_size: 8_192,
|
||||
};
|
||||
let encoded = encode_spectro_frame(&frame);
|
||||
assert_eq!(encoded.len(), 20 + frame.bins.len() * 4);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes(encoded[0..4].try_into().unwrap()),
|
||||
0x5058_5350
|
||||
);
|
||||
assert_eq!(u32::from_le_bytes(encoded[4..8].try_into().unwrap()), 42);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes(encoded[8..12].try_into().unwrap()),
|
||||
48_000
|
||||
);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes(encoded[12..16].try_into().unwrap()),
|
||||
8_192
|
||||
);
|
||||
assert_eq!(u32::from_le_bytes(encoded[16..20].try_into().unwrap()), 3);
|
||||
for (index, expected) in frame.bins.iter().enumerate() {
|
||||
let offset = 20 + index * 4;
|
||||
let actual = f32::from_le_bytes(encoded[offset..offset + 4].try_into().unwrap());
|
||||
assert_eq!(actual, *expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wave_envelopes_merge_without_losing_columns() {
|
||||
let mut target = Some(WaveEnvFrame {
|
||||
data: vec![-0.5, 0.5],
|
||||
columns: 1,
|
||||
channels: 1,
|
||||
column_samples: 5,
|
||||
sample_rate: 48_000,
|
||||
});
|
||||
merge_wave_env(
|
||||
&mut target,
|
||||
Some(WaveEnvFrame {
|
||||
data: vec![-0.25, 0.25, -0.1, 0.1],
|
||||
columns: 2,
|
||||
channels: 1,
|
||||
column_samples: 5,
|
||||
sample_rate: 48_000,
|
||||
}),
|
||||
);
|
||||
let merged = target.unwrap();
|
||||
assert_eq!(merged.columns, 3);
|
||||
assert_eq!(merged.data, vec![-0.5, 0.5, -0.25, 0.25, -0.1, 0.1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_binary_contains_xy_and_wave_envelope() {
|
||||
let frame = meter_frame();
|
||||
let encoded = encode_visual_frame(&frame).unwrap();
|
||||
assert_eq!(
|
||||
u32::from_le_bytes(encoded[0..4].try_into().unwrap()),
|
||||
0x5058_5653
|
||||
);
|
||||
assert_eq!(u16::from_le_bytes(encoded[4..6].try_into().unwrap()), 1);
|
||||
assert_eq!(u16::from_le_bytes(encoded[6..8].try_into().unwrap()), 3);
|
||||
assert_eq!(u32::from_le_bytes(encoded[8..12].try_into().unwrap()), 7);
|
||||
assert_eq!(u32::from_le_bytes(encoded[12..16].try_into().unwrap()), 2);
|
||||
assert_eq!(u32::from_le_bytes(encoded[16..20].try_into().unwrap()), 2);
|
||||
assert_eq!(u16::from_le_bytes(encoded[20..22].try_into().unwrap()), 1);
|
||||
assert_eq!(u32::from_le_bytes(encoded[24..28].try_into().unwrap()), 5);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes(encoded[28..32].try_into().unwrap()),
|
||||
48_000
|
||||
);
|
||||
assert_eq!(encoded.len(), 32 + 2 * 8 + 4 * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_metrics_do_not_repeat_large_visual_payloads() {
|
||||
let mut frame = meter_frame();
|
||||
strip_visual_payloads(&mut frame);
|
||||
let json = serde_json::to_string(&frame).unwrap();
|
||||
assert!(!json.contains("wave_l"));
|
||||
assert!(!json.contains("wave_r"));
|
||||
assert!(!json.contains("xy_l"));
|
||||
assert!(!json.contains("xy_r"));
|
||||
assert!(!json.contains("wave_env"));
|
||||
assert!(!json.contains("spectro"));
|
||||
assert!(
|
||||
json.len() < 1_000,
|
||||
"scalar metrics JSON grew to {} bytes",
|
||||
json.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+551
@@ -0,0 +1,551 @@
|
||||
//! Realtime-analyzer filter primitives.
|
||||
//!
|
||||
//! Fractional-octave bands are designed as complete Butterworth bandpasses:
|
||||
//! an analog low-pass prototype is transformed to a bandpass, pre-warped and
|
||||
//! mapped with the bilinear transform, then emitted as distinct SOS sections.
|
||||
|
||||
pub const RTW_THIRD_OCTAVE_CENTERS: &[f32] = &[
|
||||
20.0, 25.0, 31.5, 40.0, 50.0, 63.0, 80.0, 100.0, 125.0, 160.0, 200.0, 250.0, 315.0, 400.0,
|
||||
500.0, 630.0, 800.0, 1_000.0, 1_250.0, 1_600.0, 2_000.0, 2_500.0, 3_150.0, 4_000.0, 5_000.0,
|
||||
6_300.0, 8_000.0, 10_000.0, 12_500.0, 16_000.0, 20_000.0,
|
||||
];
|
||||
|
||||
/// Maps a rounded preferred label to its exact IEC base-ten center frequency.
|
||||
pub fn exact_fractional_octave_center(nominal_hz: f32, bands_per_octave: usize) -> f32 {
|
||||
let bpo = bands_per_octave.max(1) as f32;
|
||||
let step = (bpo * (nominal_hz / 1_000.0).log10() / 0.3).round();
|
||||
1_000.0 * 10.0f32.powf(0.3 * step / bpo)
|
||||
}
|
||||
|
||||
pub fn fractional_octave_edges(center_hz: f32, bands_per_octave: usize) -> (f32, f32) {
|
||||
let factor = 10.0f32.powf(3.0 / (20.0 * bands_per_octave.max(1) as f32));
|
||||
(center_hz / factor, center_hz * factor)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct BiquadCoeffs {
|
||||
pub b0: f64,
|
||||
pub b1: f64,
|
||||
pub b2: f64,
|
||||
pub a1: f64,
|
||||
pub a2: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Complex {
|
||||
re: f64,
|
||||
im: f64,
|
||||
}
|
||||
|
||||
impl Complex {
|
||||
fn new(re: f64, im: f64) -> Self {
|
||||
Self { re, im }
|
||||
}
|
||||
|
||||
fn add(self, other: Self) -> Self {
|
||||
Self::new(self.re + other.re, self.im + other.im)
|
||||
}
|
||||
|
||||
fn sub(self, other: Self) -> Self {
|
||||
Self::new(self.re - other.re, self.im - other.im)
|
||||
}
|
||||
|
||||
fn mul(self, other: Self) -> Self {
|
||||
Self::new(
|
||||
self.re * other.re - self.im * other.im,
|
||||
self.re * other.im + self.im * other.re,
|
||||
)
|
||||
}
|
||||
|
||||
fn scale(self, value: f64) -> Self {
|
||||
Self::new(self.re * value, self.im * value)
|
||||
}
|
||||
|
||||
fn div(self, other: Self) -> Self {
|
||||
let denom = other.re * other.re + other.im * other.im;
|
||||
Self::new(
|
||||
(self.re * other.re + self.im * other.im) / denom,
|
||||
(self.im * other.re - self.re * other.im) / denom,
|
||||
)
|
||||
}
|
||||
|
||||
fn abs(self) -> f64 {
|
||||
(self.re * self.re + self.im * self.im).sqrt()
|
||||
}
|
||||
|
||||
fn sqrt(self) -> Self {
|
||||
let magnitude = self.abs();
|
||||
let re = ((magnitude + self.re) * 0.5).max(0.0).sqrt();
|
||||
let im = ((magnitude - self.re) * 0.5)
|
||||
.max(0.0)
|
||||
.sqrt()
|
||||
.copysign(self.im);
|
||||
Self::new(re, im)
|
||||
}
|
||||
}
|
||||
|
||||
fn prewarp(freq_hz: f64, sample_rate: f64) -> f64 {
|
||||
2.0 * sample_rate * (std::f64::consts::PI * freq_hz / sample_rate).tan()
|
||||
}
|
||||
|
||||
fn bilinear_pole(pole: Complex, sample_rate: f64) -> Complex {
|
||||
let two_fs = Complex::new(2.0 * sample_rate, 0.0);
|
||||
two_fs.add(pole).div(two_fs.sub(pole))
|
||||
}
|
||||
|
||||
/// Designs a 2N-order digital Butterworth bandpass from an N-order prototype.
|
||||
/// `prototype_order = 3` produces the conventional sixth-order analyzer band.
|
||||
pub fn design_fractional_octave_band(
|
||||
center_hz: f32,
|
||||
lower_hz: f32,
|
||||
upper_hz: f32,
|
||||
sample_rate: u32,
|
||||
prototype_order: usize,
|
||||
) -> Vec<BiquadCoeffs> {
|
||||
let fs = f64::from(sample_rate.max(8_000));
|
||||
let nyquist = fs * 0.5;
|
||||
let lower = f64::from(lower_hz).clamp(0.01, nyquist * 0.999_8);
|
||||
let upper = f64::from(upper_hz).clamp(lower * 1.000_001, nyquist * 0.999_9);
|
||||
let order = prototype_order.clamp(1, 4);
|
||||
let omega_1 = prewarp(lower, fs);
|
||||
let omega_2 = prewarp(upper, fs);
|
||||
let bandwidth = omega_2 - omega_1;
|
||||
let omega_0_sq = omega_1 * omega_2;
|
||||
let mut positive_poles = Vec::with_capacity(order);
|
||||
|
||||
for index in 0..order {
|
||||
let angle = std::f64::consts::PI * (2 * index + 1 + order) as f64 / (2 * order) as f64;
|
||||
let prototype_pole = Complex::new(angle.cos(), angle.sin());
|
||||
let bp = prototype_pole.scale(bandwidth);
|
||||
let discriminant = bp.mul(bp).sub(Complex::new(4.0 * omega_0_sq, 0.0)).sqrt();
|
||||
for root in [
|
||||
bp.add(discriminant).scale(0.5),
|
||||
bp.sub(discriminant).scale(0.5),
|
||||
] {
|
||||
let digital = bilinear_pole(root, fs);
|
||||
if digital.im > 1.0e-10 {
|
||||
positive_poles.push(digital);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
positive_poles.sort_by(|left, right| left.re.total_cmp(&right.re));
|
||||
let mut sections: Vec<BiquadCoeffs> = positive_poles
|
||||
.into_iter()
|
||||
.map(|pole| BiquadCoeffs {
|
||||
// Each section receives one zero at DC and one at Nyquist.
|
||||
b0: 1.0,
|
||||
b1: 0.0,
|
||||
b2: -1.0,
|
||||
a1: -2.0 * pole.re,
|
||||
a2: pole.re * pole.re + pole.im * pole.im,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if sections.is_empty() {
|
||||
return sections;
|
||||
}
|
||||
let magnitude = cascade_magnitude(§ions, center_hz, sample_rate).max(1.0e-30);
|
||||
let per_section_gain = (1.0 / magnitude).powf(1.0 / sections.len() as f64);
|
||||
for section in &mut sections {
|
||||
section.b0 *= per_section_gain;
|
||||
section.b1 *= per_section_gain;
|
||||
section.b2 *= per_section_gain;
|
||||
}
|
||||
sections
|
||||
}
|
||||
|
||||
pub fn cascade_magnitude(sections: &[BiquadCoeffs], freq_hz: f32, sample_rate: u32) -> f64 {
|
||||
let omega = 2.0 * std::f64::consts::PI * f64::from(freq_hz) / f64::from(sample_rate.max(8_000));
|
||||
let z1 = Complex::new(omega.cos(), -omega.sin());
|
||||
let z2 = z1.mul(z1);
|
||||
sections.iter().fold(1.0, |magnitude, section| {
|
||||
let numerator = Complex::new(section.b0, 0.0)
|
||||
.add(z1.scale(section.b1))
|
||||
.add(z2.scale(section.b2));
|
||||
let denominator = Complex::new(1.0, 0.0)
|
||||
.add(z1.scale(section.a1))
|
||||
.add(z2.scale(section.a2));
|
||||
magnitude * numerator.div(denominator).abs()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn cascade_db(sections: &[BiquadCoeffs], freq_hz: f32, sample_rate: u32) -> f32 {
|
||||
(20.0
|
||||
* cascade_magnitude(sections, freq_hz, sample_rate)
|
||||
.max(1.0e-30)
|
||||
.log10()) as f32
|
||||
}
|
||||
|
||||
pub fn integrate_power(
|
||||
previous: f64,
|
||||
block_power: f64,
|
||||
block_samples: usize,
|
||||
sample_rate: u32,
|
||||
tau_seconds: f32,
|
||||
) -> f64 {
|
||||
let dt = block_samples as f64 / f64::from(sample_rate.max(1));
|
||||
let tau = f64::from(tau_seconds.max(0.001));
|
||||
let alpha = 1.0 - (-dt / tau).exp();
|
||||
previous + alpha * (block_power - previous)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct StereoBiquad {
|
||||
coeffs: BiquadCoeffs,
|
||||
z1_l: f64,
|
||||
z2_l: f64,
|
||||
z1_r: f64,
|
||||
z2_r: f64,
|
||||
}
|
||||
|
||||
pub struct StereoCascade {
|
||||
sections: Vec<StereoBiquad>,
|
||||
}
|
||||
|
||||
impl StereoCascade {
|
||||
pub fn new(coefficients: Vec<BiquadCoeffs>) -> Self {
|
||||
Self {
|
||||
sections: coefficients
|
||||
.into_iter()
|
||||
.map(|coeffs| StereoBiquad {
|
||||
coeffs,
|
||||
z1_l: 0.0,
|
||||
z2_l: 0.0,
|
||||
z1_r: 0.0,
|
||||
z2_r: 0.0,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process(&mut self, left: f32, right: f32) -> (f32, f32) {
|
||||
let mut out_l = f64::from(left);
|
||||
let mut out_r = f64::from(right);
|
||||
for section in &mut self.sections {
|
||||
out_l = section.process(out_l, false);
|
||||
out_r = section.process(out_r, true);
|
||||
}
|
||||
(out_l as f32, out_r as f32)
|
||||
}
|
||||
}
|
||||
|
||||
impl StereoBiquad {
|
||||
fn process(&mut self, input: f64, right: bool) -> f64 {
|
||||
let (z1, z2) = if right {
|
||||
(&mut self.z1_r, &mut self.z2_r)
|
||||
} else {
|
||||
(&mut self.z1_l, &mut self.z2_l)
|
||||
};
|
||||
let output = self.coeffs.b0 * input + *z1;
|
||||
*z1 = self.coeffs.b1 * input - self.coeffs.a1 * output + *z2;
|
||||
*z2 = self.coeffs.b2 * input - self.coeffs.a2 * output;
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FrequencyWeighting {
|
||||
mode: &'static str,
|
||||
cascade: StereoCascade,
|
||||
}
|
||||
|
||||
impl FrequencyWeighting {
|
||||
pub fn new(mode: &str, sample_rate: u32) -> Self {
|
||||
let mode = normalize_weighting(mode);
|
||||
let cascade = StereoCascade::new(design_weighting(mode, sample_rate));
|
||||
Self { mode, cascade }
|
||||
}
|
||||
|
||||
pub fn process(&mut self, left: f32, right: f32) -> (f32, f32) {
|
||||
if self.mode == "z" {
|
||||
return (left, right);
|
||||
}
|
||||
self.cascade.process(left, right)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_weighting(value: &str) -> &'static str {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"a" => "a",
|
||||
"c" => "c",
|
||||
_ => "z",
|
||||
}
|
||||
}
|
||||
|
||||
fn mapped_real_pole(freq_hz: f64, sample_rate: f64) -> f64 {
|
||||
let analog = prewarp(freq_hz.min(sample_rate * 0.499), sample_rate);
|
||||
(2.0 * sample_rate - analog) / (2.0 * sample_rate + analog)
|
||||
}
|
||||
|
||||
fn real_pole_section(zero_1: f64, zero_2: f64, pole_1: f64, pole_2: f64) -> BiquadCoeffs {
|
||||
BiquadCoeffs {
|
||||
b0: 1.0,
|
||||
b1: -(zero_1 + zero_2),
|
||||
b2: zero_1 * zero_2,
|
||||
a1: -(pole_1 + pole_2),
|
||||
a2: pole_1 * pole_2,
|
||||
}
|
||||
}
|
||||
|
||||
fn design_weighting(mode: &str, sample_rate: u32) -> Vec<BiquadCoeffs> {
|
||||
if mode == "z" {
|
||||
return Vec::new();
|
||||
}
|
||||
let fs = f64::from(sample_rate.max(8_000));
|
||||
let p1 = mapped_real_pole(20.598_997, fs);
|
||||
let p2 = mapped_real_pole(107.652_65, fs);
|
||||
let p3 = mapped_real_pole(737.862_23, fs);
|
||||
let p4 = mapped_real_pole(12_194.217, fs);
|
||||
let mut sections = if mode == "a" {
|
||||
vec![
|
||||
real_pole_section(1.0, 1.0, p1, p1),
|
||||
real_pole_section(1.0, 1.0, p2, p3),
|
||||
real_pole_section(-1.0, -1.0, p4, p4),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
real_pole_section(1.0, 1.0, p1, p1),
|
||||
real_pole_section(-1.0, -1.0, p4, p4),
|
||||
]
|
||||
};
|
||||
let magnitude = cascade_magnitude(§ions, 1_000.0, sample_rate).max(1.0e-30);
|
||||
let gain = (1.0 / magnitude).powf(1.0 / sections.len() as f64);
|
||||
for section in &mut sections {
|
||||
section.b0 *= gain;
|
||||
section.b1 *= gain;
|
||||
section.b2 *= gain;
|
||||
}
|
||||
sections
|
||||
}
|
||||
|
||||
pub fn weighting_response_db(mode: &str, freq_hz: f32, sample_rate: u32) -> f32 {
|
||||
cascade_db(
|
||||
&design_weighting(normalize_weighting(mode), sample_rate),
|
||||
freq_hz,
|
||||
sample_rate,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn weighting_reference_db(mode: &str, freq_hz: f32) -> f32 {
|
||||
if !freq_hz.is_finite() || freq_hz <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let f2 = freq_hz * freq_hz;
|
||||
match normalize_weighting(mode) {
|
||||
"a" => {
|
||||
let numerator = (12_194.0f32 * 12_194.0) * f2 * f2;
|
||||
let denominator = (f2 + 20.6f32 * 20.6)
|
||||
* ((f2 + 107.7f32 * 107.7) * (f2 + 737.9f32 * 737.9)).sqrt()
|
||||
* (f2 + 12_194.0f32 * 12_194.0);
|
||||
20.0 * (numerator / denominator).max(1.0e-12).log10() + 2.0
|
||||
}
|
||||
"c" => {
|
||||
let numerator = (12_194.0f32 * 12_194.0) * f2;
|
||||
let denominator = (f2 + 20.6f32 * 20.6) * (f2 + 12_194.0f32 * 12_194.0);
|
||||
20.0 * (numerator / denominator).max(1.0e-12).log10() + 0.06
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Corrects the finite-rate digital weighting filter at a band's center to
|
||||
/// the standardized A/C reference curve. This is especially important near
|
||||
/// Nyquist, where the bilinear filter necessarily bends toward zero.
|
||||
pub fn weighting_power_correction(mode: &str, freq_hz: f32, sample_rate: u32) -> f64 {
|
||||
if normalize_weighting(mode) == "z" {
|
||||
return 1.0;
|
||||
}
|
||||
let difference =
|
||||
weighting_reference_db(mode, freq_hz) - weighting_response_db(mode, freq_hz, sample_rate);
|
||||
10.0f64.powf(f64::from(difference) / 10.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn third_octave_butterworth_has_correct_center_and_edges() {
|
||||
assert_eq!(RTW_THIRD_OCTAVE_CENTERS.len(), 31);
|
||||
for &nominal_center in RTW_THIRD_OCTAVE_CENTERS {
|
||||
let center = exact_fractional_octave_center(nominal_center, 3);
|
||||
let (lower, upper) = fractional_octave_edges(center, 3);
|
||||
let sections = design_fractional_octave_band(center, lower, upper, 48_000, 3);
|
||||
assert_eq!(sections.len(), 3);
|
||||
assert!(cascade_db(§ions, center, 48_000).abs() < 0.01);
|
||||
let lower_db = cascade_db(§ions, lower, 48_000);
|
||||
let upper_db = cascade_db(§ions, upper, 48_000);
|
||||
assert!(
|
||||
(lower_db + 3.0103).abs() < 0.08,
|
||||
"{center}: lower {lower_db}"
|
||||
);
|
||||
assert!(
|
||||
(upper_db + 3.0103).abs() < 0.08,
|
||||
"{center}: upper {upper_db}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_labels_map_to_exact_iec_centers() {
|
||||
assert!((exact_fractional_octave_center(31.5, 3) - 31.622_776).abs() < 0.000_1);
|
||||
assert_eq!(exact_fractional_octave_center(1_000.0, 3), 1_000.0);
|
||||
assert!((exact_fractional_octave_center(20_000.0, 3) - 19_952.623).abs() < 0.01);
|
||||
let center = exact_fractional_octave_center(1_250.0, 3);
|
||||
let (lower, upper) = fractional_octave_edges(center, 3);
|
||||
assert!(((lower * upper).sqrt() - center).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neighboring_third_octave_centers_are_suppressed() {
|
||||
let factor = 2.0f32.powf(1.0 / 6.0);
|
||||
let sections =
|
||||
design_fractional_octave_band(1_000.0, 1_000.0 / factor, 1_000.0 * factor, 48_000, 3);
|
||||
assert!(cascade_db(§ions, 1_000.0 * 2.0f32.powf(1.0 / 3.0), 48_000) < -18.0);
|
||||
assert!(cascade_db(§ions, 1_000.0 * 2.0f32.powf(-1.0 / 3.0), 48_000) < -18.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_tracks_sample_rate() {
|
||||
let factor = 2.0f32.powf(1.0 / 6.0);
|
||||
for sample_rate in [44_100, 48_000, 96_000] {
|
||||
let sections = design_fractional_octave_band(
|
||||
10_000.0,
|
||||
10_000.0 / factor,
|
||||
10_000.0 * factor,
|
||||
sample_rate,
|
||||
3,
|
||||
);
|
||||
assert!(cascade_db(§ions, 10_000.0, sample_rate).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_integration_is_block_size_independent() {
|
||||
fn run(block_size: usize) -> f64 {
|
||||
let sample_rate = 48_000;
|
||||
let mut value = 0.0;
|
||||
let mut processed = 0usize;
|
||||
while processed < sample_rate as usize {
|
||||
let count = block_size.min(sample_rate as usize - processed);
|
||||
value = integrate_power(value, 1.0, count, sample_rate, 0.125);
|
||||
processed += count;
|
||||
}
|
||||
value
|
||||
}
|
||||
let reference = run(1);
|
||||
for block_size in [64, 128, 192, 512, 1_024] {
|
||||
let actual = run(block_size);
|
||||
assert!(
|
||||
(actual - reference).abs() < 1.0e-12,
|
||||
"block {block_size}: {actual} vs {reference}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weighting_is_normalized_and_directionally_correct() {
|
||||
for mode in ["a", "c"] {
|
||||
assert!(weighting_response_db(mode, 1_000.0, 48_000).abs() < 0.01);
|
||||
}
|
||||
assert!(weighting_response_db("a", 31.5, 48_000) < -35.0);
|
||||
assert!(weighting_response_db("c", 31.5, 48_000) < -2.0);
|
||||
assert!(weighting_response_db("a", 8_000.0, 48_000) < 0.0);
|
||||
assert_eq!(weighting_response_db("z", 31.5, 48_000), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weighting_center_calibration_tracks_standard_reference_points() {
|
||||
let a_points = [
|
||||
(31.5, -39.5),
|
||||
(63.0, -26.2),
|
||||
(125.0, -16.1),
|
||||
(1_000.0, 0.0),
|
||||
(8_000.0, -1.1),
|
||||
(16_000.0, -6.6),
|
||||
];
|
||||
let c_points = [
|
||||
(31.5, -3.0),
|
||||
(63.0, -0.8),
|
||||
(1_000.0, 0.0),
|
||||
(8_000.0, -3.0),
|
||||
(16_000.0, -8.5),
|
||||
];
|
||||
for (freq, expected) in a_points {
|
||||
let raw = weighting_response_db("a", freq, 48_000);
|
||||
let correction = 10.0 * weighting_power_correction("a", freq, 48_000).log10() as f32;
|
||||
let actual = raw + correction;
|
||||
assert!((actual - expected).abs() < 1.0, "A {freq} Hz: {actual} dB");
|
||||
}
|
||||
for (freq, expected) in c_points {
|
||||
let raw = weighting_response_db("c", freq, 48_000);
|
||||
let correction = 10.0 * weighting_power_correction("c", freq, 48_000).log10() as f32;
|
||||
let actual = raw + correction;
|
||||
assert!((actual - expected).abs() < 1.0, "C {freq} Hz: {actual} dB");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weighting_time_domain_is_finite_and_normalized() {
|
||||
let sample_rate = 48_000;
|
||||
for freq in [31.5, 1_000.0, 16_000.0] {
|
||||
let mut weighting = FrequencyWeighting::new("a", sample_rate);
|
||||
let mut input_power = 0.0f64;
|
||||
let mut output_power = 0.0f64;
|
||||
for index in 0..sample_rate as usize * 2 {
|
||||
let input =
|
||||
(2.0 * std::f32::consts::PI * freq * index as f32 / sample_rate as f32).sin();
|
||||
let (output, _) = weighting.process(input, input);
|
||||
assert!(output.is_finite());
|
||||
if index >= sample_rate as usize {
|
||||
input_power += f64::from(input * input);
|
||||
output_power += f64::from(output * output);
|
||||
}
|
||||
}
|
||||
let raw_db = 10.0 * (output_power / input_power).log10();
|
||||
let correction_db = 10.0 * weighting_power_correction("a", freq, sample_rate).log10();
|
||||
let calibrated_db = raw_db + correction_db;
|
||||
let expected = f64::from(weighting_reference_db("a", freq));
|
||||
assert!(
|
||||
(calibrated_db - expected).abs() < 0.05,
|
||||
"A {freq} Hz: {calibrated_db} dB vs {expected} dB"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn measured_sine_gain(filter: &mut StereoCascade, freq: f32, sample_rate: u32) -> f32 {
|
||||
let total = sample_rate as usize * 3;
|
||||
let settle = sample_rate as usize * 2;
|
||||
let mut input_power = 0.0f64;
|
||||
let mut output_power = 0.0f64;
|
||||
for index in 0..total {
|
||||
let phase = 2.0 * std::f32::consts::PI * freq * index as f32 / sample_rate as f32;
|
||||
let input = phase.sin();
|
||||
let (output, _) = filter.process(input, input);
|
||||
if index >= settle {
|
||||
input_power += f64::from(input * input);
|
||||
output_power += f64::from(output * output);
|
||||
}
|
||||
}
|
||||
(10.0 * (output_power / input_power).log10()) as f32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_domain_filter_matches_designed_response() {
|
||||
let factor = 2.0f32.powf(1.0 / 6.0);
|
||||
let coefficients =
|
||||
design_fractional_octave_band(1_000.0, 1_000.0 / factor, 1_000.0 * factor, 48_000, 3);
|
||||
let center = measured_sine_gain(
|
||||
&mut StereoCascade::new(coefficients.clone()),
|
||||
1_000.0,
|
||||
48_000,
|
||||
);
|
||||
let edge = measured_sine_gain(
|
||||
&mut StereoCascade::new(coefficients),
|
||||
1_000.0 * factor,
|
||||
48_000,
|
||||
);
|
||||
assert!(center.abs() < 0.02, "center gain {center}");
|
||||
assert!((edge + 3.0103).abs() < 0.08, "edge gain {edge}");
|
||||
}
|
||||
}
|
||||
+224
-50
@@ -2,8 +2,7 @@ use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
Arc,
|
||||
Mutex,
|
||||
Arc, Mutex,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,7 +15,10 @@ use tokio::{
|
||||
use crate::{
|
||||
audio::{spawn_audio_capture_worker, AudioWorkerDeps},
|
||||
config::PhoenixConfig,
|
||||
model::{InputSource, MeterFrame, PhoenixGlobalConfig, PhoenixGlobalConfigEnvelope, PhoenixRtaConfig, ServiceStatus},
|
||||
model::{
|
||||
InputSource, MeterFrame, PhoenixGlobalConfig, PhoenixGlobalConfigEnvelope,
|
||||
PhoenixRtaConfig, ServiceStatus,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -24,7 +26,8 @@ pub struct AppState {
|
||||
pub config: PhoenixConfig,
|
||||
current_input: Arc<RwLock<InputSource>>,
|
||||
seq: Arc<AtomicU64>,
|
||||
metrics_tx: broadcast::Sender<MeterFrame>,
|
||||
actual_sample_rate: Arc<AtomicU64>,
|
||||
metrics_tx: broadcast::Sender<Arc<MeterFrame>>,
|
||||
restart_token: Arc<AtomicU64>,
|
||||
rta_config: Arc<RwLock<PhoenixRtaConfig>>,
|
||||
global_config: Arc<RwLock<PhoenixGlobalConfig>>,
|
||||
@@ -49,7 +52,6 @@ pub(crate) struct NativeWavRecorder {
|
||||
pub pending_discontinuity: bool,
|
||||
}
|
||||
|
||||
|
||||
const NATIVE_RECORDING_EDGE_FADE_FRAMES: usize = 128;
|
||||
|
||||
fn apply_native_recording_edge_fades(pcm_bytes: &mut [u8], channels: u16) {
|
||||
@@ -62,7 +64,9 @@ fn apply_native_recording_edge_fades(pcm_bytes: &mut [u8], channels: u16) {
|
||||
if total_frames < 2 {
|
||||
return;
|
||||
}
|
||||
let fade_frames = NATIVE_RECORDING_EDGE_FADE_FRAMES.min(total_frames / 2).max(1);
|
||||
let fade_frames = NATIVE_RECORDING_EDGE_FADE_FRAMES
|
||||
.min(total_frames / 2)
|
||||
.max(1);
|
||||
|
||||
for frame_idx in 0..fade_frames {
|
||||
let in_gain = (frame_idx as f32 + 1.0) / fade_frames as f32;
|
||||
@@ -71,12 +75,16 @@ fn apply_native_recording_edge_fades(pcm_bytes: &mut [u8], channels: u16) {
|
||||
for ch_idx in 0..ch {
|
||||
let in_off = frame_idx * frame_size + ch_idx * 2;
|
||||
let in_sample = i16::from_le_bytes([pcm_bytes[in_off], pcm_bytes[in_off + 1]]);
|
||||
let in_scaled = (in_sample as f32 * in_gain).round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
|
||||
let in_scaled = (in_sample as f32 * in_gain)
|
||||
.round()
|
||||
.clamp(i16::MIN as f32, i16::MAX as f32) as i16;
|
||||
pcm_bytes[in_off..in_off + 2].copy_from_slice(&in_scaled.to_le_bytes());
|
||||
|
||||
let out_off = out_frame_idx * frame_size + ch_idx * 2;
|
||||
let out_sample = i16::from_le_bytes([pcm_bytes[out_off], pcm_bytes[out_off + 1]]);
|
||||
let out_scaled = (out_sample as f32 * out_gain).round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
|
||||
let out_scaled = (out_sample as f32 * out_gain)
|
||||
.round()
|
||||
.clamp(i16::MIN as f32, i16::MAX as f32) as i16;
|
||||
pcm_bytes[out_off..out_off + 2].copy_from_slice(&out_scaled.to_le_bytes());
|
||||
}
|
||||
}
|
||||
@@ -84,13 +92,18 @@ fn apply_native_recording_edge_fades(pcm_bytes: &mut [u8], channels: u16) {
|
||||
|
||||
impl AppState {
|
||||
pub fn new(config: PhoenixConfig) -> Self {
|
||||
let (metrics_tx, _) = broadcast::channel(512);
|
||||
// WebSocket consumers always drain to the newest state. A small
|
||||
// channel bounds memory and prevents seconds of stale measurements.
|
||||
let (metrics_tx, _) = broadcast::channel(32);
|
||||
let initial_global_config = load_global_config(&config);
|
||||
let initial_rta_config = apply_global_to_rta(config.default_rta_config(), &initial_global_config);
|
||||
let initial_rta_config =
|
||||
apply_global_to_rta(config.default_rta_config(), &initial_global_config);
|
||||
let configured_sample_rate = config.sample_rate;
|
||||
Self {
|
||||
config,
|
||||
current_input: Arc::new(RwLock::new(InputSource::Line)),
|
||||
seq: Arc::new(AtomicU64::new(0)),
|
||||
actual_sample_rate: Arc::new(AtomicU64::new(configured_sample_rate as u64)),
|
||||
metrics_tx,
|
||||
restart_token: Arc::new(AtomicU64::new(0)),
|
||||
rta_config: Arc::new(RwLock::new(initial_rta_config)),
|
||||
@@ -100,7 +113,7 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscribe_metrics(&self) -> broadcast::Receiver<MeterFrame> {
|
||||
pub fn subscribe_metrics(&self) -> broadcast::Receiver<Arc<MeterFrame>> {
|
||||
self.metrics_tx.subscribe()
|
||||
}
|
||||
|
||||
@@ -115,7 +128,10 @@ impl AppState {
|
||||
audio_engine: "alsa-direct",
|
||||
metrics_mode: "live",
|
||||
alsa_device: self.config.alsa_device(),
|
||||
sample_rate: self.config.sample_rate,
|
||||
sample_rate: self
|
||||
.actual_sample_rate
|
||||
.load(Ordering::SeqCst)
|
||||
.min(u32::MAX as u64) as u32,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +164,7 @@ impl AppState {
|
||||
lufs_i_norm_enabled: normalized.lufs_i_norm_enabled,
|
||||
ppm_din_loudness_boxes: current.ppm_din_loudness_boxes,
|
||||
ppm_din_loudness_offset_db: current.ppm_din_loudness_offset_db,
|
||||
xy_points: current.xy_points,
|
||||
xy_points: normalized.xy_points,
|
||||
gonio_display_gain_db: current.gonio_display_gain_db,
|
||||
record_output_format: current.record_output_format.clone(),
|
||||
record_mp3_bitrate_kbps: current.record_mp3_bitrate_kbps,
|
||||
@@ -158,6 +174,8 @@ impl AppState {
|
||||
clock_led_color: current.clock_led_color.clone(),
|
||||
header_text_color: current.header_text_color.clone(),
|
||||
rta_bar_base_color: current.rta_bar_base_color.clone(),
|
||||
spectro_gamma: current.spectro_gamma,
|
||||
spectro_scroll_mode: current.spectro_scroll_mode,
|
||||
peak_history_scroll_mode: current.peak_history_scroll_mode,
|
||||
peak_history_fill_enabled: current.peak_history_fill_enabled,
|
||||
peak_history_fill_invert: current.peak_history_fill_invert,
|
||||
@@ -248,6 +266,7 @@ impl AppState {
|
||||
global_config_rev: self.global_config_rev.clone(),
|
||||
native_wav_recorders: self.native_wav_recorders.clone(),
|
||||
seq: self.seq.clone(),
|
||||
actual_sample_rate: self.actual_sample_rate.clone(),
|
||||
metrics_tx: self.metrics_tx.clone(),
|
||||
restart_token: self.restart_token.clone(),
|
||||
});
|
||||
@@ -264,7 +283,10 @@ impl AppState {
|
||||
guard.insert(
|
||||
session_id,
|
||||
NativeWavRecorder {
|
||||
sample_rate: self.config.sample_rate,
|
||||
sample_rate: self
|
||||
.actual_sample_rate
|
||||
.load(Ordering::SeqCst)
|
||||
.min(u32::MAX as u64) as u32,
|
||||
channels: 2,
|
||||
pcm_bytes: Vec::new(),
|
||||
total_frames: 0,
|
||||
@@ -334,34 +356,69 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig {
|
||||
};
|
||||
config.integration = match config.integration.trim().to_ascii_lowercase().as_str() {
|
||||
"impulse" => "impulse".to_string(),
|
||||
"medium" => "medium".to_string(),
|
||||
"slow" => "slow".to_string(),
|
||||
"average" => "average".to_string(),
|
||||
"peak" => "peak".to_string(),
|
||||
_ => "fast".to_string(),
|
||||
};
|
||||
config.order = config.order.clamp(2, 8);
|
||||
if config.order % 2 != 0 {
|
||||
config.order = (config.order + 1).min(8);
|
||||
}
|
||||
if config.layout == "rtw" {
|
||||
config.engine = "iir".to_string();
|
||||
config.bpo = "1_3".to_string();
|
||||
config.order = 6;
|
||||
config.freq_range = "norm".to_string();
|
||||
}
|
||||
|
||||
let tau_fast = if config.tau_fast.is_finite() { config.tau_fast } else { 0.12 };
|
||||
let tau_slow = if config.tau_slow.is_finite() { config.tau_slow } else { 1.0 };
|
||||
let tau_fast = if config.tau_fast.is_finite() {
|
||||
config.tau_fast
|
||||
} else {
|
||||
0.12
|
||||
};
|
||||
let tau_slow = if config.tau_slow.is_finite() {
|
||||
config.tau_slow
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
config.tau_fast = tau_fast.clamp(0.01, 3.0);
|
||||
config.tau_slow = tau_slow.clamp(0.05, 10.0);
|
||||
if config.tau_slow < config.tau_fast {
|
||||
config.tau_slow = config.tau_fast;
|
||||
}
|
||||
let offset_l = if config.input_offset_db_l.is_finite() { config.input_offset_db_l } else { -5.0 };
|
||||
let offset_r = if config.input_offset_db_r.is_finite() { config.input_offset_db_r } else { -5.0 };
|
||||
let offset_l = if config.input_offset_db_l.is_finite() {
|
||||
config.input_offset_db_l
|
||||
} else {
|
||||
-5.0
|
||||
};
|
||||
let offset_r = if config.input_offset_db_r.is_finite() {
|
||||
config.input_offset_db_r
|
||||
} else {
|
||||
-5.0
|
||||
};
|
||||
config.input_offset_db_l = offset_l.clamp(-60.0, 20.0);
|
||||
config.input_offset_db_r = offset_r.clamp(-60.0, 20.0);
|
||||
let din_attack_ms = if config.ppm_din_attack_ms.is_finite() { config.ppm_din_attack_ms } else { 5.0 };
|
||||
let din_decay = if config.ppm_din_decay_db_per_s.is_finite() { config.ppm_din_decay_db_per_s } else { 20.0 / 1.7 };
|
||||
let ebu_attack_ms = if config.ppm_ebu_attack_ms.is_finite() { config.ppm_ebu_attack_ms } else { 10.0 };
|
||||
let ebu_decay = if config.ppm_ebu_decay_db_per_s.is_finite() { config.ppm_ebu_decay_db_per_s } else { 24.0 / 2.8 };
|
||||
config.ppm_din_attack_ms = din_attack_ms.clamp(0.1, 100.0);
|
||||
config.ppm_din_decay_db_per_s = din_decay.clamp(0.1, 120.0);
|
||||
// Standard profiles are fixed. Keeping the serialized fields preserves
|
||||
// compatibility with older clients without allowing silent mistuning.
|
||||
config.ppm_din_attack_ms = 10.0;
|
||||
config.ppm_din_decay_db_per_s = 20.0 / 1.5;
|
||||
config.ppm_din_fast_attack = !!config.ppm_din_fast_attack;
|
||||
config.ppm_ebu_attack_ms = ebu_attack_ms.clamp(0.1, 100.0);
|
||||
config.ppm_ebu_decay_db_per_s = ebu_decay.clamp(0.1, 120.0);
|
||||
config.ppm_ebu_attack_ms = 10.0;
|
||||
config.ppm_ebu_decay_db_per_s = 24.0 / 2.8;
|
||||
config.lufs_i_window_min = config.lufs_i_window_min.clamp(1, 10);
|
||||
config.lufs_i_norm_enabled = !!config.lufs_i_norm_enabled;
|
||||
config.correlation_response_s =
|
||||
crate::correlation::normalize_response_seconds(config.correlation_response_s);
|
||||
config.xy_points = match config.xy_points {
|
||||
128 | 256 | 512 | 1024 | 2048 => config.xy_points,
|
||||
n if n < 192 => 128,
|
||||
n if n < 384 => 256,
|
||||
n if n < 768 => 512,
|
||||
n if n < 1536 => 1024,
|
||||
_ => 2048,
|
||||
};
|
||||
config
|
||||
}
|
||||
|
||||
@@ -388,7 +445,10 @@ fn normalize_meter_slot(input: String, fallback: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixConfig) -> PhoenixGlobalConfig {
|
||||
fn normalize_global_config(
|
||||
mut config: PhoenixGlobalConfig,
|
||||
runtime: &PhoenixConfig,
|
||||
) -> PhoenixGlobalConfig {
|
||||
config.fft_size = match config.fft_size {
|
||||
2048 | 4096 | 8192 | 16384 => config.fft_size,
|
||||
n if n < 3072 => 2048,
|
||||
@@ -402,8 +462,16 @@ fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixCon
|
||||
"1_12" => "1_12".to_string(),
|
||||
_ => "1_6".to_string(),
|
||||
};
|
||||
let offset_l = if config.input_offset_db_l.is_finite() { config.input_offset_db_l } else { -5.0 };
|
||||
let offset_r = if config.input_offset_db_r.is_finite() { config.input_offset_db_r } else { -5.0 };
|
||||
let offset_l = if config.input_offset_db_l.is_finite() {
|
||||
config.input_offset_db_l
|
||||
} else {
|
||||
-5.0
|
||||
};
|
||||
let offset_r = if config.input_offset_db_r.is_finite() {
|
||||
config.input_offset_db_r
|
||||
} else {
|
||||
-5.0
|
||||
};
|
||||
config.input_offset_db_l = offset_l.clamp(-10.0, 10.0);
|
||||
config.input_offset_db_r = offset_r.clamp(-10.0, 10.0);
|
||||
config.mono_input = !!config.mono_input;
|
||||
@@ -415,22 +483,26 @@ fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixCon
|
||||
};
|
||||
config.lr_fractional_delay_samples = delay.clamp(-1.5, 1.5);
|
||||
config.al_markers_enabled = !!config.al_markers_enabled;
|
||||
let thin = if config.meter_bar_thin.is_finite() { config.meter_bar_thin } else { 0.55 };
|
||||
let thin = if config.meter_bar_thin.is_finite() {
|
||||
config.meter_bar_thin
|
||||
} else {
|
||||
0.55
|
||||
};
|
||||
config.meter_bar_thin = thin.clamp(0.35, 0.9);
|
||||
config.panel_dividers_enabled = !!config.panel_dividers_enabled;
|
||||
let din_attack_ms = if config.ppm_din_attack_ms.is_finite() { config.ppm_din_attack_ms } else { 5.0 };
|
||||
let din_decay = if config.ppm_din_decay_db_per_s.is_finite() { config.ppm_din_decay_db_per_s } else { 20.0 / 1.7 };
|
||||
let ebu_attack_ms = if config.ppm_ebu_attack_ms.is_finite() { config.ppm_ebu_attack_ms } else { 10.0 };
|
||||
let ebu_decay = if config.ppm_ebu_decay_db_per_s.is_finite() { config.ppm_ebu_decay_db_per_s } else { 24.0 / 2.8 };
|
||||
config.ppm_din_attack_ms = din_attack_ms.clamp(0.1, 100.0);
|
||||
config.ppm_din_decay_db_per_s = din_decay.clamp(0.1, 120.0);
|
||||
config.ppm_din_attack_ms = 10.0;
|
||||
config.ppm_din_decay_db_per_s = 20.0 / 1.5;
|
||||
config.ppm_din_fast_attack = !!config.ppm_din_fast_attack;
|
||||
config.ppm_ebu_attack_ms = ebu_attack_ms.clamp(0.1, 100.0);
|
||||
config.ppm_ebu_decay_db_per_s = ebu_decay.clamp(0.1, 120.0);
|
||||
config.ppm_ebu_attack_ms = 10.0;
|
||||
config.ppm_ebu_decay_db_per_s = 24.0 / 2.8;
|
||||
config.lufs_i_window_min = config.lufs_i_window_min.clamp(1, 10);
|
||||
config.lufs_i_norm_enabled = !!config.lufs_i_norm_enabled;
|
||||
config.ppm_din_loudness_boxes = !!config.ppm_din_loudness_boxes;
|
||||
let loudness_offset = if config.ppm_din_loudness_offset_db.is_finite() { config.ppm_din_loudness_offset_db } else { 0.0 };
|
||||
let loudness_offset = if config.ppm_din_loudness_offset_db.is_finite() {
|
||||
config.ppm_din_loudness_offset_db
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
config.ppm_din_loudness_offset_db = loudness_offset.clamp(-7.0, 7.0);
|
||||
config.xy_points = match config.xy_points {
|
||||
128 | 256 | 512 | 1024 | 2048 => config.xy_points,
|
||||
@@ -440,9 +512,18 @@ fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixCon
|
||||
n if n < 1536 => 1024,
|
||||
_ => 2048,
|
||||
};
|
||||
let gonio_gain = if config.gonio_display_gain_db.is_finite() { config.gonio_display_gain_db } else { -5.0 };
|
||||
let gonio_gain = if config.gonio_display_gain_db.is_finite() {
|
||||
config.gonio_display_gain_db
|
||||
} else {
|
||||
-5.0
|
||||
};
|
||||
config.gonio_display_gain_db = gonio_gain.clamp(-35.0, 35.0);
|
||||
config.record_output_format = match config.record_output_format.trim().to_ascii_lowercase().as_str() {
|
||||
config.record_output_format = match config
|
||||
.record_output_format
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"mp3" => "mp3".to_string(),
|
||||
"webm" => "webm".to_string(),
|
||||
_ => "wav".to_string(),
|
||||
@@ -459,13 +540,39 @@ fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixCon
|
||||
"volumio" => "volumio".to_string(),
|
||||
_ => "analyzer".to_string(),
|
||||
};
|
||||
let auto_gap = if config.record_auto_split_gap_sec.is_finite() { config.record_auto_split_gap_sec } else { 1.5 };
|
||||
let auto_gap = if config.record_auto_split_gap_sec.is_finite() {
|
||||
config.record_auto_split_gap_sec
|
||||
} else {
|
||||
1.5
|
||||
};
|
||||
config.record_auto_split_gap_sec = (auto_gap.clamp(0.5, 3.0) * 2.0).round() / 2.0;
|
||||
let auto_threshold = if config.record_auto_threshold_dbfs.is_finite() { config.record_auto_threshold_dbfs } else { -50.0 };
|
||||
let auto_threshold = if config.record_auto_threshold_dbfs.is_finite() {
|
||||
config.record_auto_threshold_dbfs
|
||||
} else {
|
||||
-50.0
|
||||
};
|
||||
config.record_auto_threshold_dbfs = auto_threshold.clamp(-120.0, 20.0);
|
||||
config.clock_led_color = normalize_ui_color(config.clock_led_color, "#ff0000");
|
||||
config.header_text_color = normalize_ui_color(config.header_text_color, "#ffe066");
|
||||
config.rta_bar_base_color = normalize_ui_color(config.rta_bar_base_color, "#ffe066");
|
||||
config.spectro_gamma = if config.spectro_gamma.is_finite() {
|
||||
config.spectro_gamma.clamp(0.3, 1.2)
|
||||
} else {
|
||||
0.9
|
||||
};
|
||||
config.spectro_scroll_mode = match config.spectro_scroll_mode {
|
||||
x if !x.is_finite() => 1.0,
|
||||
x if (x - 0.5).abs() < f32::EPSILON => 0.5,
|
||||
x if (x - 1.0).abs() < f32::EPSILON => 1.0,
|
||||
x if (x - 2.0).abs() < f32::EPSILON => 2.0,
|
||||
x if (x - 4.0).abs() < f32::EPSILON => 4.0,
|
||||
x if (x - 6.0).abs() < f32::EPSILON => 6.0,
|
||||
x if x < 0.75 => 0.5,
|
||||
x if x < 1.5 => 1.0,
|
||||
x if x < 3.0 => 2.0,
|
||||
x if x < 5.0 => 4.0,
|
||||
_ => 6.0,
|
||||
};
|
||||
config.peak_history_scroll_mode = match config.peak_history_scroll_mode {
|
||||
x if !x.is_finite() => 1.0,
|
||||
x if (x - 0.5).abs() < f32::EPSILON => 0.5,
|
||||
@@ -482,9 +589,18 @@ fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixCon
|
||||
config.peak_history_fill_enabled = !!config.peak_history_fill_enabled;
|
||||
config.peak_history_fill_invert = !!config.peak_history_fill_invert;
|
||||
config.peak_history_slot = normalize_meter_slot(config.peak_history_slot, "ppm-din");
|
||||
let phase_gain = if config.phase_display_gain_db.is_finite() { config.phase_display_gain_db } else { 0.0 };
|
||||
let phase_gain = if config.phase_display_gain_db.is_finite() {
|
||||
config.phase_display_gain_db
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
config.phase_display_gain_db = (phase_gain.clamp(-35.0, 35.0) / 5.0).round() * 5.0;
|
||||
config.phase_amplitude_mode = match config.phase_amplitude_mode.trim().to_ascii_lowercase().as_str() {
|
||||
config.phase_amplitude_mode = match config
|
||||
.phase_amplitude_mode
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"ppm-din" => "ppm-din".to_string(),
|
||||
_ => "bandpass".to_string(),
|
||||
};
|
||||
@@ -513,9 +629,17 @@ fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixCon
|
||||
config.lufs_color_s = normalize_ui_color(config.lufs_color_s, "#ff3b3b");
|
||||
config.lufs_scale_label_color = normalize_ui_color(config.lufs_scale_label_color, "#8fd3d4");
|
||||
config.screensaver_enabled = !!config.screensaver_enabled;
|
||||
let idle = if config.screensaver_idle_min.is_finite() { config.screensaver_idle_min } else { 5.0 };
|
||||
let idle = if config.screensaver_idle_min.is_finite() {
|
||||
config.screensaver_idle_min
|
||||
} else {
|
||||
5.0
|
||||
};
|
||||
config.screensaver_idle_min = idle.clamp(0.0, 120.0);
|
||||
let activity = if config.screensaver_activity_db.is_finite() { config.screensaver_activity_db } else { -50.0 };
|
||||
let activity = if config.screensaver_activity_db.is_finite() {
|
||||
config.screensaver_activity_db
|
||||
} else {
|
||||
-50.0
|
||||
};
|
||||
config.screensaver_activity_db = activity.clamp(-120.0, 0.0);
|
||||
config.screensaver_mode = match config.screensaver_mode.trim().to_ascii_lowercase().as_str() {
|
||||
"starfield" => "starfield".to_string(),
|
||||
@@ -525,11 +649,18 @@ fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixCon
|
||||
};
|
||||
config.screensaver_led_glow = !!config.screensaver_led_glow;
|
||||
let color = config.screensaver_led_color.trim();
|
||||
config.screensaver_led_color = if color.is_empty() { "#ff0000".to_string() } else { color.to_string() };
|
||||
config.screensaver_led_color = if color.is_empty() {
|
||||
"#ff0000".to_string()
|
||||
} else {
|
||||
color.to_string()
|
||||
};
|
||||
config
|
||||
}
|
||||
|
||||
fn apply_global_to_rta(mut config: PhoenixRtaConfig, global: &PhoenixGlobalConfig) -> PhoenixRtaConfig {
|
||||
fn apply_global_to_rta(
|
||||
mut config: PhoenixRtaConfig,
|
||||
global: &PhoenixGlobalConfig,
|
||||
) -> PhoenixRtaConfig {
|
||||
config.fft_size = global.fft_size;
|
||||
config.bpo = global.rta_bpo_mode.clone();
|
||||
config.input_offset_db_l = global.input_offset_db_l;
|
||||
@@ -544,6 +675,7 @@ fn apply_global_to_rta(mut config: PhoenixRtaConfig, global: &PhoenixGlobalConfi
|
||||
config.ppm_ebu_decay_db_per_s = global.ppm_ebu_decay_db_per_s;
|
||||
config.lufs_i_window_min = global.lufs_i_window_min;
|
||||
config.lufs_i_norm_enabled = global.lufs_i_norm_enabled;
|
||||
config.xy_points = global.xy_points;
|
||||
normalize_rta_config(config)
|
||||
}
|
||||
|
||||
@@ -558,7 +690,10 @@ fn load_global_config(runtime: &PhoenixConfig) -> PhoenixGlobalConfig {
|
||||
normalize_global_config(parsed, runtime)
|
||||
}
|
||||
|
||||
async fn persist_global_config(runtime: &PhoenixConfig, config: &PhoenixGlobalConfig) -> anyhow::Result<()> {
|
||||
async fn persist_global_config(
|
||||
runtime: &PhoenixConfig,
|
||||
config: &PhoenixGlobalConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let path = runtime.global_config_path.clone();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
@@ -571,3 +706,42 @@ async fn persist_global_config(runtime: &PhoenixConfig, config: &PhoenixGlobalCo
|
||||
.with_context(|| format!("failed to write Phoenix config {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rtw_profile_is_always_iir_third_octave() {
|
||||
let config = normalize_rta_config(PhoenixRtaConfig {
|
||||
engine: "fft".to_string(),
|
||||
bpo: "1_12".to_string(),
|
||||
order: 2,
|
||||
freq_range: "lf".to_string(),
|
||||
layout: "rtw".to_string(),
|
||||
..PhoenixRtaConfig::default()
|
||||
});
|
||||
assert_eq!(config.engine, "iir");
|
||||
assert_eq!(config.bpo, "1_3");
|
||||
assert_eq!(config.order, 6);
|
||||
assert_eq!(config.freq_range, "norm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phoenix_profile_retains_explicit_extensions() {
|
||||
let config = normalize_rta_config(PhoenixRtaConfig {
|
||||
engine: "fft".to_string(),
|
||||
bpo: "1_12".to_string(),
|
||||
order: 8,
|
||||
freq_range: "lf".to_string(),
|
||||
layout: "iec".to_string(),
|
||||
integration: "medium".to_string(),
|
||||
..PhoenixRtaConfig::default()
|
||||
});
|
||||
assert_eq!(config.engine, "fft");
|
||||
assert_eq!(config.bpo, "1_12");
|
||||
assert_eq!(config.order, 8);
|
||||
assert_eq!(config.freq_range, "lf");
|
||||
assert_eq!(config.integration, "medium");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user