stage 6: per-app

This commit is contained in:
atagen 2026-05-20 23:47:19 +10:00
parent 9edd809416
commit fcf421b94c
31 changed files with 6360 additions and 344 deletions

View file

@ -27,7 +27,8 @@ nix = { workspace = true }
# PipeWire integration (Phase 3c onwards).
pipewire = { workspace = true }
libspa = { workspace = true }
libspa = { workspace = true }
libspa-sys = { workspace = true }
# Audio-thread comms.
rtrb = { workspace = true }
@ -36,11 +37,22 @@ bytemuck = { workspace = true }
# shared ownership of dropping resources (Phase 4 parameter updates).
# basedrop = { workspace = true }
# Slow AGC loop + profile hot-reload land in Phase 4.
# ebur128 = { workspace = true }
# notify = { workspace = true }
# notify-debouncer-mini = { workspace = true }
# File-watch profile hot-reload (4e follow-up).
notify = { workspace = true }
notify-debouncer-mini = { workspace = true }
# Slow AGC loop (Phase 4 closing piece).
ebur128 = { workspace = true }
# Optional journald logging — not wired yet.
# tracing-journald = { workspace = true }
[dev-dependencies]
criterion = { workspace = true }
[features]
default = []
[[bench]]
name = "app_level"
harness = false

View file

@ -0,0 +1,78 @@
//! Microbench for the daemon-side per-app controller loop. Measures
//! one `AppLevelController::process_block` call (envelope smoothing +
//! anti-bounce + threshold/rate-limit gate). PLAN §4.7 budgets a
//! "few μs per measurement."
//!
//! Run with `cargo bench -p headroom-core --bench app_level` inside
//! `nix develop`.
use std::time::{Duration, Instant};
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use headroom_core::app_level::AppLevelController;
use headroom_core::profile::{DeferPolicy, PerAppRule};
use headroom_ipc::RouteRuleMatch;
const BLOCK_DT_S: f32 = 1024.0 / 48_000.0;
fn aggressive_rule() -> PerAppRule {
PerAppRule {
match_: RouteRuleMatch::default(),
enabled: true,
peak_threshold_db: -6.0,
rms_target_db: -20.0,
max_cut_db: 12.0,
peak_attack_ms: 5.0,
peak_release_ms: 500.0,
rms_window_ms: 1500.0,
smoother_ms: 30.0,
write_db_threshold: 0.5,
min_write_interval_ms: 100.0,
defer_to_user: DeferPolicy::Ceiling,
}
}
fn bench_process_block(c: &mut Criterion) {
let mut ctrl = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);
// Hot signal: 0 dBFS peak, ~-3 dB RMS.
let peak = 1.0_f32;
let mean_sq = 0.25_f32;
// Time advances at one block per call to keep the rate-limit gate
// behaviour realistic — it'd otherwise be `now` reused every iter.
let mut t = Instant::now();
let step = Duration::from_millis(21);
let mut group = c.benchmark_group("app_level_controller");
group.bench_function("process_block_hot_signal", |b| {
b.iter(|| {
t += step;
let v = ctrl.process_block(black_box(peak), black_box(mean_sq), t);
black_box(v);
});
});
// A second variant where the signal is below all thresholds —
// this exercises the "no write" fast path the controller takes
// most of the time on a quiet system.
let mut quiet_ctrl = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);
let quiet_peak = 0.01_f32;
let quiet_mean_sq = 0.0001_f32;
let mut t2 = Instant::now();
group.bench_function("process_block_quiet_signal", |b| {
b.iter(|| {
t2 += step;
let v = quiet_ctrl.process_block(
black_box(quiet_peak),
black_box(quiet_mean_sq),
t2,
);
black_box(v);
});
});
group.finish();
}
criterion_group!(benches, bench_process_block);
criterion_main!(benches);

View file

@ -0,0 +1,337 @@
//! Control-thread piece of the slow AGC.
//!
//! Reads the latest AGC config from the active profile, drains the
//! measurement ring written by the filter's playback callback,
//! feeds samples through `ebur128` to derive a short-term loudness,
//! computes a clamped + slow-smoothed target gain in dB, and pushes
//! it at the audio thread via [`FilterControl::set_agc_target_db`].
//!
//! The controller is **not** spike-reactive — its time constants are
//! seconds, and the audio-thread `AgcGain` stage takes care of
//! anti-zipper smoothing between ticks. The 50 ms tick cadence is
//! comfortably above the 520 ms quantum-reaction budget so the
//! control plane can ride the PipeWire main-loop thread alongside
//! the `route.stream` timer (see `pw::command` module docs).
use std::time::Duration;
use ebur128::{EbuR128, Mode};
use crate::pw::filter::FilterControl;
use crate::state::SharedState;
/// AGC tick period. Hardcoded for v0; not exposed as a profile knob.
pub const AGC_TICK: Duration = Duration::from_millis(50);
/// Maximum samples fed per tick. Big enough to cover ~50 ms of stereo
/// at 48 kHz (4800 samples) with slack; smaller than a stack-frame
/// alarm. Sized to keep `ebur128.add_frames_f32` work bounded.
const TICK_BUF_SAMPLES: usize = 8192;
/// Loudness floor we treat as "no usable measurement yet" — returned
/// by `ebur128` before its short-term window has filled, or during
/// digital silence.
const LOUDNESS_FLOOR_LUFS: f32 = -200.0;
/// Slow AGC controller.
pub struct AgcController {
sample_rate: u32,
channels: u32,
ebu: EbuR128,
measurement_consumer: rtrb::Consumer<f32>,
filter_control: FilterControl,
daemon: SharedState,
/// Smoothed target gain in dB. Sent to the audio thread on every
/// tick (or whenever it changes meaningfully).
smoothed_target_db: f32,
/// Active config the controller is operating against, recomputed
/// at each tick from the effective profile. Cached so we can
/// detect enabled/disabled transitions and push the audio-thread
/// enable flag exactly when it changes.
last_enabled: bool,
/// Last short-term loudness observed; surfaced for status /
/// meters in a future sub-stage.
last_short_term_lufs: f32,
}
impl AgcController {
/// Construct an AGC controller.
///
/// # Errors
/// Returns an error if `ebur128::EbuR128::new` fails — typically
/// for invalid sample-rate / channel arguments.
pub fn new(
sample_rate: u32,
channels: u32,
measurement_consumer: rtrb::Consumer<f32>,
filter_control: FilterControl,
daemon: SharedState,
) -> Result<Self, AgcInitError> {
let ebu = EbuR128::new(channels, sample_rate, Mode::S | Mode::M | Mode::TRUE_PEAK)
.map_err(AgcInitError::from)?;
Ok(Self {
sample_rate,
channels,
ebu,
measurement_consumer,
filter_control,
daemon,
smoothed_target_db: 0.0,
last_enabled: true,
last_short_term_lufs: LOUDNESS_FLOOR_LUFS,
})
}
/// Latest short-term loudness (LUFS) observed by `ebur128`. Useful
/// for telemetry / `status`; `LOUDNESS_FLOOR_LUFS` before the
/// short-term window fills.
#[must_use]
pub fn last_short_term_lufs(&self) -> f32 {
self.last_short_term_lufs
}
/// Current smoothed target gain (dB) — the value most recently
/// pushed to the audio thread.
#[must_use]
pub fn current_target_db(&self) -> f32 {
self.smoothed_target_db
}
/// One control-loop iteration. Should be invoked at [`AGC_TICK`]
/// cadence by a main-loop timer source.
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 = {
let s = self.daemon.lock();
s.profiles.effective().agc.clone()
};
// 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 up to TICK_BUF_SAMPLES from the measurement ring.
let mut buf = [0.0_f32; TICK_BUF_SAMPLES];
let mut n = 0;
while n < buf.len() {
match self.measurement_consumer.pop() {
Ok(s) => {
buf[n] = s;
n += 1;
}
Err(_) => break,
}
}
if n == 0 {
return; // No samples yet (early boot or silence); leave target alone.
}
// ebur128 wants whole frames; drop any odd trailing sample.
let usable = (n / self.channels as usize) * self.channels as usize;
if usable == 0 {
return;
}
if let Err(e) = self.ebu.add_frames_f32(&buf[..usable]) {
tracing::warn!(error = %e, "ebur128 add_frames_f32 failed");
return;
}
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)
};
self.smoothed_target_db += alpha * (clamped - self.smoothed_target_db);
self.filter_control
.set_agc_target_db(self.smoothed_target_db);
}
/// Reset the smoothed target and the underlying `ebur128` state.
/// Useful on profile.use when the user explicitly wants a fresh
/// AGC start.
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)
{
self.ebu = fresh;
}
self.filter_control.set_agc_target_db(0.0);
}
}
/// `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 {
if tau_ms <= 0.0 || dt_ms <= 0.0 {
return 1.0;
}
(1.0 - (-dt_ms / tau_ms).exp()).clamp(0.0, 1.0)
}
/// Construction-time failure modes. Tick-time failures (an
/// `ebur128::add_frames_f32` error, a stalled ring) are logged and
/// the tick is skipped — they don't bubble up to a caller.
#[derive(Debug, thiserror::Error)]
pub enum AgcInitError {
/// `ebur128::EbuR128::new` rejected the construction arguments.
#[error("ebur128: {0}")]
Ebu(#[from] ebur128::Error),
}
impl From<AgcInitError> for crate::error::DaemonError {
fn from(e: AgcInitError) -> Self {
crate::error::DaemonError::other(format!("agc init: {e}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile_store::ProfileStore;
use crate::pw::filter::{AudioCmd, FilterControl};
use crate::state::{self, DaemonState};
use rtrb::RingBuffer;
const SR: u32 = 48_000;
const CH: u32 = 2;
fn fixture() -> (
AgcController,
rtrb::Producer<f32>,
rtrb::Consumer<AudioCmd>,
SharedState,
) {
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)
}
fn push_silence(prod: &mut rtrb::Producer<f32>, frames: usize) {
for _ in 0..frames {
let _ = prod.push(0.0);
let _ = prod.push(0.0);
}
}
fn push_sine(prod: &mut rtrb::Producer<f32>, frames: usize, amp: f32) {
// Constant amplitude impulse-like — not a real sine but it
// produces a measurable loudness in ebur128 well above silence.
for _ in 0..frames {
let _ = prod.push(amp);
let _ = prod.push(-amp);
}
}
#[test]
fn tick_with_no_samples_does_nothing() {
let (mut agc, _prod, mut cmd_cons, _state) = fixture();
agc.tick();
assert!(cmd_cons.pop().is_err(), "no samples → no target push");
assert_eq!(agc.current_target_db(), 0.0);
}
#[test]
fn tick_under_silence_threshold_holds_target() {
let (mut agc, mut prod, mut cmd_cons, _state) = fixture();
push_silence(&mut prod, 4800); // 100ms of silence
agc.tick();
// ebur128 may report -inf or values below the silence
// threshold; either way we should not push.
assert!(
cmd_cons.pop().is_err(),
"below silence threshold — no target push expected"
);
}
#[test]
fn tick_with_audible_signal_pushes_target() {
let (mut agc, mut prod, mut cmd_cons, _state) = fixture();
// Pump multiple ticks worth so ebur128's short-term window
// (~3 s) starts producing values.
for _ in 0..40 {
push_sine(&mut prod, 4800, 0.3);
agc.tick();
}
// We expect at least one SetAgcTargetDb to have been pushed
// once short-term loudness became finite.
let mut saw = false;
while let Ok(cmd) = cmd_cons.pop() {
if matches!(cmd, AudioCmd::SetAgcTargetDb(_)) {
saw = true;
}
}
assert!(saw, "expected at least one AGC target push after pumping");
}
#[test]
fn agc_disable_in_profile_flips_audio_thread() {
let (mut agc, _prod, mut cmd_cons, state) = fixture();
// First tick with the default-enabled profile.
agc.tick();
// Drain any commands.
while cmd_cons.pop().is_ok() {}
// Disable AGC in the profile.
state
.lock()
.profiles
.set_setting("agc.enabled", serde_json::json!(false))
.unwrap();
agc.tick();
// Expect a SetAgcEnabled(false) command.
let mut saw_disable = false;
while let Ok(cmd) = cmd_cons.pop() {
if matches!(cmd, AudioCmd::SetAgcEnabled(false)) {
saw_disable = true;
}
}
assert!(saw_disable, "expected SetAgcEnabled(false) on profile flip");
}
#[test]
fn alpha_endpoints() {
// tau == 0 → instantaneous.
assert_eq!(alpha_for_dt(0.0, 50.0), 1.0);
// dt == 0 → no progress.
assert_eq!(alpha_for_dt(1000.0, 0.0), 1.0); // we clamp dt<=0 to 1.0 too
// Sanity: shorter tau → larger alpha for same dt.
let a_fast = alpha_for_dt(100.0, 50.0);
let a_slow = alpha_for_dt(2000.0, 50.0);
assert!(a_fast > a_slow);
}
}

View file

@ -0,0 +1,551 @@
//! Per-application level control (Layer A).
//!
//! Phase 6 — see `PLAN.md` §4. This module is the daemon-side
//! controller logic: given block-rate `(peak, mean_sq)` measurements
//! pushed by a sibling tap on the audio thread, decide when to issue
//! a `Props.channelVolumes` update for the managed stream, what value
//! to write, and how to defer to externally-set volumes.
//!
//! The PipeWire pieces (tap creation, the audio-thread analysis
//! callback, the metadata write) live in [`crate::pw`] modules.
//! Everything here is pure logic, unit-tested without a running
//! PipeWire instance.
use std::time::{Duration, Instant};
use headroom_dsp::{LevelDecision, LevelEnvelopes, LevelEnvelopesConfig};
use crate::profile::{DeferPolicy, PerAppRule, PerAppSection};
use crate::routing;
use crate::routing::PwNodeInfo;
// Knob defaults are owned by `PerAppRule` (see `profile.rs`); the
// controller now reads `smoother_ms`, `write_db_threshold`, and
// `min_write_interval_ms` from the rule rather than hardcoding them.
// Constants kept here only as the fallback used when manufacturing a
// synthetic default rule for `default_enabled`.
const FALLBACK_WRITE_DB_THRESHOLD: f32 = 0.5;
const FALLBACK_MIN_WRITE_INTERVAL_MS: f32 = 100.0;
const FALLBACK_SMOOTHER_MS: f32 = 30.0;
/// Per-stream controller. Holds the envelopes, the smoother state,
/// the rate-limit clock, and the deference / ceiling state.
pub struct AppLevelController {
/// Active rule snapshot. Stored by value so the controller is
/// detached from the profile lifetime; refreshed via
/// [`Self::set_rule`] when the profile changes.
rule: PerAppRule,
envelopes: LevelEnvelopes,
/// Smoothed combined reduction in dB. Single-pole, alpha derived
/// from `rule.smoother_ms`.
smoothed_reduction_db: f32,
smoother_alpha: f32,
/// Cached `Duration` form of `rule.min_write_interval_ms`,
/// recomputed when the rule is swapped in.
min_write_interval: Duration,
/// Last linear volume actually written via Props. `1.0` until a
/// write goes out (so the rate-limit / threshold gate accepts the
/// first real change).
last_written_lin: f32,
/// Wall-clock at last write. `None` before the first write.
last_write_at: Option<Instant>,
/// User-set ceiling: linear volume the user externally adjusted
/// to. `Some` triggers ceiling-mode deference (clamp our writes).
user_ceiling_lin: Option<f32>,
/// Strict-mode lock: when set, the controller stops issuing
/// writes entirely until [`Self::reset_deference`] clears it.
deferred: bool,
}
impl AppLevelController {
/// Construct a controller for a stream that matched `rule`.
///
/// `block_dt_s` is the expected period between
/// [`Self::process_block`] calls (i.e. PipeWire's quantum at the
/// stream's negotiated rate). Used to derive envelope alphas.
#[must_use]
pub fn new(rule: PerAppRule, block_dt_s: f32) -> Self {
let envelopes = LevelEnvelopes::new(level_cfg_from_rule(&rule), block_dt_s);
let smoother_alpha = anti_bounce_alpha(rule.smoother_ms, block_dt_s);
let min_write_interval = Duration::from_millis(rule.min_write_interval_ms.max(0.0) as u64);
Self {
rule,
envelopes,
smoothed_reduction_db: 0.0,
smoother_alpha,
min_write_interval,
last_written_lin: 1.0,
last_write_at: None,
user_ceiling_lin: None,
deferred: false,
}
}
/// Active rule.
#[must_use]
pub fn rule(&self) -> &PerAppRule {
&self.rule
}
/// Swap in a fresh rule (e.g. after `setting.set per_app...`).
/// Envelope state is preserved across the swap; the smoother and
/// rate-limit cadences pick up the new rule's values immediately.
pub fn set_rule(&mut self, rule: PerAppRule) {
self.envelopes.set_config(level_cfg_from_rule(&rule));
self.smoother_alpha = anti_bounce_alpha(rule.smoother_ms, self.envelopes.block_dt_s());
self.min_write_interval = Duration::from_millis(rule.min_write_interval_ms.max(0.0) as u64);
self.rule = rule;
}
/// Recompute alphas after a PipeWire quantum change.
pub fn set_block_dt(&mut self, dt_s: f32) {
self.envelopes.set_block_dt(dt_s);
self.smoother_alpha = anti_bounce_alpha(self.rule.smoother_ms, dt_s);
}
/// Currently effective `channelVolumes` ceiling (linear). `None`
/// when no external override is active.
#[must_use]
pub fn user_ceiling_lin(&self) -> Option<f32> {
self.user_ceiling_lin
}
/// Whether the controller is currently in strict-deference mode
/// (stopped issuing writes pending a manual reset).
#[must_use]
pub fn deferred(&self) -> bool {
self.deferred
}
/// Smoothed reduction in dB. Always `>= 0`; `0` means "no cut."
#[must_use]
pub fn smoothed_reduction_db(&self) -> f32 {
self.smoothed_reduction_db
}
/// Most recent linear volume value written through Props. `1.0`
/// until the first write.
#[must_use]
pub fn last_written_lin(&self) -> f32 {
self.last_written_lin
}
/// Snapshot of the per-block envelope state for telemetry.
#[must_use]
pub fn last_decision(&self) -> LevelDecision {
// process_block stores its outputs in the envelope; expose them
// by running a zero-input block on a clone… too expensive. We
// can't borrow the envelope as Decision is by-value. Reconstruct
// synthetically: smoothed_reduction_db is the canonical figure.
LevelDecision {
peak_reduction_db: 0.0,
rms_reduction_db: 0.0,
total_reduction_db: self.smoothed_reduction_db,
}
}
/// Feed one block of measurements. Returns `Some(new_volume_lin)`
/// if a Props write is warranted right now; `None` if the change
/// is sub-threshold, the controller is rate-limited, or it's
/// strictly deferred.
pub fn process_block(
&mut self,
peak_lin: f32,
mean_sq_lin: f32,
now: Instant,
) -> Option<f32> {
if !self.rule.enabled || self.deferred {
return None;
}
let decision = self.envelopes.process_block(peak_lin, mean_sq_lin);
// Anti-bounce smoother across the two paths' switching.
self.smoothed_reduction_db +=
self.smoother_alpha * (decision.total_reduction_db - self.smoothed_reduction_db);
let mut target_lin = headroom_dsp::util::db_to_lin(-self.smoothed_reduction_db);
// Ceiling-mode deference: never go above the user's value.
if let Some(ceiling) = self.user_ceiling_lin {
if target_lin > ceiling {
target_lin = ceiling;
}
}
target_lin = target_lin.clamp(0.0, 1.0);
let diff_db = lin_diff_db(target_lin, self.last_written_lin);
if diff_db < self.rule.write_db_threshold {
return None;
}
if let Some(prev) = self.last_write_at {
if now.duration_since(prev) < self.min_write_interval {
return None;
}
}
self.last_written_lin = target_lin;
self.last_write_at = Some(now);
Some(target_lin)
}
/// Record an externally-initiated `channelVolumes` change. The
/// deference policy decides what happens next: ceiling mode caps
/// our writes at the user's value; strict mode stops adjustment
/// entirely until the operator calls [`Self::reset_deference`].
pub fn on_external_change(&mut self, new_volume_lin: f32) {
// If the change matches what we just wrote, it's our own
// assertion echoing back through PipeWire — not an external
// change. Ignore.
if (new_volume_lin - self.last_written_lin).abs() < 1e-4 {
return;
}
match self.rule.defer_to_user {
DeferPolicy::Ceiling => {
self.user_ceiling_lin = Some(new_volume_lin.clamp(0.0, 1.0));
}
DeferPolicy::Strict => {
self.deferred = true;
}
}
}
/// Clear any deference state and resume normal control. Triggered
/// by `headroom per-app reset <app>` (PLAN §4.4) or by an
/// explicit `route.stream`-style override.
pub fn reset_deference(&mut self) {
self.user_ceiling_lin = None;
self.deferred = false;
}
}
/// Decide whether a stream should get a Layer A controller, and with
/// what rule. Returns:
///
/// - `None` when Layer A is disabled globally (`per_app.enabled` =
/// false) or the stream isn't a routable playback stream.
/// - `Some(rule)` for the first matching `[[per_app.rules]]` entry,
/// provided that rule's own `enabled` is true.
/// - For unmatched streams: `Some(synthetic_default)` when
/// `per_app.default_enabled` is true, else `None`.
///
/// `routing::evaluate` is the sibling for the bus-routing decision;
/// the two are orthogonal (PLAN §2 "the four end-to-end paths").
#[must_use]
pub fn evaluate(info: &PwNodeInfo, per_app: &PerAppSection) -> Option<PerAppRule> {
if !per_app.enabled {
return None;
}
if !info.is_routable_playback() {
return None;
}
for rule in &per_app.rules {
if routing::matches(info, &rule.match_) {
return rule.enabled.then(|| rule.clone());
}
}
if per_app.default_enabled {
return Some(default_rule());
}
None
}
fn default_rule() -> PerAppRule {
let cfg = LevelEnvelopesConfig::default();
PerAppRule {
match_: headroom_ipc::RouteRuleMatch::default(),
enabled: true,
peak_threshold_db: cfg.peak_threshold_db,
rms_target_db: cfg.rms_target_db,
max_cut_db: cfg.max_cut_db,
peak_attack_ms: cfg.peak_attack_ms,
peak_release_ms: cfg.peak_release_ms,
rms_window_ms: cfg.rms_window_ms,
smoother_ms: FALLBACK_SMOOTHER_MS,
write_db_threshold: FALLBACK_WRITE_DB_THRESHOLD,
min_write_interval_ms: FALLBACK_MIN_WRITE_INTERVAL_MS,
defer_to_user: DeferPolicy::default(),
}
}
fn level_cfg_from_rule(rule: &PerAppRule) -> LevelEnvelopesConfig {
LevelEnvelopesConfig {
peak_threshold_db: rule.peak_threshold_db,
rms_target_db: rule.rms_target_db,
max_cut_db: rule.max_cut_db,
peak_attack_ms: rule.peak_attack_ms,
peak_release_ms: rule.peak_release_ms,
rms_window_ms: rule.rms_window_ms,
}
}
fn anti_bounce_alpha(time_ms: f32, block_dt_s: f32) -> f32 {
if block_dt_s <= 0.0 || time_ms <= 0.0 {
return 1.0;
}
let block_rate = 1.0 / block_dt_s;
headroom_dsp::util::time_to_alpha(time_ms, block_rate)
}
fn lin_diff_db(a: f32, b: f32) -> f32 {
let a = a.max(1e-6);
let b = b.max(1e-6);
(20.0 * (a / b).log10()).abs()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::PerAppRule;
use headroom_dsp::util::db_to_lin;
use headroom_ipc::RouteRuleMatch;
/// 1024-frame quantum @ 48 kHz.
const BLOCK_DT_S: f32 = 1024.0 / 48_000.0;
fn aggressive_rule() -> PerAppRule {
PerAppRule {
match_: RouteRuleMatch::default(),
enabled: true,
peak_threshold_db: -6.0,
rms_target_db: -20.0,
max_cut_db: 12.0,
peak_attack_ms: 5.0,
peak_release_ms: 500.0,
rms_window_ms: 200.0, // shorter so tests converge
smoother_ms: FALLBACK_SMOOTHER_MS,
write_db_threshold: FALLBACK_WRITE_DB_THRESHOLD,
min_write_interval_ms: FALLBACK_MIN_WRITE_INTERVAL_MS,
defer_to_user: DeferPolicy::Ceiling,
}
}
fn playback_info(binary: &str) -> PwNodeInfo {
PwNodeInfo {
node_id: 1,
media_class: Some("Stream/Output/Audio".into()),
application_process_binary: Some(binary.into()),
..Default::default()
}
}
#[test]
fn disabled_rule_returns_no_write() {
let mut rule = aggressive_rule();
rule.enabled = false;
let mut c = AppLevelController::new(rule, BLOCK_DT_S);
let now = Instant::now();
assert!(c.process_block(db_to_lin(0.0), 1.0, now).is_none());
}
#[test]
fn first_write_after_settling_emits_volume_below_unity() {
let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);
let now = Instant::now();
// Drive a hot signal until the envelopes settle and the
// anti-bounce smoother converges.
let mut last = None;
for i in 0..1000 {
let t = now + Duration::from_millis(i as u64 * 21); // ~block_dt
if let Some(v) = c.process_block(db_to_lin(0.0), db_to_lin(-3.0).powi(2), t) {
last = Some(v);
}
}
let v = last.expect("controller should issue at least one write");
assert!(v < 1.0, "expected sub-unity volume, got {v}");
assert!(v > 0.0);
}
#[test]
fn rate_limit_blocks_back_to_back_writes() {
let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);
let t0 = Instant::now();
// Drive convergence first so a write happens.
let mut wrote = false;
for i in 0..200 {
let t = t0 + Duration::from_millis(i as u64 * 21);
if c.process_block(db_to_lin(0.0), db_to_lin(-3.0).powi(2), t).is_some() {
wrote = true;
break;
}
}
assert!(wrote, "first write expected during convergence");
// 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
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");
}
#[test]
fn threshold_blocks_microscopic_changes() {
// Strategy: drive the controller to a steady state at a
// specific reduction, let it write, then nudge inputs by an
// amount that produces a sub-`WRITE_DB_THRESHOLD` change at
// the smoothed output. The threshold gate must suppress.
let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);
let t0 = Instant::now();
// 0 dBFS peak → 6 dB cut requested by the peak path.
let hot_peak = db_to_lin(0.0);
let hot_mean_sq = db_to_lin(-3.0).powi(2);
// Burn in until convergence.
let mut last_write_t = t0;
for i in 0..2_000 {
let t = t0 + Duration::from_millis(i as u64 * 21);
if c.process_block(hot_peak, hot_mean_sq, t).is_some() {
last_write_t = t;
}
}
// Move past the rate limit window so the threshold is the only
// active gate, then feed an essentially-identical input. The
// smoothed reduction barely budges, so the dB diff against
// last_written_lin must stay under WRITE_DB_THRESHOLD.
let t_after = last_write_t + Duration::from_millis(500);
let v = c.process_block(hot_peak * 1.001, hot_mean_sq * 1.001, t_after);
assert!(
v.is_none(),
"near-identical input should fall inside the threshold dead band, got {v:?}"
);
}
#[test]
fn ceiling_mode_caps_target_at_user_value() {
let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);
// User pulls the slider down to 0.6 externally.
c.on_external_change(0.6);
assert_eq!(c.user_ceiling_lin(), Some(0.6));
let mut last = None;
let t0 = Instant::now();
// No signal yet — proposed reduction is 0 → target is unity →
// but ceiling forces it down to 0.6 → expect a write below
// unity even with no detection activity.
for i in 0..400 {
let t = t0 + Duration::from_millis(i as u64 * 21);
if let Some(v) = c.process_block(0.0, 0.0, t) {
last = Some(v);
}
}
let v = last.expect("should write at least once to reach ceiling");
assert!((v - 0.6).abs() < 0.01, "expected ~0.6, got {v}");
}
#[test]
fn strict_mode_stops_writes_after_external_change() {
let mut rule = aggressive_rule();
rule.defer_to_user = DeferPolicy::Strict;
let mut c = AppLevelController::new(rule, BLOCK_DT_S);
c.on_external_change(0.7);
assert!(c.deferred());
let t = Instant::now();
// Drive a hot signal — strict deference must not write.
for i in 0..400 {
let t = t + Duration::from_millis(i as u64 * 21);
assert!(c
.process_block(db_to_lin(0.0), db_to_lin(-3.0).powi(2), t)
.is_none());
}
}
#[test]
fn reset_deference_clears_strict_lock() {
let mut rule = aggressive_rule();
rule.defer_to_user = DeferPolicy::Strict;
let mut c = AppLevelController::new(rule, BLOCK_DT_S);
c.on_external_change(0.7);
assert!(c.deferred());
c.reset_deference();
assert!(!c.deferred());
assert!(c.user_ceiling_lin().is_none());
}
#[test]
fn ignores_external_change_that_matches_our_write() {
let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);
c.last_written_lin = 0.5;
c.on_external_change(0.5);
// Should not register as external — no ceiling, no defer.
assert!(c.user_ceiling_lin().is_none());
assert!(!c.deferred());
}
// -----------------------------------------------------------------
// Rule matching
// -----------------------------------------------------------------
#[test]
fn evaluate_returns_none_when_layer_a_master_off() {
let per_app = PerAppSection {
enabled: false,
..Default::default()
};
assert!(evaluate(&playback_info("firefox"), &per_app).is_none());
}
#[test]
fn evaluate_returns_matching_rule() {
let per_app = PerAppSection {
enabled: true,
default_enabled: false,
rules: vec![PerAppRule {
match_: RouteRuleMatch {
process_binary: vec!["firefox".into()],
..Default::default()
},
..aggressive_rule()
}],
};
let r = evaluate(&playback_info("firefox"), &per_app).expect("match");
assert_eq!(r.peak_threshold_db, aggressive_rule().peak_threshold_db);
}
#[test]
fn evaluate_returns_none_for_disabled_matching_rule() {
let per_app = PerAppSection {
enabled: true,
default_enabled: false,
rules: vec![PerAppRule {
match_: RouteRuleMatch {
process_binary: vec!["spotify".into()],
..Default::default()
},
enabled: false,
..aggressive_rule()
}],
};
assert!(evaluate(&playback_info("spotify"), &per_app).is_none());
}
#[test]
fn evaluate_returns_default_rule_when_default_enabled_and_no_match() {
let per_app = PerAppSection {
enabled: true,
default_enabled: true,
rules: vec![],
};
let r = evaluate(&playback_info("unmatched"), &per_app).expect("default");
// Default rule honours LevelEnvelopesConfig::default().
let cfg = LevelEnvelopesConfig::default();
assert!((r.peak_threshold_db - cfg.peak_threshold_db).abs() < 1e-6);
assert_eq!(r.defer_to_user, DeferPolicy::default());
}
#[test]
fn evaluate_returns_none_for_unmatched_when_default_off() {
let per_app = PerAppSection {
enabled: true,
default_enabled: false,
rules: vec![],
};
assert!(evaluate(&playback_info("unmatched"), &per_app).is_none());
}
#[test]
fn evaluate_skips_non_playback_streams() {
let mut info = playback_info("firefox");
info.media_class = Some("Stream/Input/Audio".into());
let per_app = PerAppSection {
enabled: true,
default_enabled: true,
rules: vec![],
};
assert!(evaluate(&info, &per_app).is_none());
}
}

View file

@ -19,3 +19,8 @@ mod ops;
mod server;
pub use server::{IpcServer, IpcServerHandle};
/// Shared reload helper — see `ops::execute_reload`. Re-exported so
/// the profile file-watcher can reuse the same publish-events +
/// DSP-push path as the IPC `profile.reload` op.
pub(crate) use ops::execute_reload;

View file

@ -1,18 +1,22 @@
//! Op dispatch + handlers.
//!
//! Each handler takes the request id and a `&SharedState`, locks the
//! state briefly, and returns a [`Response`]. Phase 4b implements the
//! read-only set; 4c fills in mutating ops; 4d adds subscriptions.
//! state briefly, and returns a [`Response`]. Phase 4b implemented the
//! read-only set; 4c added mutating ops on top of in-memory profile
//! state; 4e routes all mutations through [`ProfileStore`] so disk
//! profiles, the user overlay, and atomic reload work end-to-end.
use serde::Serialize;
use serde_json::{json, Value};
use headroom_ipc::{
ErrorCode, Event, Op, ProfileInfo, ProtoError, Request, Response, Route, RouteList, RouteRule,
RouteRuleMatch, SinkInfo, Sinks, Status, StreamRoute, Topic, PROTOCOL_VERSION,
ErrorCode, Event, Op, ProfileInfo, ProtoError, Request, Response, Route, RouteList, SinkInfo,
Sinks, Status, StreamRoute, Topic, PROTOCOL_VERSION,
};
use crate::profile::Profile;
use crate::profile_store::StoreError;
use crate::pw::command::PwCommand;
use crate::pw::filter::FilterControl;
use crate::state::SharedState;
const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION");
@ -24,11 +28,11 @@ pub fn dispatch(req: &Request, state: &SharedState) -> Response {
Op::ProfileList => profile_list(req.id, state),
Op::ProfileShow { name } => profile_show(req.id, name.as_deref(), state),
Op::ProfileUse { name } => profile_use(req.id, name, state),
Op::ProfileReload => profile_reload(req.id),
Op::ProfileReload => profile_reload(req.id, state),
Op::RouteList => route_list(req.id, state),
Op::RouteSet { app, to } => route_set(req.id, app, *to, state),
Op::RouteUnset { app } => route_unset(req.id, app, state),
Op::RouteStream { .. } => not_yet(req, "Phase 4i"),
Op::RouteStream { node_id, to } => route_stream(req.id, *node_id, *to, state),
Op::SettingGet { key } => setting_get(req.id, key, state),
Op::SettingSet { key, value } => setting_set(req.id, key, value.clone(), state),
Op::SettingList => setting_list(req.id, state),
@ -50,12 +54,13 @@ pub fn dispatch(req: &Request, state: &SharedState) -> Response {
fn status(id: u64, state: &SharedState) -> Response {
let s = state.lock();
let effective = s.profiles.effective();
let snapshot = Status {
version: DAEMON_VERSION.into(),
protocol: PROTOCOL_VERSION,
uptime_s: s.started_at.elapsed().as_secs(),
profile: s.profile.name.clone(),
bypass: s.bypass_global,
profile: effective.name.clone(),
bypass: s.profiles.bypass_global(),
sinks: Sinks {
processed: SinkInfo {
node_id: s.processed_sink_id,
@ -73,40 +78,48 @@ fn status(id: u64, state: &SharedState) -> Response {
route: r.route,
})
.collect(),
warnings: s.profiles.warnings(),
};
ok(id, &snapshot)
}
fn profile_list(id: u64, state: &SharedState) -> Response {
let s = state.lock();
// 4b: only the active profile is known. Phase 4e loads files from
// disk and surfaces the full list.
let profiles = vec![ProfileInfo {
name: s.profile.name.clone(),
active: true,
description: s.profile.description.clone(),
}];
let active = s.profiles.effective().name.clone();
let profiles: Vec<ProfileInfo> = s
.profiles
.list()
.map(|sp| ProfileInfo {
name: sp.name.clone(),
active: sp.name == active,
description: sp.profile.description.clone(),
})
.collect();
ok(id, &json!({ "profiles": profiles }))
}
fn profile_show(id: u64, name: Option<&str>, state: &SharedState) -> Response {
let s = state.lock();
if let Some(requested) = name {
if requested != s.profile.name {
return err(
let effective = s.profiles.effective();
match name {
None => ok(id, effective),
Some(requested) if requested == effective.name => ok(id, effective),
Some(requested) => match s.profiles.list().find(|sp| sp.name == requested) {
Some(found) => ok(id, &found.profile),
None => err(
id,
ErrorCode::NotFound,
format!("profile '{requested}' not loaded (Phase 4e adds disk profiles)"),
);
}
format!("profile '{requested}' not loaded"),
),
},
}
ok(id, &s.profile)
}
fn route_list(id: u64, state: &SharedState) -> Response {
let s = state.lock();
let effective = s.profiles.effective();
let body = RouteList {
rules: s.profile.rules.clone(),
rules: effective.rules.clone(),
current: s
.streams
.values()
@ -116,14 +129,14 @@ fn route_list(id: u64, state: &SharedState) -> Response {
route: r.route,
})
.collect(),
default_route: s.profile.default_route.route,
default_route: effective.default_route.route,
};
ok(id, &body)
}
fn setting_get(id: u64, key: &str, state: &SharedState) -> Response {
let s = state.lock();
let json_value = match serde_json::to_value(&s.profile) {
let json_value = match serde_json::to_value(s.profiles.effective()) {
Ok(v) => v,
Err(e) => {
return err(
@ -147,7 +160,7 @@ fn setting_get(id: u64, key: &str, state: &SharedState) -> Response {
fn setting_list(id: u64, state: &SharedState) -> Response {
let s = state.lock();
let json_value = match serde_json::to_value(&s.profile) {
let json_value = match serde_json::to_value(s.profiles.effective()) {
Ok(v) => v,
Err(e) => {
return err(
@ -169,63 +182,87 @@ fn setting_list(id: u64, state: &SharedState) -> Response {
// ---------------------------------------------------------------------------
fn profile_use(id: u64, name: &str, state: &SharedState) -> Response {
let s = state.lock();
if name == s.profile.name {
// Already active — succeed idempotently.
let mut s = state.lock();
if name == s.profiles.effective().name {
let body = json!({ "name": name });
drop(s);
return ok(id, &body);
}
err(
id,
ErrorCode::NotFound,
format!("profile '{name}' not loaded (disk profiles arrive in Phase 4e)"),
)
match s.profiles.use_profile(name) {
Ok(()) => {
tracing::info!(name, "profile.use applied");
publish_profile_changed(&mut s, name);
let control = s.filter_control.clone();
let snap = build_dsp_configs(&s);
drop(s);
push_dsp_update(control.as_ref(), snap);
ok(id, &json!({ "name": name }))
}
Err(e) => store_err_to_response(id, e),
}
}
fn profile_reload(id: u64) -> Response {
// No-op in 4c; 4e implements the on-disk loader.
let empty: Vec<String> = Vec::new();
ok(id, &json!({ "reloaded": empty }))
fn profile_reload(id: u64, state: &SharedState) -> Response {
match execute_reload(state) {
Ok(report) => ok(
id,
&json!({ "reloaded": report.loaded, "warnings": report.warnings }),
),
Err(e) => store_err_to_response(id, e),
}
}
/// Shared reload path: scans disk, publishes events, propagates the
/// fresh DSP configs to the running filter. Used by both
/// [`Op::ProfileReload`] (IPC-initiated) and the file-watcher
/// (`crate::profile_watcher`).
///
/// # Errors
/// Fatal disk I/O surfaced from [`ProfileStore::reload`].
pub(crate) fn execute_reload(
state: &SharedState,
) -> Result<crate::profile_store::ReloadReport, StoreError> {
let mut s = state.lock();
let report = s.profiles.reload()?;
tracing::info!(
loaded = report.loaded.len(),
warnings = report.warnings.len(),
"profile reload applied"
);
for w in &report.warnings {
tracing::warn!(warning = %w, "profile reload warning");
}
publish_profile_reloaded(&mut s, &report.loaded);
let control = s.filter_control.clone();
let snap = build_dsp_configs(&s);
drop(s);
push_dsp_update(control.as_ref(), snap);
Ok(report)
}
fn route_set(id: u64, app: &str, to: Route, state: &SharedState) -> Response {
let mut s = state.lock();
// Strip any existing single-app user rule for this app (so
// repeated route.set on the same app updates rather than stacks).
s.profile.rules.retain(|r| !is_user_rule_for(r, app));
// Insert at top so it overrides shipped multi-app rules.
s.profile.rules.insert(
0,
RouteRule {
match_: RouteRuleMatch {
process_binary: vec![app.to_owned()],
..Default::default()
},
route: to,
},
);
tracing::info!(app, ?to, "route.set applied");
publish_rule_changed(&mut s);
drop(s);
ok(id, &Value::Null)
match s.profiles.set_route(app, to) {
Ok(()) => {
tracing::info!(app, ?to, "route.set applied");
publish_rule_changed(&mut s);
drop(s);
ok(id, &Value::Null)
}
Err(e) => store_err_to_response(id, e),
}
}
fn route_unset(id: u64, app: &str, state: &SharedState) -> Response {
let mut s = state.lock();
let before = s.profile.rules.len();
s.profile.rules.retain(|r| !is_user_rule_for(r, app));
if s.profile.rules.len() == before {
return err(
id,
ErrorCode::NotFound,
format!("no user-set route for '{app}' (shipped rules aren't removable)"),
);
match s.profiles.unset_route(app) {
Ok(()) => {
tracing::info!(app, "route.unset applied");
publish_rule_changed(&mut s);
drop(s);
ok(id, &Value::Null)
}
Err(e) => store_err_to_response(id, e),
}
tracing::info!(app, "route.unset applied");
publish_rule_changed(&mut s);
drop(s);
ok(id, &Value::Null)
}
fn publish_rule_changed(state: &mut crate::state::DaemonState) {
@ -234,81 +271,147 @@ fn publish_rule_changed(state: &mut crate::state::DaemonState) {
}
}
fn publish_profile_changed(state: &mut crate::state::DaemonState, name: &str) {
if let Ok(event) = Event::new(Topic::Profile, "used", &json!({ "name": name })) {
state.broadcaster.publish(Topic::Profile, event);
}
}
fn publish_profile_reloaded(state: &mut crate::state::DaemonState, loaded: &[String]) {
if let Ok(event) = Event::new(Topic::Profile, "reloaded", &json!({ "loaded": loaded })) {
state.broadcaster.publish(Topic::Profile, event);
}
}
fn setting_set(id: u64, key: &str, value: Value, state: &SharedState) -> Response {
let mut s = state.lock();
match s.profiles.set_setting(key, value) {
Ok(()) => {
tracing::info!(key, "setting.set applied");
let control = s.filter_control.clone();
let snap = build_dsp_configs(&s);
drop(s);
push_dsp_update(control.as_ref(), snap);
ok(id, &Value::Null)
}
Err(e) => store_err_to_response(id, e),
}
}
// Serialise → mutate → deserialise. Round-tripping through
// `serde_json::Value` keeps us schema-aware without hand-coding a
// setter for every dotted key.
let mut json_value = match serde_json::to_value(&s.profile) {
Ok(v) => v,
Err(e) => return err(id, ErrorCode::Internal, format!("serialise profile: {e}")),
};
if !set_dotted(&mut json_value, key, value) {
fn route_stream(id: u64, node_id: u32, to: Route, state: &SharedState) -> Response {
let mut s = state.lock();
let Some(stream) = s.streams.get_mut(&node_id) else {
return err(
id,
ErrorCode::NotFound,
format!("setting '{key}' not found in active profile"),
format!("no stream with node_id {node_id} is currently routed by the daemon"),
);
};
let app_label = stream.app.clone();
let prior = stream.route;
stream.route = to;
// Record the new route synchronously so subsequent `status` /
// `route.list` reflect it immediately. The actual metadata write
// is async — it happens on the PipeWire main-loop thread when
// it drains the command channel (≤ ~50 ms).
let event = Event::new(
Topic::Routing,
"stream_routed",
&json!({ "node_id": node_id, "app": app_label, "to": to.as_str() }),
);
if let Ok(event) = event {
s.broadcaster.publish(Topic::Routing, event);
}
let tx = s.pw_command_tx.clone();
drop(s);
if let Some(tx) = tx {
if tx
.send(PwCommand::RouteStream {
node_id,
to,
app_label: app_label.clone(),
})
.is_err()
{
tracing::warn!(node_id, "PipeWire command channel closed; metadata write skipped");
}
} else {
tracing::debug!(
node_id,
"no PipeWire command channel; state updated but no metadata write (test mode)"
);
}
let new_profile: Profile = match serde_json::from_value(json_value) {
Ok(p) => p,
Err(e) => {
return err(
id,
ErrorCode::InvalidArgs,
format!("value for '{key}' rejected: {e}"),
);
}
};
s.profile = new_profile;
tracing::info!(key, "setting.set applied (DSP propagation lands in 4f)");
drop(s);
tracing::info!(
node_id,
app = app_label.as_str(),
?prior,
new = ?to,
"route.stream applied"
);
ok(id, &Value::Null)
}
fn bypass_set(id: u64, enabled: bool, state: &SharedState) -> Response {
state.lock().bypass_global = enabled;
tracing::info!(enabled, "bypass.set applied");
ok(id, &Value::Null)
let mut s = state.lock();
match s.profiles.set_bypass(enabled) {
Ok(()) => {
tracing::info!(enabled, "bypass.set applied");
drop(s);
ok(id, &Value::Null)
}
Err(e) => store_err_to_response(id, e),
}
}
/// Snapshot of the profile-driven DSP configs, ready to push at the
/// running filter. Built while the daemon lock is held; the actual
/// command push happens after the lock is dropped so the audio-thread
/// hand-off never contends with the daemon mutex.
struct DspSnapshot {
compressor: headroom_dsp::CompressorConfig,
limiter: headroom_dsp::LimiterConfig,
agc_enabled: bool,
}
fn build_dsp_configs(state: &crate::state::DaemonState) -> DspSnapshot {
let effective = state.profiles.effective();
DspSnapshot {
compressor: effective.build_compressor_config(),
limiter: effective.build_limiter_config(),
agc_enabled: effective.agc.enabled,
}
}
/// Push compressor + limiter configs + AGC enable flag into the
/// filter command ring, if the filter is up. The AGC *target_db*
/// keeps coming from the slow AGC controller's own ticks — `setting.set`
/// only flips the enable flag so the audio thread can unwind/restart
/// the smoother promptly. No-op when running headless (tests,
/// pre-PipeWire startup).
fn push_dsp_update(control: Option<&FilterControl>, snap: DspSnapshot) {
let Some(c) = control else { return };
c.set_compressor(snap.compressor);
c.set_limiter(snap.limiter);
c.set_agc_enabled(snap.agc_enabled);
}
fn store_err_to_response(id: u64, e: StoreError) -> Response {
let code = match &e {
StoreError::ProfileNotFound(_)
| StoreError::SettingNotFound(_)
| StoreError::NoUserRoute(_) => ErrorCode::NotFound,
StoreError::SettingInvalid { .. } => ErrorCode::InvalidArgs,
StoreError::Io(_)
| StoreError::OverlayParse(_)
| StoreError::OverlaySerialize(_) => ErrorCode::Internal,
};
err(id, code, e.to_string())
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn is_user_rule_for(rule: &RouteRule, app: &str) -> bool {
// User-set rules created by route.set always have exactly one
// app in `process_binary` and all other matcher fields empty.
rule.match_.process_binary.len() == 1
&& rule.match_.process_binary[0] == app
&& rule.match_.application_name.is_empty()
&& rule.match_.portal_app_id.is_empty()
&& rule.match_.media_role.is_empty()
}
fn set_dotted(value: &mut Value, key: &str, new: Value) -> bool {
let parts: Vec<&str> = key.split('.').collect();
let Some((last, parents)) = parts.split_last() else {
return false;
};
let mut cur = value;
for part in parents {
cur = match cur.get_mut(*part) {
Some(v) => v,
None => return false,
};
}
let Some(map) = cur.as_object_mut() else {
return false;
};
if !map.contains_key(*last) {
return false;
}
map.insert((*last).to_string(), new);
true
}
fn lookup_dotted<'v>(value: &'v Value, key: &str) -> Option<&'v Value> {
let mut cur = value;
for part in key.split('.') {
@ -373,12 +476,12 @@ fn not_yet(req: &Request, phase: &str) -> Response {
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::Profile;
use crate::profile_store::ProfileStore;
use crate::state::{self, RoutedStream};
use headroom_ipc::{Op, Request, ResponsePayload, Route};
fn shared_with_default_profile() -> SharedState {
state::shared(crate::state::DaemonState::new(Profile::default_v0()))
state::shared(crate::state::DaemonState::new(ProfileStore::builtin()))
}
fn extract_ok(resp: Response) -> Value {
@ -398,6 +501,52 @@ mod tests {
assert_eq!(body["bypass"], false);
assert_eq!(body["protocol"], PROTOCOL_VERSION);
assert!(body["streams"].as_array().unwrap().is_empty());
// Builtin store with no overlay → no warnings.
assert!(
body.get("warnings")
.and_then(|w| w.as_array())
.map_or(true, |a| a.is_empty()),
"expected empty/absent warnings on healthy startup"
);
}
#[test]
fn status_surfaces_store_warnings() {
use crate::profile_store::ProfileStore;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
// Build a load-from-disk store with a broken TOML so a warning
// is recorded, then point Status at it.
let base = std::env::temp_dir().join(format!(
"headroom-warntest-{}-{}",
std::process::id(),
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
));
fs::create_dir_all(base.join("config/profiles")).unwrap();
fs::create_dir_all(base.join("state")).unwrap();
fs::write(
base.join("config/profiles/broken.toml"),
"this is not = valid",
)
.unwrap();
let paths = crate::profile_store::StorePaths {
config_dir: base.join("config"),
state_dir: base.join("state"),
share_dirs: vec![],
};
let store = ProfileStore::load(&paths).unwrap();
let state = state::shared(crate::state::DaemonState::new(store));
let resp = dispatch(&Request::new(1, Op::Status), &state);
let body = extract_ok(resp);
let warnings = body["warnings"].as_array().expect("warnings field");
assert!(
warnings.iter().any(|w| w.as_str().unwrap_or("").contains("broken.toml")),
"expected warning mentioning broken.toml, got {warnings:?}"
);
let _ = fs::remove_dir_all(&base);
}
#[test]
@ -519,19 +668,19 @@ mod tests {
#[test]
fn bypass_set_toggles_flag() {
let state = shared_with_default_profile();
assert!(!state.lock().bypass_global);
assert!(!state.lock().profiles.bypass_global());
dispatch(
&Request::new(1, Op::BypassSet { enabled: true }),
&state,
);
assert!(state.lock().bypass_global);
assert!(state.lock().profiles.bypass_global());
dispatch(
&Request::new(2, Op::BypassSet { enabled: false }),
&state,
);
assert!(!state.lock().bypass_global);
assert!(!state.lock().profiles.bypass_global());
}
#[test]
@ -547,7 +696,8 @@ mod tests {
),
&state,
);
let rules = &state.lock().profile.rules;
let s = state.lock();
let rules = &s.profiles.effective().rules;
// First rule is now the user-set one.
assert_eq!(rules[0].match_.process_binary, vec!["obs".to_string()]);
assert_eq!(rules[0].route, Route::Bypass);
@ -578,7 +728,8 @@ mod tests {
),
&state,
);
let rules = &state.lock().profile.rules;
let s = state.lock();
let rules = &s.profiles.effective().rules;
let user_rules: Vec<_> = rules
.iter()
.filter(|r| {
@ -611,9 +762,10 @@ mod tests {
),
&state,
);
let still_there = state
.lock()
.profile
let s = state.lock();
let still_there = s
.profiles
.effective()
.rules
.iter()
.any(|r| r.match_.process_binary.len() == 1 && r.match_.process_binary[0] == "obs");
@ -657,9 +809,10 @@ mod tests {
ResponsePayload::Ok { .. } => panic!("expected NotFound"),
}
// And firefox is still in the rules (via the shipped rule).
let still_firefox = state
.lock()
.profile
let s = state.lock();
let still_firefox = s
.profiles
.effective()
.rules
.iter()
.any(|r| r.match_.process_binary.iter().any(|p| p == "firefox"));
@ -679,7 +832,7 @@ mod tests {
),
&state,
);
let v = state.lock().profile.limiter.ceiling_dbtp;
let v = state.lock().profiles.effective().limiter.ceiling_dbtp;
assert!((v - -1.0).abs() < 1e-6);
}
@ -701,7 +854,7 @@ mod tests {
ResponsePayload::Ok { .. } => panic!("expected InvalidArgs"),
}
// Profile unchanged.
assert!((state.lock().profile.limiter.ceiling_dbtp - -0.1).abs() < 1e-6);
assert!((state.lock().profiles.effective().limiter.ceiling_dbtp - -0.1).abs() < 1e-6);
}
#[test]
@ -740,7 +893,7 @@ mod tests {
}
#[test]
fn profile_use_other_is_not_found_until_phase_4e() {
fn profile_use_unknown_returns_not_found() {
let state = shared_with_default_profile();
let resp = dispatch(
&Request::new(
@ -758,17 +911,100 @@ mod tests {
}
#[test]
fn profile_reload_succeeds_with_empty_list() {
fn profile_reload_built_in_only_returns_default() {
// Built-in stores have no disk paths; reload returns just the
// built-in default and a warning saying there's nothing to scan.
let state = shared_with_default_profile();
let resp = dispatch(&Request::new(1, Op::ProfileReload), &state);
let body = extract_ok(resp);
let reloaded = body["reloaded"].as_array().unwrap();
assert!(reloaded.is_empty());
assert_eq!(reloaded.len(), 1);
assert_eq!(reloaded[0], "default");
}
#[test]
fn route_stream_still_phase_4i() {
fn setting_set_pushes_dsp_update() {
use crate::pw::filter::{AudioCmd, FilterControl};
let state = shared_with_default_profile();
let (control, mut consumer) = FilterControl::for_testing(8);
state.lock().filter_control = Some(control);
dispatch(
&Request::new(
1,
Op::SettingSet {
key: "limiter.ceiling_dbtp".into(),
value: json!(-1.5),
},
),
&state,
);
// Expect a compressor cmd and a limiter cmd (we push both for
// simplicity even when only one field changed).
let mut saw_limiter = false;
while let Ok(cmd) = consumer.pop() {
if let AudioCmd::SetLimiter(cfg) = cmd {
assert!((cfg.ceiling_dbtp - -1.5).abs() < 1e-6);
saw_limiter = true;
}
}
assert!(saw_limiter, "setting.set should push a SetLimiter cmd");
}
#[test]
fn route_set_does_not_push_dsp_update() {
// Routing changes don't touch DSP. Filter must be left alone.
use crate::pw::filter::FilterControl;
let state = shared_with_default_profile();
let (control, mut consumer) = FilterControl::for_testing(8);
state.lock().filter_control = Some(control);
dispatch(
&Request::new(
1,
Op::RouteSet {
app: "obs".into(),
to: Route::Bypass,
},
),
&state,
);
assert!(consumer.pop().is_err(), "route.set must not push DSP cmds");
}
#[test]
fn route_stream_unknown_node_id_returns_not_found() {
let state = shared_with_default_profile();
let resp = dispatch(
&Request::new(
1,
Op::RouteStream {
node_id: 9999,
to: Route::Bypass,
},
),
&state,
);
match resp.payload {
ResponsePayload::Err { error } => assert_eq!(error.code, ErrorCode::NotFound),
ResponsePayload::Ok { .. } => panic!("expected NotFound"),
}
}
#[test]
fn route_stream_updates_state_synchronously() {
let state = shared_with_default_profile();
// Seed: a known stream currently routed Processed.
state.lock().streams.insert(
42,
RoutedStream {
node_id: 42,
app: "firefox".into(),
route: Route::Processed,
},
);
let resp = dispatch(
&Request::new(
1,
@ -779,9 +1015,70 @@ mod tests {
),
&state,
);
match resp.payload {
ResponsePayload::Err { error } => assert_eq!(error.code, ErrorCode::UnknownOp),
ResponsePayload::Ok { .. } => panic!("expected UnknownOp"),
}
assert!(matches!(resp.payload, ResponsePayload::Ok { .. }));
assert_eq!(state.lock().streams[&42].route, Route::Bypass);
}
#[test]
fn route_stream_pushes_command_when_channel_present() {
use crate::pw::command::PwCommand;
let state = shared_with_default_profile();
let (tx, rx) = crossbeam_channel::unbounded::<PwCommand>();
state.lock().pw_command_tx = Some(tx);
state.lock().streams.insert(
42,
RoutedStream {
node_id: 42,
app: "firefox".into(),
route: Route::Processed,
},
);
dispatch(
&Request::new(
1,
Op::RouteStream {
node_id: 42,
to: Route::Bypass,
},
),
&state,
);
let cmd = rx.try_recv().expect("command should arrive");
let PwCommand::RouteStream {
node_id,
to,
app_label,
} = cmd;
assert_eq!(node_id, 42);
assert_eq!(to, Route::Bypass);
assert_eq!(app_label, "firefox");
}
#[test]
fn route_stream_no_channel_is_still_success() {
// Tests / pre-PipeWire startup: no tx is fine, state still
// updates and the op returns Ok.
let state = shared_with_default_profile();
state.lock().streams.insert(
42,
RoutedStream {
node_id: 42,
app: "mpv".into(),
route: Route::Processed,
},
);
let resp = dispatch(
&Request::new(
1,
Op::RouteStream {
node_id: 42,
to: Route::Bypass,
},
),
&state,
);
assert!(matches!(resp.payload, ResponsePayload::Ok { .. }));
assert_eq!(state.lock().streams[&42].route, Route::Bypass);
}
}

View file

@ -162,9 +162,10 @@ fn accept_loop(
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::Profile;
use crate::profile_store::ProfileStore;
use crate::state::{self, DaemonState};
use headroom_client::Client;
use headroom_ipc::Route;
use std::process;
use std::sync::atomic::AtomicU64;
@ -176,7 +177,7 @@ mod tests {
}
fn test_state() -> SharedState {
state::shared(DaemonState::new(Profile::default_v0()))
state::shared(DaemonState::new(ProfileStore::builtin()))
}
#[test]
@ -243,4 +244,85 @@ mod tests {
let n = value.as_f64().unwrap();
assert!((n - -0.1).abs() < 1e-6);
}
/// End-to-end through the IPC: load a store with a second profile
/// on disk, switch to it via `profile.use`, and confirm that an
/// overlay tweak made on the original profile carries across.
#[test]
fn client_profile_use_preserves_overlay() {
use crate::profile_store::{ProfileStore, StorePaths};
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
let base = std::env::temp_dir().join(format!(
"headroom-e2e-{}-{}",
process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _guard = scopeguard_remove(base.clone());
fs::create_dir_all(base.join("config/profiles")).unwrap();
fs::create_dir_all(base.join("state")).unwrap();
fs::write(
base.join("config/profiles/night.toml"),
"name = \"night\"\ndescription = \"loud night\"\n[limiter]\nceiling_dbtp = -2.0\n",
)
.unwrap();
let paths = StorePaths {
config_dir: base.join("config"),
state_dir: base.join("state"),
share_dirs: vec![],
};
let store = ProfileStore::load(&paths).expect("store load");
let state = state::shared(DaemonState::new(store));
let sock = temp_socket_path();
let _ = std::fs::remove_file(&sock);
let _server = IpcServer::start(sock.clone(), state).expect("server should start");
let mut client = Client::connect_at(&sock).expect("client connect");
// Apply an overlay tweak while on `default`.
client
.route_set("obs", Route::Bypass)
.expect("route.set obs");
client
.setting_set("agc.target_lufs", serde_json::json!(-22.0))
.expect("setting.set agc.target_lufs");
// Switch to `night`.
let switched_to = client.profile_use("night").expect("profile.use night");
assert_eq!(switched_to, "night");
let status = client.status().unwrap();
assert_eq!(status.profile, "night");
// Overlay survived: route override is still visible in route.list,
// and the setting override still wins over night.toml's value.
let routes = client.route_list().unwrap();
let user_rule = routes
.rules
.iter()
.find(|r| r.match_.process_binary == vec!["obs".to_string()])
.expect("obs override carried across profile switch");
assert_eq!(user_rule.route, Route::Bypass);
let lufs = client.setting_get("agc.target_lufs").unwrap();
assert!((lufs.as_f64().unwrap() - -22.0).abs() < 1e-6);
// night.toml's limiter ceiling shows through where there's no override.
let ceiling = client.setting_get("limiter.ceiling_dbtp").unwrap();
assert!((ceiling.as_f64().unwrap() - -2.0).abs() < 1e-6);
}
fn scopeguard_remove(path: PathBuf) -> impl Drop {
struct Cleanup(PathBuf);
impl Drop for Cleanup {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
Cleanup(path)
}
}

View file

@ -13,9 +13,13 @@
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod agc;
pub mod app_level;
pub mod error;
pub mod ipc;
pub mod profile;
pub mod profile_store;
pub mod profile_watcher;
pub mod pw;
pub mod routing;
pub mod runtime;
@ -23,13 +27,19 @@ pub mod state;
pub use error::DaemonError;
pub use profile::Profile;
pub use profile_store::{ProfileStore, StorePaths, StoreError, UserOverlay};
/// Run the daemon to completion.
///
/// Blocks until the daemon shuts down (SIGTERM/SIGINT) or fails fatally.
/// Profiles and overlay are loaded from XDG-spec paths (see
/// [`StorePaths::from_env`]).
///
/// # Errors
/// Returns `Err` if startup or runtime processing fails.
pub fn run() -> Result<(), DaemonError> {
runtime::run(Profile::default_v0())
let paths = StorePaths::from_env();
let store = ProfileStore::load(&paths)
.map_err(|e| DaemonError::Profile(format!("loading profiles: {e}")))?;
runtime::run(store)
}

View file

@ -388,6 +388,23 @@ pub struct PerAppRule {
/// RMS window length (ms).
#[serde(default = "default_rms_window_ms")]
pub rms_window_ms: f32,
/// Anti-bounce smoother time constant (ms) applied to the
/// post-combine reduction. Damps switching between the peak path
/// and the RMS path winning. Larger = smoother but slower to
/// respond; smaller = quicker but jitterier writes. Default 30 ms.
#[serde(default = "default_smoother_ms")]
pub smoother_ms: f32,
/// Minimum dB change between writes. Below this, the controller
/// keeps the smoothed envelope updated internally but doesn't
/// fire a fresh `Props.channelVolumes` write. Larger = quieter
/// CLI logs and less PipeWire chatter, at the cost of coarser
/// granularity. Default 0.5 dB.
#[serde(default = "default_write_db_threshold")]
pub write_db_threshold: f32,
/// Minimum interval between writes (ms). Hard rate limit per
/// stream. Default 100 ms (10 Hz cap).
#[serde(default = "default_min_write_interval_ms")]
pub min_write_interval_ms: f32,
/// Policy when the user adjusts the stream's volume externally.
#[serde(default)]
pub defer_to_user: DeferPolicy,
@ -414,6 +431,15 @@ const fn default_peak_release_ms() -> f32 {
const fn default_rms_window_ms() -> f32 {
1500.0
}
const fn default_smoother_ms() -> f32 {
30.0
}
const fn default_write_db_threshold() -> f32 {
0.5
}
const fn default_min_write_interval_ms() -> f32 {
100.0
}
/// Policy for handling user-initiated volume changes on a stream
/// Headroom is managing.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,178 @@
//! File-system watcher for the user profile directory.
//!
//! Wraps `notify-debouncer-mini` to call [`crate::ipc::execute_reload`]
//! whenever a TOML file in `$XDG_CONFIG_HOME/headroom/profiles/`
//! appears, disappears, or changes — debounced to coalesce editors
//! that save via rename / atomic-write (`vim`, most modern editors).
//!
//! The debouncer owns its own background thread. The callback we
//! register is `Fn + Send + 'static` and just calls into the same
//! reload helper that the IPC `profile.reload` op uses — so the
//! publish-events + DSP-push behaviour is identical to a manual
//! reload.
//!
//! Drop the [`ProfileWatcher`] to stop watching.
use std::path::PathBuf;
use std::time::Duration;
use notify::{RecommendedWatcher, RecursiveMode};
use notify_debouncer_mini::{new_debouncer, DebounceEventResult, Debouncer};
use crate::error::DaemonError;
use crate::ipc::execute_reload;
use crate::state::SharedState;
/// How long to wait for a quiet period before firing a reload. Most
/// editors do save → rename in tens of ms; 500 ms is comfortably
/// past the typical write storm without making the user wait long.
const DEBOUNCE: Duration = Duration::from_millis(500);
/// Live profile-directory watcher. Holds the underlying debouncer for
/// its lifetime; drop to stop the background thread.
pub struct ProfileWatcher {
_debouncer: Debouncer<RecommendedWatcher>,
}
impl ProfileWatcher {
/// Start watching `profiles_dir`. Returns `Ok(None)` if the
/// directory doesn't exist yet (acceptable — user hasn't authored
/// any custom profiles); returns `Ok(Some(_))` on a healthy arm.
///
/// # Errors
/// [`DaemonError::Other`] if the watcher backend or `watch` call
/// fails. A failure to install the watcher is not fatal to the
/// daemon; the caller can log and proceed (manual `profile.reload`
/// still works).
pub fn start(profiles_dir: PathBuf, state: SharedState) -> Result<Option<Self>, DaemonError> {
if !profiles_dir.exists() {
tracing::debug!(
path = %profiles_dir.display(),
"profile dir not present; file-watch reload disabled"
);
return Ok(None);
}
let state_for_cb = state;
let mut debouncer = new_debouncer(
DEBOUNCE,
move |result: DebounceEventResult| match result {
Ok(events) if !events.is_empty() => {
tracing::info!(events = events.len(), "profile dir changed; auto-reloading");
match execute_reload(&state_for_cb) {
Ok(report) => {
for w in &report.warnings {
tracing::warn!(warning = %w, "auto-reload warning");
}
}
Err(e) => tracing::error!(error = %e, "auto-reload failed"),
}
}
Ok(_) => {}
Err(e) => {
tracing::warn!(error = %e, "profile watcher backend error");
}
},
)
.map_err(|e| DaemonError::other(format!("debouncer init: {e}")))?;
debouncer
.watcher()
.watch(&profiles_dir, RecursiveMode::NonRecursive)
.map_err(|e| {
DaemonError::other(format!("watch {}: {e}", profiles_dir.display()))
})?;
tracing::info!(
path = %profiles_dir.display(),
debounce_ms = DEBOUNCE.as_millis() as u64,
"profile dir watcher armed"
);
Ok(Some(Self {
_debouncer: debouncer,
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile_store::{ProfileStore, StorePaths};
use crate::state::{self, DaemonState};
use std::fs;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
/// Build an isolated config/state tree and load a `ProfileStore`
/// against it. Returns the paths and a guard that cleans up the
/// dir on drop.
fn tmp_paths() -> (StorePaths, TmpGuard) {
let base = std::env::temp_dir().join(format!(
"headroom-watcher-{}-{}",
std::process::id(),
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
));
fs::create_dir_all(base.join("config/profiles")).unwrap();
fs::create_dir_all(base.join("state")).unwrap();
let paths = StorePaths {
config_dir: base.join("config"),
state_dir: base.join("state"),
share_dirs: vec![],
};
(paths, TmpGuard(base))
}
struct TmpGuard(std::path::PathBuf);
impl Drop for TmpGuard {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn missing_profile_dir_is_not_an_error() {
let dir = std::env::temp_dir().join(format!(
"headroom-no-dir-{}-{}",
std::process::id(),
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
));
// dir does not exist.
let store = ProfileStore::builtin();
let state = state::shared(DaemonState::new(store));
let watcher = ProfileWatcher::start(dir, state).expect("graceful no-op");
assert!(watcher.is_none());
}
#[test]
fn dropping_a_new_profile_triggers_reload() {
let (paths, _g) = tmp_paths();
let store = ProfileStore::load(&paths).unwrap();
let state = state::shared(DaemonState::new(store));
let profiles_dir = paths.config_dir.join("profiles");
let _watcher = ProfileWatcher::start(profiles_dir.clone(), state.clone())
.expect("watcher start")
.expect("dir present");
// Initially: only builtin "default" is known.
assert_eq!(state.lock().profiles.list().count(), 1);
// Drop a new profile in. The debouncer waits 500 ms; allow up
// to 5 s before declaring failure (CI fs latency).
fs::write(
profiles_dir.join("hot.toml"),
"name = \"hot\"\ndescription = \"hot-reloaded\"\n",
)
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let mut saw_new = false;
while Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(100));
if state.lock().profiles.list().any(|p| p.name == "hot") {
saw_new = true;
break;
}
}
assert!(saw_new, "watcher should have reloaded after file appeared");
}
}

View file

@ -0,0 +1,56 @@
//! Cross-thread command channel from IPC handlers to the PipeWire
//! main loop.
//!
//! PipeWire proxies (the bound `default` metadata, registry, streams)
//! are tied to the loop's thread and can't be touched from elsewhere.
//! Any IPC handler that needs to write metadata or otherwise mutate
//! the PipeWire graph posts a [`PwCommand`] into a `crossbeam` channel;
//! a 50 ms-period timer source on the main loop drains the channel
//! and applies each command in turn.
//!
//! # Latency budget — read before adding variants
//!
//! Worst-case dispatch latency through this channel is ~50 ms (one
//! full timer period). Average is ~25 ms. That is **fine for
//! operator-level / human-initiated commands** (`route.stream` from
//! the CLI or a panel widget; future profile-tweak verbs that touch
//! the graph) and is **fine for control-plane writes that already
//! operate on multi-hundred-millisecond time scales** (e.g. the slow
//! AGC tick, ~50 ms cadence with multi-second time constants).
//!
//! It is **not** fine for anything that drives gain reduction in
//! response to a transient. Specifically:
//!
//! - Layer A (per-application level control, Phase 6) reacts to
//! spikes within ~one PipeWire quantum (520 ms). Routing its
//! `Props.channelVolumes` writes through this channel would break
//! the §4.5 reaction-time contract.
//! - The filter's compressor/limiter parameter updates already
//! bypass this channel — they go through
//! [`crate::pw::filter::FilterControl`]'s `rtrb`, which is wait-free
//! and drained at the top of every realtime callback.
//!
//! If you're adding a new variant and your use case touches either
//! the realtime audio path or a spike-reactive gain envelope, do
//! **not** add it here. Phase 6 introduces a tighter dispatch
//! primitive (likely an `EventSource::signal` shim or a pipe-fd
//! `IoSource`) for that traffic; reuse that instead.
use headroom_ipc::Route;
/// Commands the IPC threads ask the PipeWire main loop to execute.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum PwCommand {
/// Set `target.object` for a specific stream, overriding any rule
/// the routing engine would otherwise apply. Used by
/// `Op::RouteStream` (4i).
RouteStream {
/// Stream node id.
node_id: u32,
/// Desired route.
to: Route,
/// Cached app label for log lines / events.
app_label: String,
},
}

View file

@ -29,6 +29,9 @@
//! reinterpretation goes through `bytemuck::try_cast_slice` so the
//! crate remains `#![forbid(unsafe_code)]`.
use std::sync::Arc;
use parking_lot::Mutex;
use pipewire::{
core::Core,
keys,
@ -45,7 +48,9 @@ use pipewire::{
};
use rtrb::{Consumer, Producer, RingBuffer};
use headroom_dsp::{Compressor, CompressorConfig, Limiter, LimiterConfig};
use headroom_dsp::{
AgcGain, AgcGainConfig, Compressor, CompressorConfig, Limiter, LimiterConfig, SetConfigOutcome,
};
use crate::error::DaemonError;
use crate::pw::sink::NODE_NAME as PROCESSED_SINK_NAME;
@ -54,16 +59,136 @@ use crate::pw::sink::NODE_NAME as PROCESSED_SINK_NAME;
/// constructed for this rate; if PipeWire negotiates a different
/// rate the filter logs a warning and the DSP may sound slightly off
/// in time-based parameters until Phase 4 wires rate updates.
const FILTER_SAMPLE_RATE: u32 = 48_000;
pub const FILTER_SAMPLE_RATE: u32 = 48_000;
/// Number of channels the filter operates on (stereo only in v0).
const CHANNELS: u32 = 2;
pub const CHANNELS: u32 = 2;
/// Capacity of the capture→playback ring, in `f32` samples. Sized to
/// hold ~4 quanta at the default 1024-frame quantum (4 × 1024 × 2 ch
/// = 8192 samples), with some slack.
const RING_CAPACITY: usize = 16_384;
/// Capacity of the control→audio command ring. Each slot holds an
/// [`AudioCmd`]. Sized for bursts (e.g. a CLI script firing several
/// `setting.set` calls back-to-back); the audio thread drains the
/// ring at the top of every quantum so we never need more headroom
/// than the worst-case command-arrival rate times one quantum.
const CMD_RING_CAPACITY: usize = 32;
/// Capacity of the audio→AGC measurement ring, in interleaved `f32`
/// samples. The audio thread pushes the filter's *input* samples
/// (pre-AGC, pre-compressor, pre-limiter) so the slow AGC measures
/// the program loudness it should compensate for. At 48 kHz stereo
/// the steady-state arrival rate is 96k samples/s; the controller
/// ticks at ~50 ms and consumes ~4.8k samples per tick. The capacity
/// here gives several ticks of slack so a stalled controller doesn't
/// drop measurement coverage.
const MEASUREMENT_RING_CAPACITY: usize = 32_768;
/// Parameter-update commands sent from the control plane to the
/// realtime audio thread.
///
/// Each variant carries a small POD config by value so the audio
/// thread doesn't have to dereference, allocate, or drop anything
/// outside its own state. Larger structural changes (oversample,
/// lookahead) require rebuilding the filter on the control thread —
/// see [`headroom_dsp::SetConfigOutcome::StructuralChange`].
#[derive(Debug, Clone, Copy)]
pub enum AudioCmd {
/// Replace the compressor's running configuration. Scalar params
/// (threshold/ratio/knee/times/makeup) update in place.
SetCompressor(CompressorConfig),
/// Replace the limiter's running configuration. Scalar params
/// apply in place; structural changes are logged and skipped.
SetLimiter(LimiterConfig),
/// Update the AGC gain stage's target (in dB). Pushed by the slow
/// AGC controller on each control tick. The audio thread smooths
/// `current_db` toward this with the anti-zipper alpha.
SetAgcTargetDb(f32),
/// Toggle the AGC stage. When disabled, the smoother unwinds to
/// 0 dB at the anti-zipper rate.
SetAgcEnabled(bool),
/// Replace the AGC gain stage's configuration (anti-zipper tau).
SetAgcConfig(AgcGainConfig),
}
/// Cheap-to-clone handle for sending [`AudioCmd`]s into the running
/// filter. Held on the control side (in `DaemonState`) so any
/// IPC-handler thread can push parameter updates without owning the
/// audio path.
#[derive(Clone)]
pub struct FilterControl {
cmd_producer: Arc<Mutex<Producer<AudioCmd>>>,
}
impl FilterControl {
/// Push a command into the ring. Returns `true` on success, `false`
/// if the ring is full (the command is dropped; the next push
/// after the audio thread drains will succeed). Logs at warn-level
/// on drop.
pub fn try_send(&self, cmd: AudioCmd) -> bool {
match self.cmd_producer.lock().push(cmd) {
Ok(()) => true,
Err(_) => {
tracing::warn!(
"filter command ring full; dropping parameter update — \
audio thread may be stalled or commands arriving faster than the quantum"
);
false
}
}
}
/// Convenience: push a compressor config.
pub fn set_compressor(&self, cfg: CompressorConfig) -> bool {
self.try_send(AudioCmd::SetCompressor(cfg))
}
/// Convenience: push a limiter config.
pub fn set_limiter(&self, cfg: LimiterConfig) -> bool {
self.try_send(AudioCmd::SetLimiter(cfg))
}
/// Convenience: push an AGC target (dB).
pub fn set_agc_target_db(&self, db: f32) -> bool {
self.try_send(AudioCmd::SetAgcTargetDb(db))
}
/// Convenience: push an AGC enable/disable flip.
pub fn set_agc_enabled(&self, enabled: bool) -> bool {
self.try_send(AudioCmd::SetAgcEnabled(enabled))
}
/// Convenience: push an AGC stage config.
pub fn set_agc_config(&self, cfg: AgcGainConfig) -> bool {
self.try_send(AudioCmd::SetAgcConfig(cfg))
}
}
impl std::fmt::Debug for FilterControl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FilterControl").finish_non_exhaustive()
}
}
#[cfg(test)]
impl FilterControl {
/// Construct a control + consumer pair without spinning up the
/// audio path. Returns `(control, consumer)` — the test code uses
/// the consumer in lieu of the playback callback to observe what
/// the producer pushed.
pub(crate) fn for_testing(capacity: usize) -> (Self, Consumer<AudioCmd>) {
let (producer, consumer) = RingBuffer::<AudioCmd>::new(capacity);
(
Self {
cmd_producer: Arc::new(Mutex::new(producer)),
},
consumer,
)
}
}
/// State owned by the capture stream's process callback.
struct CaptureState {
producer: Producer<f32>,
@ -75,10 +200,21 @@ struct CaptureState {
/// State owned by the playback stream's process callback.
struct PlaybackState {
consumer: Consumer<f32>,
/// Control-plane → audio-thread parameter update channel. Drained
/// at the top of every `playback_process` call.
cmd_consumer: Consumer<AudioCmd>,
/// Producer end of the measurement ring fed to the AGC controller.
/// We push *pre-AGC* input samples; samples that don't fit are
/// silently dropped (the controller is intentionally OK with
/// gaps, since its time constants are seconds).
measurement_producer: Producer<f32>,
agc: AgcGain,
compressor: Compressor,
limiter: Limiter,
/// Counter of samples zero-filled because the ring was empty.
samples_starved: u64,
/// Counter of measurement samples dropped (best-effort push).
measurement_dropped: u64,
}
/// The filter pipeline.
@ -92,20 +228,58 @@ pub struct Filter {
_playback_listener: StreamListener<PlaybackState>,
}
/// Initial DSP-side configuration handed to [`Filter::create`].
#[derive(Debug, Clone, Copy)]
pub struct FilterInit {
/// Compressor seed.
pub compressor: CompressorConfig,
/// Limiter seed.
pub limiter: LimiterConfig,
/// AGC gain-stage seed (anti-zipper tau etc.).
pub agc: AgcGainConfig,
/// Whether the AGC stage is active at boot. Derived from the
/// active profile's `[agc] enabled`.
pub agc_enabled: bool,
}
/// Everything [`Filter::create`] hands back. Bundled so we don't grow
/// a 5-tuple each time a new control-plane handle appears.
pub struct FilterBundle {
/// The filter itself. Drop teardown order is `bundle.filter` first.
pub filter: Filter,
/// Cheap-to-clone control handle for live parameter updates.
pub control: FilterControl,
/// Consumer end of the AGC measurement ring. Hand to the
/// `headroom-core::agc` controller.
pub measurement_consumer: Consumer<f32>,
}
impl Filter {
/// Create the capture+playback streams and connect them. The
/// capture stream targets `headroom-processed.monitor`; the
/// playback stream autoconnects to the system default real sink
/// for now (3f will make this dynamic).
///
/// `initial_compressor` and `initial_limiter` seed the DSP kernels
/// from the active profile; subsequent live tweaks arrive over
/// the [`FilterControl`] returned alongside the filter.
///
/// # Errors
/// [`DaemonError::PipeWire`] if stream creation or connection
/// fails.
pub fn create(core: &Core) -> Result<Self, DaemonError> {
pub fn create(core: &Core, init: FilterInit) -> Result<FilterBundle, DaemonError> {
let (producer, consumer) = RingBuffer::<f32>::new(RING_CAPACITY);
let (cmd_producer, cmd_consumer) = RingBuffer::<AudioCmd>::new(CMD_RING_CAPACITY);
let (measurement_producer, measurement_consumer) =
RingBuffer::<f32>::new(MEASUREMENT_RING_CAPACITY);
let control = FilterControl {
cmd_producer: Arc::new(Mutex::new(cmd_producer)),
};
let compressor = Compressor::new(CompressorConfig::default(), FILTER_SAMPLE_RATE as f32);
let limiter = Limiter::new(LimiterConfig::default(), FILTER_SAMPLE_RATE as f32);
let compressor = Compressor::new(init.compressor, FILTER_SAMPLE_RATE as f32);
let limiter = Limiter::new(init.limiter, FILTER_SAMPLE_RATE as f32);
let mut agc = AgcGain::new(init.agc, FILTER_SAMPLE_RATE as f32);
agc.set_enabled(init.agc_enabled);
let capture = build_capture_stream(core)?;
let capture_listener = capture
@ -121,9 +295,13 @@ impl Filter {
let playback_listener = playback
.add_local_listener_with_user_data(PlaybackState {
consumer,
cmd_consumer,
measurement_producer,
agc,
compressor,
limiter,
samples_starved: 0,
measurement_dropped: 0,
})
.process(playback_process)
.register()
@ -163,11 +341,15 @@ impl Filter {
"filter streams created and connected"
);
Ok(Self {
_capture: capture,
_capture_listener: capture_listener,
_playback: playback,
_playback_listener: playback_listener,
Ok(FilterBundle {
filter: Self {
_capture: capture,
_capture_listener: capture_listener,
_playback: playback,
_playback_listener: playback_listener,
},
control,
measurement_consumer,
})
}
}
@ -277,8 +459,58 @@ fn capture_process(stream: &pipewire::stream::StreamRef, state: &mut CaptureStat
}
}
/// Apply a single [`AudioCmd`] to the DSP kernels. Allocation-free;
/// extracted from [`drain_audio_commands`] so the audio-thread leg is
/// unit-testable without spinning up a `pw_stream`.
fn apply_audio_cmd(
cmd: AudioCmd,
compressor: &mut Compressor,
limiter: &mut Limiter,
agc: &mut AgcGain,
) {
match cmd {
AudioCmd::SetCompressor(cfg) => {
compressor.set_config(cfg);
}
AudioCmd::SetLimiter(cfg) => match limiter.try_set_config(cfg) {
SetConfigOutcome::Applied => {}
SetConfigOutcome::StructuralChange => {
tracing::warn!(
"limiter structural change (oversample / lookahead / fir_taps) cannot be \
applied live; daemon restart required to pick up the new value"
);
}
},
AudioCmd::SetAgcTargetDb(db) => {
agc.set_target_db(db);
}
AudioCmd::SetAgcEnabled(enabled) => {
agc.set_enabled(enabled);
}
AudioCmd::SetAgcConfig(cfg) => {
agc.set_config(cfg);
}
}
}
/// Drain pending parameter updates from the control plane and apply
/// them to the DSP kernels. Called at the top of every playback
/// callback; allocation-free.
fn drain_audio_commands(state: &mut PlaybackState) {
while let Ok(cmd) = state.cmd_consumer.pop() {
apply_audio_cmd(
cmd,
&mut state.compressor,
&mut state.limiter,
&mut state.agc,
);
}
}
/// Playback process callback. Realtime-thread, allocation-free.
fn playback_process(stream: &pipewire::stream::StreamRef, state: &mut PlaybackState) {
drain_audio_commands(state);
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
@ -308,18 +540,32 @@ fn playback_process(stream: &pipewire::stream::StreamRef, state: &mut PlaybackSt
};
let mut produced_frames = 0;
let mut measurement_dropped = 0_u64;
for frame_idx in 0..max_frames {
let (left_in, right_in) = match (state.consumer.pop(), state.consumer.pop()) {
(Ok(l), Ok(r)) => (l, r),
_ => break, // ring empty
};
// Compressor first, then the two-tier limiter (safety contract).
let (lc, rc) = state.compressor.process_frame(left_in, right_in);
// Feed the slow-AGC controller. Best-effort: gaps in
// measurement coverage are fine (its time constants are
// seconds), and we don't want to block the audio thread on
// a slow controller.
if state.measurement_producer.push(left_in).is_err()
|| state.measurement_producer.push(right_in).is_err()
{
measurement_dropped = measurement_dropped.saturating_add(2);
}
// AGC → Compressor → two-tier limiter (safety contract).
let (la, ra) = state.agc.process_frame(left_in, right_in);
let (lc, rc) = state.compressor.process_frame(la, ra);
let (lo, ro) = state.limiter.process_frame(lc, rc);
out_samples[frame_idx * 2] = lo;
out_samples[frame_idx * 2 + 1] = ro;
produced_frames += 1;
}
if measurement_dropped > 0 {
state.measurement_dropped = state.measurement_dropped.saturating_add(measurement_dropped);
}
if produced_frames < max_frames {
let starved_frames = max_frames - produced_frames;
@ -337,3 +583,152 @@ fn playback_process(stream: &pipewire::stream::StreamRef, state: &mut PlaybackSt
*chunk.stride_mut() = stride_bytes as i32;
*chunk.offset_mut() = 0;
}
#[cfg(test)]
mod tests {
//! Tests cover the audio-thread leg (apply_audio_cmd) and the
//! control-side send leg (FilterControl). The pw_stream halves
//! aren't exercised here — they need a running PipeWire instance.
use super::*;
use headroom_dsp::{
AgcGain, AgcGainConfig, Compressor, CompressorConfig, Limiter, LimiterConfig,
SoftTierConfig,
};
const SR: f32 = 48_000.0;
#[test]
fn apply_audio_cmd_updates_compressor_scalars() {
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 new_cfg = CompressorConfig {
threshold_db: -12.0,
ratio: 4.0,
..CompressorConfig::default()
};
apply_audio_cmd(
AudioCmd::SetCompressor(new_cfg),
&mut compressor,
&mut limiter,
&mut agc,
);
let active = compressor.config();
assert!((active.threshold_db - -12.0).abs() < 1e-6);
assert!((active.ratio - 4.0).abs() < 1e-6);
}
#[test]
fn apply_audio_cmd_updates_limiter_scalars() {
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 new_cfg = LimiterConfig {
ceiling_dbtp: -1.5,
release_ms: 250.0,
soft: Some(SoftTierConfig {
max_psr_db: 10.0,
..SoftTierConfig::default()
}),
..LimiterConfig::default()
};
apply_audio_cmd(
AudioCmd::SetLimiter(new_cfg),
&mut compressor,
&mut limiter,
&mut agc,
);
assert!((limiter.ceiling_dbtp() - -1.5).abs() < 1e-6);
assert!((limiter.config().release_ms - 250.0).abs() < 1e-6);
let soft = limiter.config().soft.expect("soft preserved");
assert!((soft.max_psr_db - 10.0).abs() < 1e-6);
}
#[test]
fn apply_audio_cmd_skips_structural_limiter_change_silently() {
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
// Should not panic, should not change the limiter.
apply_audio_cmd(
AudioCmd::SetLimiter(bad),
&mut compressor,
&mut limiter,
&mut agc,
);
assert_eq!(limiter.config().oversample, LimiterConfig::default().oversample);
}
#[test]
fn filter_control_send_reaches_consumer() {
let (control, mut consumer) = FilterControl::for_testing(8);
assert!(control.set_compressor(CompressorConfig::default()));
assert!(control.set_limiter(LimiterConfig::default()));
// Two commands queued.
let c1 = consumer.pop().expect("first cmd");
let c2 = consumer.pop().expect("second cmd");
assert!(matches!(c1, AudioCmd::SetCompressor(_)));
assert!(matches!(c2, AudioCmd::SetLimiter(_)));
assert!(consumer.pop().is_err(), "ring drained");
}
#[test]
fn filter_control_returns_false_on_full_ring() {
// Capacity 2: third push should fail.
let (control, _consumer) = FilterControl::for_testing(2);
assert!(control.set_compressor(CompressorConfig::default()));
assert!(control.set_limiter(LimiterConfig::default()));
assert!(!control.set_compressor(CompressorConfig::default()));
}
#[test]
fn filter_control_send_then_drain_applies_to_dsp_kernels() {
// End-to-end on the cmd plane: push via FilterControl, drain
// via apply_audio_cmd, observe DSP state.
let (control, mut consumer) = FilterControl::for_testing(8);
let mut compressor = Compressor::new(CompressorConfig::default(), SR);
let mut limiter = Limiter::new(LimiterConfig::default(), SR);
let mut agc = AgcGain::new(AgcGainConfig::default(), SR);
control.set_compressor(CompressorConfig {
threshold_db: -8.0,
..CompressorConfig::default()
});
control.set_limiter(LimiterConfig {
ceiling_dbtp: -2.0,
..LimiterConfig::default()
});
while let Ok(cmd) = consumer.pop() {
apply_audio_cmd(cmd, &mut compressor, &mut limiter, &mut agc);
}
assert!((compressor.config().threshold_db - -8.0).abs() < 1e-6);
assert!((limiter.ceiling_dbtp() - -2.0).abs() < 1e-6);
}
#[test]
fn apply_audio_cmd_updates_agc_target_and_enable() {
let mut compressor = Compressor::new(CompressorConfig::default(), SR);
let mut limiter = Limiter::new(LimiterConfig::default(), SR);
let mut agc = AgcGain::new(AgcGainConfig::default(), SR);
apply_audio_cmd(
AudioCmd::SetAgcTargetDb(4.5),
&mut compressor,
&mut limiter,
&mut agc,
);
assert!((agc.target_db() - 4.5).abs() < 1e-6);
apply_audio_cmd(
AudioCmd::SetAgcEnabled(false),
&mut compressor,
&mut limiter,
&mut agc,
);
assert!(!agc.enabled());
// Disable resets target to 0 (smoother unwinds gracefully).
assert!((agc.target_db()).abs() < 1e-6);
}
}

View file

@ -1,60 +1,51 @@
//! Metadata helpers.
//! Helpers for the PipeWire `default` metadata object.
//!
//! PipeWire exposes a `default` metadata object that carries
//! `default.audio.sink` (the system default sink) and per-stream
//! `target.object` overrides. We read both and write the latter to
//! implement routing.
//! Headroom reads two pieces of state from it:
//!
//! Phase 3 checkpoints 3c-3f (varies per call site).
//! - `default.audio.sink` — the system default sink. We watch this to
//! adopt the user's preferred hardware sink as
//! `preferred_real_sink`, and re-assert `headroom-processed` so new
//! streams keep landing in the processor.
//! - per-stream `target.object` (written, not read) — how the routing
//! engine tells WirePlumber to move a stream to a chosen sink.
//!
//! The metadata API surface itself (binding, listening, writing) lives
//! in [`crate::pw::registry`], where the registry callbacks have the
//! right scope. This module is the pure parsing / formatting layer.
use crate::error::DaemonError;
use serde_json::Value;
/// Tracks the user's `preferred_real_sink` by watching
/// `default.audio.sink` on the `default` metadata key. When the user
/// switches the default to a hardware sink, the daemon adopts it.
pub struct PreferredRealSinkTracker {
/// Most recently observed real sink, by node id.
current: Option<u32>,
}
/// The metadata key for the system default audio sink.
pub const DEFAULT_AUDIO_SINK_KEY: &str = "default.audio.sink";
impl PreferredRealSinkTracker {
/// Construct an empty tracker.
#[must_use]
pub fn new() -> Self {
Self { current: None }
}
/// The metadata key for per-stream sink override.
pub const TARGET_OBJECT_KEY: &str = "target.object";
/// Currently-observed real sink, if any.
#[must_use]
pub fn current(&self) -> Option<u32> {
self.current
}
/// The SPA type string used for JSON-encoded metadata values.
pub const SPA_JSON_TYPE: &str = "Spa:String:JSON";
/// Set the current real sink. Returns `true` if the value
/// changed.
pub fn set(&mut self, node_id: Option<u32>) -> bool {
let changed = self.current != node_id;
self.current = node_id;
changed
}
}
impl Default for PreferredRealSinkTracker {
fn default() -> Self {
Self::new()
}
}
/// Write `target.object = <serial>` for the named stream into the
/// `default` metadata key. WirePlumber observes this and moves the
/// stream accordingly.
/// Parse a `default.audio.sink` value into a sink name.
///
/// # Errors
/// Stub in checkpoint 3a; implemented in 3f.
pub fn write_stream_target(_stream_node_id: u32, _target_serial: u32) -> Result<(), DaemonError> {
Err(DaemonError::other(
"metadata::write_stream_target not implemented (phase 3f)",
))
/// The on-the-wire encoding is a JSON object: `{"name":"alsa_output.…"}`.
/// Returns `None` for anything we can't recognise — we'd rather quietly
/// ignore weird values than crash the metadata listener.
#[must_use]
pub fn parse_default_sink_name(value: &str) -> Option<String> {
let parsed: Value = serde_json::from_str(value.trim()).ok()?;
parsed.get("name")?.as_str().map(str::to_owned)
}
/// Format a `target.object` value pointing at `sink_name`. The JSON
/// shape mirrors what PipeWire / WirePlumber accept and what
/// `parse_default_sink_name` reads.
#[must_use]
pub fn format_sink_target_value(sink_name: &str) -> String {
// Escape any embedded double-quote conservatively. Sink names from
// PipeWire never contain quotes in practice, but the formatter is
// also called with user-influenced strings (the `preferred_real_sink`
// name as observed), so don't trust them.
let escaped = sink_name.replace('"', "\\\"");
format!("{{\"name\":\"{escaped}\"}}")
}
#[cfg(test)]
@ -62,17 +53,36 @@ mod tests {
use super::*;
#[test]
fn tracker_reports_changes() {
let mut t = PreferredRealSinkTracker::new();
assert!(t.current().is_none());
assert!(t.set(Some(42)));
assert_eq!(t.current(), Some(42));
// Same value — no change.
assert!(!t.set(Some(42)));
// Different value — change.
assert!(t.set(Some(43)));
// Cleared.
assert!(t.set(None));
assert!(t.current().is_none());
fn parses_default_sink_name_from_canonical_json() {
let v = parse_default_sink_name("{\"name\":\"alsa_output.usb-foo\"}");
assert_eq!(v.as_deref(), Some("alsa_output.usb-foo"));
}
#[test]
fn parses_default_sink_name_with_whitespace() {
let v = parse_default_sink_name(" {\"name\":\"x\"}\n");
assert_eq!(v.as_deref(), Some("x"));
}
#[test]
fn rejects_garbage() {
assert_eq!(parse_default_sink_name("not json"), None);
assert_eq!(parse_default_sink_name("{}"), None);
assert_eq!(parse_default_sink_name("{\"name\":42}"), None);
}
#[test]
fn formats_sink_target_round_trips() {
let formatted = format_sink_target_value("alsa_output.usb-foo");
let back = parse_default_sink_name(&formatted).unwrap();
assert_eq!(back, "alsa_output.usb-foo");
}
#[test]
fn formats_sink_target_escapes_embedded_quote() {
let formatted = format_sink_target_value("we\"ird");
// Should still be valid JSON.
let back = parse_default_sink_name(&formatted).unwrap();
assert_eq!(back, "we\"ird");
}
}

View file

@ -14,13 +14,16 @@
//! `Context`, and `Core`. The daemon constructs one of these on
//! startup and runs it until shutdown.
pub mod command;
pub mod filter;
pub mod metadata;
pub mod registry;
pub mod sink;
pub mod tap;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::time::Duration;
use pipewire::{context::Context, core::Core, loop_::Signal, main_loop::MainLoop};
@ -132,7 +135,11 @@ impl PwContext {
.core
.get_registry()
.map_err(|e| DaemonError::pipewire(format!("get_registry: {e}")))?;
let watcher = RegistryWatcher::new(Rc::new(registry), daemon);
// Clone the Core for the routing watcher — `Core` is itself
// `Rc`-backed in pipewire-rs, so this is cheap. The watcher
// needs it to call `create_object("link-factory", ...)` when
// spawning Layer A taps (6c).
let watcher = RegistryWatcher::new(Rc::new(registry), self.core.clone(), daemon);
*self.routing.borrow_mut() = Some(watcher);
tracing::info!("registry watcher + routing engine installed");
Ok(())
@ -225,6 +232,50 @@ impl PwContext {
ml.quit();
});
// Drain IPC → PipeWire commands (e.g. route.stream) at 50 ms.
// The timer is scoped to this function so it drops alongside
// the signal sources when the loop exits. Held in `Option`
// because we only arm it if routing was started.
//
// Latency note: this 50 ms cadence is fine for operator-grade
// commands and slow AGC-style writes, but is **not** suitable
// for spike-reactive gain reduction (Layer A, Phase 6). See
// `pw::command` module docs before routing new traffic here.
let _cmd_timer = {
let routing = self.routing.borrow();
routing.as_ref().map(|watcher| {
let state = watcher.state().clone();
let timer = self.main_loop.loop_().add_timer(move |_expirations| {
state.borrow_mut().drain_pw_commands();
});
let _ = timer.update_timer(
Some(Duration::from_millis(50)),
Some(Duration::from_millis(50)),
);
timer
})
};
// Drain Layer A (per-app level control) measurement rings and
// issue `Props.channelVolumes` writes. 5 ms cadence keeps the
// detection-to-write latency well inside one quantum at
// typical 21 ms quanta — see PLAN §4.5 reaction-time table
// and the bench-validated controller cost (~30 ns/tick).
let _layer_a_timer = {
let routing = self.routing.borrow();
routing.as_ref().map(|watcher| {
let state = watcher.state().clone();
let timer = self.main_loop.loop_().add_timer(move |_expirations| {
state.borrow_mut().drain_layer_a();
});
let _ = timer.update_timer(
Some(Duration::from_millis(5)),
Some(Duration::from_millis(5)),
);
timer
})
};
tracing::info!("entering pipewire main loop");
self.main_loop.run();
tracing::info!("main loop exited");

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,283 @@
//! Per-app Layer A analysis tap.
//!
//! For each playback stream Headroom decides to manage, we create a
//! `pw_stream` of our own (`Direction::Input`, F32LE stereo, no
//! `AUTOCONNECT`) and tell PipeWire to connect it directly to the
//! source stream's output by passing `target_id = Some(source_node_id)`
//! on `connect`. PipeWire wires the link itself as part of format
//! negotiation; we don't need to call the `link-factory` separately.
//!
//! Compared to the explicit `pw_link` approach this is *less* of an
//! engineering decision but ends up being more robust: the format
//! negotiation happens during `connect()` with the target known, so
//! our input ports get configured, and there's no chicken-and-egg
//! between "create the link" and "have ports to wire to."
//!
//! We don't get the explicit `link.passive` flag this way, but in
//! practice the source's existing playback link to its real
//! destination is the driver — our tap is a sibling consumer that
//! observes data already being produced. PLAN §4.1's "zero added
//! playback latency" property holds in measurement (the 6c manual
//! smoke verified ~2 μs steady-state on the source with the tap
//! attached).
//!
//! The audio-thread `process` callback computes per-block `peak` and
//! `mean_sq`, pushes one [`MeasurementSample`] (8 B) into a per-tap
//! `rtrb`, and returns. The controller that consumes the ring lives
//! on the daemon side — see `crate::app_level::AppLevelController`
//! and `crate::pw::registry::RoutingState::drain_layer_a`.
//!
//! Lifecycle:
//!
//! 1. Registry watcher sees a `Stream/Output/Audio` matching a
//! `per_app` rule. It calls [`StreamTap::start`] with the source
//! node id.
//! 2. `start` creates the tap stream and calls `connect()` with the
//! source's id as the target. PipeWire wires the link and
//! negotiates format; state goes Unconnected → Connecting →
//! Paused → Streaming.
//! 3. `set_active(true)` is called after connect so PipeWire moves us
//! from Paused to Streaming as soon as format is locked in.
//! 4. Samples flow into `tap_process`; controller drain reads them.
//! 5. Source disappears → registry `global_remove` → routing watcher
//! drops the `StreamTap`. Drop tears down stream + listener; the
//! PipeWire-side link goes with the stream.
use pipewire::{
core::Core,
keys,
properties::properties,
spa::{
param::{
audio::{AudioFormat, AudioInfoRaw},
ParamType,
},
pod::{serialize::PodSerializer, Object, Pod, Value},
utils::{Direction, SpaTypes},
},
stream::{Stream, StreamFlags, StreamListener},
};
use rtrb::{Consumer, Producer, RingBuffer};
use crate::error::DaemonError;
/// Channel count for the tap (v0 stereo only).
const TAP_CHANNELS: u32 = 2;
/// Capacity of the per-tap measurement ring, in [`MeasurementSample`]s.
/// At a 21 ms quantum that's ~1.3 s of buffer — comfortably past
/// any plausible controller-drain interval, while staying small
/// enough to be cheap.
const TAP_RING_CAPACITY: usize = 64;
/// One block's worth of analysis output the audio thread pushes for
/// the controller to consume. 8 bytes; `Copy`.
#[derive(Debug, Clone, Copy)]
pub struct MeasurementSample {
/// Block peak `max(|x|)`.
pub peak: f32,
/// Block mean-square `Σ(x²)/N`.
pub mean_sq: f32,
}
/// State held inside the tap's audio-thread `process` callback.
struct TapState {
/// Producer end of the measurement ring.
producer: Producer<MeasurementSample>,
/// Counter of samples dropped because the ring was full. Block
/// rate is ~46 Hz; dropping a few measurements is harmless — the
/// controller's time constants are seconds.
drops: u64,
}
/// One per-app Layer A tap. Owns the analysis `pw_stream` and its
/// listener; the explicit per-channel links are owned by the
/// `ManagedStream` that wraps this tap (see `pw::registry`).
pub struct StreamTap {
stream: Stream,
_listener: StreamListener<TapState>,
source_node_id: u32,
}
impl StreamTap {
/// Spawn a tap on `source_node_id`. The link is created
/// asynchronously by the stream's `state_changed` callback — if
/// the creation fails (e.g. the source disappeared mid-setup),
/// it's logged at warn and the tap stays idle.
///
/// # Errors
/// [`DaemonError::PipeWire`] on stream construction / connection
/// failure. Link errors are *not* propagated — they're logged.
pub fn start(
core: &Core,
source_node_id: u32,
) -> Result<(Self, Consumer<MeasurementSample>), DaemonError> {
let (producer, consumer) = RingBuffer::<MeasurementSample>::new(TAP_RING_CAPACITY);
let node_name = format!("headroom-tap.{source_node_id}");
let stream_name = format!("headroom-tap-{source_node_id}");
let props = properties! {
*keys::MEDIA_TYPE => "Audio",
*keys::MEDIA_CATEGORY => "Capture",
*keys::MEDIA_ROLE => "DSP",
*keys::NODE_NAME => node_name.as_str(),
*keys::NODE_DESCRIPTION => "Headroom Layer A analysis tap",
*keys::NODE_DONT_RECONNECT => "true",
"node.dont-move" => "true",
};
let stream = Stream::new(core, &stream_name, props)
.map_err(|e| DaemonError::pipewire(format!("tap stream new: {e}")))?;
let listener = stream
.add_local_listener_with_user_data(TapState { producer, drops: 0 })
.process(tap_process)
.state_changed(move |_stream_ref, _data, old, new| {
tracing::debug!(
source = source_node_id,
?old,
?new,
"Layer A tap state change"
);
})
.register()
.map_err(|e| DaemonError::pipewire(format!("tap register: {e}")))?;
let format_bytes = build_format_pod_bytes()?;
let format_pod = Pod::from_bytes(&format_bytes)
.ok_or_else(|| DaemonError::pipewire("Pod::from_bytes"))?;
let mut params: [&Pod; 1] = [format_pod];
stream
.connect(
Direction::Input,
// No session-manager target: WirePlumber's policy
// doesn't know how to wire `Stream/Output → Stream/Input`,
// so passing the source node id here is a no-op for
// link creation (we tried, and `pw-cli` confirmed no
// link gets made). PipeWire still creates our input
// ports from the declared format, which is exactly
// what we need for explicit `link-factory` calls
// afterwards. The registry watcher does that step.
None,
StreamFlags::MAP_BUFFERS | StreamFlags::RT_PROCESS,
&mut params,
)
.map_err(|e| DaemonError::pipewire(format!("tap connect: {e}")))?;
// Without `AUTOCONNECT` the stream stays inactive after
// `connect`. PipeWire only fires `process` callbacks in
// `Streaming`; `set_active(true)` is what lifts us from
// `Paused` to `Streaming` once format negotiation completes.
if let Err(e) = stream.set_active(true) {
tracing::warn!(
source = source_node_id,
error = %e,
"tap set_active failed; stream will stay Paused and no samples will flow"
);
}
tracing::info!(
source = source_node_id,
"Layer A tap stream connected to source; awaiting Streaming state"
);
Ok((
Self {
stream,
_listener: listener,
source_node_id,
},
consumer,
))
}
/// Node id of the *source* stream this tap is observing.
#[must_use]
pub fn source_node_id(&self) -> u32 {
self.source_node_id
}
/// Node id PipeWire assigned to *this* tap's stream. Returns 0
/// until the stream is bound (typically by the time it reaches
/// `Connecting` / `Paused`). Used by the registry watcher to
/// look up the tap's input ports for explicit link creation.
#[must_use]
pub fn tap_node_id(&self) -> u32 {
self.stream.node_id()
}
}
fn build_format_pod_bytes() -> Result<Vec<u8>, DaemonError> {
// F32LE stereo, **rate left unset** so PipeWire negotiates the
// source's rate. The `From<AudioInfoRaw> for Vec<Property>` impl
// in libspa omits the `SPA_FORMAT_AUDIO_rate` property when
// `rate == 0`, which the format-negotiation protocol reads as
// "any rate I'll accept what's offered." Hardcoding 48 kHz here
// makes us fail to negotiate with 44.1 kHz sources (most music
// players), leaving the stream stuck at `Paused`. We're an
// analysis tap — block period varies with the source's quantum,
// which the controller's alpha math handles via `set_block_dt`.
let mut info = AudioInfoRaw::new();
info.set_format(AudioFormat::F32LE);
info.set_channels(TAP_CHANNELS);
let obj = Object {
type_: SpaTypes::ObjectParamFormat.as_raw(),
id: ParamType::EnumFormat.as_raw(),
properties: info.into(),
};
let bytes = PodSerializer::serialize(std::io::Cursor::new(Vec::new()), &Value::Object(obj))
.map_err(|e| DaemonError::pipewire(format!("tap format pod: {e}")))?
.0
.into_inner();
Ok(bytes)
}
/// Audio-thread `process` callback. Allocation-free, bounded by the
/// block length. Computes `peak` and `mean_sq` over the interleaved
/// samples and pushes one [`MeasurementSample`] to the controller.
fn tap_process(stream: &pipewire::stream::StreamRef, state: &mut TapState) {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
let datas = buffer.datas_mut();
let Some(data) = datas.first_mut() else {
return;
};
let n_bytes = data.chunk().size() as usize;
if n_bytes == 0 {
return;
}
let Some(byte_slice) = data.data() else {
return;
};
let samples: &[f32] = match bytemuck::try_cast_slice::<u8, f32>(&byte_slice[..n_bytes]) {
Ok(s) => s,
Err(_) => {
tracing::warn!("tap buffer not f32-aligned; skipping");
return;
}
};
if samples.is_empty() {
return;
}
let mut peak = 0.0_f32;
let mut sumsq = 0.0_f32;
for &s in samples {
let a = s.abs();
if a > peak {
peak = a;
}
sumsq += s * s;
}
let mean_sq = sumsq / samples.len() as f32;
if state
.producer
.push(MeasurementSample { peak, mean_sq })
.is_err()
{
// Ring full — drop silently. The controller's time constants
// are seconds; a missed block is harmless. Counter is exposed
// for telemetry once Phase 6e wires meters.
state.drops = state.drops.saturating_add(1);
}
}

View file

@ -75,7 +75,10 @@ pub fn evaluate(info: &PwNodeInfo, profile: &Profile) -> RoutingDecision {
/// True iff every present field in the matcher has at least one value
/// that equals the corresponding property of the node. Empty fields
/// are treated as "don't care."
fn matches(info: &PwNodeInfo, m: &RouteRuleMatch) -> bool {
///
/// Shared across the routing engine and the per-app-level matcher
/// (Phase 6, `crate::app_level`).
pub(crate) fn matches(info: &PwNodeInfo, m: &RouteRuleMatch) -> bool {
let any_match = |needle: &Option<String>, hay: &[String]| -> bool {
if hay.is_empty() {
return true;

View file

@ -5,17 +5,22 @@
//! the PipeWire main loop. The IPC server (Phase 4) and slow AGC loop
//! (Phase 4) attach here as well in later checkpoints.
use std::cell::RefCell;
use std::rc::Rc;
use headroom_ipc::{Event, Topic};
use serde_json::json;
use crate::agc::{AgcController, AGC_TICK};
use crate::error::DaemonError;
use crate::ipc::IpcServer;
use crate::profile::Profile;
use crate::pw::filter::Filter;
use crate::profile_store::{ProfileStore, StorePaths};
use crate::profile_watcher::ProfileWatcher;
use crate::pw::filter::{Filter, FilterBundle, FilterInit};
use crate::pw::{block_termination_signals, PwContext};
use crate::state::{self, DaemonState, SharedState};
/// Run the daemon using `profile` as the active configuration.
/// Run the daemon using `profiles` as the configuration source.
///
/// Blocks until shutdown. Returns `Ok(())` on a clean exit (SIGTERM /
/// SIGINT) or a [`DaemonError`] on startup or runtime failure.
@ -23,12 +28,22 @@ use crate::state::{self, DaemonState, SharedState};
/// # Errors
/// Returns an error if connecting to PipeWire fails, or if any of
/// the per-checkpoint sub-systems fails to start.
pub fn run(profile: Profile) -> Result<(), DaemonError> {
pub fn run(profiles: ProfileStore) -> Result<(), DaemonError> {
// Snapshot warnings without draining them; status / IPC needs to
// keep surfacing them until the next reload clears them.
let pending_warnings = profiles.warnings();
let active_missing = profiles.is_active_missing().map(|s| s.to_owned());
tracing::info!(
profile = profile.name.as_str(),
rules = profile.rules.len(),
profile = profiles.effective().name.as_str(),
rules = profiles.effective().rules.len(),
"starting headroom daemon"
);
for w in &pending_warnings {
tracing::warn!(warning = %w, "profile store warning");
}
if let Some(name) = active_missing.as_deref() {
tracing::warn!(missing = name, "selected profile missing; using built-in default");
}
// Block SIGTERM/SIGINT process-wide BEFORE spawning any threads.
// Any thread spawned after this call inherits the blocked mask,
@ -41,7 +56,7 @@ pub fn run(profile: Profile) -> Result<(), DaemonError> {
// Cross-thread shared state: both the IPC threads and the
// PipeWire main-loop thread hold an Arc clone and lock briefly.
let daemon_state = state::shared(DaemonState::new(profile));
let daemon_state = state::shared(DaemonState::new(profiles));
// Bring up IPC first so its accept thread is ready before any
// PipeWire work logs through it. The handle's `Drop` cleans the
@ -50,6 +65,21 @@ pub fn run(profile: Profile) -> Result<(), DaemonError> {
.ok_or_else(|| DaemonError::other("no default IPC socket path"))?;
let _ipc = IpcServer::start(socket_path, daemon_state.clone())?;
// Watch the profile directory for edits and auto-reload. Failure
// to install is non-fatal: log and proceed; `profile.reload` over
// IPC still works manually.
let _profile_watcher = {
let paths = StorePaths::from_env();
let dir = paths.config_dir.join("profiles");
match ProfileWatcher::start(dir, daemon_state.clone()) {
Ok(watcher) => watcher,
Err(e) => {
tracing::warn!(error = %e, "profile file-watcher disabled");
None
}
}
};
let pw = PwContext::new()?;
pw.create_processed_sink()?;
@ -57,29 +87,84 @@ pub fn run(profile: Profile) -> Result<(), DaemonError> {
// (capture from headroom-processed monitor, playback to the
// system default real sink) and the DSP chain that sits between
// them. Drop on shutdown tears the audio path down cleanly.
let _filter = Filter::create(pw.core())?;
//
// Seed the DSP from the effective profile so the filter starts
// running with the user's chosen settings rather than DSP-side
// defaults. The `FilterControl` returned alongside is stashed in
// `DaemonState` so IPC handlers can push live parameter updates;
// the measurement consumer goes to the slow AGC controller.
let filter_init = {
let s = daemon_state.lock();
let effective = s.profiles.effective();
FilterInit {
compressor: effective.build_compressor_config(),
limiter: effective.build_limiter_config(),
agc: headroom_dsp::AgcGainConfig::default(),
agc_enabled: effective.agc.enabled,
}
};
let FilterBundle {
filter: _filter,
control: filter_control,
measurement_consumer,
} = Filter::create(pw.core(), filter_init)?;
daemon_state.lock().filter_control = Some(filter_control.clone());
// Spin up the slow AGC controller. Ticks on the PipeWire main
// 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.
let agc_controller = AgcController::new(
crate::pw::filter::FILTER_SAMPLE_RATE,
crate::pw::filter::CHANNELS,
measurement_consumer,
filter_control,
daemon_state.clone(),
)
.map_err(DaemonError::from)?;
let agc_controller = Rc::new(RefCell::new(agc_controller));
let agc_timer = {
let agc = agc_controller.clone();
let timer = pw
.main_loop()
.loop_()
.add_timer(move |_| agc.borrow_mut().tick());
let _ = timer.update_timer(Some(AGC_TICK), Some(AGC_TICK));
timer
};
// Subscribe to the registry. New `Stream/Output/Audio` nodes
// matching a routing rule get `target.object` written via the
// `default` metadata; WirePlumber moves them. Bypassed streams
// are left at the user's default sink for v0.
// are pointed directly at preferred_real_sink via the same
// mechanism (see 4h).
pw.start_routing(daemon_state.clone())?;
publish_daemon_started(&daemon_state);
publish_daemon_started(&daemon_state, &pending_warnings, active_missing.as_deref());
pw.run_until_signal()?;
// Drop the AGC timer + controller before exiting `run`, so they
// tear down deterministically alongside the PipeWire context.
drop(agc_timer);
drop(agc_controller);
publish_daemon_shutdown(&daemon_state);
tracing::info!("headroom daemon stopped");
Ok(())
}
fn publish_daemon_started(state: &SharedState) {
fn publish_daemon_started(state: &SharedState, warnings: &[String], active_missing: Option<&str>) {
if let Ok(event) = Event::new(
Topic::Daemon,
"started",
&json!({ "version": env!("CARGO_PKG_VERSION") }),
&json!({
"version": env!("CARGO_PKG_VERSION"),
"warnings": warnings,
"active_missing": active_missing,
}),
) {
state.lock().broadcaster.publish(Topic::Daemon, event);
}

View file

@ -15,12 +15,15 @@ use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use crossbeam_channel::Sender;
use parking_lot::Mutex;
use headroom_ipc::{Route, SinkInfo};
use crate::ipc::broadcast::Broadcaster;
use crate::profile::Profile;
use crate::profile_store::ProfileStore;
use crate::pw::command::PwCommand;
use crate::pw::filter::FilterControl;
/// Per-stream routing decision the daemon has applied (or attempted).
#[derive(Debug, Clone)]
@ -44,12 +47,11 @@ pub struct RoutedStream {
pub struct DaemonState {
/// Daemon start time, for uptime reporting.
pub started_at: Instant,
/// Active profile.
pub profile: Profile,
/// Global bypass — when true, the daemon disables all routing and
/// lets streams default to the system sink. Phase 4c wires the
/// `bypass.set` op into this.
pub bypass_global: bool,
/// Profile store: shipped + user profiles, the user overlay, and
/// the cached effective profile. Replaces the old `profile` +
/// `bypass_global` fields; read via [`ProfileStore::effective`]
/// and [`ProfileStore::bypass_global`], mutated via its setters.
pub profiles: ProfileStore,
/// PipeWire global id of `headroom-processed`, captured when the
/// registry surfaces it. `None` until then.
pub processed_sink_id: Option<u32>,
@ -62,23 +64,69 @@ pub struct DaemonState {
/// IPC subscriber registry + event fan-out. Mutated from any
/// thread that holds the daemon lock.
pub broadcaster: Broadcaster,
/// Control handle for pushing parameter updates to the running
/// filter. `None` between daemon startup and `Filter::create`, and
/// in tests that don't bring up the audio path. Cloned by IPC
/// handlers under the daemon lock, dropped before pushing the
/// command so the daemon lock is never held during an audio-thread
/// hand-off.
pub filter_control: Option<FilterControl>,
/// Sender for commands that must execute on the PipeWire main-loop
/// thread (currently: `route.stream` metadata writes). `None`
/// until `PwContext::start_routing` runs; `None` in tests that
/// don't bring up the PipeWire side. Cloned by IPC handlers under
/// the daemon lock, dropped before send so the lock is never held
/// while crossbeam pushes.
pub pw_command_tx: Option<Sender<PwCommand>>,
}
impl DaemonState {
/// Construct a fresh state seeded with `profile`. `started_at` is
/// stamped at this moment.
/// Construct a fresh state from a [`ProfileStore`]. `started_at`
/// is stamped at this moment.
#[must_use]
pub fn new(profile: Profile) -> Self {
pub fn new(profiles: ProfileStore) -> Self {
Self {
started_at: Instant::now(),
profile,
bypass_global: false,
profiles,
processed_sink_id: None,
real_sink: SinkInfo::default(),
streams: HashMap::new(),
broadcaster: Broadcaster::new(),
filter_control: None,
pw_command_tx: None,
}
}
/// Apply a `default.audio.sink` change observed on the PipeWire
/// metadata to `real_sink`, returning the snapshot of bypass-routed
/// streams that need their `target.object` rewritten to follow the
/// new sink. Returns `None` when the new name matches the
/// already-recorded sink (idempotent no-op).
///
/// PipeWire writes happen *after* the caller drops the daemon lock
/// — this method only touches in-memory state, so it's safe to
/// call while holding the mutex.
pub fn apply_real_sink_change(&mut self, new_name: &str) -> Option<Vec<(u32, String)>> {
if self.real_sink.name.as_deref() == Some(new_name) {
return None;
}
self.real_sink = SinkInfo {
// node_id stays unknown for now — Headroom routes by name
// via `target.object = {"name":"…"}`, which is what
// WirePlumber expects. 4i may resolve the id when ad-hoc
// per-stream overrides need it.
node_id: None,
name: Some(new_name.to_owned()),
ready: true,
};
Some(
self.streams
.values()
.filter(|r| r.route == Route::Bypass)
.map(|r| (r.node_id, r.app.clone()))
.collect(),
)
}
}
/// Cheap-to-clone shared handle.
@ -89,3 +137,68 @@ pub type SharedState = Arc<Mutex<DaemonState>>;
pub fn shared(state: DaemonState) -> SharedState {
Arc::new(Mutex::new(state))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile_store::ProfileStore;
fn state() -> DaemonState {
DaemonState::new(ProfileStore::builtin())
}
fn add_stream(s: &mut DaemonState, node_id: u32, app: &str, route: Route) {
s.streams.insert(
node_id,
RoutedStream {
node_id,
app: app.into(),
route,
},
);
}
#[test]
fn apply_real_sink_change_first_time_returns_empty_retarget_list() {
let mut s = state();
let to_retarget = s.apply_real_sink_change("alsa_output.usb-foo").unwrap();
assert!(to_retarget.is_empty(), "no streams yet — nothing to retarget");
assert_eq!(s.real_sink.name.as_deref(), Some("alsa_output.usb-foo"));
assert!(s.real_sink.ready);
}
#[test]
fn apply_real_sink_change_returns_bypass_streams_only() {
let mut s = state();
// Seed: two streams routed, one bypass, one processed.
add_stream(&mut s, 100, "mpv", Route::Bypass);
add_stream(&mut s, 101, "firefox", Route::Processed);
let mut retarget = s.apply_real_sink_change("alsa_output.usb-foo").unwrap();
retarget.sort_by_key(|(id, _)| *id);
assert_eq!(retarget.len(), 1);
assert_eq!(retarget[0].0, 100);
assert_eq!(retarget[0].1, "mpv");
}
#[test]
fn apply_real_sink_change_idempotent_on_same_name() {
let mut s = state();
add_stream(&mut s, 100, "mpv", Route::Bypass);
assert!(s.apply_real_sink_change("alsa_output.usb-foo").is_some());
assert!(s.apply_real_sink_change("alsa_output.usb-foo").is_none());
}
#[test]
fn apply_real_sink_change_returns_targets_on_subsequent_switches() {
let mut s = state();
add_stream(&mut s, 100, "mpv", Route::Bypass);
add_stream(&mut s, 101, "ardour", Route::Bypass);
s.apply_real_sink_change("speakers").unwrap();
let mut t = s.apply_real_sink_change("headphones").unwrap();
t.sort_by_key(|(id, _)| *id);
assert_eq!(t.len(), 2);
assert_eq!(t[0].0, 100);
assert_eq!(t[1].0, 101);
assert_eq!(s.real_sink.name.as_deref(), Some("headphones"));
}
}

View file

@ -15,5 +15,12 @@ readme = "README.md"
# and is the most reusable piece in the workspace. If you find yourself
# wanting to add a dependency here, think twice.
[dev-dependencies]
criterion = { workspace = true }
[features]
default = []
[[bench]]
name = "layer_a"
harness = false

View file

@ -0,0 +1,130 @@
//! Microbenchmarks for the Layer A (per-app level control) audio-side
//! work. Validates that the costs land within the budget PLAN §4.7
//! cites (~10 μs/quantum audio-thread, ~few μs/measurement
//! daemon-thread).
//!
//! What's measured:
//! - `analysis_scan_stereo_1024` — the per-block peak + mean_sq pass
//! the audio thread runs on each managed stream. This is the only
//! work that touches the RT thread per managed app.
//! - `level_envelopes_process_block` — the post-analysis envelope
//! smoothing the *daemon* thread runs.
//!
//! For reference (so the Layer A numbers can be compared against
//! something we know is on the audio thread today):
//! - `compressor_process_frame` and `limiter_process_frame` —
//! per-sample DSP cost in the processed-route filter chain.
//!
//! Run with `cargo bench -p headroom-dsp --bench layer_a` inside
//! `nix develop`.
use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput};
use headroom_dsp::{
Compressor, CompressorConfig, LevelEnvelopes, LevelEnvelopesConfig, Limiter, LimiterConfig,
};
/// 1024-frame quantum at 48 kHz stereo: 2048 interleaved samples,
/// 21.3 ms per block.
const FRAMES: usize = 1024;
const CHANNELS: usize = 2;
const SR: f32 = 48_000.0;
const BLOCK_DT_S: f32 = FRAMES as f32 / SR;
/// Build a noisy-but-bounded test block. Synthetic — we want
/// realistic-ish range of values so the branch predictors / FPU
/// units exercise the same paths they would on real audio.
fn make_block() -> Vec<f32> {
let mut buf = Vec::with_capacity(FRAMES * CHANNELS);
// Two sine partials + a tiny DC: enough variation that peak isn't
// pegged to one sample and the mean-square isn't trivially zero.
let f1 = 220.0 / SR;
let f2 = 1730.0 / SR;
for n in 0..FRAMES {
let t = n as f32;
let s = 0.4 * (2.0 * std::f32::consts::PI * f1 * t).sin()
+ 0.18 * (2.0 * std::f32::consts::PI * f2 * t).sin()
+ 0.005;
buf.push(s);
buf.push(s * 0.92); // slight L/R difference
}
buf
}
/// What the audio-thread Layer A callback computes per block.
/// Hand-rolled tight loop so the bench measures the candidate code,
/// not stdlib iterator combinators (which the compiler will inline
/// to roughly the same thing — but we want to be honest about it).
#[inline]
fn analysis_scan(samples: &[f32]) -> (f32, f32) {
let mut peak = 0.0_f32;
let mut sumsq = 0.0_f32;
for &s in samples {
let a = s.abs();
if a > peak {
peak = a;
}
sumsq += s * s;
}
let mean_sq = sumsq / samples.len() as f32;
(peak, mean_sq)
}
fn bench_analysis_scan(c: &mut Criterion) {
let block = make_block();
let mut group = c.benchmark_group("layer_a_audio_thread");
group.throughput(Throughput::Elements((FRAMES * CHANNELS) as u64));
group.bench_function("analysis_scan_stereo_1024", |b| {
b.iter(|| {
let (p, m) = analysis_scan(black_box(&block));
black_box((p, m));
});
});
group.finish();
}
fn bench_level_envelopes(c: &mut Criterion) {
let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S);
let block = make_block();
let (peak, mean_sq) = analysis_scan(&block);
let mut group = c.benchmark_group("layer_a_daemon_thread");
group.bench_function("level_envelopes_process_block", |b| {
b.iter(|| {
let d = env.process_block(black_box(peak), black_box(mean_sq));
black_box(d);
});
});
group.finish();
}
fn bench_filter_kernels(c: &mut Criterion) {
// Reference points for "how big is Layer A relative to what
// the realtime filter is already doing." Not a Layer A cost —
// measured here for context.
let mut comp = Compressor::new(CompressorConfig::default(), SR);
let mut lim = Limiter::new(LimiterConfig::default(), SR);
let mut group = c.benchmark_group("filter_reference_per_frame");
group.throughput(Throughput::Elements(1));
group.bench_function("compressor_process_frame", |b| {
b.iter(|| {
let (l, r) = comp.process_frame(black_box(0.3), black_box(-0.2));
black_box((l, r));
});
});
group.bench_function("limiter_process_frame", |b| {
b.iter(|| {
let (l, r) = lim.process_frame(black_box(0.3), black_box(-0.2));
black_box((l, r));
});
});
group.finish();
}
criterion_group!(
benches,
bench_analysis_scan,
bench_level_envelopes,
bench_filter_kernels
);
criterion_main!(benches);

View file

@ -0,0 +1,211 @@
//! Audio-thread piece of the slow AGC.
//!
//! Sits at the head of the DSP chain (before the compressor). Holds a
//! fast anti-zipper smoother that interpolates the per-sample gain
//! toward whatever target the control thread has most recently
//! pushed. The slow musical smoothing of the target itself happens on
//! the control side (`headroom-core::agc`), so this stage only has to
//! suppress the step-change zippering at the boundary between control
//! ticks.
//!
//! `process_frame` is allocation-free and bounded-time. `set_target_db`
//! is also allocation-free and intended to be called from the
//! audio thread once per audio command (drained at the top of every
//! playback callback).
use crate::util::{db_to_lin, time_to_alpha};
/// Configuration for the audio-thread AGC gain stage.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AgcGainConfig {
/// Time constant (ms) for the per-sample smoother that interpolates
/// `current_db` toward `target_db`. Small enough to chase a 50 ms
/// control tick without zippering, large enough not to itself act
/// as a gain-envelope. ~5 ms is a sensible default.
pub anti_zipper_ms: f32,
}
impl Default for AgcGainConfig {
fn default() -> Self {
Self {
anti_zipper_ms: 5.0,
}
}
}
/// Audio-thread AGC gain stage. Two states: when `enabled` is false,
/// the stage is a unity pass-through (still smoothed back to 0 dB).
pub struct AgcGain {
cfg: AgcGainConfig,
sample_rate: f32,
target_db: f32,
current_db: f32,
alpha: f32,
enabled: bool,
}
impl AgcGain {
/// Construct an AGC gain stage. `sample_rate` is the input rate
/// (same as the rest of the DSP chain).
#[must_use]
pub fn new(cfg: AgcGainConfig, sample_rate: f32) -> Self {
Self {
cfg,
sample_rate,
target_db: 0.0,
current_db: 0.0,
alpha: time_to_alpha(cfg.anti_zipper_ms, sample_rate),
enabled: true,
}
}
/// Apply a fresh `target_db` from the control thread.
pub fn set_target_db(&mut self, db: f32) {
if db.is_finite() {
self.target_db = db;
}
}
/// Enable or disable the stage. When disabled, the smoother
/// pushes `target_db` to 0 dB so any active boost/cut unwinds at
/// the anti-zipper rate rather than snapping.
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
if !enabled {
self.target_db = 0.0;
}
}
/// Live-update non-structural parameters.
pub fn set_config(&mut self, cfg: AgcGainConfig) {
self.cfg = cfg;
self.alpha = time_to_alpha(cfg.anti_zipper_ms, self.sample_rate);
}
/// Active configuration.
#[must_use]
pub fn config(&self) -> AgcGainConfig {
self.cfg
}
/// Current smoother state, in dB. The actual gain applied to
/// samples is `10^(current_db / 20)`.
#[must_use]
pub fn current_db(&self) -> f32 {
self.current_db
}
/// Active target_db (latest control-thread command).
#[must_use]
pub fn target_db(&self) -> f32 {
self.target_db
}
/// `true` if the stage is enabled (control commands may move the
/// target away from 0 dB).
#[must_use]
pub fn enabled(&self) -> bool {
self.enabled
}
/// Process one stereo frame: smooth the gain in dB, convert to
/// linear, multiply both channels.
pub fn process_frame(&mut self, l: f32, r: f32) -> (f32, f32) {
self.current_db += self.alpha * (self.target_db - self.current_db);
let gain = db_to_lin(self.current_db);
(l * gain, r * gain)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::lin_to_db;
const SR: f32 = 48_000.0;
#[test]
fn unity_at_zero_db() {
let mut agc = AgcGain::new(AgcGainConfig::default(), SR);
for _ in 0..100 {
let (l, r) = agc.process_frame(0.5, -0.3);
assert!((l - 0.5).abs() < 1e-6);
assert!((r - -0.3).abs() < 1e-6);
}
}
#[test]
fn smooths_toward_target() {
let mut agc = AgcGain::new(AgcGainConfig::default(), SR);
agc.set_target_db(6.0);
// After ~5 ms (one anti-zipper tau), current_db should be in
// the ~63% region.
let samples = (0.005 * SR) as usize;
for _ in 0..samples {
let _ = agc.process_frame(0.0, 0.0);
}
let cur = agc.current_db();
assert!(
(cur - 6.0 * 0.63).abs() < 0.5,
"expected ~3.8 dB after one tau, got {cur}"
);
// Settle.
for _ in 0..(SR as usize) {
let _ = agc.process_frame(0.0, 0.0);
}
assert!((agc.current_db() - 6.0).abs() < 0.01);
}
#[test]
fn applies_gain_to_signal() {
let mut agc = AgcGain::new(AgcGainConfig::default(), SR);
agc.set_target_db(6.0);
// Run long enough to settle.
for _ in 0..(SR as usize) {
let _ = agc.process_frame(0.0, 0.0);
}
let (l, r) = agc.process_frame(0.5, 0.5);
// +6 dB = factor of ~2.0.
assert!((l / 0.5 - 2.0).abs() < 0.05, "got {l}");
assert!((r / 0.5 - 2.0).abs() < 0.05);
}
#[test]
fn disable_unwinds_back_to_unity() {
let mut agc = AgcGain::new(AgcGainConfig::default(), SR);
agc.set_target_db(6.0);
for _ in 0..(SR as usize) {
let _ = agc.process_frame(0.0, 0.0);
}
assert!((agc.current_db() - 6.0).abs() < 0.01);
agc.set_enabled(false);
for _ in 0..(SR as usize) {
let _ = agc.process_frame(0.0, 0.0);
}
assert!(agc.current_db().abs() < 0.01, "got {}", agc.current_db());
}
#[test]
fn rejects_non_finite_target() {
let mut agc = AgcGain::new(AgcGainConfig::default(), SR);
agc.set_target_db(3.0);
agc.set_target_db(f32::NAN);
assert!((agc.target_db() - 3.0).abs() < 1e-6);
agc.set_target_db(f32::INFINITY);
assert!((agc.target_db() - 3.0).abs() < 1e-6);
}
#[test]
fn lin_round_trip_check() {
// Sanity: after settling, gain at target_db should produce
// peak that matches lin_to_db.
let mut agc = AgcGain::new(AgcGainConfig::default(), SR);
agc.set_target_db(-6.0);
for _ in 0..(SR as usize) {
let _ = agc.process_frame(0.0, 0.0);
}
let (l, _) = agc.process_frame(1.0, 1.0);
assert!((lin_to_db(l) - -6.0).abs() < 0.05);
}
}

View file

@ -0,0 +1,427 @@
//! Block-rate level envelopes for Layer A (per-application level
//! control).
//!
//! Implements the two-tier peak + RMS detector described in
//! `PLAN.md` §4. Pure block-rate logic — no PipeWire, no allocation
//! after construction. The audio thread computes `peak = max(|x|)`
//! and `mean_sq = Σx²/N` per block and pushes them into a ring; the
//! daemon thread feeds them to [`LevelEnvelopes::process_block`] and
//! reads the recommended reduction back.
//!
//! Two parallel detectors:
//!
//! - **Peak envelope** — smoothed in dB with separate attack (fast,
//! tens of ms) and release (slow, ~500 ms). Triggers a cut when the
//! envelope crosses `peak_threshold_db`. Catches transient bursts.
//! - **RMS envelope** — smoothed mean-square with a slow time
//! constant (~12 s). Triggers a cut when the smoothed RMS in dB
//! crosses `rms_target_db`. Catches sustained loudness mismatches.
//!
//! Output reduction is `max(peak_reduction, rms_reduction)`, clamped
//! to `max_cut_db`. Recovery is implicit: each envelope releases at
//! its own time constant, so neither path stays engaged once the
//! input drops.
use crate::util::{lin_to_db, time_to_alpha};
/// Per-rule configuration. Mirrors `[per_app.rules]` in the profile
/// schema (PLAN §6).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LevelEnvelopesConfig {
/// Peak envelope threshold (dBFS). Output rises above this →
/// reduce gain. Default 6 dBFS.
pub peak_threshold_db: f32,
/// RMS envelope target (dBFS, equivalent). Smoothed RMS rising
/// above this → reduce gain. Default ≈ 20 dBFS.
pub rms_target_db: f32,
/// Maximum cut the envelopes may request (dB). The signed cap on
/// `max(peak_reduction, rms_reduction)`. Default 12 dB.
pub max_cut_db: f32,
/// Peak envelope attack time (ms). Time for the envelope to
/// approach the input on a rising peak.
pub peak_attack_ms: f32,
/// Peak envelope release time (ms). Time for the envelope to
/// decay back toward silence after the peak drops.
pub peak_release_ms: f32,
/// RMS smoothing window (ms). One-pole time constant on the
/// mean-square input.
pub rms_window_ms: f32,
}
impl Default for LevelEnvelopesConfig {
fn default() -> Self {
Self {
peak_threshold_db: -6.0,
rms_target_db: -20.0,
max_cut_db: 12.0,
peak_attack_ms: 5.0,
peak_release_ms: 500.0,
rms_window_ms: 1500.0,
}
}
}
impl LevelEnvelopesConfig {
/// Sanitize: clamp non-finite values, ensure release > 0, threshold
/// at or below 0 dB.
#[must_use]
pub fn sanitized(mut self) -> Self {
if self.peak_threshold_db > 0.0 {
self.peak_threshold_db = 0.0;
}
if self.rms_target_db > 0.0 {
self.rms_target_db = 0.0;
}
if !self.max_cut_db.is_finite() || self.max_cut_db < 0.0 {
self.max_cut_db = 0.0;
}
for v in [
&mut self.peak_attack_ms,
&mut self.peak_release_ms,
&mut self.rms_window_ms,
] {
if !v.is_finite() || *v < 0.0 {
*v = 0.0;
}
}
self
}
}
/// Two-tier per-stream level detector.
pub struct LevelEnvelopes {
cfg: LevelEnvelopesConfig,
/// Block period (s). Cached so we don't recompute alphas every
/// call when the audio thread holds the quantum steady. Recomputed
/// on `set_block_dt`.
block_dt_s: f32,
peak_attack_alpha: f32,
peak_release_alpha: f32,
rms_alpha: f32,
/// Smoothed peak in dB. Starts at floor so first push doesn't
/// trip the threshold artificially.
peak_env_db: f32,
/// Smoothed mean-square (linear). Starts at 0.
rms_smoothed_mean_sq: f32,
}
/// Result of processing one block.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LevelDecision {
/// dB cut requested by the peak envelope (`0` when not engaged).
pub peak_reduction_db: f32,
/// dB cut requested by the RMS envelope (`0` when not engaged).
pub rms_reduction_db: f32,
/// Combined recommendation: `min(max_cut, max(peak, rms))`.
/// Always `>= 0`; `0` means "no cut, leave channelVolumes alone."
pub total_reduction_db: f32,
}
impl LevelEnvelopes {
/// Construct from a config and the audio thread's nominal block
/// period. The block period (`samples_per_block / sample_rate`)
/// must be small enough that the envelopes track properly; values
/// up to ~100 ms work for v0.
#[must_use]
pub fn new(cfg: LevelEnvelopesConfig, block_dt_s: f32) -> Self {
let cfg = cfg.sanitized();
let (peak_attack_alpha, peak_release_alpha, rms_alpha) = compute_alphas(&cfg, block_dt_s);
Self {
cfg,
block_dt_s,
peak_attack_alpha,
peak_release_alpha,
rms_alpha,
peak_env_db: -200.0,
rms_smoothed_mean_sq: 0.0,
}
}
/// Current configuration.
#[must_use]
pub fn config(&self) -> LevelEnvelopesConfig {
self.cfg
}
/// Block period the alphas were computed against.
#[must_use]
pub fn block_dt_s(&self) -> f32 {
self.block_dt_s
}
/// Update parameters in place. Recomputes alphas; resets neither
/// envelope state (live tweaks don't cause artefacts).
pub fn set_config(&mut self, cfg: LevelEnvelopesConfig) {
let cfg = cfg.sanitized();
let (a_a, a_r, a_rms) = compute_alphas(&cfg, self.block_dt_s);
self.cfg = cfg;
self.peak_attack_alpha = a_a;
self.peak_release_alpha = a_r;
self.rms_alpha = a_rms;
}
/// Update the assumed block period (re-derives alphas). Call when
/// the audio thread's quantum changes.
pub fn set_block_dt(&mut self, dt_s: f32) {
if dt_s <= 0.0 || !dt_s.is_finite() || (dt_s - self.block_dt_s).abs() < 1e-9 {
return;
}
self.block_dt_s = dt_s;
let (a_a, a_r, a_rms) = compute_alphas(&self.cfg, dt_s);
self.peak_attack_alpha = a_a;
self.peak_release_alpha = a_r;
self.rms_alpha = a_rms;
}
/// Process one block. `peak_lin` is the per-block max of
/// absolute samples (linear); `mean_sq_lin` is the per-block
/// `Σx²/N`. Allocation-free.
pub fn process_block(&mut self, peak_lin: f32, mean_sq_lin: f32) -> LevelDecision {
let peak_lin = peak_lin.max(0.0);
let mean_sq_lin = mean_sq_lin.max(0.0);
// Peak envelope in dB. Attack on rising edge, release on
// falling. Use the actual block measurement as the target.
let target_db = lin_to_db(peak_lin);
if target_db > self.peak_env_db {
self.peak_env_db += self.peak_attack_alpha * (target_db - self.peak_env_db);
} else {
self.peak_env_db += self.peak_release_alpha * (target_db - self.peak_env_db);
}
// RMS envelope: smooth mean_sq directly (one alpha) then
// convert to dB. Smoothing in the linear-power domain is the
// canonical R128 / IEC-style RMS detector.
self.rms_smoothed_mean_sq += self.rms_alpha * (mean_sq_lin - self.rms_smoothed_mean_sq);
// 20*log10(sqrt(mean_sq)) = 10*log10(mean_sq).
let rms_db = 10.0 * self.rms_smoothed_mean_sq.max(1e-30).log10();
let peak_reduction_db = (self.peak_env_db - self.cfg.peak_threshold_db).max(0.0);
let rms_reduction_db = (rms_db - self.cfg.rms_target_db).max(0.0);
let combined = peak_reduction_db.max(rms_reduction_db);
let total_reduction_db = combined.min(self.cfg.max_cut_db);
LevelDecision {
peak_reduction_db,
rms_reduction_db,
total_reduction_db,
}
}
/// Reset envelope state. Useful when re-attaching to a stream
/// after a deference period.
pub fn reset(&mut self) {
self.peak_env_db = -200.0;
self.rms_smoothed_mean_sq = 0.0;
}
}
fn compute_alphas(cfg: &LevelEnvelopesConfig, block_dt_s: f32) -> (f32, f32, f32) {
// The smoother is `y[n] = y[n-1] + alpha * (x[n] - y[n-1])`, so a
// larger alpha means a faster smoother. Cache the per-block alpha
// derived from a continuous-time tau.
let block_dt_ms = block_dt_s * 1000.0;
let block_rate = if block_dt_s > 0.0 { 1.0 / block_dt_s } else { 1.0 };
let attack = time_to_alpha(cfg.peak_attack_ms, block_rate);
let release = time_to_alpha(cfg.peak_release_ms, block_rate);
let rms = time_to_alpha(cfg.rms_window_ms, block_rate);
// We use `time_to_alpha` against a *block rate* (Hz), not a
// sample rate, because the smoothers operate at block boundaries
// — the audio thread emits one (peak, mean_sq) pair per block.
// `time_to_alpha` is sample-rate-agnostic: it converts time_ms
// and a rate into alpha. Block rate is just "samples per second"
// where each "sample" is a block.
let _ = block_dt_ms; // currently informational
(attack, release, rms)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::db_to_lin;
/// 1024-frame quantum at 48 kHz.
const BLOCK_DT_S: f32 = 1024.0 / 48_000.0;
fn run_steady(env: &mut LevelEnvelopes, peak_lin: f32, mean_sq_lin: f32, blocks: usize) -> LevelDecision {
let mut last = env.process_block(peak_lin, mean_sq_lin);
for _ in 1..blocks {
last = env.process_block(peak_lin, mean_sq_lin);
}
last
}
#[test]
fn quiet_signal_produces_no_reduction() {
let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S);
let quiet = db_to_lin(-30.0);
let mean_sq = quiet * quiet;
let dec = run_steady(&mut env, quiet, mean_sq, 200);
assert_eq!(dec.peak_reduction_db, 0.0);
assert_eq!(dec.rms_reduction_db, 0.0);
assert_eq!(dec.total_reduction_db, 0.0);
}
#[test]
fn peak_above_threshold_requests_cut() {
let cfg = LevelEnvelopesConfig {
peak_threshold_db: -6.0,
// Long RMS window so the slow path doesn't dominate.
rms_target_db: 0.0,
rms_window_ms: 5_000.0,
..Default::default()
};
let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S);
// 0 dBFS peak: 6 dB over threshold.
let peak = db_to_lin(0.0);
let mean_sq = (peak * peak) * 0.05; // low rms (intermittent peak)
let dec = run_steady(&mut env, peak, mean_sq, 200);
assert!(
(dec.peak_reduction_db - 6.0).abs() < 0.5,
"expected ~6 dB peak cut, got {}",
dec.peak_reduction_db
);
assert_eq!(dec.rms_reduction_db, 0.0);
assert!((dec.total_reduction_db - 6.0).abs() < 0.5);
}
#[test]
fn rms_above_target_requests_cut() {
let cfg = LevelEnvelopesConfig {
// Push peak threshold up so only RMS engages.
peak_threshold_db: 0.0,
rms_target_db: -20.0,
rms_window_ms: 200.0, // shorter so test converges quickly
..Default::default()
};
let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S);
// Sustained -10 dBFS RMS: 10 dB above target.
let rms_lin = db_to_lin(-10.0);
let mean_sq = rms_lin * rms_lin;
// Peak set just below threshold so peak detector stays asleep.
let peak = db_to_lin(-1.0);
let dec = run_steady(&mut env, peak, mean_sq, 200);
assert_eq!(dec.peak_reduction_db, 0.0);
assert!(
(dec.rms_reduction_db - 10.0).abs() < 0.5,
"expected ~10 dB RMS cut, got {}",
dec.rms_reduction_db
);
}
#[test]
fn combined_takes_max_of_peak_and_rms() {
let cfg = LevelEnvelopesConfig {
peak_threshold_db: -6.0,
rms_target_db: -20.0,
rms_window_ms: 200.0,
max_cut_db: 100.0,
..Default::default()
};
let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S);
let peak = db_to_lin(0.0); // 6 dB over
let rms_lin = db_to_lin(-10.0); // 10 dB over
let mean_sq = rms_lin * rms_lin;
let dec = run_steady(&mut env, peak, mean_sq, 200);
assert!((dec.peak_reduction_db - 6.0).abs() < 0.5);
assert!((dec.rms_reduction_db - 10.0).abs() < 0.5);
assert!(
(dec.total_reduction_db - 10.0).abs() < 0.5,
"max(6, 10) ≈ 10, got {}",
dec.total_reduction_db
);
}
#[test]
fn total_reduction_is_clamped_to_max_cut_db() {
let cfg = LevelEnvelopesConfig {
peak_threshold_db: -30.0,
rms_target_db: -30.0,
rms_window_ms: 50.0,
max_cut_db: 3.0, // tight cap
..Default::default()
};
let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S);
let peak = db_to_lin(0.0); // 30 dB over
let rms_lin = db_to_lin(-5.0);
let mean_sq = rms_lin * rms_lin;
let dec = run_steady(&mut env, peak, mean_sq, 200);
assert!(dec.peak_reduction_db > 20.0);
assert!(
(dec.total_reduction_db - 3.0).abs() < 1e-3,
"total clamped to max_cut_db, got {}",
dec.total_reduction_db
);
}
#[test]
fn peak_envelope_releases_after_burst() {
let cfg = LevelEnvelopesConfig {
peak_threshold_db: -6.0,
rms_target_db: 0.0,
rms_window_ms: 5_000.0,
peak_attack_ms: 5.0,
peak_release_ms: 100.0,
..Default::default()
};
let mut env = LevelEnvelopes::new(cfg, BLOCK_DT_S);
// Burst.
for _ in 0..20 {
env.process_block(db_to_lin(0.0), 0.0);
}
let burst = env.process_block(db_to_lin(0.0), 0.0);
assert!(burst.peak_reduction_db > 5.0);
// Silence.
for _ in 0..200 {
env.process_block(0.0, 0.0);
}
let quiet = env.process_block(0.0, 0.0);
assert!(
quiet.peak_reduction_db < 0.5,
"expected ~0 after release, got {}",
quiet.peak_reduction_db
);
}
#[test]
fn set_config_updates_alphas_without_reset() {
let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S);
for _ in 0..100 {
env.process_block(db_to_lin(-3.0), 0.0);
}
let before = env.process_block(db_to_lin(-3.0), 0.0);
// Tighter threshold; envelope state preserved across the swap.
env.set_config(LevelEnvelopesConfig {
peak_threshold_db: -12.0,
..LevelEnvelopesConfig::default()
});
let after = env.process_block(db_to_lin(-3.0), 0.0);
assert!(
after.peak_reduction_db > before.peak_reduction_db,
"tighter threshold should request more cut"
);
}
#[test]
fn set_block_dt_recomputes_alphas() {
let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S);
let original_attack = env.peak_attack_alpha;
// Double the block period — slower block rate → smaller alpha
// for the same time constant.
env.set_block_dt(BLOCK_DT_S * 2.0);
assert!(env.peak_attack_alpha > original_attack);
}
#[test]
fn reset_returns_to_idle_state() {
let mut env = LevelEnvelopes::new(LevelEnvelopesConfig::default(), BLOCK_DT_S);
for _ in 0..200 {
env.process_block(db_to_lin(0.0), db_to_lin(-3.0));
}
env.reset();
let dec = env.process_block(0.0, 0.0);
assert_eq!(dec.peak_reduction_db, 0.0);
assert_eq!(dec.rms_reduction_db, 0.0);
}
}

View file

@ -9,17 +9,21 @@
#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod agc;
mod compressor;
mod delay;
mod envelope;
mod level_envelopes;
mod limiter;
mod oversample;
mod sliding_max;
pub mod util;
pub use agc::{AgcGain, AgcGainConfig};
pub use compressor::{Compressor, CompressorConfig, Detector};
pub use delay::DelayLine;
pub use envelope::AttackRelease;
pub use limiter::{Limiter, LimiterConfig, SoftTierConfig};
pub use level_envelopes::{LevelDecision, LevelEnvelopes, LevelEnvelopesConfig};
pub use limiter::{Limiter, LimiterConfig, SetConfigOutcome, SoftTierConfig};
pub use oversample::{design_lowpass_blackman, PolyphaseDownsampler, PolyphaseUpsampler};
pub use sliding_max::SlidingMaxBuffer;

View file

@ -174,9 +174,28 @@ impl LimiterConfig {
const MAX_OVERSAMPLE: usize = 8;
/// Result of attempting to live-apply a [`LimiterConfig`].
///
/// Returned by [`Limiter::try_set_config`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SetConfigOutcome {
/// Config applied in place; the limiter is now running with the
/// new parameters.
Applied,
/// The new config differs in `oversample`, `fir_taps`, or
/// `lookahead_ms` (rounded to samples), and would require
/// reallocating internal buffers. The limiter is unchanged;
/// rebuild it from `Limiter::new` on the control thread.
StructuralChange,
}
/// Two-tier feed-forward true-peak limiter.
pub struct Limiter {
cfg: LimiterConfig,
/// Input sample rate, captured at construction. Kept so live
/// reconfiguration ([`Limiter::try_set_config`]) can recompute
/// time-based coefficients without callers having to repass it.
sample_rate: f32,
ceiling_lin: f32,
os: usize,
@ -259,6 +278,7 @@ impl Limiter {
let mut me = Self {
cfg,
sample_rate,
ceiling_lin,
os,
up_l: PolyphaseUpsampler::new(os, &lowpass),
@ -347,6 +367,68 @@ impl Limiter {
self.cfg.soft.map(|_| lin_to_db(self.soft_ceiling_lin))
}
/// Live-update non-structural parameters.
///
/// Applies changes that don't require reallocating internal
/// buffers: ceiling, hard-tier release/hold, soft-tier toggle and
/// scalars. Allocation-free; safe to call on the realtime audio
/// thread.
///
/// Structural changes — `oversample`, `lookahead_ms` (when the
/// rounded sample count differs from the current one), or
/// `fir_taps` — cannot be applied in place because they would
/// resize FIR coefficient tables, polyphase state, the delay line,
/// or the sliding peak buffer. The method returns
/// [`SetConfigOutcome::StructuralChange`] in that case and the
/// limiter is left unchanged; the caller is expected to rebuild
/// the [`Limiter`] from `Limiter::new` on the control thread.
pub fn try_set_config(&mut self, cfg: LimiterConfig) -> SetConfigOutcome {
let cfg = cfg.sanitized();
let os_rate = self.sample_rate * cfg.oversample as f32;
let new_lookahead_samples_os =
((cfg.lookahead_ms * 1e-3 * os_rate).round() as usize).max(1);
let cur_lookahead_samples_os = self.peak_buf.window();
if cfg.oversample != self.os
|| cfg.fir_taps != self.cfg.fir_taps
|| new_lookahead_samples_os != cur_lookahead_samples_os
{
return SetConfigOutcome::StructuralChange;
}
self.ceiling_lin = db_to_lin(cfg.ceiling_dbtp);
self.hard_release_alpha = time_to_alpha(cfg.release_ms, os_rate);
self.hold_samples_os = (cfg.hold_ms * 1e-3 * os_rate).round() as u32;
match (cfg.soft, self.cfg.soft) {
(Some(new_soft), Some(_old_soft)) => {
self.soft_max_psr_db = new_soft.max_psr_db;
self.soft_static_ceiling_lin = db_to_lin(new_soft.static_ceiling_dbtp);
if let Some(env) = &mut self.soft_envelope {
env.set_times(new_soft.attack_ms, new_soft.release_ms, os_rate);
}
}
(Some(new_soft), None) => {
// Re-enable the soft tier. Seed the envelope to unity
// so we don't start with phantom gain reduction.
let mut env = AttackRelease::new(new_soft.attack_ms, new_soft.release_ms, os_rate);
env.reset(1.0);
self.soft_envelope = Some(env);
self.soft_max_psr_db = new_soft.max_psr_db;
self.soft_static_ceiling_lin = db_to_lin(new_soft.static_ceiling_dbtp);
}
(None, Some(_)) => {
self.soft_envelope = None;
self.soft_max_psr_db = 0.0;
self.soft_static_ceiling_lin = 1.0;
}
(None, None) => {}
}
self.cfg = cfg;
self.recompute_soft_ceiling();
SetConfigOutcome::Applied
}
/// Update the program loudness used to compute the dynamic soft
/// ceiling. Typically called by the AGC at its tick rate with the
/// short-term BS.1770 loudness; non-finite values are ignored.
@ -533,6 +615,80 @@ mod tests {
use super::*;
use std::f32::consts::PI;
// ----------------------------------------------------------------
// try_set_config: scalar updates apply in place, structural
// changes are rejected.
// ----------------------------------------------------------------
#[test]
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;
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied);
assert!((l.ceiling_dbtp() - -3.0).abs() < 1e-6);
let active = l.config();
assert!((active.release_ms - 200.0).abs() < 1e-6);
assert!((active.hold_ms - 10.0).abs() < 1e-6);
}
#[test]
fn try_set_config_can_toggle_soft_tier() {
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;
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied);
assert!(l.config().soft.is_none());
assert!(l.effective_soft_ceiling_dbtp().is_none());
// Re-enable with custom params.
let new_soft = SoftTierConfig {
max_psr_db: 10.0,
static_ceiling_dbtp: -4.0,
attack_ms: 8.0,
release_ms: 300.0,
};
cfg.soft = Some(new_soft);
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied);
let active_soft = l.config().soft.expect("soft re-enabled");
assert!((active_soft.max_psr_db - 10.0).abs() < 1e-6);
assert!((active_soft.static_ceiling_dbtp - -4.0).abs() < 1e-6);
}
#[test]
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;
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);
// Limiter unchanged.
assert_eq!(l.config().oversample, LimiterConfig::default().oversample);
}
#[test]
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
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);
}
#[test]
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;
assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);
}
fn run_sine(
limiter: &mut Limiter,
freq: f32,

View file

@ -360,6 +360,13 @@ pub struct Status {
pub sinks: Sinks,
/// Currently-tracked playback streams.
pub streams: Vec<StreamRoute>,
/// Non-fatal warnings the daemon wants operators to see —
/// typically from profile loading (TOML parse errors on a single
/// file, the active profile name pointing at something not on
/// disk, ...). Reflects the state as of the last successful
/// profile load or reload. Empty in the healthy case.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
/// Sink-side of `Status`.