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:
parent
fcf421b94c
commit
79e4baedd0
9 changed files with 309 additions and 70 deletions
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
88
crates/headroom-core/src/meters.rs
Normal file
88
crates/headroom-core/src/meters.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue