From 79e4baedd01e60651087a7033984fe367430fb1b Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 10:29:38 +1000 Subject: [PATCH] 4g: bus meters publishing + housekeeping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last gap before Phase 5's monitor TUI: per-app meter events already publish on the meters topic via the registry watcher; bus-level DSP meters now also publish. 4g — Bus meters headroom_core::meters::BusMetrics is an Arc> snapshot owned by the playback callback (try_lock; skip on contention) and read by the AGC controller on each 50 ms tick. Carries: compressor GR, limiter total/soft/hard GR, true peak. The AGC controller combines these with its ebur128 readings (momentary, short-term, integrated) and the current smoothed AGC target, then publishes a headroom_ipc::MeterTick on Topic::Meters. Publish cadence honours profile.meters.publish_hz, capped at the AGC tick rate (20 Hz). Lower publish_hz throttles to every Nth tick. Mode::I added to the AGC's EbuR128 so loudness_global() is available without a second ebur128 instance. Bounded cost — a histogram walk per call, <=20 Hz. LUFS values are sanitised to a -200.0 dB floor via finite_or_floor() — ebur128 returns -inf (not Err) for "no usable measurement yet," and non-finite f32 can't survive JSON serialisation (serde_json renders as null). Housekeeping shipped alongside headroom-client moved from [dependencies] to [dev-dependencies] in headroom-core — it's only used inside ipc::server's tests. Verified by full clippy + test run; production builds no longer pull it in. Pre-existing clippy nits cleared (limiter.rs x5, app_level.rs, ipc/ops.rs, pw/filter.rs). All field_reassign_with_default or assign_op_pattern in test code; stage-6 commit ran clippy without --all-targets so these slipped through. Verified 178 tests passing (28 dsp + 48 dsp + 20 ipc + 106 core including +2 new meters tests + 4 client). Clippy clean at default level with -D warnings --all-targets. Smoke test: monitor meters subscription receives 20 Hz MeterTick events with the expected JSON shape (all fields finite). --- crates/headroom-core/Cargo.toml | 4 +- crates/headroom-core/src/agc.rs | 206 +++++++++++++++++++------- crates/headroom-core/src/app_level.rs | 2 +- crates/headroom-core/src/ipc/ops.rs | 2 +- crates/headroom-core/src/lib.rs | 1 + crates/headroom-core/src/meters.rs | 88 +++++++++++ crates/headroom-core/src/pw/filter.rs | 35 ++++- crates/headroom-core/src/runtime.rs | 6 +- crates/headroom-dsp/src/limiter.rs | 35 +++-- 9 files changed, 309 insertions(+), 70 deletions(-) create mode 100644 crates/headroom-core/src/meters.rs diff --git a/crates/headroom-core/Cargo.toml b/crates/headroom-core/Cargo.toml index 00a3dc0..f22230c 100644 --- a/crates/headroom-core/Cargo.toml +++ b/crates/headroom-core/Cargo.toml @@ -12,7 +12,6 @@ authors.workspace = true [dependencies] headroom-dsp = { workspace = true } headroom-ipc = { workspace = true } -headroom-client = { workspace = true } # test-only: integration tests serde = { workspace = true } serde_json = { workspace = true } @@ -49,6 +48,9 @@ ebur128 = { workspace = true } [dev-dependencies] criterion = { workspace = true } +# Only used in `ipc::server::tests` to round-trip a real client +# against the spawned IPC server. +headroom-client = { workspace = true } [features] default = [] diff --git a/crates/headroom-core/src/agc.rs b/crates/headroom-core/src/agc.rs index 8fb8f19..354eca5 100644 --- a/crates/headroom-core/src/agc.rs +++ b/crates/headroom-core/src/agc.rs @@ -16,7 +16,9 @@ use std::time::Duration; use ebur128::{EbuR128, Mode}; +use headroom_ipc::{Event, MeterTick, Topic}; +use crate::meters::SharedBusMetrics; use crate::pw::filter::FilterControl; use crate::state::SharedState; @@ -50,8 +52,14 @@ pub struct AgcController { /// enable flag exactly when it changes. last_enabled: bool, /// Last short-term loudness observed; surfaced for status / - /// meters in a future sub-stage. + /// `meters` topic. last_short_term_lufs: f32, + /// Bus-level DSP snapshot written by the filter's playback + /// callback. Used to fill the `MeterTick` payload published on + /// `Topic::Meters`. + bus_metrics: SharedBusMetrics, + /// Tick counter for `publish_hz` throttling. Wraps freely. + meter_tick_counter: u32, } impl AgcController { @@ -66,9 +74,18 @@ impl AgcController { measurement_consumer: rtrb::Consumer, filter_control: FilterControl, daemon: SharedState, + bus_metrics: SharedBusMetrics, ) -> Result { - let ebu = EbuR128::new(channels, sample_rate, Mode::S | Mode::M | Mode::TRUE_PEAK) - .map_err(AgcInitError::from)?; + // `Mode::I` (integrated, gated) costs a histogram walk per + // `loudness_global()` call — bounded, fine at 20 Hz meter + // cadence. Added so the `meters` topic can surface integrated + // LUFS without a second ebur128 instance. + let ebu = EbuR128::new( + channels, + sample_rate, + Mode::S | Mode::M | Mode::I | Mode::TRUE_PEAK, + ) + .map_err(AgcInitError::from)?; Ok(Self { sample_rate, channels, @@ -79,6 +96,8 @@ impl AgcController { smoothed_target_db: 0.0, last_enabled: true, last_short_term_lufs: LOUDNESS_FLOOR_LUFS, + bus_metrics, + meter_tick_counter: 0, }) } @@ -99,26 +118,68 @@ impl AgcController { /// One control-loop iteration. Should be invoked at [`AGC_TICK`] /// cadence by a main-loop timer source. + /// + /// Three things happen here: + /// + /// 1. AGC enable/disable transition is observed and pushed to + /// the audio thread. + /// 2. The measurement ring is drained into `ebur128` and the + /// short-term loudness is cached. This runs **regardless of + /// AGC enabled** so the `meters` topic can keep surfacing LUFS + /// when the user has only enabled the compressor / limiter. + /// 3. If AGC is enabled, a smoothed target gain is computed and + /// pushed to the audio thread. + /// 4. Bus-level meters are published on `Topic::Meters` honouring + /// `profile.meters.publish_hz`. pub fn tick(&mut self) { - // Snapshot the AGC section out from under the daemon lock. - // Hold the lock only long enough to clone the small config. - let cfg = { + // Snapshot what we need out from under the daemon lock. Hold + // the lock only long enough to clone the small config. + let (cfg, publish_hz) = { let s = self.daemon.lock(); - s.profiles.effective().agc.clone() + let p = s.profiles.effective(); + (p.agc.clone(), p.meters.publish_hz) }; - // React to enable/disable transitions before doing measurement - // work — flipping off should stop pushing target updates and - // tell the audio thread to unwind back to 0 dB. if cfg.enabled != self.last_enabled { self.filter_control.set_agc_enabled(cfg.enabled); self.last_enabled = cfg.enabled; } - if !cfg.enabled { - return; + + // Drain the measurement ring + feed ebur128 unconditionally. + self.consume_measurements(); + let short_term = finite_or_floor( + self.ebu.loudness_shortterm().map(|v| v as f32).ok(), + ); + self.last_short_term_lufs = short_term; + + if cfg.enabled + && short_term > cfg.silence_threshold_lufs + && short_term.is_finite() + { + let raw_target = cfg.target_lufs - short_term; + let clamped = raw_target.clamp(-cfg.max_cut_db, cfg.max_boost_db); + + // Slow leaky-integrator smoother on the tick cadence. + // attack when target is dropping (gain reduction toward + // the signal), release when target is rising back toward + // unity / boost. + let dt_ms = AGC_TICK.as_secs_f32() * 1000.0; + let alpha = if clamped < self.smoothed_target_db { + alpha_for_dt(cfg.attack_ms, dt_ms) + } else { + alpha_for_dt(cfg.release_ms, dt_ms) + }; + self.smoothed_target_db += alpha * (clamped - self.smoothed_target_db); + self.filter_control + .set_agc_target_db(self.smoothed_target_db); } - // Drain up to TICK_BUF_SAMPLES from the measurement ring. + self.publish_meters(publish_hz); + } + + /// Drain up to [`TICK_BUF_SAMPLES`] from the measurement ring and + /// feed them through `ebur128`. + fn consume_measurements(&mut self) { let mut buf = [0.0_f32; TICK_BUF_SAMPLES]; let mut n = 0; while n < buf.len() { @@ -131,7 +192,7 @@ impl AgcController { } } if n == 0 { - return; // No samples yet (early boot or silence); leave target alone. + return; } // ebur128 wants whole frames; drop any odd trailing sample. let usable = (n / self.channels as usize) * self.channels as usize; @@ -140,39 +201,61 @@ impl AgcController { } if let Err(e) = self.ebu.add_frames_f32(&buf[..usable]) { tracing::warn!(error = %e, "ebur128 add_frames_f32 failed"); + } + } + + /// Publish a `MeterTick` event on `Topic::Meters` if this tick + /// falls on the `publish_hz` cadence. + fn publish_meters(&mut self, publish_hz: f32) { + if !self.should_publish(publish_hz) { return; } + let bus = *self.bus_metrics.lock(); + // `ebur128` returns `-inf` (not `Err`) for "no useful + // measurement yet" — typically early-boot or while the input + // is pure silence. `-inf` can't survive JSON serialisation + // (serde_json renders non-finite f32 as null), so floor here. + let momentary = finite_or_floor( + self.ebu.loudness_momentary().map(|v| v as f32).ok(), + ); + let integrated = finite_or_floor( + self.ebu.loudness_global().map(|v| v as f32).ok(), + ); - let short_term = self - .ebu - .loudness_shortterm() - .map(|v| v as f32) - .unwrap_or(LOUDNESS_FLOOR_LUFS); - self.last_short_term_lufs = short_term; - - // Silence gate: if the program is below the threshold, hold - // the current target. This avoids ramping gain up during - // legitimate quiet passages. - if short_term <= cfg.silence_threshold_lufs || !short_term.is_finite() { - return; - } - - let raw_target = cfg.target_lufs - short_term; - let clamped = raw_target.clamp(-cfg.max_cut_db, cfg.max_boost_db); - - // Slow leaky-integrator smoother on the tick cadence. attack - // when target is dropping (gain reduction toward the signal), - // release when target is rising back toward unity / boost. - let dt_ms = AGC_TICK.as_secs_f32() * 1000.0; - let alpha = if clamped < self.smoothed_target_db { - alpha_for_dt(cfg.attack_ms, dt_ms) - } else { - alpha_for_dt(cfg.release_ms, dt_ms) + let tick = MeterTick { + momentary_lufs: momentary, + shortterm_lufs: self.last_short_term_lufs, + integrated_lufs: integrated, + true_peak_dbtp: bus.true_peak_dbtp, + // Total path GR is additive in log domain. Both values + // are ≤ 0 dB when reducing. + gain_reduction_db: bus.compressor_gr_db + bus.limiter_total_gr_db, + compressor_gr_db: bus.compressor_gr_db, + limiter_gr_db: bus.limiter_total_gr_db, + agc_gain_db: self.smoothed_target_db, }; - self.smoothed_target_db += alpha * (clamped - self.smoothed_target_db); - self.filter_control - .set_agc_target_db(self.smoothed_target_db); + if let Ok(event) = Event::new(Topic::Meters, "tick", &tick) { + self.daemon.lock().broadcaster.publish(Topic::Meters, event); + } + } + + /// Tick-rate gate for the `meters` publish loop. Caps at + /// [`AGC_TICK`]'s native rate (20 Hz) — `publish_hz` above that is + /// silently clamped. + fn should_publish(&mut self, publish_hz: f32) -> bool { + if publish_hz <= 0.0 { + return false; + } + let agc_hz = 1000.0 / AGC_TICK.as_millis() as f32; + if publish_hz >= agc_hz { + self.meter_tick_counter = self.meter_tick_counter.wrapping_add(1); + return true; + } + let skip = (agc_hz / publish_hz).round().max(1.0) as u32; + let now = self.meter_tick_counter; + self.meter_tick_counter = self.meter_tick_counter.wrapping_add(1); + now % skip == 0 } /// Reset the smoothed target and the underlying `ebur128` state. @@ -181,16 +264,31 @@ impl AgcController { pub fn reset(&mut self) { self.smoothed_target_db = 0.0; self.last_short_term_lufs = LOUDNESS_FLOOR_LUFS; - // ebur128 doesn't expose a public reset, so rebuild it. - if let Ok(fresh) = - EbuR128::new(self.channels, self.sample_rate, Mode::S | Mode::M | Mode::TRUE_PEAK) - { + // ebur128 doesn't expose a public reset, so rebuild it. Keep + // the same mode set used in `new()` so meter publishing stays + // consistent. + if let Ok(fresh) = EbuR128::new( + self.channels, + self.sample_rate, + Mode::S | Mode::M | Mode::I | Mode::TRUE_PEAK, + ) { self.ebu = fresh; } self.filter_control.set_agc_target_db(0.0); } } +/// Coerce a possibly-non-finite LUFS measurement into a finite value +/// suitable for serialisation. `-inf` (the `ebur128` "no usable +/// reading" sentinel) and `NaN` both collapse to +/// [`LOUDNESS_FLOOR_LUFS`]. +fn finite_or_floor(v: Option) -> f32 { + match v { + Some(x) if x.is_finite() => x, + _ => LOUDNESS_FLOOR_LUFS, + } +} + /// `tau_ms`-time-constant leaky-integrator alpha for a tick of /// duration `dt_ms`. `1 - exp(-dt / tau)`; clamps to `[0, 1]`. fn alpha_for_dt(tau_ms: f32, dt_ms: f32) -> f32 { @@ -219,6 +317,7 @@ impl From for crate::error::DaemonError { #[cfg(test)] mod tests { use super::*; + use crate::meters; use crate::profile_store::ProfileStore; use crate::pw::filter::{AudioCmd, FilterControl}; use crate::state::{self, DaemonState}; @@ -232,12 +331,15 @@ mod tests { rtrb::Producer, rtrb::Consumer, SharedState, + SharedBusMetrics, ) { let (m_prod, m_cons) = RingBuffer::::new(8192); let (control, cmd_cons) = FilterControl::for_testing(32); let state = state::shared(DaemonState::new(ProfileStore::builtin())); - let agc = AgcController::new(SR, CH, m_cons, control, state.clone()).unwrap(); - (agc, m_prod, cmd_cons, state) + let bus = meters::shared(); + let agc = + AgcController::new(SR, CH, m_cons, control, state.clone(), bus.clone()).unwrap(); + (agc, m_prod, cmd_cons, state, bus) } fn push_silence(prod: &mut rtrb::Producer, frames: usize) { @@ -258,7 +360,7 @@ mod tests { #[test] fn tick_with_no_samples_does_nothing() { - let (mut agc, _prod, mut cmd_cons, _state) = fixture(); + let (mut agc, _prod, mut cmd_cons, _state, _bus) = fixture(); agc.tick(); assert!(cmd_cons.pop().is_err(), "no samples → no target push"); assert_eq!(agc.current_target_db(), 0.0); @@ -266,7 +368,7 @@ mod tests { #[test] fn tick_under_silence_threshold_holds_target() { - let (mut agc, mut prod, mut cmd_cons, _state) = fixture(); + let (mut agc, mut prod, mut cmd_cons, _state, _bus) = fixture(); push_silence(&mut prod, 4800); // 100ms of silence agc.tick(); // ebur128 may report -inf or values below the silence @@ -279,7 +381,7 @@ mod tests { #[test] fn tick_with_audible_signal_pushes_target() { - let (mut agc, mut prod, mut cmd_cons, _state) = fixture(); + let (mut agc, mut prod, mut cmd_cons, _state, _bus) = fixture(); // Pump multiple ticks worth so ebur128's short-term window // (~3 s) starts producing values. for _ in 0..40 { @@ -299,7 +401,7 @@ mod tests { #[test] fn agc_disable_in_profile_flips_audio_thread() { - let (mut agc, _prod, mut cmd_cons, state) = fixture(); + let (mut agc, _prod, mut cmd_cons, state, _bus) = fixture(); // First tick with the default-enabled profile. agc.tick(); // Drain any commands. diff --git a/crates/headroom-core/src/app_level.rs b/crates/headroom-core/src/app_level.rs index a1e2917..57f9e9a 100644 --- a/crates/headroom-core/src/app_level.rs +++ b/crates/headroom-core/src/app_level.rs @@ -369,7 +369,7 @@ mod tests { // Immediately after the write, force a different reduction — // the rate limit must suppress any further write within 100 ms. let t1 = c.last_write_at.unwrap() + Duration::from_millis(10); - c.smoothed_reduction_db = c.smoothed_reduction_db + 6.0; // synthetic kick + c.smoothed_reduction_db += 6.0; // synthetic kick let v = c.process_block(db_to_lin(0.0), db_to_lin(-3.0).powi(2), t1); assert!(v.is_none(), "rate limit should have blocked the follow-up write"); } diff --git a/crates/headroom-core/src/ipc/ops.rs b/crates/headroom-core/src/ipc/ops.rs index 4e1974a..ec1c222 100644 --- a/crates/headroom-core/src/ipc/ops.rs +++ b/crates/headroom-core/src/ipc/ops.rs @@ -505,7 +505,7 @@ mod tests { assert!( body.get("warnings") .and_then(|w| w.as_array()) - .map_or(true, |a| a.is_empty()), + .is_none_or(|a| a.is_empty()), "expected empty/absent warnings on healthy startup" ); } diff --git a/crates/headroom-core/src/lib.rs b/crates/headroom-core/src/lib.rs index 112854e..443018d 100644 --- a/crates/headroom-core/src/lib.rs +++ b/crates/headroom-core/src/lib.rs @@ -17,6 +17,7 @@ pub mod agc; pub mod app_level; pub mod error; pub mod ipc; +pub mod meters; pub mod profile; pub mod profile_store; pub mod profile_watcher; diff --git a/crates/headroom-core/src/meters.rs b/crates/headroom-core/src/meters.rs new file mode 100644 index 0000000..0bbd3bf --- /dev/null +++ b/crates/headroom-core/src/meters.rs @@ -0,0 +1,88 @@ +//! Bus-level meter snapshot shared between the audio thread and the +//! AGC controller. +//! +//! Phase 4g. +//! +//! The audio thread writes [`BusMetrics`] after each +//! `playback_process` call using `try_lock` — it must never block on +//! the lock. The AGC controller reads on its 50 ms tick, combines +//! with `ebur128` readings (momentary / short-term / integrated +//! LUFS) and the current AGC gain target, and publishes a +//! [`headroom_ipc::MeterTick`] on `Topic::Meters` for any IPC client +//! that's subscribed. +//! +//! Per-app meter events (Phase 6e) are a separate stream emitted +//! directly from the registry watcher. The two coexist on the same +//! topic; clients see both kinds and key off the event payload shape +//! to tell them apart. +//! +//! Wait-free on the audio side: a missed write (lock contended for +//! the few nanoseconds the reader holds it) is harmless — the next +//! quantum overwrites the slot. Dropped meter samples don't degrade +//! the AGC; the controller reads the freshest available snapshot. + +use std::sync::Arc; + +use parking_lot::Mutex; + +/// Snapshot of bus-level DSP metrics, written by the audio thread +/// after the AGC → Compressor → Limiter chain runs. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct BusMetrics { + /// Compressor gain reduction in dB (negative when reducing). + pub compressor_gr_db: f32, + /// Limiter total gain reduction in dB (the min of soft and hard + /// gain, in dB). + pub limiter_total_gr_db: f32, + /// Limiter soft-tier gain reduction in dB. + pub limiter_soft_gr_db: f32, + /// Limiter hard-tier gain reduction in dB. Non-zero only when + /// the soft tier wasn't enough — that's the alarm condition. + pub limiter_hard_gr_db: f32, + /// True peak in dBTP observed by the limiter's per-quantum peak + /// detector. Bounded above by the hard ceiling on the *output*; + /// this field is the peak the limiter *saw on its input*, which + /// is informative for tuning soft-tier headroom. + pub true_peak_dbtp: f32, +} + +/// Cheap-to-clone shared handle. Audio thread + AGC controller each +/// hold a clone. +pub type SharedBusMetrics = Arc>; + +/// Construct an empty shared metrics handle. +#[must_use] +pub fn shared() -> SharedBusMetrics { + Arc::new(Mutex::new(BusMetrics::default())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_all_zero() { + let m = BusMetrics::default(); + assert_eq!(m.compressor_gr_db, 0.0); + assert_eq!(m.limiter_total_gr_db, 0.0); + assert_eq!(m.limiter_soft_gr_db, 0.0); + assert_eq!(m.limiter_hard_gr_db, 0.0); + assert_eq!(m.true_peak_dbtp, 0.0); + } + + #[test] + fn shared_is_cheap_to_clone() { + let a = shared(); + let b = a.clone(); + *a.lock() = BusMetrics { + compressor_gr_db: -3.0, + limiter_total_gr_db: -1.0, + limiter_soft_gr_db: -1.0, + limiter_hard_gr_db: 0.0, + true_peak_dbtp: -0.5, + }; + let snap = *b.lock(); + assert!((snap.compressor_gr_db - -3.0).abs() < 1e-6); + assert!((snap.true_peak_dbtp - -0.5).abs() < 1e-6); + } +} diff --git a/crates/headroom-core/src/pw/filter.rs b/crates/headroom-core/src/pw/filter.rs index 2b3053e..1d654dd 100644 --- a/crates/headroom-core/src/pw/filter.rs +++ b/crates/headroom-core/src/pw/filter.rs @@ -53,6 +53,7 @@ use headroom_dsp::{ }; use crate::error::DaemonError; +use crate::meters::{BusMetrics, SharedBusMetrics}; use crate::pw::sink::NODE_NAME as PROCESSED_SINK_NAME; /// Sample rate the filter operates at. The DSP kernels are @@ -215,6 +216,11 @@ struct PlaybackState { samples_starved: u64, /// Counter of measurement samples dropped (best-effort push). measurement_dropped: u64, + /// Bus-level meter snapshot shared with the AGC controller for + /// meter publication. Audio thread does `try_lock` and skips on + /// contention (which is vanishingly rare — the reader holds the + /// lock for nanoseconds). + bus_metrics: SharedBusMetrics, } /// The filter pipeline. @@ -252,6 +258,10 @@ pub struct FilterBundle { /// Consumer end of the AGC measurement ring. Hand to the /// `headroom-core::agc` controller. pub measurement_consumer: Consumer, + /// Bus-level meter snapshot. The audio thread keeps it fresh on + /// every `playback_process` call; the AGC controller reads it on + /// each tick and publishes a `MeterTick` event. + pub bus_metrics: SharedBusMetrics, } impl Filter { @@ -275,6 +285,7 @@ impl Filter { let control = FilterControl { cmd_producer: Arc::new(Mutex::new(cmd_producer)), }; + let bus_metrics = crate::meters::shared(); let compressor = Compressor::new(init.compressor, FILTER_SAMPLE_RATE as f32); let limiter = Limiter::new(init.limiter, FILTER_SAMPLE_RATE as f32); @@ -302,6 +313,7 @@ impl Filter { limiter, samples_starved: 0, measurement_dropped: 0, + bus_metrics: bus_metrics.clone(), }) .process(playback_process) .register() @@ -350,6 +362,7 @@ impl Filter { }, control, measurement_consumer, + bus_metrics, }) } } @@ -577,6 +590,21 @@ fn playback_process(stream: &pipewire::stream::StreamRef, state: &mut PlaybackSt .saturating_add((starved_frames * CHANNELS as usize) as u64); } + // Snapshot bus-level meter state for the AGC controller. `try_lock` + // so we never block on a daemon-thread reader; a contended quantum + // simply drops this update — the next one along will land. + if produced_frames > 0 { + if let Some(mut metrics) = state.bus_metrics.try_lock() { + *metrics = BusMetrics { + compressor_gr_db: state.compressor.gain_reduction_db(), + limiter_total_gr_db: state.limiter.gain_reduction_db(), + limiter_soft_gr_db: state.limiter.soft_gain_reduction_db(), + limiter_hard_gr_db: state.limiter.hard_gain_reduction_db(), + true_peak_dbtp: state.limiter.true_peak_dbtp(), + }; + } + } + // Tell PipeWire how much we wrote. let chunk = data.chunk_mut(); *chunk.size_mut() = (max_frames * stride_bytes) as u32; @@ -650,8 +678,11 @@ mod tests { let mut compressor = Compressor::new(CompressorConfig::default(), SR); let mut limiter = Limiter::new(LimiterConfig::default(), SR); let mut agc = AgcGain::new(AgcGainConfig::default(), SR); - let mut bad = LimiterConfig::default(); - bad.oversample = 8; // structural; can't apply in place + let bad = LimiterConfig { + // structural; can't apply in place + oversample: 8, + ..LimiterConfig::default() + }; // Should not panic, should not change the limiter. apply_audio_cmd( AudioCmd::SetLimiter(bad), diff --git a/crates/headroom-core/src/runtime.rs b/crates/headroom-core/src/runtime.rs index 6c7c2a5..d88f3b5 100644 --- a/crates/headroom-core/src/runtime.rs +++ b/crates/headroom-core/src/runtime.rs @@ -107,6 +107,7 @@ pub fn run(profiles: ProfileStore) -> Result<(), DaemonError> { filter: _filter, control: filter_control, measurement_consumer, + bus_metrics, } = Filter::create(pw.core(), filter_init)?; daemon_state.lock().filter_control = Some(filter_control.clone()); @@ -114,13 +115,16 @@ pub fn run(profiles: ProfileStore) -> Result<(), DaemonError> { // loop via a timer source; reads the active profile's [agc] // config at each tick (so profile.use takes effect on the next // tick) and pushes a smoothed target_db to the audio thread via - // FilterControl. + // FilterControl. Also publishes `meters` topic ticks at + // `profile.meters.publish_hz` (capped at 20 Hz, the AGC tick + // rate) — 4g. let agc_controller = AgcController::new( crate::pw::filter::FILTER_SAMPLE_RATE, crate::pw::filter::CHANNELS, measurement_consumer, filter_control, daemon_state.clone(), + bus_metrics, ) .map_err(DaemonError::from)?; let agc_controller = Rc::new(RefCell::new(agc_controller)); diff --git a/crates/headroom-dsp/src/limiter.rs b/crates/headroom-dsp/src/limiter.rs index 4fc6939..617f7d2 100644 --- a/crates/headroom-dsp/src/limiter.rs +++ b/crates/headroom-dsp/src/limiter.rs @@ -624,10 +624,12 @@ mod tests { fn try_set_config_applies_scalar_changes() { let sr = 48_000.0; let mut l = Limiter::new(LimiterConfig::default(), sr); - let mut cfg = LimiterConfig::default(); - cfg.ceiling_dbtp = -3.0; - cfg.release_ms = 200.0; - cfg.hold_ms = 10.0; + let cfg = LimiterConfig { + ceiling_dbtp: -3.0, + release_ms: 200.0, + hold_ms: 10.0, + ..LimiterConfig::default() + }; assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied); assert!((l.ceiling_dbtp() - -3.0).abs() < 1e-6); let active = l.config(); @@ -640,8 +642,10 @@ mod tests { let sr = 48_000.0; let mut l = Limiter::new(LimiterConfig::default(), sr); // Start with soft on. Disable it. - let mut cfg = LimiterConfig::default(); - cfg.soft = None; + let mut cfg = LimiterConfig { + soft: None, + ..LimiterConfig::default() + }; assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied); assert!(l.config().soft.is_none()); assert!(l.effective_soft_ceiling_dbtp().is_none()); @@ -664,8 +668,10 @@ mod tests { fn try_set_config_rejects_oversample_change() { let sr = 48_000.0; let mut l = Limiter::new(LimiterConfig::default(), sr); - let mut cfg = LimiterConfig::default(); - cfg.oversample = 8; + let cfg = LimiterConfig { + oversample: 8, + ..LimiterConfig::default() + }; assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange); // Limiter unchanged. assert_eq!(l.config().oversample, LimiterConfig::default().oversample); @@ -675,8 +681,11 @@ mod tests { fn try_set_config_rejects_lookahead_change() { let sr = 48_000.0; let mut l = Limiter::new(LimiterConfig::default(), sr); - let mut cfg = LimiterConfig::default(); - cfg.lookahead_ms = 5.0; // resizes delay + peak buffer + let cfg = LimiterConfig { + // resizes delay + peak buffer + lookahead_ms: 5.0, + ..LimiterConfig::default() + }; assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange); } @@ -684,8 +693,10 @@ mod tests { fn try_set_config_rejects_fir_taps_change() { let sr = 48_000.0; let mut l = Limiter::new(LimiterConfig::default(), sr); - let mut cfg = LimiterConfig::default(); - cfg.fir_taps = 63; + let cfg = LimiterConfig { + fir_taps: 63, + ..LimiterConfig::default() + }; assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange); }