4g: bus meters publishing + housekeeping

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<parking_lot::Mutex<...>>
  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).
This commit is contained in:
atagen 2026-05-21 10:29:38 +10:00
parent fcf421b94c
commit 79e4baedd0
9 changed files with 309 additions and 70 deletions

View file

@ -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 = []

View file

@ -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<f32>,
filter_control: FilterControl,
daemon: SharedState,
bus_metrics: SharedBusMetrics,
) -> Result<Self, AgcInitError> {
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>) -> 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<AgcInitError> 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<f32>,
rtrb::Consumer<AudioCmd>,
SharedState,
SharedBusMetrics,
) {
let (m_prod, m_cons) = RingBuffer::<f32>::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<f32>, 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.

View file

@ -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");
}

View file

@ -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"
);
}

View file

@ -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;

View file

@ -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<Mutex<BusMetrics>>;
/// 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);
}
}

View file

@ -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<f32>,
/// 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),

View file

@ -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));

View file

@ -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);
}