stage 6: per-app
This commit is contained in:
parent
9edd809416
commit
fcf421b94c
31 changed files with 6360 additions and 344 deletions
|
|
@ -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
|
||||
|
|
|
|||
78
crates/headroom-core/benches/app_level.rs
Normal file
78
crates/headroom-core/benches/app_level.rs
Normal 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);
|
||||
337
crates/headroom-core/src/agc.rs
Normal file
337
crates/headroom-core/src/agc.rs
Normal 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 5–20 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);
|
||||
}
|
||||
}
|
||||
551
crates/headroom-core/src/app_level.rs
Normal file
551
crates/headroom-core/src/app_level.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
1084
crates/headroom-core/src/profile_store.rs
Normal file
1084
crates/headroom-core/src/profile_store.rs
Normal file
File diff suppressed because it is too large
Load diff
178
crates/headroom-core/src/profile_watcher.rs
Normal file
178
crates/headroom-core/src/profile_watcher.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
56
crates/headroom-core/src/pw/command.rs
Normal file
56
crates/headroom-core/src/pw/command.rs
Normal 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 (5–20 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,
|
||||
},
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
283
crates/headroom-core/src/pw/tap.rs
Normal file
283
crates/headroom-core/src/pw/tap.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
130
crates/headroom-dsp/benches/layer_a.rs
Normal file
130
crates/headroom-dsp/benches/layer_a.rs
Normal 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);
|
||||
211
crates/headroom-dsp/src/agc.rs
Normal file
211
crates/headroom-dsp/src/agc.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
427
crates/headroom-dsp/src/level_envelopes.rs
Normal file
427
crates/headroom-dsp/src/level_envelopes.rs
Normal 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 (~1–2 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue