atagen

headroom

public
Star0
Watch0

Commit 65250d1c123c

tarball

atagen <boss@atagen.co>2026-06-04 03:27 UTC

24 files changed+7909-0
core: pipewire daemon with bus filter, routing, dsp, meters, ipc server
Added file: crates/headroom-core/Cargo.toml+55 additions
@@ -0,0 +1,55 @@
1+[package]
2+name = "headroom-core"
3+description = "automatic loudness and per-app volume control (pipewire)"
4+version.workspace = true
5+edition.workspace = true
6+license.workspace = true
7+homepage.workspace = true
8+repository.workspace = true
9+authors.workspace = true
10+
11+[dependencies]
12+headroom-dsp = { workspace = true }
13+headroom-ipc = { workspace = true }
14+
15+serde = { workspace = true }
16+serde_json = { workspace = true }
17+toml = { workspace = true }
18+thiserror = { workspace = true }
19+tracing = { workspace = true }
20+tracing-subscriber = { workspace = true }
21+crossbeam-channel = { workspace = true }
22+parking_lot = { workspace = true }
23+signal-hook = { workspace = true }
24+nix = { workspace = true }
25+
26+pipewire = { workspace = true }
27+libspa = { workspace = true }
28+libspa-sys = { workspace = true }
29+# own crate: headroom-core forbids unsafe, pipewire-rs 0.8 has no `Filter` API
30+pipewire-filter = { workspace = true }
31+
32+rtrb = { workspace = true }
33+bytemuck = { workspace = true }
34+# basedrop = { workspace = true }
35+
36+notify = { workspace = true }
37+notify-debouncer-mini = { workspace = true }
38+
39+ebur128 = { workspace = true }
40+
41+# debug: panics on alloc inside `assert_no_alloc!`; release: no-op. wraps each `process` callback
42+assert_no_alloc = { workspace = true }
43+
44+# tracing-journald = { workspace = true }
45+
46+[dev-dependencies]
47+criterion = { workspace = true }
48+headroom-client = { workspace = true }
49+
50+[features]
51+default = []
52+
53+[[bench]]
54+name = "app_level"
55+harness = false
Added file: crates/headroom-core/src/agc.rs+543 additions
@@ -0,0 +1,543 @@
1+//! control-thread piece of the slow agc; not spike-reactive (time constants are seconds)
2+
3+use std::time::Duration;
4+
5+use ebur128::{EbuR128, Mode};
6+use headroom_ipc::{Event, MeterTick, Topic};
7+
8+use crate::meters::SharedBusMetrics;
9+use crate::pw::filter::FilterControl;
10+use crate::state::SharedState;
11+
12+/// hardcoded for v0; not a profile knob
13+pub const AGC_TICK: Duration = Duration::from_millis(50);
14+
15+/// ~50 ms of stereo at 48k (4800 samples) plus slack; bounds `ebur128.add_frames_f32` work
16+const TICK_BUF_SAMPLES: usize = 8192;
17+
18+/// "no usable measurement yet" — `ebur128` before its window fills, or during silence. published
19+/// verbatim in `MeterTick.*_lufs` so clients can recognise it without hardcoding the number.
20+pub const LOUDNESS_FLOOR_LUFS: f32 = -200.0;
21+
22+pub struct AgcController {
23+ sample_rate: u32,
24+ channels: u32,
25+ ebu: EbuR128,
26+ measurement_consumer: rtrb::Consumer<f32>,
27+ filter_control: FilterControl,
28+ daemon: SharedState,
29+ smoothed_target_db: f32,
30+ /// cached so we push the audio-thread enable flag exactly on transition
31+ last_enabled: bool,
32+ last_short_term_lufs: f32,
33+ /// last successfully queued limiter program loudness
34+ last_program_lufs: Option<f32>,
35+ bus_metrics: SharedBusMetrics,
36+ /// wraps freely
37+ meter_tick_counter: u32,
38+ timing: crate::meters::SharedPlaybackTiming,
39+ /// previous values, for computing per-log deltas (only warn on new events)
40+ last_logged_spike_count: u64,
41+ last_logged_starved: u64,
42+ last_logged_dropped: u64,
43+ last_logged_format_errors: u64,
44+ timing_log_counter: u32,
45+}
46+
47+impl AgcController {
48+ pub fn new(
49+ sample_rate: u32,
50+ channels: u32,
51+ measurement_consumer: rtrb::Consumer<f32>,
52+ filter_control: FilterControl,
53+ daemon: SharedState,
54+ bus_metrics: SharedBusMetrics,
55+ timing: crate::meters::SharedPlaybackTiming,
56+ ) -> Result<Self, AgcInitError> {
57+ // Mode::I costs a histogram walk per `loudness_global()` — bounded, fine at 20 Hz.
58+ // lets the `meters` topic surface integrated lufs without a second ebur128 instance.
59+ let ebu = EbuR128::new(
60+ channels,
61+ sample_rate,
62+ Mode::S | Mode::M | Mode::I | Mode::TRUE_PEAK,
63+ )
64+ .map_err(AgcInitError::from)?;
65+ Ok(Self {
66+ sample_rate,
67+ channels,
68+ ebu,
69+ measurement_consumer,
70+ filter_control,
71+ daemon,
72+ smoothed_target_db: 0.0,
73+ last_enabled: true,
74+ last_short_term_lufs: LOUDNESS_FLOOR_LUFS,
75+ last_program_lufs: None,
76+ bus_metrics,
77+ meter_tick_counter: 0,
78+ timing,
79+ last_logged_spike_count: 0,
80+ last_logged_starved: 0,
81+ last_logged_dropped: 0,
82+ last_logged_format_errors: 0,
83+ timing_log_counter: 0,
84+ })
85+ }
86+
87+ /// `LOUDNESS_FLOOR_LUFS` before the short-term window fills.
88+ #[must_use]
89+ pub fn last_short_term_lufs(&self) -> f32 {
90+ self.last_short_term_lufs
91+ }
92+
93+ #[must_use]
94+ pub fn current_target_db(&self) -> f32 {
95+ self.smoothed_target_db
96+ }
97+
98+ /// one control-loop iteration; invoked at [`AGC_TICK`] cadence by a main-loop timer.
99+ pub fn tick(&mut self) {
100+ // snapshot config out from under the lock
101+ let (cfg, compressor_enabled, publish_hz) = {
102+ let s = self.daemon.lock();
103+ let p = s.profiles.effective();
104+ (p.agc.clone(), p.compressor.enabled, p.meters.publish_hz)
105+ };
106+
107+ if cfg.enabled != self.last_enabled {
108+ self.filter_control.set_agc_enabled(cfg.enabled);
109+ self.last_enabled = cfg.enabled;
110+ }
111+
112+ // unconditional so `meters` keeps surfacing lufs even with only compressor/limiter on
113+ self.consume_measurements();
114+ let short_term = finite_or_floor(
115+ self.ebu.loudness_shortterm().map(|v| v as f32).ok(),
116+ );
117+ self.last_short_term_lufs = short_term;
118+
119+ if cfg.enabled
120+ && short_term > cfg.silence_threshold_lufs
121+ && short_term.is_finite()
122+ {
123+ let raw_target = cfg.target_lufs - short_term;
124+ let clamped = raw_target.clamp(-cfg.max_cut_db, cfg.max_boost_db);
125+
126+ // leaky integrator: attack when target drops, release when it rises
127+ let dt_ms = AGC_TICK.as_secs_f32() * 1000.0;
128+ let alpha = if clamped < self.smoothed_target_db {
129+ alpha_for_dt(cfg.attack_ms, dt_ms)
130+ } else {
131+ alpha_for_dt(cfg.release_ms, dt_ms)
132+ };
133+ self.smoothed_target_db += alpha * (clamped - self.smoothed_target_db);
134+ self.filter_control
135+ .set_agc_target_db(self.smoothed_target_db);
136+ }
137+
138+ // limiter sees post-agc loudness; ebur128 measures pre-agc
139+ let program_lufs = if short_term.is_finite() && short_term > cfg.silence_threshold_lufs {
140+ let makeup_db = if cfg.enabled { self.smoothed_target_db } else { 0.0 };
141+ Some(short_term + makeup_db)
142+ } else {
143+ None
144+ };
145+ self.push_program_loudness(program_lufs);
146+
147+ self.publish_meters(publish_hz, cfg.enabled, compressor_enabled);
148+ self.log_playback_timing();
149+ }
150+
151+ fn push_program_loudness(&mut self, program_lufs: Option<f32>) {
152+ const EPSILON_DB: f32 = 0.1;
153+ let changed = match (self.last_program_lufs, program_lufs) {
154+ (Some(prev), Some(next)) => (prev - next).abs() >= EPSILON_DB,
155+ (None, None) => false,
156+ _ => true,
157+ };
158+ if changed && self.filter_control.set_program_loudness_lufs(program_lufs) {
159+ self.last_program_lufs = program_lufs;
160+ }
161+ }
162+
163+ /// throttled (~1 Hz) log of playback callback timing; lock-free atomic loads.
164+ fn log_playback_timing(&mut self) {
165+ // 20 Hz tick → every 20 ticks ≈ 1 Hz
166+ self.timing_log_counter = self.timing_log_counter.wrapping_add(1);
167+ if self.timing_log_counter % 20 != 0 {
168+ return;
169+ }
170+ let snap = self.timing.snapshot();
171+ if snap.call_count == 0 {
172+ return;
173+ }
174+ let avg_us = snap.sum_us / snap.call_count.max(1);
175+ let new_spikes = snap.spike_count.saturating_sub(self.last_logged_spike_count);
176+ self.last_logged_spike_count = snap.spike_count;
177+ let new_starved = snap.samples_starved.saturating_sub(self.last_logged_starved);
178+ self.last_logged_starved = snap.samples_starved;
179+ let new_dropped = snap.samples_dropped.saturating_sub(self.last_logged_dropped);
180+ self.last_logged_dropped = snap.samples_dropped;
181+ if new_spikes > 0 {
182+ tracing::warn!(
183+ avg_us,
184+ max_us = snap.max_us,
185+ new_spikes,
186+ total_spikes = snap.spike_count,
187+ last_spike_us = snap.last_spike_us,
188+ last_spike_at_call = snap.last_spike_at_call,
189+ call_count = snap.call_count,
190+ "playback callback BUSY spike(s) since last log"
191+ );
192+ } else {
193+ tracing::debug!(
194+ avg_us,
195+ max_us = snap.max_us,
196+ call_count = snap.call_count,
197+ "playback callback timing"
198+ );
199+ }
200+ // ring-imbalance diagnostic: steady-state is all zeros. non-zero delta = capture→playback
201+ // ring drained/stuffed within a quantum (the "tremolo every quantum" mechanism). warn so
202+ // it shows at default tracing level.
203+ if new_starved > 0 || new_dropped > 0 {
204+ tracing::warn!(
205+ new_starved,
206+ total_starved = snap.samples_starved,
207+ new_dropped,
208+ total_dropped = snap.samples_dropped,
209+ call_count = snap.call_count,
210+ "filter ring imbalance — playback zero-filled and/or capture dropped samples"
211+ );
212+ }
213+ // rt format errors are logged here to keep the callback allocation-free
214+ let new_format_errors = snap.format_errors.saturating_sub(self.last_logged_format_errors);
215+ self.last_logged_format_errors = snap.format_errors;
216+ if new_format_errors > 0 {
217+ tracing::warn!(
218+ new_format_errors,
219+ total_format_errors = snap.format_errors,
220+ "filter skipped short/misaligned audio buffer(s)"
221+ );
222+ }
223+ }
224+
225+ fn consume_measurements(&mut self) {
226+ let mut buf = [0.0_f32; TICK_BUF_SAMPLES];
227+ let mut n = 0;
228+ while n < buf.len() {
229+ match self.measurement_consumer.pop() {
230+ Ok(s) => {
231+ buf[n] = s;
232+ n += 1;
233+ }
234+ Err(_) => break,
235+ }
236+ }
237+ if n == 0 {
238+ return;
239+ }
240+ // ebur128 wants whole frames; drop any odd trailing sample.
241+ let usable = (n / self.channels as usize) * self.channels as usize;
242+ if usable == 0 {
243+ return;
244+ }
245+ if let Err(e) = self.ebu.add_frames_f32(&buf[..usable]) {
246+ tracing::warn!(error = %e, "ebur128 add_frames_f32 failed");
247+ }
248+ }
249+
250+ fn publish_meters(&mut self, publish_hz: f32, agc_enabled: bool, compressor_enabled: bool) {
251+ if !self.should_publish(publish_hz) {
252+ return;
253+ }
254+ let bus = *self.bus_metrics.lock();
255+ // ebur128 returns -inf (not Err) for "no measurement yet"; serde_json renders non-finite
256+ // f32 as null, so floor here.
257+ let momentary = finite_or_floor(
258+ self.ebu.loudness_momentary().map(|v| v as f32).ok(),
259+ );
260+ let integrated = finite_or_floor(
261+ self.ebu.loudness_global().map(|v| v as f32).ok(),
262+ );
263+
264+ let tick = MeterTick {
265+ momentary_lufs: momentary,
266+ shortterm_lufs: self.last_short_term_lufs,
267+ integrated_lufs: integrated,
268+ true_peak_dbtp: bus.true_peak_dbtp,
269+ // path GR is additive in log domain; both ≤ 0 dB when reducing
270+ gain_reduction_db: bus.compressor_gr_db + bus.limiter_total_gr_db,
271+ compressor_gr_db: bus.compressor_gr_db,
272+ limiter_gr_db: bus.limiter_total_gr_db,
273+ agc_gain_db: self.smoothed_target_db,
274+ agc_enabled,
275+ compressor_enabled,
276+ };
277+
278+ if let Ok(event) = Event::new(Topic::Meters, "tick", &tick) {
279+ self.daemon.lock().broadcaster.publish(Topic::Meters, event);
280+ }
281+ }
282+
283+ /// tick-rate gate for `meters`; caps at [`AGC_TICK`]'s 20 Hz, higher `publish_hz` clamped.
284+ fn should_publish(&mut self, publish_hz: f32) -> bool {
285+ if publish_hz <= 0.0 {
286+ return false;
287+ }
288+ let agc_hz = 1000.0 / AGC_TICK.as_millis() as f32;
289+ if publish_hz >= agc_hz {
290+ self.meter_tick_counter = self.meter_tick_counter.wrapping_add(1);
291+ return true;
292+ }
293+ let skip = (agc_hz / publish_hz).round().max(1.0) as u32;
294+ let now = self.meter_tick_counter;
295+ self.meter_tick_counter = self.meter_tick_counter.wrapping_add(1);
296+ now % skip == 0
297+ }
298+
299+ pub fn reset(&mut self) {
300+ self.smoothed_target_db = 0.0;
301+ self.last_short_term_lufs = LOUDNESS_FLOOR_LUFS;
302+ self.last_program_lufs = None;
303+ // ebur128 has no public reset; rebuild with the same mode set as new()
304+ if let Ok(fresh) = EbuR128::new(
305+ self.channels,
306+ self.sample_rate,
307+ Mode::S | Mode::M | Mode::I | Mode::TRUE_PEAK,
308+ ) {
309+ self.ebu = fresh;
310+ }
311+ self.filter_control.set_agc_target_db(0.0);
312+ self.filter_control.set_program_loudness_lufs(None);
313+ }
314+
315+ /// rebind to a freshly-built filter after rate-change. old consumer/control point at rtrbs
316+ /// whose producers were just dropped (sends would fail), so swap handles + rebuild ebur128 at
317+ /// the new rate.
318+ pub fn rebind(
319+ &mut self,
320+ measurement_consumer: rtrb::Consumer<f32>,
321+ filter_control: FilterControl,
322+ sample_rate: u32,
323+ ) {
324+ self.measurement_consumer = measurement_consumer;
325+ self.filter_control = filter_control;
326+ self.sample_rate = sample_rate;
327+ self.reset();
328+ }
329+}
330+
331+/// -inf (ebur128 "no reading" sentinel) and NaN collapse to [`LOUDNESS_FLOOR_LUFS`].
332+fn finite_or_floor(v: Option<f32>) -> f32 {
333+ match v {
334+ Some(x) if x.is_finite() => x,
335+ _ => LOUDNESS_FLOOR_LUFS,
336+ }
337+}
338+
339+/// leaky-integrator alpha: `1 - exp(-dt / tau)`, clamped to `[0, 1]`.
340+fn alpha_for_dt(tau_ms: f32, dt_ms: f32) -> f32 {
341+ if tau_ms <= 0.0 || dt_ms <= 0.0 {
342+ return 1.0;
343+ }
344+ (1.0 - (-dt_ms / tau_ms).exp()).clamp(0.0, 1.0)
345+}
346+
347+/// construction-time failures only; tick-time errors are logged and the tick skipped.
348+#[derive(Debug, thiserror::Error)]
349+pub enum AgcInitError {
350+ #[error("ebur128: {0}")]
351+ Ebu(#[from] ebur128::Error),
352+}
353+
354+impl From<AgcInitError> for crate::error::DaemonError {
355+ fn from(e: AgcInitError) -> Self {
356+ crate::error::DaemonError::other(format!("agc init: {e}"))
357+ }
358+}
359+
360+#[cfg(test)]
361+mod tests {
362+ use super::*;
363+ use crate::meters;
364+ use crate::profile_store::ProfileStore;
365+ use crate::pw::filter::{AudioCmd, FilterControl};
366+ use crate::state::{self, DaemonState};
367+ use rtrb::RingBuffer;
368+
369+ const SR: u32 = 48_000;
370+ const CH: u32 = 2;
371+
372+ fn fixture() -> (
373+ AgcController,
374+ rtrb::Producer<f32>,
375+ rtrb::Consumer<AudioCmd>,
376+ SharedState,
377+ SharedBusMetrics,
378+ ) {
379+ let (m_prod, m_cons) = RingBuffer::<f32>::new(8192);
380+ let (control, cmd_cons) = FilterControl::for_testing(32);
381+ let state = state::shared(DaemonState::new(ProfileStore::builtin()));
382+ let bus = meters::shared();
383+ let timing = meters::shared_timing();
384+ let agc = AgcController::new(
385+ SR,
386+ CH,
387+ m_cons,
388+ control,
389+ state.clone(),
390+ bus.clone(),
391+ timing,
392+ )
393+ .unwrap();
394+ (agc, m_prod, cmd_cons, state, bus)
395+ }
396+
397+ fn push_silence(prod: &mut rtrb::Producer<f32>, frames: usize) {
398+ for _ in 0..frames {
399+ let _ = prod.push(0.0);
400+ let _ = prod.push(0.0);
401+ }
402+ }
403+
404+ fn push_sine(prod: &mut rtrb::Producer<f32>, frames: usize, amp: f32) {
405+ // Constant amplitude impulse-like — not a real sine but it
406+ // produces a measurable loudness in ebur128 well above silence.
407+ for _ in 0..frames {
408+ let _ = prod.push(amp);
409+ let _ = prod.push(-amp);
410+ }
411+ }
412+
413+ #[test]
414+ fn tick_with_no_samples_does_nothing() {
415+ let (mut agc, _prod, mut cmd_cons, _state, _bus) = fixture();
416+ agc.tick();
417+ assert!(cmd_cons.pop().is_err(), "no samples → no target push");
418+ assert_eq!(agc.current_target_db(), 0.0);
419+ }
420+
421+ #[test]
422+ fn tick_under_silence_threshold_holds_target() {
423+ let (mut agc, mut prod, mut cmd_cons, _state, _bus) = fixture();
424+ push_silence(&mut prod, 4800); // 100ms of silence
425+ agc.tick();
426+ // ebur128 may report -inf or values below the silence
427+ // threshold; either way we should not push.
428+ assert!(
429+ cmd_cons.pop().is_err(),
430+ "below silence threshold — no target push expected"
431+ );
432+ }
433+
434+ #[test]
435+ fn tick_with_audible_signal_pushes_target() {
436+ let (mut agc, mut prod, mut cmd_cons, _state, _bus) = fixture();
437+ // Pump multiple ticks worth so ebur128's short-term window
438+ // (~3 s) starts producing values.
439+ for _ in 0..40 {
440+ push_sine(&mut prod, 4800, 0.3);
441+ agc.tick();
442+ }
443+ // We expect at least one SetAgcTargetDb to have been pushed
444+ // once short-term loudness became finite.
445+ let mut saw = false;
446+ while let Ok(cmd) = cmd_cons.pop() {
447+ if matches!(cmd, AudioCmd::SetAgcTargetDb(_)) {
448+ saw = true;
449+ }
450+ }
451+ assert!(saw, "expected at least one AGC target push after pumping");
452+ }
453+
454+ #[test]
455+ fn tick_with_audible_signal_pushes_program_loudness() {
456+ let (mut agc, mut prod, mut cmd_cons, _state, _bus) = fixture();
457+ for _ in 0..40 {
458+ push_sine(&mut prod, 4800, 0.3);
459+ agc.tick();
460+ }
461+ let mut program: Option<f32> = None;
462+ while let Ok(cmd) = cmd_cons.pop() {
463+ if let AudioCmd::SetProgramLoudnessLufs(v) = cmd {
464+ program = v;
465+ }
466+ }
467+ let lufs = program.expect("expected a SetProgramLoudnessLufs(Some) push");
468+ assert!(
469+ lufs.is_finite() && lufs > -200.0,
470+ "program loudness should be a real reading, got {lufs}"
471+ );
472+ }
473+
474+ #[test]
475+ fn silence_then_audio_clears_then_sets_program_loudness() {
476+ let (mut agc, mut prod, mut cmd_cons, _state, _bus) = fixture();
477+ for _ in 0..5 {
478+ push_silence(&mut prod, 4800);
479+ agc.tick();
480+ }
481+ while let Ok(cmd) = cmd_cons.pop() {
482+ if let AudioCmd::SetProgramLoudnessLufs(Some(v)) = cmd {
483+ panic!("silence should not set a program loudness, got {v}");
484+ }
485+ }
486+ }
487+
488+ #[test]
489+ fn reset_clears_program_loudness() {
490+ let (mut agc, mut prod, mut cmd_cons, _state, _bus) = fixture();
491+ for _ in 0..40 {
492+ push_sine(&mut prod, 4800, 0.3);
493+ agc.tick();
494+ }
495+ while cmd_cons.pop().is_ok() {}
496+ agc.reset();
497+ let mut saw_clear = false;
498+ while let Ok(cmd) = cmd_cons.pop() {
499+ if matches!(cmd, AudioCmd::SetProgramLoudnessLufs(None)) {
500+ saw_clear = true;
501+ }
502+ }
503+ assert!(saw_clear, "reset should clear the limiter's program loudness");
504+ }
505+
506+ #[test]
507+ fn agc_disable_in_profile_flips_audio_thread() {
508+ let (mut agc, _prod, mut cmd_cons, state, _bus) = fixture();
509+ // First tick with the default-enabled profile.
510+ agc.tick();
511+ // Drain any commands.
512+ while cmd_cons.pop().is_ok() {}
513+
514+ // Disable AGC in the profile.
515+ state
516+ .lock()
517+ .profiles
518+ .set_setting("agc.enabled", serde_json::json!(false))
519+ .unwrap();
520+ agc.tick();
521+
522+ // Expect a SetAgcEnabled(false) command.
523+ let mut saw_disable = false;
524+ while let Ok(cmd) = cmd_cons.pop() {
525+ if matches!(cmd, AudioCmd::SetAgcEnabled(false)) {
526+ saw_disable = true;
527+ }
528+ }
529+ assert!(saw_disable, "expected SetAgcEnabled(false) on profile flip");
530+ }
531+
532+ #[test]
533+ fn alpha_endpoints() {
534+ // tau == 0 → instantaneous.
535+ assert_eq!(alpha_for_dt(0.0, 50.0), 1.0);
536+ // dt == 0 → no progress.
537+ assert_eq!(alpha_for_dt(1000.0, 0.0), 1.0); // we clamp dt<=0 to 1.0 too
538+ // Sanity: shorter tau → larger alpha for same dt.
539+ let a_fast = alpha_for_dt(100.0, 50.0);
540+ let a_slow = alpha_for_dt(2000.0, 50.0);
541+ assert!(a_fast > a_slow);
542+ }
543+}
Added file: crates/headroom-core/src/error.rs+39 additions
@@ -0,0 +1,39 @@
1+//! daemon error types.
2+
3+#[derive(Debug, thiserror::Error)]
4+#[non_exhaustive]
5+pub enum DaemonError {
6+ #[error("io: {0}")]
7+ Io(#[from] std::io::Error),
8+
9+ #[error("json: {0}")]
10+ Json(#[from] serde_json::Error),
11+
12+ #[error("toml: {0}")]
13+ Toml(#[from] toml::de::Error),
14+
15+ #[error("pipewire: {0}")]
16+ PipeWire(String),
17+
18+ #[error("pipewire object not found: {0}")]
19+ PipeWireNotFound(String),
20+
21+ #[error("profile: {0}")]
22+ Profile(String),
23+
24+ #[error("daemon shutting down")]
25+ Shutdown,
26+
27+ #[error("{0}")]
28+ Other(String),
29+}
30+
31+impl DaemonError {
32+ pub fn pipewire(msg: impl std::fmt::Display) -> Self {
33+ DaemonError::PipeWire(msg.to_string())
34+ }
35+
36+ pub fn other(msg: impl std::fmt::Display) -> Self {
37+ DaemonError::Other(msg.to_string())
38+ }
39+}
Added file: crates/headroom-core/src/ipc/broadcast.rs+288 additions
@@ -0,0 +1,288 @@
1+//! per-subscriber event fan-out. full queue → event dropped + per-(sub,
2+//! topic) counter, flushed as a `daemon::overflow` event on next success.
3+//! see `IPC.md` §4 Backpressure.
4+
5+use std::collections::{HashMap, HashSet};
6+
7+use crossbeam_channel::{Sender, TrySendError};
8+use serde_json::json;
9+
10+use headroom_ipc::{Event, ServerFrame, Topic};
11+
12+pub const SUBSCRIBER_CAPACITY: usize = 64;
13+
14+/// stable for the life of the connection.
15+pub type SubscriberId = u64;
16+
17+struct Subscriber {
18+ tx: Sender<ServerFrame>,
19+ topics: HashSet<Topic>,
20+ /// per-topic drops since the last successful overflow flush.
21+ dropped: HashMap<Topic, u64>,
22+ /// lifetime total, reset only on subscriber removal.
23+ dropped_total: u64,
24+}
25+
26+#[derive(Default)]
27+pub struct Broadcaster {
28+ subscribers: HashMap<SubscriberId, Subscriber>,
29+ next_id: SubscriberId,
30+}
31+
32+impl std::fmt::Debug for Broadcaster {
33+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34+ f.debug_struct("Broadcaster")
35+ .field("subscribers", &self.subscribers.len())
36+ .field("next_id", &self.next_id)
37+ .finish()
38+ }
39+}
40+
41+impl Broadcaster {
42+ #[must_use]
43+ pub fn new() -> Self {
44+ Self::default()
45+ }
46+
47+ /// register a connection's outbound channel.
48+ pub fn register(&mut self, tx: Sender<ServerFrame>) -> SubscriberId {
49+ let id = self.next_id;
50+ self.next_id = self.next_id.wrapping_add(1);
51+ self.subscribers.insert(
52+ id,
53+ Subscriber {
54+ tx,
55+ topics: HashSet::new(),
56+ dropped: HashMap::new(),
57+ dropped_total: 0,
58+ },
59+ );
60+ id
61+ }
62+
63+ /// idempotent.
64+ pub fn unregister(&mut self, id: SubscriberId) {
65+ self.subscribers.remove(&id);
66+ }
67+
68+ /// add `topics` to subscriber `id`'s interest set; returns the
69+ /// accepted set.
70+ pub fn subscribe(&mut self, id: SubscriberId, topics: &[Topic]) -> Vec<Topic> {
71+ let Some(sub) = self.subscribers.get_mut(&id) else {
72+ return Vec::new();
73+ };
74+ for t in topics {
75+ // `control` is reserved for the connect-time hello.
76+ if matches!(t, Topic::Control) {
77+ continue;
78+ }
79+ sub.topics.insert(*t);
80+ }
81+ sub.topics.iter().copied().collect()
82+ }
83+
84+ pub fn unsubscribe(&mut self, id: SubscriberId, topics: &[Topic]) -> Vec<Topic> {
85+ let Some(sub) = self.subscribers.get_mut(&id) else {
86+ return Vec::new();
87+ };
88+ for t in topics {
89+ sub.topics.remove(t);
90+ }
91+ topics.to_vec()
92+ }
93+
94+ #[must_use]
95+ pub fn subscriber_count(&self) -> usize {
96+ self.subscribers.len()
97+ }
98+
99+ /// publish on `topic`: non-blocking try-send to each interested
100+ /// subscriber; full-queue failures accrue to the drop counter.
101+ pub fn publish(&mut self, topic: Topic, event: Event) {
102+ for sub in self.subscribers.values_mut() {
103+ if !sub.topics.contains(&topic) {
104+ continue;
105+ }
106+ match sub.tx.try_send(ServerFrame::Event(event.clone())) {
107+ Ok(()) => {
108+ if sub.topics.contains(&Topic::Daemon) {
109+ flush_overflow(sub);
110+ }
111+ }
112+ Err(TrySendError::Full(_)) => {
113+ *sub.dropped.entry(topic).or_insert(0) += 1;
114+ sub.dropped_total = sub.dropped_total.wrapping_add(1);
115+ }
116+ Err(TrySendError::Disconnected(_)) => {
117+ // reaped on unregister.
118+ }
119+ }
120+ }
121+ }
122+}
123+
124+/// flush pending per-topic overflow counts as `daemon::overflow`
125+/// events; failed entries stay in `dropped` for next time.
126+fn flush_overflow(sub: &mut Subscriber) {
127+ if sub.dropped.is_empty() {
128+ return;
129+ }
130+ // drain first so a failed try_send doesn't double-emit.
131+ let entries: Vec<(Topic, u64)> = sub.dropped.drain().collect();
132+ for (lost_topic, lost) in entries {
133+ let lost_u32: u32 = u32::try_from(lost).unwrap_or(u32::MAX);
134+ let data = json!({
135+ "lost_topic": lost_topic.as_str(),
136+ "lost": lost_u32,
137+ "total_lost": sub.dropped_total,
138+ });
139+ let event = match Event::new(Topic::Daemon, "overflow", &data) {
140+ Ok(e) => e,
141+ Err(_) => continue,
142+ };
143+ match sub.tx.try_send(ServerFrame::Event(event)) {
144+ Ok(()) => {}
145+ Err(_) => {
146+ // reinstate the count for the next flush.
147+ *sub.dropped.entry(lost_topic).or_insert(0) += lost;
148+ }
149+ }
150+ }
151+}
152+
153+#[cfg(test)]
154+mod tests {
155+ use super::*;
156+ use crossbeam_channel::bounded;
157+ use serde_json::Value;
158+
159+ fn make_subscriber(b: &mut Broadcaster, capacity: usize) -> (SubscriberId, crossbeam_channel::Receiver<ServerFrame>) {
160+ let (tx, rx) = bounded(capacity);
161+ let id = b.register(tx);
162+ (id, rx)
163+ }
164+
165+ fn ev(topic: Topic, name: &str) -> Event {
166+ Event::new(topic, name, &json!({})).unwrap()
167+ }
168+
169+ #[test]
170+ fn register_and_unregister() {
171+ let mut b = Broadcaster::new();
172+ assert_eq!(b.subscriber_count(), 0);
173+ let (id, _rx) = make_subscriber(&mut b, 4);
174+ assert_eq!(b.subscriber_count(), 1);
175+ b.unregister(id);
176+ assert_eq!(b.subscriber_count(), 0);
177+ }
178+
179+ #[test]
180+ fn publish_reaches_only_subscribed_topic_subscribers() {
181+ let mut b = Broadcaster::new();
182+ let (id_a, rx_a) = make_subscriber(&mut b, 4);
183+ let (id_b, rx_b) = make_subscriber(&mut b, 4);
184+ b.subscribe(id_a, &[Topic::Routing]);
185+ b.subscribe(id_b, &[Topic::Profile]);
186+
187+ b.publish(Topic::Routing, ev(Topic::Routing, "stream_routed"));
188+ // A receives, B does not.
189+ assert!(rx_a.try_recv().is_ok());
190+ assert!(rx_b.try_recv().is_err());
191+ }
192+
193+ #[test]
194+ fn control_topic_is_not_user_subscribable() {
195+ let mut b = Broadcaster::new();
196+ let (id, rx) = make_subscriber(&mut b, 4);
197+ let acked = b.subscribe(id, &[Topic::Control]);
198+ // Control was filtered out, so the ack list is empty for
199+ // this single-topic subscribe.
200+ assert!(acked.is_empty());
201+ // Publishing on Control doesn't reach the client.
202+ b.publish(Topic::Control, ev(Topic::Control, "hello"));
203+ assert!(rx.try_recv().is_err());
204+ }
205+
206+ #[test]
207+ fn overflow_accrues_and_flushes_on_next_success() {
208+ let mut b = Broadcaster::new();
209+ // Capacity 2 so the flush has room for one routing + one
210+ // overflow event after we've drained. Subscriber needs the
211+ // Daemon topic on its interest list to receive overflow
212+ // notices.
213+ let (id, rx) = make_subscriber(&mut b, 2);
214+ b.subscribe(id, &[Topic::Routing, Topic::Daemon]);
215+
216+ // First two publishes fill the queue.
217+ b.publish(Topic::Routing, ev(Topic::Routing, "stream_routed"));
218+ b.publish(Topic::Routing, ev(Topic::Routing, "stream_routed"));
219+ // Subsequent publishes overflow — counted, not delivered.
220+ b.publish(Topic::Routing, ev(Topic::Routing, "stream_routed"));
221+ b.publish(Topic::Routing, ev(Topic::Routing, "stream_routed"));
222+
223+ // Drain both messages so the queue is empty again.
224+ let _ = rx.recv().unwrap();
225+ let _ = rx.recv().unwrap();
226+
227+ // Now publish again; this should succeed AND piggyback the
228+ // overflow notice on the daemon topic.
229+ b.publish(Topic::Routing, ev(Topic::Routing, "stream_routed"));
230+
231+ // Collect remaining messages.
232+ let mut got: Vec<ServerFrame> = Vec::new();
233+ while let Ok(m) = rx.try_recv() {
234+ got.push(m);
235+ }
236+ // Expect one routing event + one daemon::overflow.
237+ let topics: Vec<Topic> = got
238+ .iter()
239+ .map(|f| match f {
240+ ServerFrame::Event(e) => e.topic,
241+ ServerFrame::Response(_) => panic!("no response expected"),
242+ })
243+ .collect();
244+ assert!(topics.contains(&Topic::Routing));
245+ assert!(topics.contains(&Topic::Daemon));
246+
247+ // The daemon event has the overflow payload.
248+ let overflow = got
249+ .into_iter()
250+ .find_map(|f| match f {
251+ ServerFrame::Event(e) if e.topic == Topic::Daemon => Some(e),
252+ _ => None,
253+ })
254+ .unwrap();
255+ assert_eq!(overflow.event, "overflow");
256+ let data = overflow.data;
257+ assert_eq!(data["lost_topic"], Value::String("routing".into()));
258+ assert!(data["lost"].as_u64().unwrap() >= 1);
259+ }
260+
261+ #[test]
262+ fn unsubscribe_stops_delivery() {
263+ let mut b = Broadcaster::new();
264+ let (id, rx) = make_subscriber(&mut b, 4);
265+ b.subscribe(id, &[Topic::Routing]);
266+ b.publish(Topic::Routing, ev(Topic::Routing, "stream_routed"));
267+ rx.try_recv().unwrap();
268+
269+ b.unsubscribe(id, &[Topic::Routing]);
270+ b.publish(Topic::Routing, ev(Topic::Routing, "stream_routed"));
271+ assert!(rx.try_recv().is_err());
272+ }
273+
274+ #[test]
275+ fn disconnected_subscriber_is_a_silent_no_op_until_unregistered() {
276+ let mut b = Broadcaster::new();
277+ let (id, rx) = make_subscriber(&mut b, 4);
278+ b.subscribe(id, &[Topic::Routing]);
279+ drop(rx); // simulate the reader thread dying
280+
281+ // Publish doesn't panic and doesn't error.
282+ b.publish(Topic::Routing, ev(Topic::Routing, "stream_routed"));
283+ // Subscriber still listed until explicit unregister.
284+ assert_eq!(b.subscriber_count(), 1);
285+ b.unregister(id);
286+ assert_eq!(b.subscriber_count(), 0);
287+ }
288+}
Added file: crates/headroom-core/src/ipc/connection.rs+146 additions
@@ -0,0 +1,146 @@
1+//! per-connection handler. each client spawns a writer thread (owns
2+//! the write half, drains a bounded channel the broadcaster also feeds)
3+//! and runs the reader on the current thread (dispatches requests,
4+//! sends responses through the channel). split so events don't
5+//! interleave with request/response writes.
6+
7+use std::io::{BufReader, BufWriter, Write};
8+use std::os::unix::net::UnixStream;
9+use std::sync::atomic::{AtomicBool, Ordering};
10+use std::sync::Arc;
11+use std::thread;
12+use std::time::Duration;
13+
14+use crossbeam_channel::{bounded, Receiver, RecvTimeoutError, Sender};
15+
16+use headroom_ipc::{
17+ Codec, Event, HelloData, Op, Request, Response, ServerFrame, Topic, PROTOCOL_VERSION,
18+};
19+
20+use crate::ipc::broadcast::{SubscriberId, SUBSCRIBER_CAPACITY};
21+use crate::ipc::ops;
22+use crate::state::SharedState;
23+
24+const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION");
25+const DAEMON_NAME: &str = "headroom";
26+
27+/// how often the writer wakes to check the shutdown flag.
28+const WRITER_POLL_INTERVAL: Duration = Duration::from_millis(100);
29+
30+pub fn handle_connection(
31+ stream: UnixStream,
32+ state: SharedState,
33+ shutdown: Arc<AtomicBool>,
34+) {
35+ let codec = Codec::new();
36+ let reader_stream = match stream.try_clone() {
37+ Ok(s) => s,
38+ Err(e) => {
39+ tracing::warn!(error = %e, "ipc conn: failed to clone stream");
40+ return;
41+ }
42+ };
43+ let mut reader = BufReader::new(reader_stream);
44+
45+ let (outbound_tx, outbound_rx) = bounded::<ServerFrame>(SUBSCRIBER_CAPACITY);
46+
47+ // hello before anything else lands on the wire.
48+ if outbound_tx.try_send(ServerFrame::Event(hello())).is_err() {
49+ tracing::warn!("ipc conn: outbound queue full at hello (capacity misconfigured?)");
50+ return;
51+ }
52+
53+ let sub_id = state.lock().broadcaster.register(outbound_tx.clone());
54+
55+ let writer_stream = stream;
56+ let writer_shutdown = shutdown.clone();
57+ let writer_handle = thread::Builder::new()
58+ .name("headroom-ipc-conn-writer".into())
59+ .spawn(move || writer_loop(writer_stream, outbound_rx, writer_shutdown))
60+ .expect("spawn writer thread");
61+
62+ serve(&codec, &mut reader, &outbound_tx, &state, sub_id, &shutdown);
63+
64+ // drop outbound_tx so the writer sees disconnection and exits.
65+ state.lock().broadcaster.unregister(sub_id);
66+ drop(outbound_tx);
67+ let _ = writer_handle.join();
68+}
69+
70+fn hello() -> Event {
71+ let data = HelloData {
72+ daemon: DAEMON_NAME.into(),
73+ version: DAEMON_VERSION.into(),
74+ protocol: PROTOCOL_VERSION,
75+ };
76+ // unwrap: HelloData always serialises.
77+ Event::new(Topic::Control, "hello", &data).expect("hello serialises")
78+}
79+
80+fn writer_loop(stream: UnixStream, rx: Receiver<ServerFrame>, shutdown: Arc<AtomicBool>) {
81+ let codec = Codec::new();
82+ let mut writer = BufWriter::new(stream);
83+ while !shutdown.load(Ordering::Relaxed) {
84+ match rx.recv_timeout(WRITER_POLL_INTERVAL) {
85+ Ok(frame) => {
86+ if let Err(e) = codec.write(&mut writer, &frame) {
87+ tracing::warn!(error = %e, "ipc writer: codec write failed; closing");
88+ return;
89+ }
90+ // flush after each frame so events land promptly.
91+ if let Err(e) = writer.flush() {
92+ tracing::warn!(error = %e, "ipc writer: flush failed; closing");
93+ return;
94+ }
95+ }
96+ Err(RecvTimeoutError::Timeout) => continue,
97+ Err(RecvTimeoutError::Disconnected) => return,
98+ }
99+ }
100+}
101+
102+fn serve<R: std::io::Read>(
103+ codec: &Codec,
104+ reader: &mut R,
105+ outbound: &Sender<ServerFrame>,
106+ state: &SharedState,
107+ sub_id: SubscriberId,
108+ shutdown: &AtomicBool,
109+) {
110+ while !shutdown.load(Ordering::Relaxed) {
111+ let req: Request = match codec.read(&mut *reader) {
112+ Ok(r) => r,
113+ Err(headroom_ipc::Error::Closed) => return,
114+ Err(headroom_ipc::Error::Io(e))
115+ if matches!(
116+ e.kind(),
117+ std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::BrokenPipe
118+ ) =>
119+ {
120+ return
121+ }
122+ Err(e) => {
123+ tracing::warn!(error = %e, "ipc reader: read failed; closing");
124+ return;
125+ }
126+ };
127+
128+ // subscribe/unsubscribe mutate the broadcaster directly so
129+ // sub_id needn't thread through every op handler.
130+ let response: Response = match &req.op {
131+ Op::Subscribe { topics } => {
132+ let acked = state.lock().broadcaster.subscribe(sub_id, topics);
133+ ops::ok_value(req.id, &serde_json::json!({ "subscribed": acked }))
134+ }
135+ Op::Unsubscribe { topics } => {
136+ let acked = state.lock().broadcaster.unsubscribe(sub_id, topics);
137+ ops::ok_value(req.id, &serde_json::json!({ "unsubscribed": acked }))
138+ }
139+ _ => ops::dispatch(&req, state),
140+ };
141+
142+ if outbound.send(ServerFrame::Response(response)).is_err() {
143+ return; // writer gone
144+ }
145+ }
146+}
Added file: crates/headroom-core/src/ipc/mod.rs+13 additions
@@ -0,0 +1,13 @@
1+//! daemon IPC server (server side of `IPC.md`). one blocking thread per
2+//! connection. no async runtime.
3+
4+pub mod broadcast;
5+mod connection;
6+mod ops;
7+mod server;
8+
9+pub use server::{IpcServer, IpcServerHandle};
10+
11+/// re-exported so the profile file-watcher reuses the same path as the
12+/// `profile.reload` op.
13+pub(crate) use ops::execute_reload;
Added file: crates/headroom-core/src/ipc/ops.rs+1166 additions
@@ -0,0 +1,1166 @@
1+//! op dispatch + handlers. each locks state briefly and returns a
2+//! [`Response`]; mutations route through [`ProfileStore`].
3+
4+use serde::Serialize;
5+use serde_json::{json, Value};
6+
7+use headroom_ipc::{
8+ ErrorCode, Event, HelloData, Op, ProfileInfo, ProtoError, Request, Response, Route, RouteList,
9+ SinkInfo, Sinks, Status, StreamRoute, Topic, PROTOCOL_VERSION,
10+};
11+
12+use crate::profile_store::StoreError;
13+use crate::pw::command::PwCommand;
14+use crate::state::SharedState;
15+
16+const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION");
17+
18+mod profile_effects;
19+use profile_effects::ProfileEffects;
20+
21+pub fn dispatch(req: &Request, state: &SharedState) -> Response {
22+ match &req.op {
23+ Op::Status => status(req.id, state),
24+ Op::ProfileList => profile_list(req.id, state),
25+ Op::ProfileShow { name } => profile_show(req.id, name.as_deref(), state),
26+ Op::ProfileUse { name } => profile_use(req.id, name, state),
27+ Op::ProfileReload => profile_reload(req.id, state),
28+ Op::RouteList => route_list(req.id, state),
29+ Op::RouteSet { app, to } => route_set(req.id, app, *to, state),
30+ Op::RouteUnset { app } => route_unset(req.id, app, state),
31+ Op::RouteStream { node_id, to } => route_stream(req.id, *node_id, *to, state),
32+ Op::SettingGet { key } => setting_get(req.id, key, state),
33+ Op::SettingSet { key, value } => setting_set(req.id, key, value.clone(), state),
34+ Op::SettingList => setting_list(req.id, state),
35+ Op::SettingClear { key } => setting_clear(req.id, key, state),
36+ Op::SettingReset => setting_reset(req.id, state),
37+ Op::BypassSet { enabled } => bypass_set(req.id, *enabled, state),
38+ Op::LayerAList => layer_a_list(req.id, state),
39+ Op::PerAppSet { app, enabled } => per_app_set(req.id, app, *enabled, state),
40+ Op::PerAppMaster { enabled } => per_app_master(req.id, *enabled, state),
41+ Op::LayerAReset { node_id } => layer_a_reset(req.id, *node_id, state),
42+ Op::Hello { protocol } => hello(req.id, *protocol),
43+ Op::Subscribe { .. } | Op::Unsubscribe { .. } => not_yet(req, "Phase 4d"),
44+ // Op is #[non_exhaustive]; future ops look unknown here.
45+ _ => err(
46+ req.id,
47+ ErrorCode::UnknownOp,
48+ format!("op '{}' is not recognised by this daemon", req.op.name()),
49+ ),
50+ }
51+}
52+
53+// ---------------------------------------------------------------------------
54+// Read-only ops
55+// ---------------------------------------------------------------------------
56+
57+fn status(id: u64, state: &SharedState) -> Response {
58+ let s = state.lock();
59+ let effective = s.profiles.effective();
60+ let snapshot = Status {
61+ version: DAEMON_VERSION.into(),
62+ protocol: PROTOCOL_VERSION,
63+ uptime_s: s.started_at.elapsed().as_secs(),
64+ profile: effective.name.clone(),
65+ bypass: s.profiles.bypass_global(),
66+ per_app: effective.per_app.enabled,
67+ sinks: Sinks {
68+ processed: SinkInfo {
69+ node_id: s.processed_sink_id,
70+ name: Some(crate::pw::sink::NODE_NAME.to_owned()),
71+ ready: s.processed_sink_id.is_some(),
72+ // filter's current rate; `None` only pre-`Filter::create`.
73+ sample_rate: s.filter_sample_rate,
74+ },
75+ real: s.real_sink.clone(),
76+ },
77+ streams: s
78+ .streams
79+ .values()
80+ .map(|r| StreamRoute {
81+ node_id: r.node_id,
82+ app: r.app.clone(),
83+ route: r.route,
84+ })
85+ .collect(),
86+ layer_a: s.layer_a.values().cloned().collect(),
87+ warnings: s.profiles.warnings(),
88+ setting_overrides: s.profiles.setting_overrides(),
89+ };
90+ ok(id, &snapshot)
91+}
92+
93+fn hello(id: u64, client_protocol: u32) -> Response {
94+ if client_protocol != PROTOCOL_VERSION {
95+ tracing::warn!(
96+ client_protocol,
97+ daemon_protocol = PROTOCOL_VERSION,
98+ "IPC client protocol version differs from daemon; \
99+ continuing best-effort — some ops or fields may not line up"
100+ );
101+ }
102+ ok(
103+ id,
104+ &HelloData {
105+ daemon: "headroom".into(),
106+ version: DAEMON_VERSION.into(),
107+ protocol: PROTOCOL_VERSION,
108+ },
109+ )
110+}
111+
112+fn profile_list(id: u64, state: &SharedState) -> Response {
113+ let s = state.lock();
114+ let active = s.profiles.effective().name.clone();
115+ let profiles: Vec<ProfileInfo> = s
116+ .profiles
117+ .list()
118+ .map(|sp| ProfileInfo {
119+ name: sp.name.clone(),
120+ active: sp.name == active,
121+ description: sp.profile.description.clone(),
122+ })
123+ .collect();
124+ ok(id, &json!({ "profiles": profiles }))
125+}
126+
127+fn profile_show(id: u64, name: Option<&str>, state: &SharedState) -> Response {
128+ let s = state.lock();
129+ let effective = s.profiles.effective();
130+ match name {
131+ None => ok(id, effective),
132+ Some(requested) if requested == effective.name => ok(id, effective),
133+ Some(requested) => match s.profiles.list().find(|sp| sp.name == requested) {
134+ Some(found) => ok(id, &found.profile),
135+ None => err(
136+ id,
137+ ErrorCode::NotFound,
138+ format!("profile '{requested}' not loaded"),
139+ ),
140+ },
141+ }
142+}
143+
144+fn route_list(id: u64, state: &SharedState) -> Response {
145+ let s = state.lock();
146+ let effective = s.profiles.effective();
147+ let body = RouteList {
148+ rules: effective.rules.clone(),
149+ current: s
150+ .streams
151+ .values()
152+ .map(|r| StreamRoute {
153+ node_id: r.node_id,
154+ app: r.app.clone(),
155+ route: r.route,
156+ })
157+ .collect(),
158+ default_route: effective.default_route.route,
159+ };
160+ ok(id, &body)
161+}
162+
163+fn setting_get(id: u64, key: &str, state: &SharedState) -> Response {
164+ let s = state.lock();
165+ let json_value = match serde_json::to_value(s.profiles.effective()) {
166+ Ok(v) => v,
167+ Err(e) => {
168+ return err(id, ErrorCode::Internal, format!("serialise profile: {e}"));
169+ }
170+ };
171+ drop(s);
172+
173+ let Some(found) = lookup_dotted(&json_value, key) else {
174+ return err(
175+ id,
176+ ErrorCode::NotFound,
177+ format!("setting '{key}' not found in active profile"),
178+ );
179+ };
180+ ok(id, &json!({ "key": key, "value": found }))
181+}
182+
183+fn setting_list(id: u64, state: &SharedState) -> Response {
184+ let s = state.lock();
185+ let json_value = match serde_json::to_value(s.profiles.effective()) {
186+ Ok(v) => v,
187+ Err(e) => {
188+ return err(id, ErrorCode::Internal, format!("serialise profile: {e}"));
189+ }
190+ };
191+ drop(s);
192+
193+ let mut flat = serde_json::Map::new();
194+ flatten(&json_value, "", &mut flat);
195+ ok(id, &json!({ "settings": flat }))
196+}
197+
198+// ---------------------------------------------------------------------------
199+// Mutating ops
200+// ---------------------------------------------------------------------------
201+
202+fn profile_use(id: u64, name: &str, state: &SharedState) -> Response {
203+ let mut s = state.lock();
204+ if name == s.profiles.effective().name {
205+ let body = json!({ "name": name });
206+ return ok(id, &body);
207+ }
208+ match s.profiles.use_profile(name) {
209+ Ok(()) => {
210+ tracing::info!(name, "profile.use applied");
211+ let post = ProfileEffects::profile_used(name).apply(&mut s);
212+ drop(s);
213+ post.finish();
214+ ok(id, &json!({ "name": name }))
215+ }
216+ Err(e) => store_err_to_response(id, e),
217+ }
218+}
219+
220+fn profile_reload(id: u64, state: &SharedState) -> Response {
221+ match execute_reload(state) {
222+ Ok(report) => ok(
223+ id,
224+ &json!({ "reloaded": report.loaded, "warnings": report.warnings }),
225+ ),
226+ Err(e) => store_err_to_response(id, e),
227+ }
228+}
229+
230+/// scan disk, publish events, push fresh DSP configs to the filter.
231+/// used by [`Op::ProfileReload`] and the file-watcher.
232+///
233+/// # Errors
234+/// fatal disk I/O from [`ProfileStore::reload`].
235+pub(crate) fn execute_reload(
236+ state: &SharedState,
237+) -> Result<crate::profile_store::ReloadReport, StoreError> {
238+ let mut s = state.lock();
239+ let report = s.profiles.reload()?;
240+ tracing::info!(
241+ loaded = report.loaded.len(),
242+ warnings = report.warnings.len(),
243+ "profile reload applied"
244+ );
245+ for w in &report.warnings {
246+ tracing::warn!(warning = %w, "profile reload warning");
247+ }
248+ let post = ProfileEffects::profile_reloaded(report.loaded.clone()).apply(&mut s);
249+ drop(s);
250+ post.finish();
251+ Ok(report)
252+}
253+
254+fn route_set(id: u64, app: &str, to: Route, state: &SharedState) -> Response {
255+ let mut s = state.lock();
256+ match s.profiles.set_route(app, to) {
257+ Ok(()) => {
258+ tracing::info!(app, ?to, "route.set applied");
259+ let post = ProfileEffects::route_rule_changed().apply(&mut s);
260+ drop(s);
261+ post.finish();
262+ ok(id, &Value::Null)
263+ }
264+ Err(e) => store_err_to_response(id, e),
265+ }
266+}
267+
268+fn route_unset(id: u64, app: &str, state: &SharedState) -> Response {
269+ let mut s = state.lock();
270+ match s.profiles.unset_route(app) {
271+ Ok(()) => {
272+ tracing::info!(app, "route.unset applied");
273+ let post = ProfileEffects::route_rule_changed().apply(&mut s);
274+ drop(s);
275+ post.finish();
276+ ok(id, &Value::Null)
277+ }
278+ Err(e) => store_err_to_response(id, e),
279+ }
280+}
281+
282+fn setting_set(id: u64, key: &str, value: Value, state: &SharedState) -> Response {
283+ let mut s = state.lock();
284+ match s.profiles.set_setting(key, value) {
285+ Ok(()) => {
286+ tracing::info!(key, "setting.set applied");
287+ let post = ProfileEffects::settings_changed().apply(&mut s);
288+ drop(s);
289+ post.finish();
290+ ok(id, &Value::Null)
291+ }
292+ Err(e) => store_err_to_response(id, e),
293+ }
294+}
295+
296+fn setting_clear(id: u64, key: &str, state: &SharedState) -> Response {
297+ let mut s = state.lock();
298+ match s.profiles.clear_setting(key) {
299+ Ok(existed) => {
300+ tracing::info!(key, existed, "setting.clear applied");
301+ let post = ProfileEffects::settings_changed().apply(&mut s);
302+ drop(s);
303+ post.finish();
304+ ok(id, &json!({ "key": key, "cleared": existed }))
305+ }
306+ Err(e) => store_err_to_response(id, e),
307+ }
308+}
309+
310+fn setting_reset(id: u64, state: &SharedState) -> Response {
311+ let mut s = state.lock();
312+ match s.profiles.clear_all_settings() {
313+ Ok(cleared) => {
314+ tracing::info!(cleared, "setting.reset applied");
315+ let post = ProfileEffects::settings_changed().apply(&mut s);
316+ drop(s);
317+ post.finish();
318+ ok(id, &json!({ "cleared": cleared }))
319+ }
320+ Err(e) => store_err_to_response(id, e),
321+ }
322+}
323+
324+fn route_stream(id: u64, node_id: u32, to: Route, state: &SharedState) -> Response {
325+ let mut s = state.lock();
326+ let Some(stream) = s.streams.get_mut(&node_id) else {
327+ return err(
328+ id,
329+ ErrorCode::NotFound,
330+ format!("no stream with node_id {node_id} is currently routed by the daemon"),
331+ );
332+ };
333+ let app_label = stream.app.clone();
334+ let prior = stream.route;
335+ stream.route = to;
336+ // record synchronously so `status`/`route.list` reflect it now;
337+ // the metadata write is async on the main loop (≤ ~50 ms).
338+ let event = Event::new(
339+ Topic::Routing,
340+ "stream_routed",
341+ &json!({ "node_id": node_id, "app": app_label, "to": to.as_str() }),
342+ );
343+ if let Ok(event) = event {
344+ s.broadcaster.publish(Topic::Routing, event);
345+ }
346+ let tx = s.pw_command_tx.clone();
347+ drop(s);
348+ if let Some(tx) = tx {
349+ if tx
350+ .send(PwCommand::RouteStream {
351+ node_id,
352+ to,
353+ app_label: app_label.clone(),
354+ })
355+ .is_err()
356+ {
357+ tracing::warn!(
358+ node_id,
359+ "PipeWire command channel closed; metadata write skipped"
360+ );
361+ }
362+ } else {
363+ tracing::debug!(
364+ node_id,
365+ "no PipeWire command channel; state updated but no metadata write (test mode)"
366+ );
367+ }
368+ tracing::info!(
369+ node_id,
370+ app = app_label.as_str(),
371+ ?prior,
372+ new = ?to,
373+ "route.stream applied"
374+ );
375+ ok(id, &Value::Null)
376+}
377+
378+fn bypass_set(id: u64, enabled: bool, state: &SharedState) -> Response {
379+ let mut s = state.lock();
380+ match s.profiles.set_bypass(enabled) {
381+ Ok(()) => {
382+ tracing::info!(enabled, "bypass.set applied");
383+ let post = ProfileEffects::bypass_changed().apply(&mut s);
384+ drop(s);
385+ post.finish();
386+ ok(id, &Value::Null)
387+ }
388+ Err(e) => store_err_to_response(id, e),
389+ }
390+}
391+
392+// ---------------------------------------------------------------------------
393+// Per-app (Layer A) ops
394+// ---------------------------------------------------------------------------
395+
396+fn layer_a_list(id: u64, state: &SharedState) -> Response {
397+ let s = state.lock();
398+ let mut list: Vec<headroom_ipc::LayerASnapshot> = s.layer_a.values().cloned().collect();
399+ drop(s);
400+ list.sort_by_key(|snap| snap.node_id);
401+ ok(id, &json!({ "layer_a": list }))
402+}
403+
404+fn per_app_set(id: u64, app: &str, enabled: bool, state: &SharedState) -> Response {
405+ let mut s = state.lock();
406+ match s.profiles.set_per_app_enabled(app, enabled) {
407+ Ok(()) => {
408+ tracing::info!(app, enabled, "per-app.set applied");
409+ let post = ProfileEffects::per_app_changed().apply(&mut s);
410+ drop(s);
411+ post.finish();
412+ ok(id, &Value::Null)
413+ }
414+ Err(e) => store_err_to_response(id, e),
415+ }
416+}
417+
418+fn per_app_master(id: u64, enabled: bool, state: &SharedState) -> Response {
419+ let mut s = state.lock();
420+ match s.profiles.set_per_app_master(enabled) {
421+ Ok(()) => {
422+ tracing::info!(enabled, "per-app.master applied");
423+ let post = ProfileEffects::per_app_changed().apply(&mut s);
424+ drop(s);
425+ post.finish();
426+ ok(id, &Value::Null)
427+ }
428+ Err(e) => store_err_to_response(id, e),
429+ }
430+}
431+
432+fn layer_a_reset(id: u64, node_id: u32, state: &SharedState) -> Response {
433+ let s = state.lock();
434+ let tx = s.pw_command_tx.clone();
435+ drop(s);
436+ if let Some(tx) = tx {
437+ if tx
438+ .send(PwCommand::LayerAResetDeference { node_id })
439+ .is_err()
440+ {
441+ tracing::warn!(
442+ node_id,
443+ "PipeWire command channel closed; layer-a reset lost"
444+ );
445+ }
446+ } else {
447+ tracing::debug!(
448+ node_id,
449+ "no PipeWire command channel; layer-a reset skipped (test mode)"
450+ );
451+ }
452+ tracing::info!(node_id, "per-app.reset applied");
453+ ok(id, &Value::Null)
454+}
455+
456+fn store_err_to_response(id: u64, e: StoreError) -> Response {
457+ let code = match &e {
458+ StoreError::ProfileNotFound(_)
459+ | StoreError::SettingNotFound(_)
460+ | StoreError::NoUserRoute(_) => ErrorCode::NotFound,
461+ StoreError::SettingInvalid { .. } => ErrorCode::InvalidArgs,
462+ StoreError::Io(_) | StoreError::OverlayParse(_) | StoreError::OverlaySerialize(_) => {
463+ ErrorCode::Internal
464+ }
465+ };
466+ err(id, code, e.to_string())
467+}
468+
469+// ---------------------------------------------------------------------------
470+// Helpers
471+// ---------------------------------------------------------------------------
472+
473+fn lookup_dotted<'v>(value: &'v Value, key: &str) -> Option<&'v Value> {
474+ let mut cur = value;
475+ for part in key.split('.') {
476+ cur = cur.get(part)?;
477+ }
478+ Some(cur)
479+}
480+
481+fn flatten(value: &Value, prefix: &str, out: &mut serde_json::Map<String, Value>) {
482+ match value {
483+ Value::Object(map) => {
484+ for (k, v) in map {
485+ let next = if prefix.is_empty() {
486+ k.clone()
487+ } else {
488+ format!("{prefix}.{k}")
489+ };
490+ flatten(v, &next, out);
491+ }
492+ }
493+ // arrays + primitives surfaced wholesale at their key prefix;
494+ // rule arrays etc. are better read via route.list/profile.show.
495+ _ => {
496+ out.insert(prefix.to_string(), value.clone());
497+ }
498+ }
499+}
500+
501+fn ok<T: Serialize>(id: u64, body: &T) -> Response {
502+ Response::ok(id, body).unwrap_or_else(|e| {
503+ Response::err(
504+ id,
505+ ProtoError::new(ErrorCode::Internal, format!("serialise reply: {e}")),
506+ )
507+ })
508+}
509+
510+/// used by the connection handler for subscribe/unsubscribe ops.
511+pub(crate) fn ok_value<T: Serialize>(id: u64, body: &T) -> Response {
512+ ok(id, body)
513+}
514+
515+fn err(id: u64, code: ErrorCode, msg: impl Into<String>) -> Response {
516+ Response::err(id, ProtoError::new(code, msg))
517+}
518+
519+fn not_yet(req: &Request, phase: &str) -> Response {
520+ err(
521+ req.id,
522+ ErrorCode::UnknownOp,
523+ format!("op '{}' not implemented yet ({})", req.op.name(), phase),
524+ )
525+}
526+
527+// ---------------------------------------------------------------------------
528+// Tests
529+// ---------------------------------------------------------------------------
530+
531+#[cfg(test)]
532+mod effect_tests;
533+
534+#[cfg(test)]
535+mod tests {
536+ use super::*;
537+ use crate::profile_store::ProfileStore;
538+ use crate::state::{self, RoutedStream};
539+ use headroom_ipc::{Op, Request, ResponsePayload, Route};
540+
541+ fn shared_with_default_profile() -> SharedState {
542+ state::shared(crate::state::DaemonState::new(ProfileStore::builtin()))
543+ }
544+
545+ fn extract_ok(resp: Response) -> Value {
546+ match resp.payload {
547+ ResponsePayload::Ok { result } => result,
548+ ResponsePayload::Err { error } => panic!("expected ok, got {error}"),
549+ }
550+ }
551+
552+ #[test]
553+ fn hello_echoes_daemon_handshake_and_serves_on_mismatch() {
554+ let state = shared_with_default_profile();
555+ let resp = dispatch(
556+ &Request::new(
557+ 1,
558+ Op::Hello {
559+ protocol: PROTOCOL_VERSION,
560+ },
561+ ),
562+ &state,
563+ );
564+ let body = extract_ok(resp);
565+ assert_eq!(body["daemon"], "headroom");
566+ assert_eq!(body["protocol"], PROTOCOL_VERSION);
567+ let resp = dispatch(
568+ &Request::new(
569+ 2,
570+ Op::Hello {
571+ protocol: PROTOCOL_VERSION + 1,
572+ },
573+ ),
574+ &state,
575+ );
576+ let body = extract_ok(resp);
577+ assert_eq!(body["protocol"], PROTOCOL_VERSION);
578+ }
579+
580+ #[test]
581+ fn status_reports_active_profile_and_zero_streams() {
582+ let state = shared_with_default_profile();
583+ let req = Request::new(1, Op::Status);
584+ let resp = dispatch(&req, &state);
585+ let body = extract_ok(resp);
586+ assert_eq!(body["profile"], "default");
587+ assert_eq!(body["bypass"], false);
588+ assert_eq!(body["protocol"], PROTOCOL_VERSION);
589+ assert!(body["streams"].as_array().unwrap().is_empty());
590+ // Builtin store with no overlay → no warnings.
591+ assert!(
592+ body.get("warnings")
593+ .and_then(|w| w.as_array())
594+ .is_none_or(|a| a.is_empty()),
595+ "expected empty/absent warnings on healthy startup"
596+ );
597+ }
598+
599+ #[test]
600+ fn status_surfaces_store_warnings() {
601+ use crate::profile_store::ProfileStore;
602+ use std::fs;
603+ use std::time::{SystemTime, UNIX_EPOCH};
604+
605+ // Build a load-from-disk store with a broken TOML so a warning
606+ // is recorded, then point Status at it.
607+ let base = std::env::temp_dir().join(format!(
608+ "headroom-warntest-{}-{}",
609+ std::process::id(),
610+ SystemTime::now()
611+ .duration_since(UNIX_EPOCH)
612+ .unwrap()
613+ .as_nanos()
614+ ));
615+ fs::create_dir_all(base.join("config/profiles")).unwrap();
616+ fs::create_dir_all(base.join("state")).unwrap();
617+ fs::write(
618+ base.join("config/profiles/broken.toml"),
619+ "this is not = valid",
620+ )
621+ .unwrap();
622+ let paths = crate::profile_store::StorePaths {
623+ config_dir: base.join("config"),
624+ state_dir: base.join("state"),
625+ share_dirs: vec![],
626+ extra_profile_dirs: vec![],
627+ };
628+ let store = ProfileStore::load(&paths).unwrap();
629+ let state = state::shared(crate::state::DaemonState::new(store));
630+
631+ let resp = dispatch(&Request::new(1, Op::Status), &state);
632+ let body = extract_ok(resp);
633+ let warnings = body["warnings"].as_array().expect("warnings field");
634+ assert!(
635+ warnings
636+ .iter()
637+ .any(|w| w.as_str().unwrap_or("").contains("broken.toml")),
638+ "expected warning mentioning broken.toml, got {warnings:?}"
639+ );
640+
641+ let _ = fs::remove_dir_all(&base);
642+ }
643+
644+ #[test]
645+ fn status_surfaces_routed_streams() {
646+ let state = shared_with_default_profile();
647+ state.lock().streams.insert(
648+ 42,
649+ RoutedStream {
650+ node_id: 42,
651+ app: "firefox".into(),
652+ route: Route::Processed,
653+ },
654+ );
655+ let resp = dispatch(&Request::new(1, Op::Status), &state);
656+ let body = extract_ok(resp);
657+ let streams = body["streams"].as_array().unwrap();
658+ assert_eq!(streams.len(), 1);
659+ assert_eq!(streams[0]["app"], "firefox");
660+ assert_eq!(streams[0]["route"], "processed");
661+ }
662+
663+ #[test]
664+ fn profile_list_returns_active() {
665+ let state = shared_with_default_profile();
666+ let resp = dispatch(&Request::new(1, Op::ProfileList), &state);
667+ let body = extract_ok(resp);
668+ let list = body["profiles"].as_array().unwrap();
669+ assert_eq!(list.len(), 1);
670+ assert_eq!(list[0]["name"], "default");
671+ assert_eq!(list[0]["active"], true);
672+ }
673+
674+ #[test]
675+ fn profile_show_default_returns_active_profile() {
676+ let state = shared_with_default_profile();
677+ let resp = dispatch(&Request::new(1, Op::ProfileShow { name: None }), &state);
678+ let body = extract_ok(resp);
679+ assert_eq!(body["name"], "default");
680+ }
681+
682+ #[test]
683+ fn profile_show_unknown_returns_not_found() {
684+ let state = shared_with_default_profile();
685+ let resp = dispatch(
686+ &Request::new(
687+ 1,
688+ Op::ProfileShow {
689+ name: Some("nightclub-mix".into()),
690+ },
691+ ),
692+ &state,
693+ );
694+ match resp.payload {
695+ ResponsePayload::Err { error } => assert_eq!(error.code, ErrorCode::NotFound),
696+ ResponsePayload::Ok { .. } => panic!("expected NotFound"),
697+ }
698+ }
699+
700+ #[test]
701+ fn route_list_returns_profile_rules_and_default_route() {
702+ let state = shared_with_default_profile();
703+ let resp = dispatch(&Request::new(1, Op::RouteList), &state);
704+ let body = extract_ok(resp);
705+ // default profile carries the bypass + processed rule sets.
706+ let rules = body["rules"].as_array().unwrap();
707+ assert_eq!(rules.len(), 2);
708+ assert_eq!(body["default_route"], "processed");
709+ }
710+
711+ #[test]
712+ fn setting_get_dotted_path() {
713+ let state = shared_with_default_profile();
714+ let req = Request::new(
715+ 1,
716+ Op::SettingGet {
717+ key: "limiter.ceiling_dbtp".into(),
718+ },
719+ );
720+ let resp = dispatch(&req, &state);
721+ let body = extract_ok(resp);
722+ assert_eq!(body["key"], "limiter.ceiling_dbtp");
723+ let v = body["value"].as_f64().unwrap();
724+ assert!((v - -0.1).abs() < 1e-6);
725+ }
726+
727+ #[test]
728+ fn setting_get_unknown_key_is_not_found() {
729+ let state = shared_with_default_profile();
730+ let resp = dispatch(
731+ &Request::new(
732+ 1,
733+ Op::SettingGet {
734+ key: "completely.not.a.key".into(),
735+ },
736+ ),
737+ &state,
738+ );
739+ match resp.payload {
740+ ResponsePayload::Err { error } => assert_eq!(error.code, ErrorCode::NotFound),
741+ ResponsePayload::Ok { .. } => panic!("expected NotFound"),
742+ }
743+ }
744+
745+ #[test]
746+ fn setting_list_flattens_to_dotted_keys() {
747+ let state = shared_with_default_profile();
748+ let resp = dispatch(&Request::new(1, Op::SettingList), &state);
749+ let body = extract_ok(resp);
750+ let settings = body["settings"].as_object().unwrap();
751+ assert!(settings.contains_key("agc.target_lufs"));
752+ assert!(settings.contains_key("limiter.ceiling_dbtp"));
753+ assert!(settings.contains_key("compressor.ratio"));
754+ }
755+
756+ // -----------------------------------------------------------------
757+ // 4c mutating ops
758+ // -----------------------------------------------------------------
759+
760+ #[test]
761+ fn bypass_set_toggles_flag() {
762+ let state = shared_with_default_profile();
763+ assert!(!state.lock().profiles.bypass_global());
764+
765+ dispatch(&Request::new(1, Op::BypassSet { enabled: true }), &state);
766+ assert!(state.lock().profiles.bypass_global());
767+
768+ dispatch(&Request::new(2, Op::BypassSet { enabled: false }), &state);
769+ assert!(!state.lock().profiles.bypass_global());
770+ }
771+
772+ #[test]
773+ fn route_set_inserts_user_rule_at_top() {
774+ let state = shared_with_default_profile();
775+ dispatch(
776+ &Request::new(
777+ 1,
778+ Op::RouteSet {
779+ app: "obs".into(),
780+ to: Route::Bypass,
781+ },
782+ ),
783+ &state,
784+ );
785+ let s = state.lock();
786+ let rules = &s.profiles.effective().rules;
787+ // First rule is now the user-set one.
788+ assert_eq!(rules[0].match_.process_binary, vec!["obs".to_string()]);
789+ assert_eq!(rules[0].route, Route::Bypass);
790+ }
791+
792+ #[test]
793+ fn route_set_replaces_existing_user_rule() {
794+ let state = shared_with_default_profile();
795+ // First set: bypass.
796+ dispatch(
797+ &Request::new(
798+ 1,
799+ Op::RouteSet {
800+ app: "obs".into(),
801+ to: Route::Bypass,
802+ },
803+ ),
804+ &state,
805+ );
806+ // Second set on the same app: processed. Should replace, not stack.
807+ dispatch(
808+ &Request::new(
809+ 2,
810+ Op::RouteSet {
811+ app: "obs".into(),
812+ to: Route::Processed,
813+ },
814+ ),
815+ &state,
816+ );
817+ let s = state.lock();
818+ let rules = &s.profiles.effective().rules;
819+ let user_rules: Vec<_> = rules
820+ .iter()
821+ .filter(|r| r.match_.process_binary.len() == 1 && r.match_.process_binary[0] == "obs")
822+ .collect();
823+ assert_eq!(user_rules.len(), 1);
824+ assert_eq!(user_rules[0].route, Route::Processed);
825+ }
826+
827+ #[test]
828+ fn route_unset_removes_user_rule() {
829+ let state = shared_with_default_profile();
830+ dispatch(
831+ &Request::new(
832+ 1,
833+ Op::RouteSet {
834+ app: "obs".into(),
835+ to: Route::Bypass,
836+ },
837+ ),
838+ &state,
839+ );
840+ dispatch(
841+ &Request::new(2, Op::RouteUnset { app: "obs".into() }),
842+ &state,
843+ );
844+ let s = state.lock();
845+ let still_there = s
846+ .profiles
847+ .effective()
848+ .rules
849+ .iter()
850+ .any(|r| r.match_.process_binary.len() == 1 && r.match_.process_binary[0] == "obs");
851+ assert!(!still_there);
852+ }
853+
854+ #[test]
855+ fn route_unset_unknown_app_is_not_found() {
856+ let state = shared_with_default_profile();
857+ let resp = dispatch(
858+ &Request::new(
859+ 1,
860+ Op::RouteUnset {
861+ app: "no-such-app".into(),
862+ },
863+ ),
864+ &state,
865+ );
866+ match resp.payload {
867+ ResponsePayload::Err { error } => assert_eq!(error.code, ErrorCode::NotFound),
868+ ResponsePayload::Ok { .. } => panic!("expected NotFound"),
869+ }
870+ }
871+
872+ #[test]
873+ fn route_unset_does_not_remove_shipped_rules() {
874+ let state = shared_with_default_profile();
875+ // "firefox" is in a shipped multi-app rule; route.unset must
876+ // refuse to touch it.
877+ let resp = dispatch(
878+ &Request::new(
879+ 1,
880+ Op::RouteUnset {
881+ app: "firefox".into(),
882+ },
883+ ),
884+ &state,
885+ );
886+ match resp.payload {
887+ ResponsePayload::Err { error } => assert_eq!(error.code, ErrorCode::NotFound),
888+ ResponsePayload::Ok { .. } => panic!("expected NotFound"),
889+ }
890+ // And firefox is still in the rules (via the shipped rule).
891+ let s = state.lock();
892+ let still_firefox = s
893+ .profiles
894+ .effective()
895+ .rules
896+ .iter()
897+ .any(|r| r.match_.process_binary.iter().any(|p| p == "firefox"));
898+ assert!(still_firefox);
899+ }
900+
901+ #[test]
902+ fn setting_set_mutates_value() {
903+ let state = shared_with_default_profile();
904+ dispatch(
905+ &Request::new(
906+ 1,
907+ Op::SettingSet {
908+ key: "limiter.ceiling_dbtp".into(),
909+ value: json!(-1.0),
910+ },
911+ ),
912+ &state,
913+ );
914+ let v = state.lock().profiles.effective().limiter.ceiling_dbtp;
915+ assert!((v - -1.0).abs() < 1e-6);
916+ }
917+
918+ #[test]
919+ fn setting_set_rejects_wrong_type() {
920+ let state = shared_with_default_profile();
921+ let resp = dispatch(
922+ &Request::new(
923+ 1,
924+ Op::SettingSet {
925+ key: "limiter.ceiling_dbtp".into(),
926+ value: json!("not a number"),
927+ },
928+ ),
929+ &state,
930+ );
931+ match resp.payload {
932+ ResponsePayload::Err { error } => assert_eq!(error.code, ErrorCode::InvalidArgs),
933+ ResponsePayload::Ok { .. } => panic!("expected InvalidArgs"),
934+ }
935+ // Profile unchanged.
936+ assert!((state.lock().profiles.effective().limiter.ceiling_dbtp - -0.1).abs() < 1e-6);
937+ }
938+
939+ #[test]
940+ fn setting_set_unknown_key_is_not_found() {
941+ let state = shared_with_default_profile();
942+ let resp = dispatch(
943+ &Request::new(
944+ 1,
945+ Op::SettingSet {
946+ key: "limiter.does_not_exist".into(),
947+ value: json!(1),
948+ },
949+ ),
950+ &state,
951+ );
952+ match resp.payload {
953+ ResponsePayload::Err { error } => assert_eq!(error.code, ErrorCode::NotFound),
954+ ResponsePayload::Ok { .. } => panic!("expected NotFound"),
955+ }
956+ }
957+
958+ #[test]
959+ fn profile_use_active_is_noop_success() {
960+ let state = shared_with_default_profile();
961+ let resp = dispatch(
962+ &Request::new(
963+ 1,
964+ Op::ProfileUse {
965+ name: "default".into(),
966+ },
967+ ),
968+ &state,
969+ );
970+ let body = extract_ok(resp);
971+ assert_eq!(body["name"], "default");
972+ }
973+
974+ #[test]
975+ fn profile_use_unknown_returns_not_found() {
976+ let state = shared_with_default_profile();
977+ let resp = dispatch(
978+ &Request::new(
979+ 1,
980+ Op::ProfileUse {
981+ name: "night".into(),
982+ },
983+ ),
984+ &state,
985+ );
986+ match resp.payload {
987+ ResponsePayload::Err { error } => assert_eq!(error.code, ErrorCode::NotFound),
988+ ResponsePayload::Ok { .. } => panic!("expected NotFound"),
989+ }
990+ }
991+
992+ #[test]
993+ fn profile_reload_built_in_only_returns_default() {
994+ // Built-in stores have no disk paths; reload returns just the
995+ // built-in default and a warning saying there's nothing to scan.
996+ let state = shared_with_default_profile();
997+ let resp = dispatch(&Request::new(1, Op::ProfileReload), &state);
998+ let body = extract_ok(resp);
999+ let reloaded = body["reloaded"].as_array().unwrap();
1000+ assert_eq!(reloaded.len(), 1);
1001+ assert_eq!(reloaded[0], "default");
1002+ }
1003+
1004+ #[test]
1005+ fn setting_set_pushes_dsp_update() {
1006+ use crate::pw::filter::{AudioCmd, FilterControl};
1007+ let state = shared_with_default_profile();
1008+ let (control, mut consumer) = FilterControl::for_testing(8);
1009+ state.lock().filter_control = Some(control);
1010+
1011+ dispatch(
1012+ &Request::new(
1013+ 1,
1014+ Op::SettingSet {
1015+ key: "limiter.ceiling_dbtp".into(),
1016+ value: json!(-1.5),
1017+ },
1018+ ),
1019+ &state,
1020+ );
1021+
1022+ // Expect a compressor cmd and a limiter cmd (we push both for
1023+ // simplicity even when only one field changed).
1024+ let mut saw_limiter = false;
1025+ while let Ok(cmd) = consumer.pop() {
1026+ if let AudioCmd::SetLimiter(cfg) = cmd {
1027+ assert!((cfg.ceiling_dbtp - -1.5).abs() < 1e-6);
1028+ saw_limiter = true;
1029+ }
1030+ }
1031+ assert!(saw_limiter, "setting.set should push a SetLimiter cmd");
1032+ }
1033+
1034+ #[test]
1035+ fn route_set_does_not_push_dsp_update() {
1036+ // Routing changes don't touch DSP. Filter must be left alone.
1037+ use crate::pw::filter::FilterControl;
1038+ let state = shared_with_default_profile();
1039+ let (control, mut consumer) = FilterControl::for_testing(8);
1040+ state.lock().filter_control = Some(control);
1041+
1042+ dispatch(
1043+ &Request::new(
1044+ 1,
1045+ Op::RouteSet {
1046+ app: "obs".into(),
1047+ to: Route::Bypass,
1048+ },
1049+ ),
1050+ &state,
1051+ );
1052+ assert!(consumer.pop().is_err(), "route.set must not push DSP cmds");
1053+ }
1054+
1055+ #[test]
1056+ fn route_stream_unknown_node_id_returns_not_found() {
1057+ let state = shared_with_default_profile();
1058+ let resp = dispatch(
1059+ &Request::new(
1060+ 1,
1061+ Op::RouteStream {
1062+ node_id: 9999,
1063+ to: Route::Bypass,
1064+ },
1065+ ),
1066+ &state,
1067+ );
1068+ match resp.payload {
1069+ ResponsePayload::Err { error } => assert_eq!(error.code, ErrorCode::NotFound),
1070+ ResponsePayload::Ok { .. } => panic!("expected NotFound"),
1071+ }
1072+ }
1073+
1074+ #[test]
1075+ fn route_stream_updates_state_synchronously() {
1076+ let state = shared_with_default_profile();
1077+ // Seed: a known stream currently routed Processed.
1078+ state.lock().streams.insert(
1079+ 42,
1080+ RoutedStream {
1081+ node_id: 42,
1082+ app: "firefox".into(),
1083+ route: Route::Processed,
1084+ },
1085+ );
1086+
1087+ let resp = dispatch(
1088+ &Request::new(
1089+ 1,
1090+ Op::RouteStream {
1091+ node_id: 42,
1092+ to: Route::Bypass,
1093+ },
1094+ ),
1095+ &state,
1096+ );
1097+ assert!(matches!(resp.payload, ResponsePayload::Ok { .. }));
1098+ assert_eq!(state.lock().streams[&42].route, Route::Bypass);
1099+ }
1100+
1101+ #[test]
1102+ fn route_stream_pushes_command_when_channel_present() {
1103+ use crate::pw::command::PwCommand;
1104+ let state = shared_with_default_profile();
1105+ let (tx, rx) = crossbeam_channel::unbounded::<PwCommand>();
1106+ state.lock().pw_command_tx = Some(tx);
1107+ state.lock().streams.insert(
1108+ 42,
1109+ RoutedStream {
1110+ node_id: 42,
1111+ app: "firefox".into(),
1112+ route: Route::Processed,
1113+ },
1114+ );
1115+
1116+ dispatch(
1117+ &Request::new(
1118+ 1,
1119+ Op::RouteStream {
1120+ node_id: 42,
1121+ to: Route::Bypass,
1122+ },
1123+ ),
1124+ &state,
1125+ );
1126+ let cmd = rx.try_recv().expect("command should arrive");
1127+ let PwCommand::RouteStream {
1128+ node_id,
1129+ to,
1130+ app_label,
1131+ } = cmd
1132+ else {
1133+ panic!("expected RouteStream, got {cmd:?}");
1134+ };
1135+ assert_eq!(node_id, 42);
1136+ assert_eq!(to, Route::Bypass);
1137+ assert_eq!(app_label, "firefox");
1138+ }
1139+
1140+ #[test]
1141+ fn route_stream_no_channel_is_still_success() {
1142+ // Tests / pre-PipeWire startup: no tx is fine, state still
1143+ // updates and the op returns Ok.
1144+ let state = shared_with_default_profile();
1145+ state.lock().streams.insert(
1146+ 42,
1147+ RoutedStream {
1148+ node_id: 42,
1149+ app: "mpv".into(),
1150+ route: Route::Processed,
1151+ },
1152+ );
1153+ let resp = dispatch(
1154+ &Request::new(
1155+ 1,
1156+ Op::RouteStream {
1157+ node_id: 42,
1158+ to: Route::Bypass,
1159+ },
1160+ ),
1161+ &state,
1162+ );
1163+ assert!(matches!(resp.payload, ResponsePayload::Ok { .. }));
1164+ assert_eq!(state.lock().streams[&42].route, Route::Bypass);
1165+ }
1166+}
Added file: crates/headroom-core/src/ipc/ops/effect_tests.rs+433 additions
@@ -0,0 +1,433 @@
1+use std::fs;
2+use std::path::PathBuf;
3+use std::time::{SystemTime, UNIX_EPOCH};
4+
5+use crossbeam_channel::Receiver;
6+use rtrb::Consumer;
7+use serde_json::{json, Value};
8+
9+use super::*;
10+use crate::profile_store::{ProfileStore, StorePaths};
11+use crate::pw::command::PwCommand;
12+use crate::pw::filter::{AudioCmd, FilterControl};
13+use crate::state::{self, SharedState};
14+use headroom_ipc::{Op, Request, ResponsePayload, Route, ServerFrame, Topic};
15+
16+struct TempDir(PathBuf);
17+
18+impl Drop for TempDir {
19+ fn drop(&mut self) {
20+ let _ = fs::remove_dir_all(&self.0);
21+ }
22+}
23+
24+fn temp_paths() -> (StorePaths, TempDir) {
25+ let base = std::env::temp_dir().join(format!(
26+ "headroom-ipc-effects-{}-{}",
27+ std::process::id(),
28+ SystemTime::now()
29+ .duration_since(UNIX_EPOCH)
30+ .unwrap()
31+ .as_nanos()
32+ ));
33+ fs::create_dir_all(base.join("config/profiles")).unwrap();
34+ fs::create_dir_all(base.join("state")).unwrap();
35+ (
36+ StorePaths {
37+ config_dir: base.join("config"),
38+ state_dir: base.join("state"),
39+ share_dirs: vec![],
40+ extra_profile_dirs: vec![],
41+ },
42+ TempDir(base),
43+ )
44+}
45+
46+fn shared_with_default_profile() -> SharedState {
47+ state::shared(crate::state::DaemonState::new(ProfileStore::builtin()))
48+}
49+
50+fn shared_with_night_profile() -> (SharedState, TempDir) {
51+ let (paths, guard) = temp_paths();
52+ fs::write(
53+ paths.config_dir.join("profiles/night.toml"),
54+ r#"
55+name = "night"
56+description = "night profile"
57+[limiter]
58+ceiling_dbtp = -2.0
59+"#,
60+ )
61+ .unwrap();
62+ let store = ProfileStore::load(&paths).unwrap();
63+ (state::shared(crate::state::DaemonState::new(store)), guard)
64+}
65+
66+fn ok(resp: Response) -> Value {
67+ match resp.payload {
68+ ResponsePayload::Ok { result } => result,
69+ ResponsePayload::Err { error } => panic!("expected ok, got {error}"),
70+ }
71+}
72+
73+fn attach_filter(state: &SharedState) -> Consumer<AudioCmd> {
74+ let (control, consumer) = FilterControl::for_testing(16);
75+ state.lock().filter_control = Some(control);
76+ consumer
77+}
78+
79+fn attach_pw_commands(state: &SharedState) -> Receiver<PwCommand> {
80+ let (tx, rx) = crossbeam_channel::unbounded();
81+ state.lock().pw_command_tx = Some(tx);
82+ rx
83+}
84+
85+fn attach_events(state: &SharedState, topics: &[Topic]) -> Receiver<ServerFrame> {
86+ let (tx, rx) = crossbeam_channel::unbounded();
87+ let mut state = state.lock();
88+ let id = state.broadcaster.register(tx);
89+ state.broadcaster.subscribe(id, topics);
90+ rx
91+}
92+
93+fn drain_audio(consumer: &mut Consumer<AudioCmd>) -> Vec<AudioCmd> {
94+ let mut out = Vec::new();
95+ while let Ok(cmd) = consumer.pop() {
96+ out.push(cmd);
97+ }
98+ out
99+}
100+
101+fn drain_pw(rx: &Receiver<PwCommand>) -> Vec<PwCommand> {
102+ rx.try_iter().collect()
103+}
104+
105+fn drain_events(rx: &Receiver<ServerFrame>) -> Vec<headroom_ipc::Event> {
106+ rx.try_iter()
107+ .filter_map(|frame| match frame {
108+ ServerFrame::Event(event) => Some(event),
109+ ServerFrame::Response(_) => None,
110+ })
111+ .collect()
112+}
113+
114+fn setting_set(key: &str, value: Value, state: &SharedState) {
115+ let _ = ok(dispatch(
116+ &Request::new(
117+ 1,
118+ Op::SettingSet {
119+ key: key.into(),
120+ value,
121+ },
122+ ),
123+ state,
124+ ));
125+}
126+
127+fn count_reevaluate_all(cmds: &[PwCommand]) -> usize {
128+ cmds.iter()
129+ .filter(|cmd| matches!(cmd, PwCommand::ReevaluateAll))
130+ .count()
131+}
132+
133+fn count_reevaluate_layer_a(cmds: &[PwCommand]) -> usize {
134+ cmds.iter()
135+ .filter(|cmd| matches!(cmd, PwCommand::ReevaluateLayerA))
136+ .count()
137+}
138+
139+fn assert_reevaluate_counts(cmds: &[PwCommand], route: usize, layer_a: usize) {
140+ assert_eq!(
141+ count_reevaluate_all(cmds),
142+ route,
143+ "expected {route} route reapply command(s), got {cmds:?}"
144+ );
145+ assert_eq!(
146+ count_reevaluate_layer_a(cmds),
147+ layer_a,
148+ "expected {layer_a} layer a reapply command(s), got {cmds:?}"
149+ );
150+}
151+
152+#[test]
153+fn setting_set_agc_enabled_pushes_filter_enable() {
154+ let state = shared_with_default_profile();
155+ let mut audio = attach_filter(&state);
156+
157+ setting_set("agc.enabled", json!(false), &state);
158+
159+ let cmds = drain_audio(&mut audio);
160+ assert!(
161+ cmds.iter()
162+ .any(|cmd| matches!(cmd, AudioCmd::SetAgcEnabled(false))),
163+ "expected agc enable update, got {cmds:?}"
164+ );
165+}
166+
167+#[test]
168+fn setting_set_compressor_enabled_pushes_compressor_config() {
169+ let state = shared_with_default_profile();
170+ let mut audio = attach_filter(&state);
171+
172+ setting_set("compressor.enabled", json!(false), &state);
173+
174+ let cmds = drain_audio(&mut audio);
175+ assert!(
176+ cmds.iter()
177+ .any(|cmd| { matches!(cmd, AudioCmd::SetCompressor(cfg) if !cfg.enabled) }),
178+ "expected compressor update, got {cmds:?}"
179+ );
180+}
181+
182+#[test]
183+fn setting_set_limiter_ceiling_pushes_limiter_config() {
184+ let state = shared_with_default_profile();
185+ let mut audio = attach_filter(&state);
186+
187+ setting_set("limiter.ceiling_dbtp", json!(-1.5), &state);
188+
189+ let cmds = drain_audio(&mut audio);
190+ assert!(
191+ cmds.iter().any(|cmd| {
192+ matches!(cmd, AudioCmd::SetLimiter(cfg) if (cfg.ceiling_dbtp - -1.5).abs() < 1e-6)
193+ }),
194+ "expected limiter update, got {cmds:?}"
195+ );
196+}
197+
198+#[test]
199+fn setting_set_default_route_posts_route_reapply() {
200+ let state = shared_with_default_profile();
201+ let rx = attach_pw_commands(&state);
202+
203+ setting_set("default_route.route", json!("bypass"), &state);
204+
205+ let cmds = drain_pw(&rx);
206+ assert_reevaluate_counts(&cmds, 1, 1);
207+}
208+
209+#[test]
210+fn setting_set_per_app_enabled_posts_layer_a_reapply() {
211+ let state = shared_with_default_profile();
212+ let rx = attach_pw_commands(&state);
213+
214+ setting_set("per_app.enabled", json!(false), &state);
215+
216+ let cmds = drain_pw(&rx);
217+ assert_reevaluate_counts(&cmds, 1, 1);
218+}
219+
220+#[test]
221+fn setting_clear_posts_route_and_layer_a_reapply() {
222+ let state = shared_with_default_profile();
223+ setting_set("limiter.ceiling_dbtp", json!(-2.0), &state);
224+ let rx = attach_pw_commands(&state);
225+
226+ let _ = ok(dispatch(
227+ &Request::new(
228+ 2,
229+ Op::SettingClear {
230+ key: "limiter.ceiling_dbtp".into(),
231+ },
232+ ),
233+ &state,
234+ ));
235+
236+ let cmds = drain_pw(&rx);
237+ assert_reevaluate_counts(&cmds, 1, 1);
238+}
239+
240+#[test]
241+fn setting_reset_posts_route_and_layer_a_reapply() {
242+ let state = shared_with_default_profile();
243+ setting_set("limiter.ceiling_dbtp", json!(-2.0), &state);
244+ let rx = attach_pw_commands(&state);
245+
246+ let _ = ok(dispatch(&Request::new(2, Op::SettingReset), &state));
247+
248+ let cmds = drain_pw(&rx);
249+ assert_reevaluate_counts(&cmds, 1, 1);
250+}
251+
252+#[test]
253+fn profile_use_posts_dsp_and_reapply() {
254+ let (state, _guard) = shared_with_night_profile();
255+ let mut audio = attach_filter(&state);
256+ let rx = attach_pw_commands(&state);
257+
258+ let _ = ok(dispatch(
259+ &Request::new(
260+ 1,
261+ Op::ProfileUse {
262+ name: "night".into(),
263+ },
264+ ),
265+ &state,
266+ ));
267+
268+ let audio_cmds = drain_audio(&mut audio);
269+ let pw_cmds = drain_pw(&rx);
270+ assert!(
271+ audio_cmds
272+ .iter()
273+ .any(|cmd| matches!(cmd, AudioCmd::SetLimiter(_))),
274+ "expected dsp update, got {audio_cmds:?}"
275+ );
276+ assert_reevaluate_counts(&pw_cmds, 1, 1);
277+}
278+
279+#[test]
280+fn profile_reload_posts_dsp_and_reapply() {
281+ let (state, _guard) = shared_with_night_profile();
282+ let mut audio = attach_filter(&state);
283+ let rx = attach_pw_commands(&state);
284+
285+ let _ = ok(dispatch(&Request::new(1, Op::ProfileReload), &state));
286+
287+ let audio_cmds = drain_audio(&mut audio);
288+ let pw_cmds = drain_pw(&rx);
289+ assert!(
290+ audio_cmds
291+ .iter()
292+ .any(|cmd| matches!(cmd, AudioCmd::SetLimiter(_))),
293+ "expected dsp update, got {audio_cmds:?}"
294+ );
295+ assert_reevaluate_counts(&pw_cmds, 1, 1);
296+}
297+
298+#[test]
299+fn bypass_set_posts_route_reapply() {
300+ let state = shared_with_default_profile();
301+ let rx = attach_pw_commands(&state);
302+
303+ let _ = ok(dispatch(
304+ &Request::new(1, Op::BypassSet { enabled: true }),
305+ &state,
306+ ));
307+
308+ let cmds = drain_pw(&rx);
309+ assert_reevaluate_counts(&cmds, 1, 0);
310+}
311+
312+#[test]
313+fn route_set_and_unset_post_route_reapply() {
314+ let state = shared_with_default_profile();
315+ let rx = attach_pw_commands(&state);
316+
317+ let _ = ok(dispatch(
318+ &Request::new(
319+ 1,
320+ Op::RouteSet {
321+ app: "obs".into(),
322+ to: Route::Bypass,
323+ },
324+ ),
325+ &state,
326+ ));
327+ let cmds = drain_pw(&rx);
328+ assert_reevaluate_counts(&cmds, 1, 0);
329+
330+ let _ = ok(dispatch(
331+ &Request::new(2, Op::RouteUnset { app: "obs".into() }),
332+ &state,
333+ ));
334+ let cmds = drain_pw(&rx);
335+ assert_reevaluate_counts(&cmds, 1, 0);
336+}
337+
338+#[test]
339+fn per_app_set_and_master_post_layer_a_reapply() {
340+ let state = shared_with_default_profile();
341+ let rx = attach_pw_commands(&state);
342+
343+ let _ = ok(dispatch(
344+ &Request::new(
345+ 1,
346+ Op::PerAppSet {
347+ app: "discord".into(),
348+ enabled: true,
349+ },
350+ ),
351+ &state,
352+ ));
353+ let cmds = drain_pw(&rx);
354+ assert_reevaluate_counts(&cmds, 0, 1);
355+
356+ let _ = ok(dispatch(
357+ &Request::new(2, Op::PerAppMaster { enabled: true }),
358+ &state,
359+ ));
360+ let cmds = drain_pw(&rx);
361+ assert_reevaluate_counts(&cmds, 0, 1);
362+}
363+
364+#[test]
365+fn profile_use_publishes_used_event() {
366+ let (state, _guard) = shared_with_night_profile();
367+ let rx = attach_events(&state, &[Topic::Profile]);
368+
369+ let _ = ok(dispatch(
370+ &Request::new(
371+ 1,
372+ Op::ProfileUse {
373+ name: "night".into(),
374+ },
375+ ),
376+ &state,
377+ ));
378+
379+ let events = drain_events(&rx);
380+ assert!(
381+ events.iter().any(|event| {
382+ event.topic == Topic::Profile
383+ && event.event == "used"
384+ && event.data["name"] == json!("night")
385+ }),
386+ "expected profile used event, got {events:?}"
387+ );
388+}
389+
390+#[test]
391+fn profile_reload_publishes_reloaded_event() {
392+ let (state, _guard) = shared_with_night_profile();
393+ let rx = attach_events(&state, &[Topic::Profile]);
394+
395+ let _ = ok(dispatch(&Request::new(1, Op::ProfileReload), &state));
396+
397+ let events = drain_events(&rx);
398+ assert!(
399+ events.iter().any(|event| {
400+ event.topic == Topic::Profile
401+ && event.event == "reloaded"
402+ && event.data["loaded"]
403+ .as_array()
404+ .is_some_and(|loaded| loaded.iter().any(|name| name == "night"))
405+ }),
406+ "expected profile reloaded event, got {events:?}"
407+ );
408+}
409+
410+#[test]
411+fn route_rule_mutations_publish_rule_changed() {
412+ let state = shared_with_default_profile();
413+ let rx = attach_events(&state, &[Topic::Routing]);
414+
415+ let _ = ok(dispatch(
416+ &Request::new(
417+ 1,
418+ Op::RouteSet {
419+ app: "obs".into(),
420+ to: Route::Bypass,
421+ },
422+ ),
423+ &state,
424+ ));
425+
426+ let events = drain_events(&rx);
427+ assert!(
428+ events
429+ .iter()
430+ .any(|event| event.topic == Topic::Routing && event.event == "rule_changed"),
431+ "expected route rule change event, got {events:?}"
432+ );
433+}
Added file: crates/headroom-core/src/ipc/ops/profile_effects.rs+151 additions
@@ -0,0 +1,151 @@
1+use serde_json::json;
2+
3+use headroom_ipc::{Event, Topic};
4+
5+use crate::pw::command::PwCommand;
6+use crate::pw::filter::FilterControl;
7+use crate::state::DaemonState;
8+
9+pub(super) struct ProfileEffects {
10+ event: Option<EffectEvent>,
11+ dsp: DspEffect,
12+ commands: &'static [PwCommand],
13+}
14+
15+enum EffectEvent {
16+ ProfileUsed(String),
17+ ProfileReloaded(Vec<String>),
18+ RouteRuleChanged,
19+}
20+
21+enum DspEffect {
22+ None,
23+ PushEffective,
24+}
25+
26+pub(super) struct PostProfileEffects {
27+ dsp: Option<(FilterControl, DspSnapshot)>,
28+}
29+
30+const ROUTE_REAPPLY: &[PwCommand] = &[PwCommand::ReevaluateAll];
31+const LAYER_A_REAPPLY: &[PwCommand] = &[PwCommand::ReevaluateLayerA];
32+const ROUTE_AND_LAYER_A_REAPPLY: &[PwCommand] =
33+ &[PwCommand::ReevaluateAll, PwCommand::ReevaluateLayerA];
34+
35+impl ProfileEffects {
36+ pub(super) fn profile_used(name: &str) -> Self {
37+ Self {
38+ event: Some(EffectEvent::ProfileUsed(name.to_owned())),
39+ dsp: DspEffect::PushEffective,
40+ commands: ROUTE_AND_LAYER_A_REAPPLY,
41+ }
42+ }
43+
44+ pub(super) fn profile_reloaded(loaded: Vec<String>) -> Self {
45+ Self {
46+ event: Some(EffectEvent::ProfileReloaded(loaded)),
47+ dsp: DspEffect::PushEffective,
48+ commands: ROUTE_AND_LAYER_A_REAPPLY,
49+ }
50+ }
51+
52+ pub(super) fn route_rule_changed() -> Self {
53+ Self {
54+ event: Some(EffectEvent::RouteRuleChanged),
55+ dsp: DspEffect::None,
56+ commands: ROUTE_REAPPLY,
57+ }
58+ }
59+
60+ pub(super) fn settings_changed() -> Self {
61+ Self {
62+ event: None,
63+ dsp: DspEffect::PushEffective,
64+ commands: ROUTE_AND_LAYER_A_REAPPLY,
65+ }
66+ }
67+
68+ pub(super) fn bypass_changed() -> Self {
69+ Self {
70+ event: None,
71+ dsp: DspEffect::None,
72+ commands: ROUTE_REAPPLY,
73+ }
74+ }
75+
76+ pub(super) fn per_app_changed() -> Self {
77+ Self {
78+ event: Some(EffectEvent::RouteRuleChanged),
79+ dsp: DspEffect::None,
80+ commands: LAYER_A_REAPPLY,
81+ }
82+ }
83+
84+ pub(super) fn apply(self, state: &mut DaemonState) -> PostProfileEffects {
85+ publish(state, self.event);
86+ let dsp = match self.dsp {
87+ DspEffect::None => None,
88+ DspEffect::PushEffective => state
89+ .filter_control
90+ .clone()
91+ .map(|control| (control, DspSnapshot::from(state))),
92+ };
93+ for cmd in self.commands {
94+ post_command(state, cmd);
95+ }
96+ PostProfileEffects { dsp }
97+ }
98+}
99+
100+impl PostProfileEffects {
101+ pub(super) fn finish(self) {
102+ if let Some((control, snap)) = self.dsp {
103+ control.set_compressor(snap.compressor);
104+ control.set_limiter(snap.limiter);
105+ control.set_agc_enabled(snap.agc_enabled);
106+ }
107+ }
108+}
109+
110+fn publish(state: &mut DaemonState, event: Option<EffectEvent>) {
111+ let Some(event) = event else { return };
112+ let event = match event {
113+ EffectEvent::ProfileUsed(name) => {
114+ Event::new(Topic::Profile, "used", &json!({ "name": name }))
115+ }
116+ EffectEvent::ProfileReloaded(loaded) => {
117+ Event::new(Topic::Profile, "reloaded", &json!({ "loaded": loaded }))
118+ }
119+ EffectEvent::RouteRuleChanged => Event::new(Topic::Routing, "rule_changed", &json!({})),
120+ };
121+ if let Ok(event) = event {
122+ state.broadcaster.publish(event.topic, event);
123+ }
124+}
125+
126+fn post_command(state: &DaemonState, cmd: &PwCommand) {
127+ let Some(tx) = state.pw_command_tx.as_ref() else {
128+ tracing::debug!(?cmd, "no PipeWire command channel; profile effect skipped");
129+ return;
130+ };
131+ if tx.send((*cmd).clone()).is_err() {
132+ tracing::warn!(?cmd, "PipeWire command channel closed; profile effect lost");
133+ }
134+}
135+
136+struct DspSnapshot {
137+ compressor: headroom_dsp::CompressorConfig,
138+ limiter: headroom_dsp::LimiterConfig,
139+ agc_enabled: bool,
140+}
141+
142+impl DspSnapshot {
143+ fn from(state: &DaemonState) -> Self {
144+ let effective = state.profiles.effective();
145+ Self {
146+ compressor: effective.build_compressor_config(),
147+ limiter: effective.build_limiter_config(),
148+ agc_enabled: effective.agc.enabled,
149+ }
150+ }
151+}
Added file: crates/headroom-core/src/ipc/server.rs+306 additions
@@ -0,0 +1,306 @@
1+//! unix-domain socket listener + accept loop.
2+
3+use std::os::unix::fs::PermissionsExt;
4+use std::os::unix::net::{UnixListener, UnixStream};
5+use std::path::PathBuf;
6+use std::sync::atomic::{AtomicBool, Ordering};
7+use std::sync::Arc;
8+use std::thread::{self, JoinHandle};
9+use std::time::Duration;
10+
11+use crate::error::DaemonError;
12+use crate::ipc::connection::handle_connection;
13+use crate::state::SharedState;
14+
15+/// accept-thread shutdown poll interval.
16+const ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(50);
17+
18+/// owner-only ($XDG_RUNTIME_DIR/headroom/).
19+const SOCKET_DIR_MODE: u32 = 0o700;
20+
21+/// owner-only — IPC auth is filesystem perms, as PipeWire/Wayland do.
22+const SOCKET_MODE: u32 = 0o600;
23+
24+pub struct IpcServer;
25+
26+/// drop or [`IpcServerHandle::shutdown`] to stop the server.
27+pub struct IpcServerHandle {
28+ shutdown: Arc<AtomicBool>,
29+ accept_thread: Option<JoinHandle<()>>,
30+ socket_path: PathBuf,
31+}
32+
33+impl IpcServer {
34+ /// bind a [`UnixListener`] at `socket_path` and spawn the accept
35+ /// thread. stale sockets are unlinked; a reachable path means
36+ /// another daemon is running → [`DaemonError::Other`].
37+ ///
38+ /// # Errors
39+ /// - [`DaemonError::Io`] for filesystem failures.
40+ /// - [`DaemonError::Other`] if another daemon owns the path.
41+ pub fn start(
42+ socket_path: PathBuf,
43+ state: SharedState,
44+ ) -> Result<IpcServerHandle, DaemonError> {
45+ if let Some(parent) = socket_path.parent() {
46+ std::fs::create_dir_all(parent)?;
47+ // best-effort: $XDG_RUNTIME_DIR is already user-owned.
48+ let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(SOCKET_DIR_MODE));
49+ }
50+
51+ if socket_path.exists() {
52+ if UnixStream::connect(&socket_path).is_ok() {
53+ return Err(DaemonError::other(format!(
54+ "another headroom daemon is already listening at {}",
55+ socket_path.display()
56+ )));
57+ }
58+ std::fs::remove_file(&socket_path)?;
59+ tracing::debug!(path = %socket_path.display(), "removed stale socket");
60+ }
61+
62+ let listener = UnixListener::bind(&socket_path)?;
63+ std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(SOCKET_MODE))?;
64+ listener.set_nonblocking(true)?;
65+
66+ let shutdown = Arc::new(AtomicBool::new(false));
67+ let shutdown_for_thread = shutdown.clone();
68+ let state_for_thread = state;
69+ let accept_thread = thread::Builder::new()
70+ .name("headroom-ipc-accept".into())
71+ .spawn(move || accept_loop(listener, state_for_thread, shutdown_for_thread))?;
72+
73+ tracing::info!(path = %socket_path.display(), "ipc server listening");
74+
75+ Ok(IpcServerHandle {
76+ shutdown,
77+ accept_thread: Some(accept_thread),
78+ socket_path,
79+ })
80+ }
81+}
82+
83+impl IpcServerHandle {
84+ /// stop + join the accept thread and unlink the socket. idempotent.
85+ /// connection threads outlive this; they exit when peers close.
86+ pub fn shutdown(&mut self) {
87+ self.shutdown.store(true, Ordering::Relaxed);
88+ if let Some(t) = self.accept_thread.take() {
89+ if let Err(e) = t.join() {
90+ tracing::warn!(?e, "ipc accept thread join failed");
91+ }
92+ }
93+ // remove the socket file so the next daemon sees no stale entry.
94+ let _ = std::fs::remove_file(&self.socket_path);
95+ tracing::info!(path = %self.socket_path.display(), "ipc server stopped");
96+ }
97+}
98+
99+impl Drop for IpcServerHandle {
100+ fn drop(&mut self) {
101+ self.shutdown();
102+ }
103+}
104+
105+fn accept_loop(
106+ listener: UnixListener,
107+ state: SharedState,
108+ shutdown: Arc<AtomicBool>,
109+) {
110+ while !shutdown.load(Ordering::Relaxed) {
111+ match listener.accept() {
112+ Ok((stream, _addr)) => {
113+ // connection threads use blocking I/O.
114+ if let Err(e) = stream.set_nonblocking(false) {
115+ tracing::warn!(error = %e, "set_nonblocking(false) failed; dropping conn");
116+ continue;
117+ }
118+ let shutdown_for_conn = shutdown.clone();
119+ let state_for_conn = state.clone();
120+ if let Err(e) = thread::Builder::new()
121+ .name("headroom-ipc-conn".into())
122+ .spawn(move || handle_connection(stream, state_for_conn, shutdown_for_conn))
123+ {
124+ tracing::warn!(error = %e, "ipc conn spawn failed");
125+ }
126+ }
127+ Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
128+ thread::sleep(ACCEPT_POLL_INTERVAL);
129+ }
130+ Err(e) => {
131+ tracing::error!(error = %e, "ipc accept failed; stopping accept loop");
132+ break;
133+ }
134+ }
135+ }
136+ tracing::debug!("ipc accept loop exited");
137+}
138+
139+#[cfg(test)]
140+mod tests {
141+ use super::*;
142+ use crate::profile_store::ProfileStore;
143+ use crate::state::{self, DaemonState};
144+ use headroom_client::Client;
145+ use headroom_ipc::Route;
146+ use std::process;
147+ use std::sync::atomic::AtomicU64;
148+
149+ static NEXT_TEST_SOCKET: AtomicU64 = AtomicU64::new(0);
150+
151+ fn temp_socket_path() -> PathBuf {
152+ let n = NEXT_TEST_SOCKET.fetch_add(1, Ordering::Relaxed);
153+ std::env::temp_dir().join(format!("headroom-test-{}-{}.sock", process::id(), n))
154+ }
155+
156+ fn test_state() -> SharedState {
157+ state::shared(DaemonState::new(ProfileStore::builtin()))
158+ }
159+
160+ #[test]
161+ fn start_and_shutdown_cleanly() {
162+ let path = temp_socket_path();
163+ let _ = std::fs::remove_file(&path);
164+
165+ let mut handle = IpcServer::start(path.clone(), test_state()).expect("server should start");
166+ assert!(path.exists(), "socket file should exist while server runs");
167+ handle.shutdown();
168+ assert!(!path.exists(), "shutdown should unlink the socket");
169+ }
170+
171+ #[test]
172+ fn rejects_double_start() {
173+ let path = temp_socket_path();
174+ let _ = std::fs::remove_file(&path);
175+
176+ let _first = IpcServer::start(path.clone(), test_state()).expect("first server should start");
177+ let second = IpcServer::start(path.clone(), test_state());
178+ assert!(
179+ second.is_err(),
180+ "second server should refuse to take over a live socket"
181+ );
182+ }
183+
184+ #[test]
185+ fn stale_socket_is_reclaimed() {
186+ let path = temp_socket_path();
187+ let _ = std::fs::remove_file(&path);
188+
189+ // Create a "stale" socket file with no listener behind it.
190+ {
191+ let _l = UnixListener::bind(&path).expect("bind for stale fixture");
192+ }
193+ // Listener dropped; the file remains but is unreachable.
194+
195+ let handle = IpcServer::start(path.clone(), test_state());
196+ assert!(
197+ handle.is_ok(),
198+ "should reclaim a stale socket (file present, no listener)"
199+ );
200+ }
201+
202+ #[test]
203+ fn client_can_status_and_setting_get() {
204+ let path = temp_socket_path();
205+ let _ = std::fs::remove_file(&path);
206+ let _server = IpcServer::start(path.clone(), test_state()).expect("server should start");
207+
208+ let mut client = Client::connect_at(&path).expect("client connect");
209+ let hello = client.hello();
210+ assert_eq!(hello.daemon, "headroom");
211+ assert_eq!(hello.protocol, headroom_ipc::PROTOCOL_VERSION);
212+
213+ // Read-only ops land in 4b.
214+ let status = client.status().expect("status should succeed");
215+ assert_eq!(status.profile, "default");
216+ assert_eq!(status.protocol, headroom_ipc::PROTOCOL_VERSION);
217+
218+ let value = client
219+ .setting_get("limiter.ceiling_dbtp")
220+ .expect("setting.get should succeed");
221+ let n = value.as_f64().unwrap();
222+ assert!((n - -0.1).abs() < 1e-6);
223+ }
224+
225+ /// End-to-end through the IPC: load a store with a second profile
226+ /// on disk, switch to it via `profile.use`, and confirm that an
227+ /// overlay tweak made on the original profile carries across.
228+ #[test]
229+ fn client_profile_use_preserves_overlay() {
230+ use crate::profile_store::{ProfileStore, StorePaths};
231+ use std::fs;
232+ use std::time::{SystemTime, UNIX_EPOCH};
233+
234+ let base = std::env::temp_dir().join(format!(
235+ "headroom-e2e-{}-{}",
236+ process::id(),
237+ SystemTime::now()
238+ .duration_since(UNIX_EPOCH)
239+ .unwrap()
240+ .as_nanos()
241+ ));
242+ let _guard = scopeguard_remove(base.clone());
243+ fs::create_dir_all(base.join("config/profiles")).unwrap();
244+ fs::create_dir_all(base.join("state")).unwrap();
245+ fs::write(
246+ base.join("config/profiles/night.toml"),
247+ "name = \"night\"\ndescription = \"loud night\"\n[limiter]\nceiling_dbtp = -2.0\n",
248+ )
249+ .unwrap();
250+ let paths = StorePaths {
251+ config_dir: base.join("config"),
252+ state_dir: base.join("state"),
253+ share_dirs: vec![],
254+ extra_profile_dirs: vec![],
255+ };
256+ let store = ProfileStore::load(&paths).expect("store load");
257+ let state = state::shared(DaemonState::new(store));
258+
259+ let sock = temp_socket_path();
260+ let _ = std::fs::remove_file(&sock);
261+ let _server = IpcServer::start(sock.clone(), state).expect("server should start");
262+
263+ let mut client = Client::connect_at(&sock).expect("client connect");
264+
265+ // Apply an overlay tweak while on `default`.
266+ client
267+ .route_set("obs", Route::Bypass)
268+ .expect("route.set obs");
269+ client
270+ .setting_set("agc.target_lufs", serde_json::json!(-22.0))
271+ .expect("setting.set agc.target_lufs");
272+
273+ // Switch to `night`.
274+ let switched_to = client.profile_use("night").expect("profile.use night");
275+ assert_eq!(switched_to, "night");
276+ let status = client.status().unwrap();
277+ assert_eq!(status.profile, "night");
278+
279+ // Overlay survived: route override is still visible in route.list,
280+ // and the setting override still wins over night.toml's value.
281+ let routes = client.route_list().unwrap();
282+ let user_rule = routes
283+ .rules
284+ .iter()
285+ .find(|r| r.match_.process_binary == vec!["obs".to_string()])
286+ .expect("obs override carried across profile switch");
287+ assert_eq!(user_rule.route, Route::Bypass);
288+
289+ let lufs = client.setting_get("agc.target_lufs").unwrap();
290+ assert!((lufs.as_f64().unwrap() - -22.0).abs() < 1e-6);
291+
292+ // night.toml's limiter ceiling shows through where there's no override.
293+ let ceiling = client.setting_get("limiter.ceiling_dbtp").unwrap();
294+ assert!((ceiling.as_f64().unwrap() - -2.0).abs() < 1e-6);
295+ }
296+
297+ fn scopeguard_remove(path: PathBuf) -> impl Drop {
298+ struct Cleanup(PathBuf);
299+ impl Drop for Cleanup {
300+ fn drop(&mut self) {
301+ let _ = std::fs::remove_dir_all(&self.0);
302+ }
303+ }
304+ Cleanup(path)
305+ }
306+}
Added file: crates/headroom-core/src/lib.rs+36 additions
@@ -0,0 +1,36 @@
1+//! daemon core: pipewire main loop, filter pipeline, registry, routing, agc, ipc
2+
3+#![forbid(unsafe_code)]
4+#![warn(missing_docs)]
5+
6+pub mod agc;
7+pub mod app_level;
8+pub mod error;
9+pub mod ipc;
10+pub mod meters;
11+pub mod profile;
12+pub mod profile_store;
13+pub mod profile_watcher;
14+pub mod pw;
15+pub mod routing;
16+pub mod runtime;
17+pub mod state;
18+
19+pub use error::DaemonError;
20+pub use profile::Profile;
21+pub use profile_store::{ProfileStore, StorePaths, StoreError, UserOverlay};
22+
23+/// blocks until shutdown (SIGTERM/SIGINT) or fatal failure. profiles + overlay from xdg paths.
24+pub fn run() -> Result<(), DaemonError> {
25+ run_with_profile_dirs(Vec::new())
26+}
27+
28+/// like [`run`] but also scans `extra_profile_dirs` directly for `*.toml` and watches
29+/// the first for live edits. backs the daemon's `--profile-dir` flag.
30+pub fn run_with_profile_dirs(extra_profile_dirs: Vec<std::path::PathBuf>) -> Result<(), DaemonError> {
31+ let mut paths = StorePaths::from_env();
32+ paths.extra_profile_dirs = extra_profile_dirs;
33+ let store = ProfileStore::load(&paths)
34+ .map_err(|e| DaemonError::Profile(format!("loading profiles: {e}")))?;
35+ runtime::run(store)
36+}
Added file: crates/headroom-core/src/meters.rs+169 additions
@@ -0,0 +1,169 @@
1+//! bus meter snapshot shared audio-thread → agc; audio side writes via try_lock, never blocks
2+
3+use std::sync::atomic::{AtomicU64, Ordering};
4+use std::sync::Arc;
5+
6+use parking_lot::Mutex;
7+
8+#[derive(Debug, Clone, Copy, Default, PartialEq)]
9+pub struct BusMetrics {
10+ /// negative when reducing
11+ pub compressor_gr_db: f32,
12+ /// min of soft and hard gain, in dB
13+ pub limiter_total_gr_db: f32,
14+ pub limiter_soft_gr_db: f32,
15+ /// non-zero only when the soft tier wasn't enough — alarm condition
16+ pub limiter_hard_gr_db: f32,
17+ /// peak the limiter saw on its input (not the bounded output); for tuning soft-tier headroom
18+ pub true_peak_dbtp: f32,
19+}
20+
21+pub type SharedBusMetrics = Arc<Mutex<BusMetrics>>;
22+
23+#[must_use]
24+pub fn shared() -> SharedBusMetrics {
25+ Arc::new(Mutex::new(BusMetrics::default()))
26+}
27+
28+/// rolling timing stats for the bus filter's `playback_process`; lock-free atomics from the
29+/// audio thread, read on the agc tick. detects BUSY spikes + ring imbalance.
30+#[derive(Debug, Default)]
31+pub struct PlaybackTiming {
32+ pub call_count: AtomicU64,
33+ pub sum_us: AtomicU64,
34+ pub max_us: AtomicU64,
35+ pub spike_count: AtomicU64,
36+ pub last_spike_us: AtomicU64,
37+ /// `call_count` when the last spike fired; reader compares against its prior snapshot
38+ pub last_spike_at_call: AtomicU64,
39+ /// samples zero-filled because the capture→playback ring was empty. non-zero delta = bug
40+ /// (producer/consumer not lined up within a quantum → audible drop-outs).
41+ pub samples_starved: AtomicU64,
42+ /// samples dropped because the ring was full; mirror of `samples_starved` (ring imbalance)
43+ pub samples_dropped: AtomicU64,
44+ /// short/misaligned rt buffers; logged off-thread
45+ pub format_errors: AtomicU64,
46+}
47+
48+impl PlaybackTiming {
49+ /// steady-state cost scales with quantum (~240 μs @ 1024 frames, ~2.2 ms @ 8192 release);
50+ /// 5 ms sits above both, fires only on real outliers.
51+ pub const SPIKE_THRESHOLD_US: u64 = 5_000;
52+
53+ #[inline]
54+ pub fn record(&self, dur_us: u64) {
55+ self.call_count.fetch_add(1, Ordering::Relaxed);
56+ self.sum_us.fetch_add(dur_us, Ordering::Relaxed);
57+ let mut cur_max = self.max_us.load(Ordering::Relaxed);
58+ while dur_us > cur_max {
59+ match self.max_us.compare_exchange_weak(
60+ cur_max,
61+ dur_us,
62+ Ordering::Relaxed,
63+ Ordering::Relaxed,
64+ ) {
65+ Ok(_) => break,
66+ Err(v) => cur_max = v,
67+ }
68+ }
69+ if dur_us > Self::SPIKE_THRESHOLD_US {
70+ let count = self.call_count.load(Ordering::Relaxed);
71+ self.spike_count.fetch_add(1, Ordering::Relaxed);
72+ self.last_spike_us.store(dur_us, Ordering::Relaxed);
73+ self.last_spike_at_call.store(count, Ordering::Relaxed);
74+ }
75+ }
76+
77+ #[inline]
78+ pub fn record_starved(&self, n: u64) {
79+ if n > 0 {
80+ self.samples_starved.fetch_add(n, Ordering::Relaxed);
81+ }
82+ }
83+
84+ #[inline]
85+ pub fn record_dropped(&self, n: u64) {
86+ if n > 0 {
87+ self.samples_dropped.fetch_add(n, Ordering::Relaxed);
88+ }
89+ }
90+
91+ #[inline]
92+ pub fn record_format_error(&self) {
93+ self.format_errors.fetch_add(1, Ordering::Relaxed);
94+ }
95+
96+ pub fn snapshot(&self) -> PlaybackTimingSnapshot {
97+ PlaybackTimingSnapshot {
98+ call_count: self.call_count.load(Ordering::Relaxed),
99+ sum_us: self.sum_us.load(Ordering::Relaxed),
100+ max_us: self.max_us.load(Ordering::Relaxed),
101+ spike_count: self.spike_count.load(Ordering::Relaxed),
102+ last_spike_us: self.last_spike_us.load(Ordering::Relaxed),
103+ last_spike_at_call: self.last_spike_at_call.load(Ordering::Relaxed),
104+ samples_starved: self.samples_starved.load(Ordering::Relaxed),
105+ samples_dropped: self.samples_dropped.load(Ordering::Relaxed),
106+ format_errors: self.format_errors.load(Ordering::Relaxed),
107+ }
108+ }
109+}
110+
111+#[derive(Debug, Default, Clone, Copy)]
112+pub struct PlaybackTimingSnapshot {
113+ pub call_count: u64,
114+ pub sum_us: u64,
115+ pub max_us: u64,
116+ pub spike_count: u64,
117+ pub last_spike_us: u64,
118+ pub last_spike_at_call: u64,
119+ pub samples_starved: u64,
120+ pub samples_dropped: u64,
121+ pub format_errors: u64,
122+}
123+
124+pub type SharedPlaybackTiming = Arc<PlaybackTiming>;
125+
126+#[must_use]
127+pub fn shared_timing() -> SharedPlaybackTiming {
128+ Arc::new(PlaybackTiming::default())
129+}
130+
131+#[cfg(test)]
132+mod tests {
133+ use super::*;
134+
135+ #[test]
136+ fn default_is_all_zero() {
137+ let m = BusMetrics::default();
138+ assert_eq!(m.compressor_gr_db, 0.0);
139+ assert_eq!(m.limiter_total_gr_db, 0.0);
140+ assert_eq!(m.limiter_soft_gr_db, 0.0);
141+ assert_eq!(m.limiter_hard_gr_db, 0.0);
142+ assert_eq!(m.true_peak_dbtp, 0.0);
143+ }
144+
145+ #[test]
146+ fn record_format_error_accumulates_in_snapshot() {
147+ let t = PlaybackTiming::default();
148+ assert_eq!(t.snapshot().format_errors, 0);
149+ t.record_format_error();
150+ t.record_format_error();
151+ assert_eq!(t.snapshot().format_errors, 2);
152+ }
153+
154+ #[test]
155+ fn shared_is_cheap_to_clone() {
156+ let a = shared();
157+ let b = a.clone();
158+ *a.lock() = BusMetrics {
159+ compressor_gr_db: -3.0,
160+ limiter_total_gr_db: -1.0,
161+ limiter_soft_gr_db: -1.0,
162+ limiter_hard_gr_db: 0.0,
163+ true_peak_dbtp: -0.5,
164+ };
165+ let snap = *b.lock();
166+ assert!((snap.compressor_gr_db - -3.0).abs() < 1e-6);
167+ assert!((snap.true_peak_dbtp - -0.5).abs() < 1e-6);
168+ }
169+}
Added file: crates/headroom-core/src/pw/command.rs+36 additions
@@ -0,0 +1,36 @@
1+//! cross-thread command channel from IPC handlers to the pipewire main loop.
2+//!
3+//! pipewire proxies are tied to the loop's thread; IPC handlers post a
4+//! [`PwCommand`] drained by a 50 ms timer source instead. ~50 ms
5+//! worst-case dispatch — do NOT route anything spike-reactive here
6+//! (Layer A `channelVolumes`, rt gain reduction); that breaks the §4.5
7+//! reaction-time contract. filter DSP updates bypass this via
8+//! [`crate::pw::filter::FilterControl`]'s wait-free rtrb.
9+
10+use headroom_ipc::Route;
11+
12+#[derive(Debug, Clone)]
13+#[non_exhaustive]
14+pub enum PwCommand {
15+ /// set `target.object` for one stream, overriding the routing rule.
16+ RouteStream {
17+ node_id: u32,
18+ to: Route,
19+ app_label: String,
20+ },
21+ /// re-run `routing::evaluate` against every known stream. stale post harmless.
22+ ReevaluateAll,
23+ /// rebuild the bus filter at a new rate. posted when the real sink's
24+ /// Format listener sees a rate the filter isn't running at (ALSA
25+ /// sinks publish rate only via Format, not the props dict → initial
26+ /// filter is at fallback rate; also sink hot-swap). ~50–100 ms gap.
27+ RebuildFilter {
28+ sample_rate: u32,
29+ },
30+ /// reconcile Layer A taps against `[per_app]`. stale post harmless.
31+ ReevaluateLayerA,
32+ /// clear a managed stream's deference (user-ceiling / strict lock).
33+ LayerAResetDeference {
34+ node_id: u32,
35+ },
36+}
Added file: crates/headroom-core/src/pw/filter.rs+728 additions
@@ -0,0 +1,728 @@
1+//! the bus filter: a single `pw_filter` node sandwiching the DSP chain.
2+
3+use std::sync::Arc;
4+
5+use parking_lot::Mutex;
6+use pipewire::{
7+ core::Core,
8+ keys,
9+ properties::properties,
10+ spa::{pod::Pod, utils::Direction},
11+};
12+use pipewire_filter::{Filter as PwFilter, FilterFlags, FilterListener, PortData, PortFlags};
13+use rtrb::{Consumer, Producer, RingBuffer};
14+
15+use headroom_dsp::{
16+ AgcGain, AgcGainConfig, Compressor, CompressorConfig, Limiter, LimiterConfig, SetConfigOutcome,
17+};
18+
19+use crate::error::DaemonError;
20+use crate::meters::{BusMetrics, SharedBusMetrics, SharedPlaybackTiming};
21+
22+/// `node.name` the routing engine looks for to wire the explicit
23+/// monitor → input and output → real-sink links — wireplumber does
24+/// not auto-link `pw_filter` nodes.
25+pub const NODE_NAME: &str = "headroom-filter";
26+
27+/// rate used until a real sink is known; [`Filter::create`] overrides
28+/// it from the captured real-sink rate.
29+pub const DEFAULT_SAMPLE_RATE: u32 = 48_000;
30+
31+/// back-compat alias for out-of-tree code.
32+pub const FILTER_SAMPLE_RATE: u32 = DEFAULT_SAMPLE_RATE;
33+
34+/// stereo only in v0.
35+pub const CHANNELS: u32 = 2;
36+
37+const CMD_RING_CAPACITY: usize = 32;
38+
39+/// audio→AGC measurement ring, interleaved f32. several controller
40+/// ticks of slack so a stalled controller doesn't drop coverage.
41+const MEASUREMENT_RING_CAPACITY: usize = 32_768;
42+
43+/// control-plane param updates to the rt audio thread. structural
44+/// limiter changes (oversample, lookahead) can't apply live — see
45+/// [`headroom_dsp::SetConfigOutcome::StructuralChange`].
46+#[derive(Debug, Clone, Copy)]
47+pub enum AudioCmd {
48+ SetCompressor(CompressorConfig),
49+ SetLimiter(LimiterConfig),
50+ /// AGC target dB, pushed by the slow controller each tick.
51+ SetAgcTargetDb(f32),
52+ /// toggle AGC; when disabled the smoother unwinds to 0 dB.
53+ SetAgcEnabled(bool),
54+ SetAgcConfig(AgcGainConfig),
55+ /// limiter soft-ceiling program loudness; `None` resets
56+ SetProgramLoudnessLufs(Option<f32>),
57+}
58+
59+/// cheap-to-clone handle for pushing [`AudioCmd`]s into the running
60+/// filter. held in `DaemonState` so any IPC thread can update params.
61+#[derive(Clone)]
62+pub struct FilterControl {
63+ cmd_producer: Arc<Mutex<Producer<AudioCmd>>>,
64+}
65+
66+impl FilterControl {
67+ /// `false` (and a warn) if the ring is full; the command is dropped.
68+ pub fn try_send(&self, cmd: AudioCmd) -> bool {
69+ match self.cmd_producer.lock().push(cmd) {
70+ Ok(()) => true,
71+ Err(_) => {
72+ tracing::warn!(
73+ "filter command ring full; dropping parameter update — \
74+ audio thread may be stalled or commands arriving faster than the quantum"
75+ );
76+ false
77+ }
78+ }
79+ }
80+
81+ pub fn set_compressor(&self, cfg: CompressorConfig) -> bool {
82+ self.try_send(AudioCmd::SetCompressor(cfg))
83+ }
84+
85+ pub fn set_limiter(&self, cfg: LimiterConfig) -> bool {
86+ self.try_send(AudioCmd::SetLimiter(cfg))
87+ }
88+
89+ pub fn set_agc_target_db(&self, db: f32) -> bool {
90+ self.try_send(AudioCmd::SetAgcTargetDb(db))
91+ }
92+
93+ pub fn set_agc_enabled(&self, enabled: bool) -> bool {
94+ self.try_send(AudioCmd::SetAgcEnabled(enabled))
95+ }
96+
97+ pub fn set_agc_config(&self, cfg: AgcGainConfig) -> bool {
98+ self.try_send(AudioCmd::SetAgcConfig(cfg))
99+ }
100+
101+ pub fn set_program_loudness_lufs(&self, lufs: Option<f32>) -> bool {
102+ self.try_send(AudioCmd::SetProgramLoudnessLufs(lufs))
103+ }
104+}
105+
106+impl std::fmt::Debug for FilterControl {
107+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108+ f.debug_struct("FilterControl").finish_non_exhaustive()
109+ }
110+}
111+
112+#[cfg(test)]
113+impl FilterControl {
114+ pub(crate) fn for_testing(capacity: usize) -> (Self, Consumer<AudioCmd>) {
115+ let (producer, consumer) = RingBuffer::<AudioCmd>::new(capacity);
116+ (
117+ Self {
118+ cmd_producer: Arc::new(Mutex::new(producer)),
119+ },
120+ consumer,
121+ )
122+ }
123+}
124+
125+/// user-data carried into the rt process callback. ports are mono
126+/// (`format.dsp = "32 bit float mono audio"`), one per channel per
127+/// direction — canonical `pw_filter` shape; explicit links pair
128+/// FL/FR port-by-port.
129+struct FilterState {
130+ in_l: PortData,
131+ in_r: PortData,
132+ out_l: PortData,
133+ out_r: PortData,
134+ cmd_consumer: Consumer<AudioCmd>,
135+ /// pre-AGC input samples for the slow AGC. over-capacity dropped
136+ /// silently (controller tolerates gaps).
137+ measurement_producer: Producer<f32>,
138+ agc: AgcGain,
139+ compressor: Compressor,
140+ limiter: Limiter,
141+ measurement_dropped: u64,
142+ bus_metrics: SharedBusMetrics,
143+ timing: SharedPlaybackTiming,
144+}
145+
146+/// owns the `pw_filter` node + listener; drop tears down the audio
147+/// path. drop order is listener-first, filter-second (field order),
148+/// required by the `pipewire-filter` wrapper.
149+pub struct Filter {
150+ _listener: FilterListener<FilterState>,
151+ _filter: PwFilter,
152+}
153+
154+/// initial DSP config for [`Filter::create`].
155+#[derive(Debug, Clone, Copy)]
156+pub struct FilterInit {
157+ pub compressor: CompressorConfig,
158+ pub limiter: LimiterConfig,
159+ pub agc: AgcGainConfig,
160+ pub agc_enabled: bool,
161+}
162+
163+pub struct FilterBundle {
164+ pub filter: Filter,
165+ pub control: FilterControl,
166+ pub measurement_consumer: Consumer<f32>,
167+ pub bus_metrics: SharedBusMetrics,
168+ pub timing: SharedPlaybackTiming,
169+ /// rate the filter runs at — captured from the real sink, or
170+ /// [`DEFAULT_SAMPLE_RATE`] if none known yet.
171+ pub sample_rate: u32,
172+}
173+
174+impl Filter {
175+ /// create the node, add four mono ports, register the rt callback,
176+ /// and connect.
177+ ///
178+ /// wireplumber does *not* auto-link `pw_filter` nodes; the routing
179+ /// layer builds the `processed.monitor → filter.in.*` and
180+ /// `filter.out.* → real_sink` links explicitly. filter sits in
181+ /// `Paused` until both land.
182+ ///
183+ /// # Errors
184+ /// [`DaemonError::PipeWire`] on filter/port/connect failure.
185+ pub fn create(
186+ core: &Core,
187+ init: FilterInit,
188+ sample_rate: u32,
189+ ) -> Result<FilterBundle, DaemonError> {
190+ let (cmd_producer, cmd_consumer) = RingBuffer::<AudioCmd>::new(CMD_RING_CAPACITY);
191+ let (measurement_producer, measurement_consumer) =
192+ RingBuffer::<f32>::new(MEASUREMENT_RING_CAPACITY);
193+ let control = FilterControl {
194+ cmd_producer: Arc::new(Mutex::new(cmd_producer)),
195+ };
196+ let bus_metrics = crate::meters::shared();
197+ let timing = crate::meters::shared_timing();
198+
199+ // caps the internal (post-oversample) rate: 96 kHz base + 4×
200+ // auto-drops to 2× → 192 kHz internal, not 384 kHz. bounds FIR
201+ // cost at higher real-sink rates.
202+ let limiter_cfg = init.limiter.sanitize_for_rate(sample_rate as f32);
203+ let compressor = Compressor::new(init.compressor, sample_rate as f32);
204+ let limiter = Limiter::new(limiter_cfg, sample_rate as f32);
205+ let mut agc = AgcGain::new(init.agc, sample_rate as f32);
206+ agc.set_enabled(init.agc_enabled);
207+
208+ let filter = build_filter(core)?;
209+
210+ let in_l = add_mono_port(&filter, Direction::Input, "input_FL", "FL")?;
211+ let in_r = add_mono_port(&filter, Direction::Input, "input_FR", "FR")?;
212+ let out_l = add_mono_port(&filter, Direction::Output, "output_FL", "FL")?;
213+ let out_r = add_mono_port(&filter, Direction::Output, "output_FR", "FR")?;
214+
215+ let listener = filter
216+ .add_local_listener_with_user_data(FilterState {
217+ in_l,
218+ in_r,
219+ out_l,
220+ out_r,
221+ cmd_consumer,
222+ measurement_producer,
223+ agc,
224+ compressor,
225+ limiter,
226+ measurement_dropped: 0,
227+ bus_metrics: bus_metrics.clone(),
228+ timing: timing.clone(),
229+ })
230+ .process(|state, _position| process(state))
231+ .register()
232+ .map_err(|e| DaemonError::pipewire(format!("filter listener register: {e}")))?;
233+
234+ // no top-level params; per-port `format.dsp` declares mono F32.
235+ let mut connect_params: [&Pod; 0] = [];
236+ filter
237+ .connect(FilterFlags::RT_PROCESS, &mut connect_params)
238+ .map_err(|e| DaemonError::pipewire(format!("filter connect: {e}")))?;
239+
240+ tracing::info!(
241+ sample_rate,
242+ channels = CHANNELS,
243+ "bus filter (pw_filter) created and connected"
244+ );
245+
246+ Ok(FilterBundle {
247+ filter: Self {
248+ _listener: listener,
249+ _filter: filter,
250+ },
251+ control,
252+ measurement_consumer,
253+ bus_metrics,
254+ timing,
255+ sample_rate,
256+ })
257+ }
258+}
259+
260+/// add a mono DSP port. `channel` is `audio.channel` ("FL"/"FR"),
261+/// which the routing engine pairs on. `format.dsp` declares the
262+/// format up-front so no SPA POD.
263+fn add_mono_port(
264+ filter: &PwFilter,
265+ direction: Direction,
266+ port_name: &str,
267+ channel: &str,
268+) -> Result<PortData, DaemonError> {
269+ let props = properties! {
270+ *keys::FORMAT_DSP => "32 bit float mono audio",
271+ *keys::PORT_NAME => port_name,
272+ *keys::AUDIO_CHANNEL => channel,
273+ };
274+ let mut params: [&Pod; 0] = [];
275+ filter
276+ .add_port(direction, PortFlags::MAP_BUFFERS, props, &mut params)
277+ .map_err(|e| DaemonError::pipewire(format!("filter add_port ({port_name}): {e}")))
278+}
279+
280+/// kept from the dual-stream era so external policy that special-cased
281+/// it still applies.
282+const FILTER_LINK_GROUP: &str = "headroom.filter";
283+
284+/// latency target; pipewire rounds up to `max(this, driver_quantum)`.
285+/// small value avoids the ~250 ms pipewire picks when unset.
286+const NODE_LATENCY_HINT: &str = "256/48000";
287+
288+fn build_filter(core: &Core) -> Result<PwFilter, DaemonError> {
289+ let props = properties! {
290+ *keys::MEDIA_TYPE => "Audio",
291+ *keys::MEDIA_ROLE => "DSP",
292+ *keys::NODE_NAME => NODE_NAME,
293+ *keys::NODE_DESCRIPTION => "Headroom bus filter",
294+ // we own our linking; routing must not move us, wireplumber
295+ // must not re-target us on default-sink changes.
296+ *keys::NODE_DONT_RECONNECT => "true",
297+ "node.dont-move" => "true",
298+ "node.link-group" => FILTER_LINK_GROUP,
299+ // the real sink drives, not us.
300+ "node.passive" => "false",
301+ *keys::NODE_LATENCY => NODE_LATENCY_HINT,
302+ };
303+ PwFilter::new(core, NODE_NAME, props)
304+ .map_err(|e| DaemonError::pipewire(format!("pw_filter new: {e}")))
305+}
306+
307+/// apply one [`AudioCmd`] to the DSP kernels. allocation-free.
308+fn apply_audio_cmd(
309+ cmd: AudioCmd,
310+ compressor: &mut Compressor,
311+ limiter: &mut Limiter,
312+ agc: &mut AgcGain,
313+) {
314+ match cmd {
315+ AudioCmd::SetCompressor(cfg) => {
316+ compressor.set_config(cfg);
317+ }
318+ AudioCmd::SetLimiter(cfg) => match limiter.try_set_config(cfg) {
319+ SetConfigOutcome::Applied => {}
320+ SetConfigOutcome::StructuralChange => {
321+ tracing::warn!(
322+ "limiter structural change (oversample / lookahead / fir_taps) cannot be \
323+ applied live; daemon restart required to pick up the new value"
324+ );
325+ }
326+ },
327+ AudioCmd::SetAgcTargetDb(db) => {
328+ agc.set_target_db(db);
329+ }
330+ AudioCmd::SetAgcEnabled(enabled) => {
331+ agc.set_enabled(enabled);
332+ }
333+ AudioCmd::SetAgcConfig(cfg) => {
334+ agc.set_config(cfg);
335+ }
336+ AudioCmd::SetProgramLoudnessLufs(Some(lufs)) => {
337+ limiter.set_program_loudness_lufs(lufs);
338+ }
339+ AudioCmd::SetProgramLoudnessLufs(None) => {
340+ limiter.clear_program_loudness();
341+ }
342+ }
343+}
344+
345+/// drain control-plane param updates into the DSP kernels. allocation-free.
346+fn drain_audio_commands(state: &mut FilterState) {
347+ while let Ok(cmd) = state.cmd_consumer.pop() {
348+ apply_audio_cmd(
349+ cmd,
350+ &mut state.compressor,
351+ &mut state.limiter,
352+ &mut state.agc,
353+ );
354+ }
355+}
356+
357+/// rt process callback. allocation-free, guarded by `assert_no_alloc`
358+/// in debug builds. timed into [`PlaybackTiming`].
359+fn process(state: &mut FilterState) {
360+ let start = std::time::Instant::now();
361+ assert_no_alloc::assert_no_alloc(|| process_inner(state));
362+ let dur_us = start.elapsed().as_micros() as u64;
363+ state.timing.record(dur_us);
364+}
365+
366+fn process_inner(state: &mut FilterState) {
367+ drain_audio_commands(state);
368+
369+ // any missing buffer this quantum: bail, pipewire re-fires us.
370+ let Some(mut in_l_buf) = state.in_l.dequeue_buffer() else {
371+ return;
372+ };
373+ let Some(mut in_r_buf) = state.in_r.dequeue_buffer() else {
374+ return;
375+ };
376+ let Some(mut out_l_buf) = state.out_l.dequeue_buffer() else {
377+ return;
378+ };
379+ let Some(mut out_r_buf) = state.out_r.dequeue_buffer() else {
380+ return;
381+ };
382+
383+ let sample_bytes = std::mem::size_of::<f32>();
384+
385+ let in_l_samples = match read_mono_input(in_l_buf.datas_mut(), &state.timing) {
386+ Some(s) => s,
387+ None => return,
388+ };
389+ let in_r_samples = match read_mono_input(in_r_buf.datas_mut(), &state.timing) {
390+ Some(s) => s,
391+ None => return,
392+ };
393+ let in_frames = in_l_samples.len().min(in_r_samples.len());
394+ if in_frames == 0 {
395+ return;
396+ }
397+
398+ let out_l_datas = out_l_buf.datas_mut();
399+ let Some(out_l_data) = out_l_datas.first_mut() else {
400+ return;
401+ };
402+ let Some(out_l_bytes) = out_l_data.data() else {
403+ return;
404+ };
405+ let out_l_max = out_l_bytes.len() / sample_bytes;
406+
407+ let out_r_datas = out_r_buf.datas_mut();
408+ let Some(out_r_data) = out_r_datas.first_mut() else {
409+ return;
410+ };
411+ let Some(out_r_bytes) = out_r_data.data() else {
412+ return;
413+ };
414+ let out_r_max = out_r_bytes.len() / sample_bytes;
415+
416+ let frames = in_frames.min(out_l_max).min(out_r_max);
417+ if frames == 0 {
418+ return;
419+ }
420+
421+ // rt cast failures are logged off-thread
422+ let out_l_samples: &mut [f32] =
423+ match bytemuck::try_cast_slice_mut::<u8, f32>(&mut out_l_bytes[..frames * sample_bytes]) {
424+ Ok(s) => s,
425+ Err(_) => {
426+ state.timing.record_format_error();
427+ return;
428+ }
429+ };
430+ let out_r_samples: &mut [f32] =
431+ match bytemuck::try_cast_slice_mut::<u8, f32>(&mut out_r_bytes[..frames * sample_bytes]) {
432+ Ok(s) => s,
433+ Err(_) => {
434+ state.timing.record_format_error();
435+ return;
436+ }
437+ };
438+
439+ let measurement_dropped = push_measurements(
440+ &mut state.measurement_producer,
441+ in_l_samples,
442+ in_r_samples,
443+ frames,
444+ );
445+ for frame_idx in 0..frames {
446+ let left_in = in_l_samples[frame_idx];
447+ let right_in = in_r_samples[frame_idx];
448+ let (la, ra) = state.agc.process_frame(left_in, right_in);
449+ let (lc, rc) = state.compressor.process_frame(la, ra);
450+ let (lo, ro) = state.limiter.process_frame(lc, rc);
451+ out_l_samples[frame_idx] = lo;
452+ out_r_samples[frame_idx] = ro;
453+ }
454+ if measurement_dropped > 0 {
455+ state.measurement_dropped = state
456+ .measurement_dropped
457+ .saturating_add(measurement_dropped);
458+ }
459+
460+ // `frames < in_frames` shouldn't happen (output maxsize is sized
461+ // by `clock.quantum-limit` ≫ typical quanta); recorded as a
462+ // regression signal.
463+ if frames < in_frames {
464+ let dropped_frames = in_frames - frames;
465+ state
466+ .timing
467+ .record_dropped((dropped_frames * CHANNELS as usize) as u64);
468+ }
469+
470+ // `try_lock` so we never block on a daemon-thread reader; a
471+ // contended quantum drops this update, next one lands.
472+ if frames > 0 {
473+ if let Some(mut metrics) = state.bus_metrics.try_lock() {
474+ *metrics = BusMetrics {
475+ compressor_gr_db: state.compressor.gain_reduction_db(),
476+ limiter_total_gr_db: state.limiter.gain_reduction_db(),
477+ limiter_soft_gr_db: state.limiter.soft_gain_reduction_db(),
478+ limiter_hard_gr_db: state.limiter.hard_gain_reduction_db(),
479+ true_peak_dbtp: state.limiter.true_peak_dbtp(),
480+ };
481+ }
482+ }
483+
484+ for chunk_data in [out_l_data, out_r_data] {
485+ let chunk = chunk_data.chunk_mut();
486+ *chunk.size_mut() = (frames * sample_bytes) as u32;
487+ *chunk.stride_mut() = sample_bytes as i32;
488+ *chunk.offset_mut() = 0;
489+ }
490+}
491+
492+/// Feed the slow AGC in one ring reservation per callback. If the
493+/// controller is behind, keep the prefix that fits and drop the tail;
494+/// gaps are acceptable for loudness tracking and this never blocks.
495+fn push_measurements(
496+ producer: &mut Producer<f32>,
497+ left: &[f32],
498+ right: &[f32],
499+ frames: usize,
500+) -> u64 {
501+ let requested = frames.saturating_mul(CHANNELS as usize);
502+ if requested == 0 {
503+ return 0;
504+ }
505+ let writable = match producer.write_chunk(requested) {
506+ Ok(chunk) => {
507+ fill_measurement_chunk(chunk, left, right);
508+ return 0;
509+ }
510+ Err(rtrb::chunks::ChunkError::TooFewSlots(slots)) => slots & !(CHANNELS as usize - 1),
511+ };
512+ if writable == 0 {
513+ return requested as u64;
514+ }
515+ match producer.write_chunk(writable) {
516+ Ok(chunk) => {
517+ fill_measurement_chunk(chunk, left, right);
518+ (requested - writable) as u64
519+ }
520+ Err(_) => requested as u64,
521+ }
522+}
523+
524+fn fill_measurement_chunk(
525+ mut chunk: rtrb::chunks::WriteChunk<'_, f32>,
526+ left: &[f32],
527+ right: &[f32],
528+) {
529+ let mut sample_idx = 0;
530+ let (first, second) = chunk.as_mut_slices();
531+ sample_idx = fill_measurement_slice(first, sample_idx, left, right);
532+ let _ = fill_measurement_slice(second, sample_idx, left, right);
533+ chunk.commit_all();
534+}
535+
536+fn fill_measurement_slice(
537+ dst: &mut [f32],
538+ mut sample_idx: usize,
539+ left: &[f32],
540+ right: &[f32],
541+) -> usize {
542+ for slot in dst {
543+ let frame_idx = sample_idx / CHANNELS as usize;
544+ let sample = if sample_idx % CHANNELS as usize == 0 {
545+ left[frame_idx]
546+ } else {
547+ right[frame_idx]
548+ };
549+ *slot = sample;
550+ sample_idx += 1;
551+ }
552+ sample_idx
553+}
554+
555+/// mono input data as f32 samples; misalignment is counted off-thread
556+fn read_mono_input<'a>(
557+ datas: &'a mut [libspa::buffer::Data],
558+ timing: &SharedPlaybackTiming,
559+) -> Option<&'a [f32]> {
560+ let data = datas.first_mut()?;
561+ let n_bytes = data.chunk().size() as usize;
562+ if n_bytes == 0 {
563+ return None;
564+ }
565+ let bytes = data.data()?;
566+ let n = n_bytes.min(bytes.len());
567+ match bytemuck::try_cast_slice::<u8, f32>(&bytes[..n]) {
568+ Ok(s) => Some(s),
569+ Err(_) => {
570+ timing.record_format_error();
571+ None
572+ }
573+ }
574+}
575+
576+#[cfg(test)]
577+mod tests {
578+ //! audio-thread leg (apply_audio_cmd) + control-side send leg
579+ //! (FilterControl); pw_filter halves need a running PipeWire.
580+
581+ use super::*;
582+ use headroom_dsp::{
583+ AgcGain, AgcGainConfig, Compressor, CompressorConfig, Limiter, LimiterConfig,
584+ SoftTierConfig,
585+ };
586+
587+ const SR: f32 = 48_000.0;
588+
589+ #[test]
590+ fn apply_audio_cmd_updates_compressor_scalars() {
591+ let mut compressor = Compressor::new(CompressorConfig::default(), SR);
592+ let mut limiter = Limiter::new(LimiterConfig::default(), SR);
593+ let mut agc = AgcGain::new(AgcGainConfig::default(), SR);
594+ let new_cfg = CompressorConfig {
595+ threshold_db: -12.0,
596+ ratio: 4.0,
597+ ..CompressorConfig::default()
598+ };
599+ apply_audio_cmd(
600+ AudioCmd::SetCompressor(new_cfg),
601+ &mut compressor,
602+ &mut limiter,
603+ &mut agc,
604+ );
605+ let active = compressor.config();
606+ assert!((active.threshold_db - -12.0).abs() < 1e-6);
607+ assert!((active.ratio - 4.0).abs() < 1e-6);
608+ }
609+
610+ #[test]
611+ fn apply_audio_cmd_updates_limiter_scalars() {
612+ let mut compressor = Compressor::new(CompressorConfig::default(), SR);
613+ let mut limiter = Limiter::new(LimiterConfig::default(), SR);
614+ let mut agc = AgcGain::new(AgcGainConfig::default(), SR);
615+ let new_cfg = LimiterConfig {
616+ ceiling_dbtp: -1.5,
617+ release_ms: 250.0,
618+ soft: Some(SoftTierConfig {
619+ max_psr_db: 10.0,
620+ ..SoftTierConfig::default()
621+ }),
622+ ..LimiterConfig::default()
623+ };
624+ apply_audio_cmd(
625+ AudioCmd::SetLimiter(new_cfg),
626+ &mut compressor,
627+ &mut limiter,
628+ &mut agc,
629+ );
630+ assert!((limiter.ceiling_dbtp() - -1.5).abs() < 1e-6);
631+ assert!((limiter.config().release_ms - 250.0).abs() < 1e-6);
632+ let soft = limiter.config().soft.expect("soft preserved");
633+ assert!((soft.max_psr_db - 10.0).abs() < 1e-6);
634+ }
635+
636+ #[test]
637+ fn apply_audio_cmd_skips_structural_limiter_change_silently() {
638+ let mut compressor = Compressor::new(CompressorConfig::default(), SR);
639+ let mut limiter = Limiter::new(LimiterConfig::default(), SR);
640+ let mut agc = AgcGain::new(AgcGainConfig::default(), SR);
641+ let bad = LimiterConfig {
642+ // structural; can't apply in place
643+ oversample: 8,
644+ ..LimiterConfig::default()
645+ };
646+ // Should not panic, should not change the limiter.
647+ apply_audio_cmd(
648+ AudioCmd::SetLimiter(bad),
649+ &mut compressor,
650+ &mut limiter,
651+ &mut agc,
652+ );
653+ assert_eq!(
654+ limiter.config().oversample,
655+ LimiterConfig::default().oversample
656+ );
657+ }
658+
659+ #[test]
660+ fn filter_control_send_reaches_consumer() {
661+ let (control, mut consumer) = FilterControl::for_testing(8);
662+ assert!(control.set_compressor(CompressorConfig::default()));
663+ assert!(control.set_limiter(LimiterConfig::default()));
664+ // Two commands queued.
665+ let c1 = consumer.pop().expect("first cmd");
666+ let c2 = consumer.pop().expect("second cmd");
667+ assert!(matches!(c1, AudioCmd::SetCompressor(_)));
668+ assert!(matches!(c2, AudioCmd::SetLimiter(_)));
669+ assert!(consumer.pop().is_err(), "ring drained");
670+ }
671+
672+ #[test]
673+ fn filter_control_returns_false_on_full_ring() {
674+ // Capacity 2: third push should fail.
675+ let (control, _consumer) = FilterControl::for_testing(2);
676+ assert!(control.set_compressor(CompressorConfig::default()));
677+ assert!(control.set_limiter(LimiterConfig::default()));
678+ assert!(!control.set_compressor(CompressorConfig::default()));
679+ }
680+
681+ #[test]
682+ fn filter_control_send_then_drain_applies_to_dsp_kernels() {
683+ // End-to-end on the cmd plane: push via FilterControl, drain
684+ // via apply_audio_cmd, observe DSP state.
685+ let (control, mut consumer) = FilterControl::for_testing(8);
686+ let mut compressor = Compressor::new(CompressorConfig::default(), SR);
687+ let mut limiter = Limiter::new(LimiterConfig::default(), SR);
688+ let mut agc = AgcGain::new(AgcGainConfig::default(), SR);
689+
690+ control.set_compressor(CompressorConfig {
691+ threshold_db: -8.0,
692+ ..CompressorConfig::default()
693+ });
694+ control.set_limiter(LimiterConfig {
695+ ceiling_dbtp: -2.0,
696+ ..LimiterConfig::default()
697+ });
698+
699+ while let Ok(cmd) = consumer.pop() {
700+ apply_audio_cmd(cmd, &mut compressor, &mut limiter, &mut agc);
701+ }
702+ assert!((compressor.config().threshold_db - -8.0).abs() < 1e-6);
703+ assert!((limiter.ceiling_dbtp() - -2.0).abs() < 1e-6);
704+ }
705+
706+ #[test]
707+ fn apply_audio_cmd_updates_agc_target_and_enable() {
708+ let mut compressor = Compressor::new(CompressorConfig::default(), SR);
709+ let mut limiter = Limiter::new(LimiterConfig::default(), SR);
710+ let mut agc = AgcGain::new(AgcGainConfig::default(), SR);
711+ apply_audio_cmd(
712+ AudioCmd::SetAgcTargetDb(4.5),
713+ &mut compressor,
714+ &mut limiter,
715+ &mut agc,
716+ );
717+ assert!((agc.target_db() - 4.5).abs() < 1e-6);
718+ apply_audio_cmd(
719+ AudioCmd::SetAgcEnabled(false),
720+ &mut compressor,
721+ &mut limiter,
722+ &mut agc,
723+ );
724+ assert!(!agc.enabled());
725+ // Disable resets target to 0 (smoother unwinds gracefully).
726+ assert!((agc.target_db()).abs() < 1e-6);
727+ }
728+}
Added file: crates/headroom-core/src/pw/metadata.rs+68 additions
@@ -0,0 +1,68 @@
1+//! parse/format the PipeWire `default` metadata object. binding,
2+//! listening, and writing live in [`crate::pw::registry`].
3+
4+use serde_json::Value;
5+
6+pub const DEFAULT_AUDIO_SINK_KEY: &str = "default.audio.sink";
7+
8+pub const TARGET_OBJECT_KEY: &str = "target.object";
9+
10+pub const SPA_JSON_TYPE: &str = "Spa:String:JSON";
11+
12+/// parse a `default.audio.sink` value (`{"name":"alsa_output.…"}`) into
13+/// a sink name. `None` on anything unrecognised — ignore rather than
14+/// crash the metadata listener.
15+#[must_use]
16+pub fn parse_default_sink_name(value: &str) -> Option<String> {
17+ let parsed: Value = serde_json::from_str(value.trim()).ok()?;
18+ parsed.get("name")?.as_str().map(str::to_owned)
19+}
20+
21+/// format a `target.object` value pointing at `sink_name`.
22+#[must_use]
23+pub fn format_sink_target_value(sink_name: &str) -> String {
24+ // formatter is also called with observed (user-influenced) sink
25+ // names, so escape embedded quotes even though pipewire's never
26+ // contain them.
27+ let escaped = sink_name.replace('"', "\\\"");
28+ format!("{{\"name\":\"{escaped}\"}}")
29+}
30+
31+#[cfg(test)]
32+mod tests {
33+ use super::*;
34+
35+ #[test]
36+ fn parses_default_sink_name_from_canonical_json() {
37+ let v = parse_default_sink_name("{\"name\":\"alsa_output.usb-foo\"}");
38+ assert_eq!(v.as_deref(), Some("alsa_output.usb-foo"));
39+ }
40+
41+ #[test]
42+ fn parses_default_sink_name_with_whitespace() {
43+ let v = parse_default_sink_name(" {\"name\":\"x\"}\n");
44+ assert_eq!(v.as_deref(), Some("x"));
45+ }
46+
47+ #[test]
48+ fn rejects_garbage() {
49+ assert_eq!(parse_default_sink_name("not json"), None);
50+ assert_eq!(parse_default_sink_name("{}"), None);
51+ assert_eq!(parse_default_sink_name("{\"name\":42}"), None);
52+ }
53+
54+ #[test]
55+ fn formats_sink_target_round_trips() {
56+ let formatted = format_sink_target_value("alsa_output.usb-foo");
57+ let back = parse_default_sink_name(&formatted).unwrap();
58+ assert_eq!(back, "alsa_output.usb-foo");
59+ }
60+
61+ #[test]
62+ fn formats_sink_target_escapes_embedded_quote() {
63+ let formatted = format_sink_target_value("we\"ird");
64+ // Should still be valid JSON.
65+ let back = parse_default_sink_name(&formatted).unwrap();
66+ assert_eq!(back, "we\"ird");
67+ }
68+}
Added file: crates/headroom-core/src/pw/mod.rs+272 additions
@@ -0,0 +1,272 @@
1+//! PipeWire integration layer. [`PwContext`] owns the main loop,
2+//! `Context`, and `Core`.
3+
4+pub mod command;
5+pub mod filter;
6+pub mod metadata;
7+pub mod registry;
8+pub mod sink;
9+pub mod tap;
10+
11+use std::cell::{Cell, RefCell};
12+use std::rc::Rc;
13+use std::time::Duration;
14+
15+use pipewire::{context::Context, core::Core, loop_::Signal, main_loop::MainLoop};
16+
17+use crate::error::DaemonError;
18+use crate::pw::registry::RegistryWatcher;
19+use crate::pw::sink::VirtualSink;
20+use crate::state::SharedState;
21+
22+const CONNECT_RETRY_TIMEOUT: Duration = Duration::from_secs(20);
23+const CONNECT_RETRY_INTERVAL: Duration = Duration::from_millis(250);
24+
25+/// block `SIGTERM`/`SIGINT` so PipeWire's signalfd source can receive
26+/// them instead of the kernel's default (terminate).
27+///
28+/// **must be called before any thread is spawned** — threads spawned
29+/// beforehand keep the unblocked mask and die on signal before the
30+/// signalfd reads.
31+///
32+/// # Errors
33+/// [`DaemonError::PipeWire`] if `sigprocmask` fails.
34+pub fn block_termination_signals() -> Result<(), DaemonError> {
35+ use nix::sys::signal::{SigSet, SigmaskHow};
36+
37+ let mut set = SigSet::empty();
38+ set.add(Signal::SIGTERM);
39+ set.add(Signal::SIGINT);
40+ nix::sys::signal::sigprocmask(SigmaskHow::SIG_BLOCK, Some(&set), None)
41+ .map_err(|e| DaemonError::pipewire(format!("sigprocmask: {e}")))?;
42+ Ok(())
43+}
44+
45+/// owns the PipeWire main loop, context, and core for the daemon's run.
46+/// single-threaded by design.
47+pub struct PwContext {
48+ main_loop: MainLoop,
49+ _context: Context,
50+ core: Core,
51+ /// `RefCell` because creation happens after construction (must be
52+ /// inside the main loop for the roundtrip).
53+ sink: RefCell<VirtualSink>,
54+ /// registry watcher + routing engine; `None` until
55+ /// [`Self::start_routing`].
56+ routing: RefCell<Option<RegistryWatcher>>,
57+}
58+
59+impl PwContext {
60+ /// init PipeWire, create the main loop/context, connect, and block
61+ /// termination signals (see [`block_termination_signals`]).
62+ ///
63+ /// # Errors
64+ /// [`DaemonError::PipeWire`] on failure; usually `Context::connect`
65+ /// when no server is reachable on `$PIPEWIRE_RUNTIME_DIR`.
66+ pub fn new() -> Result<Self, DaemonError> {
67+ // idempotent if `runtime::run` already called it.
68+ block_termination_signals()?;
69+ pipewire::init();
70+ let main_loop = MainLoop::new(None)
71+ .map_err(|e| DaemonError::pipewire(format!("MainLoop::new: {e}")))?;
72+ let context = Context::new(&main_loop)
73+ .map_err(|e| DaemonError::pipewire(format!("Context::new: {e}")))?;
74+ let core = connect_with_retry(&context)?;
75+ tracing::info!("connected to pipewire");
76+ Ok(Self {
77+ main_loop,
78+ _context: context,
79+ core,
80+ sink: RefCell::new(VirtualSink::new()),
81+ routing: RefCell::new(None),
82+ })
83+ }
84+
85+ /// watch the registry and route new playback streams. idempotent;
86+ /// calling twice replaces the previous watcher.
87+ ///
88+ /// # Errors
89+ /// [`DaemonError::PipeWire`] if obtaining the registry fails.
90+ pub fn start_routing(&self, daemon: SharedState) -> Result<(), DaemonError> {
91+ let registry = self
92+ .core
93+ .get_registry()
94+ .map_err(|e| DaemonError::pipewire(format!("get_registry: {e}")))?;
95+ let watcher = RegistryWatcher::new(Rc::new(registry), self.core.clone(), daemon);
96+ *self.routing.borrow_mut() = Some(watcher);
97+ tracing::info!("registry watcher + routing engine installed");
98+ Ok(())
99+ }
100+
101+ #[must_use]
102+ pub fn main_loop(&self) -> &MainLoop {
103+ &self.main_loop
104+ }
105+
106+ #[must_use]
107+ pub fn core(&self) -> &Core {
108+ &self.core
109+ }
110+
111+ /// routing state, if [`Self::start_routing`] ran. lets `runtime`
112+ /// install the filter-rebuild handles afterwards.
113+ #[must_use]
114+ pub fn routing_state(&self) -> Option<Rc<RefCell<crate::pw::registry::RoutingState>>> {
115+ self.routing.borrow().as_ref().map(|w| w.state().clone())
116+ }
117+
118+ /// create `headroom-processed` and roundtrip to confirm. must be
119+ /// called before [`Self::run_until_signal`].
120+ ///
121+ /// # Errors
122+ /// [`DaemonError::PipeWire`] if `create_object` fails, the
123+ /// `support.null-audio-sink` factory isn't available, or the
124+ /// roundtrip times out.
125+ pub fn create_processed_sink(&self, sample_rate: u32) -> Result<(), DaemonError> {
126+ self.sink.borrow_mut().create(&self.core, sample_rate)?;
127+ self.roundtrip()?;
128+ tracing::info!(sample_rate, "headroom-processed virtual sink created");
129+ Ok(())
130+ }
131+
132+ /// block until all queued requests are acked by the server.
133+ fn roundtrip(&self) -> Result<(), DaemonError> {
134+ let done = Rc::new(Cell::new(false));
135+ let done_cb = done.clone();
136+ let loop_for_cb = self.main_loop.clone();
137+
138+ let pending = self
139+ .core
140+ .sync(0)
141+ .map_err(|e| DaemonError::pipewire(format!("core.sync: {e}")))?;
142+
143+ let _listener = self
144+ .core
145+ .add_listener_local()
146+ .done(move |id, seq| {
147+ if id == pipewire::core::PW_ID_CORE && seq == pending {
148+ done_cb.set(true);
149+ loop_for_cb.quit();
150+ }
151+ })
152+ .register();
153+
154+ while !done.get() {
155+ self.main_loop.run();
156+ }
157+ Ok(())
158+ }
159+
160+ /// run the main loop until SIGTERM/SIGINT.
161+ ///
162+ /// # Errors
163+ /// [`DaemonError::PipeWire`] if installing the signal sources fails.
164+ pub fn run_until_signal(&self) -> Result<(), DaemonError> {
165+ // SIGTERM: graceful service stop (systemd).
166+ let ml = self.main_loop.clone();
167+ let _sig_term = self
168+ .main_loop
169+ .loop_()
170+ .add_signal_local(Signal::SIGTERM, move || {
171+ tracing::info!("SIGTERM received, shutting down");
172+ ml.quit();
173+ });
174+
175+ // SIGINT: Ctrl-C in foreground.
176+ let ml = self.main_loop.clone();
177+ let _sig_int = self
178+ .main_loop
179+ .loop_()
180+ .add_signal_local(Signal::SIGINT, move || {
181+ tracing::info!("SIGINT received, shutting down");
182+ ml.quit();
183+ });
184+
185+ // drain IPC → PipeWire commands at 50 ms. operator-grade only;
186+ // NOT for spike-reactive gain reduction (Layer A) — see
187+ // `pw::command` module docs.
188+ let _cmd_timer = {
189+ let routing = self.routing.borrow();
190+ routing.as_ref().map(|watcher| {
191+ let state = watcher.state().clone();
192+ let back = state.clone();
193+ let timer = self.main_loop.loop_().add_timer(move |_expirations| {
194+ state.borrow_mut().drain_pw_commands(&back);
195+ });
196+ let _ = timer.update_timer(
197+ Some(Duration::from_millis(50)),
198+ Some(Duration::from_millis(50)),
199+ );
200+ timer
201+ })
202+ };
203+
204+ // drain Layer A measurement rings + issue `Props.channelVolumes`
205+ // writes. 5 ms keeps detection-to-write inside one quantum at
206+ // typical 21 ms quanta
207+ let _layer_a_timer = {
208+ let routing = self.routing.borrow();
209+ routing.as_ref().map(|watcher| {
210+ let state = watcher.state().clone();
211+ let back = state.clone();
212+ let timer = self.main_loop.loop_().add_timer(move |_expirations| {
213+ state.borrow_mut().drain_layer_a(&back);
214+ });
215+ let _ = timer.update_timer(
216+ Some(Duration::from_millis(5)),
217+ Some(Duration::from_millis(5)),
218+ );
219+ timer
220+ })
221+ };
222+
223+ tracing::info!("entering pipewire main loop");
224+ self.main_loop.run();
225+ tracing::info!("main loop exited");
226+
227+ // graceful shutdown: restore every attenuated app's volume,
228+ // then pump the loop a few times so the writes flush before
229+ // the connection tears down. best-effort (no SIGKILL).
230+ if let Some(watcher) = self.routing.borrow().as_ref() {
231+ watcher.state().borrow().restore_all_managed_volumes();
232+ }
233+ for _ in 0..10 {
234+ self.main_loop.loop_().iterate(Duration::from_millis(5));
235+ }
236+ Ok(())
237+ }
238+}
239+
240+fn connect_with_retry(context: &Context) -> Result<Core, DaemonError> {
241+ let started = std::time::Instant::now();
242+ let mut attempts = 0_u32;
243+ loop {
244+ attempts = attempts.saturating_add(1);
245+ match context.connect(None) {
246+ Ok(core) => {
247+ if attempts > 1 {
248+ tracing::info!(
249+ attempts,
250+ elapsed_ms = started.elapsed().as_millis(),
251+ "pipewire connect succeeded after retry"
252+ );
253+ }
254+ return Ok(core);
255+ }
256+ Err(e) if started.elapsed() < CONNECT_RETRY_TIMEOUT => {
257+ tracing::warn!(
258+ attempts,
259+ error = %e,
260+ "pipewire connect failed; retrying"
261+ );
262+ std::thread::sleep(CONNECT_RETRY_INTERVAL);
263+ }
264+ Err(e) => {
265+ return Err(DaemonError::pipewire(format!(
266+ "Context::connect after {attempts} attempts over {:?}: {e}",
267+ started.elapsed()
268+ )));
269+ }
270+ }
271+ }
272+}
Added file: crates/headroom-core/src/pw/registry.rs+2086 additions
@@ -0,0 +1,2086 @@
1+//! PipeWire registry subscription + routing decisions. watches new
2+//! globals (metadata, nodes, ports, links), routes `Stream/Output/Audio`
3+//! per the active profile, tracks `preferred_real_sink`, and owns the
4+//! explicit link layer (WP only honours `target.object` at connect time).
5+
6+use std::cell::RefCell;
7+use std::collections::HashMap;
8+use std::rc::Rc;
9+
10+use crossbeam_channel::Receiver;
11+use pipewire::{
12+ core::Core,
13+ link::Link,
14+ metadata::{Metadata, MetadataListener},
15+ node::{Node, NodeListener},
16+ properties::{properties, Properties},
17+ registry::{GlobalObject, Listener, Registry},
18+ spa::{
19+ param::ParamType,
20+ pod::{
21+ deserialize::PodDeserializer, serialize::PodSerializer, Object as PodObject, Pod,
22+ Property, PropertyFlags, Value, ValueArray,
23+ },
24+ utils::{dict::DictRef, SpaTypes},
25+ },
26+ types::ObjectType,
27+};
28+use rtrb::Consumer;
29+
30+use headroom_ipc::{Event, LayerASnapshot, Route, Topic};
31+use serde_json::json;
32+
33+use crate::app_level::{self, AppLevelController, LayerAEval};
34+use crate::pw::command::PwCommand;
35+use crate::pw::metadata::{
36+ format_sink_target_value, parse_default_sink_name, DEFAULT_AUDIO_SINK_KEY, SPA_JSON_TYPE,
37+ TARGET_OBJECT_KEY,
38+};
39+use crate::pw::sink::NODE_NAME as PROCESSED_SINK_NAME;
40+use crate::pw::tap::{MeasurementSample, StreamTap};
41+use crate::routing::{self, PwNodeInfo, RoutingDecision};
42+use crate::state::{RoutedStream, SharedState};
43+
44+/// assumed audio-thread quantum for Layer A controllers. nodes may
45+/// negotiate other values; the smoothing constants tolerate 512–2048.
46+const LAYER_A_QUANTUM_FRAMES: f32 = 1024.0;
47+
48+/// Layer A block period (seconds); falls back to 48 kHz when no real
49+/// sink rate is known.
50+fn layer_a_block_dt_s(sample_rate: Option<u32>) -> f32 {
51+ let sr = sample_rate.unwrap_or(crate::pw::filter::DEFAULT_SAMPLE_RATE);
52+ LAYER_A_QUANTUM_FRAMES / (sr as f32)
53+}
54+
55+/// view of a `Port` global. tracked because WP won't wire
56+/// `Stream/Output → Stream/Input` — we build links from these port ids.
57+#[derive(Debug, Clone)]
58+struct PortInfo {
59+ /// the port's global id (used as `link.{input,output}.port`).
60+ port_id: u32,
61+ direction: PortDirection,
62+ /// per-node ordinal (port.id), pairs FL↔FL / FR↔FR when channel
63+ /// hints are absent.
64+ ordinal: Option<u32>,
65+}
66+
67+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68+enum PortDirection {
69+ In,
70+ Out,
71+}
72+
73+/// view of a `Link` global. tracked so routing can find + destroy a
74+/// WP-created link that conflicts with the declared route — WP only
75+/// honours `target.object` at connect time, so the daemon owns the
76+/// link layer for any stream it routes.
77+#[derive(Debug, Clone, Copy)]
78+struct LinkInfo {
79+ output_port: u32,
80+ input_port: u32,
81+ /// owner of `output_port`; cached to avoid walking `ports_by_node`.
82+ output_node: u32,
83+ /// owner of `input_port`.
84+ input_node: u32,
85+}
86+
87+/// per-stream routing intent recorded by `try_route_stream`, resolved
88+/// by `apply_pending_routes` once both port sets are on the registry.
89+#[derive(Debug, Clone)]
90+struct PendingRoute {
91+ /// target sink `node.name` (`headroom-processed` or real-sink name).
92+ target_sink_name: String,
93+ app_label: String,
94+ route: Route,
95+}
96+
97+/// daemon-owned routing links for one stream + the target they were
98+/// built for. keeping them alive lets `enqueue_route` skip a
99+/// destroy+rebuild (and its audio gap) when the target is unchanged.
100+struct ManagedRoute {
101+ target_sink_name: String,
102+ links: Vec<Link>,
103+}
104+
105+/// subject for system-wide metadata keys (e.g. `default.audio.sink`).
106+const METADATA_SUBJECT_GLOBAL: u32 = 0;
107+
108+/// bus filter `node.name`; WP does not auto-link `pw_filter` nodes.
109+const FILTER_NODE_NAME: &str = crate::pw::filter::NODE_NAME;
110+
111+/// per-PipeWire-thread state. proxies aren't `Send`, so they live here
112+/// behind `Rc<RefCell<_>>` rather than in [`SharedState`].
113+pub struct RoutingState {
114+ daemon: SharedState,
115+ /// `None` until the registry surfaces the `default` metadata.
116+ default_metadata: Option<Metadata>,
117+ _default_metadata_listener: Option<MetadataListener>,
118+ registry: Rc<Registry>,
119+ core: Core,
120+ /// global id of `headroom-filter`. routing treats it as both the
121+ /// sink-side target for the processed monitor and the source-side
122+ /// target into the real sink; retargeted on default-sink change.
123+ filter_playback_id: Option<u32>,
124+ /// `Audio/Sink` node.name → global id.
125+ sinks_by_name: HashMap<String, u32>,
126+ /// IPC-originated commands, drained by [`Self::drain_pw_commands`].
127+ pw_command_rx: Receiver<PwCommand>,
128+ /// Layer A managed streams, keyed by source node id.
129+ managed_streams: HashMap<u32, ManagedStream>,
130+ /// `Port` globals keyed by owning node id; used to build links.
131+ ports_by_node: HashMap<u32, Vec<PortInfo>>,
132+ /// port id → owning node id, so `on_global_remove` distinguishes a
133+ /// port removal from a node removal under PipeWire's id reuse —
134+ /// the old "retain by port_id across all nodes" pass could wipe a
135+ /// live node's ports.
136+ port_owner: HashMap<u32, u32>,
137+ /// `Link` globals keyed by link global id. see [`LinkInfo`].
138+ links_by_id: HashMap<u32, LinkInfo>,
139+ /// outbound link ids per source node.
140+ outbound_links_by_node: HashMap<u32, Vec<u32>>,
141+ /// routes declared but not yet linked (ports still arriving).
142+ /// drained by [`Self::apply_pending_routes`].
143+ pending_routes: HashMap<u32, PendingRoute>,
144+ /// daemon-owned `Link` proxies keyed by source node; see
145+ /// [`ManagedRoute`].
146+ managed_route_links: HashMap<u32, ManagedRoute>,
147+ /// `default.audio.sink` re-assertion budget so a hostile WP can't
148+ /// hot-loop us. `(window_start, attempts)`.
149+ default_reassertion: Option<(std::time::Instant, u32)>,
150+ /// `PwNodeInfo` per routable stream, so bypass/profile reapply can
151+ /// re-run `routing::evaluate` without re-reading PipeWire props.
152+ known_streams: HashMap<u32, PwNodeInfo>,
153+ /// owned global per known stream — the Layer A reconciliation path
154+ /// runs on the drain timer (no live `global`) but `bind` only reads
155+ /// id + type, which an owned global carries.
156+ stream_globals: HashMap<u32, GlobalObject<Properties>>,
157+ /// persisted per-app `user_ceiling`, keyed as `info_app_label`.
158+ /// without this, an app that respawns a node per track (Strawberry)
159+ /// reads its inherited daemon-written volume, misreads it as a user
160+ /// change, and locks the controller at the prior track's cut.
161+ persisted_ceilings: HashMap<String, f32>,
162+ /// node proxy + Format listener for the real sink, capturing its
163+ /// negotiated `audio.rate` (ALSA sinks expose it only via Format).
164+ real_sink_format_listener: Option<(u32, Node, NodeListener)>,
165+ /// the bus filter, held here so `PwCommand::RebuildFilter` can swap
166+ /// it atomically on a rate change.
167+ bus_filter: Option<crate::pw::filter::Filter>,
168+ /// whether the processed-sink monitor -> filter -> real-sink graph
169+ /// should currently be linked.
170+ bus_graph_active: bool,
171+ /// slow AGC controller, so a rebuild can rebind it. shared with the
172+ /// AGC timer in `runtime`; the main loop serialises borrow_mut.
173+ agc_controller: Option<Rc<RefCell<crate::agc::AgcController>>>,
174+}
175+
176+/// per-stream Layer A bundle: tap + controller + measurement consumer.
177+struct ManagedStream {
178+ /// drop severs the passive link + destroys the tap stream.
179+ #[allow(dead_code)]
180+ tap: StreamTap,
181+ controller: AppLevelController,
182+ measurement_consumer: Consumer<MeasurementSample>,
183+ /// bound source `Node` for `Props.channelVolumes` writes; `None` if
184+ /// the bind failed (controller still runs, writes skipped).
185+ node: Option<Node>,
186+ /// param listener: external `channelVolumes` changes (pavucontrol,
187+ /// hotkey, the app) route through `controller.on_external_change`
188+ /// (the rule's `DeferPolicy`).
189+ #[allow(dead_code)]
190+ node_listener: Option<NodeListener>,
191+ /// explicit per-channel passive links source → tap; empty until the
192+ /// drain-timer retry finds both port sets.
193+ #[allow(dead_code)]
194+ links: Vec<Link>,
195+ app_label: String,
196+}
197+
198+impl RoutingState {
199+ #[must_use]
200+ pub fn new(
201+ daemon: SharedState,
202+ registry: Rc<Registry>,
203+ core: Core,
204+ pw_command_rx: Receiver<PwCommand>,
205+ ) -> Self {
206+ Self {
207+ daemon,
208+ default_metadata: None,
209+ _default_metadata_listener: None,
210+ registry,
211+ core,
212+ filter_playback_id: None,
213+ sinks_by_name: HashMap::new(),
214+ pw_command_rx,
215+ managed_streams: HashMap::new(),
216+ ports_by_node: HashMap::new(),
217+ port_owner: HashMap::new(),
218+ links_by_id: HashMap::new(),
219+ outbound_links_by_node: HashMap::new(),
220+ pending_routes: HashMap::new(),
221+ managed_route_links: HashMap::new(),
222+ default_reassertion: None,
223+ known_streams: HashMap::new(),
224+ stream_globals: HashMap::new(),
225+ persisted_ceilings: HashMap::new(),
226+ real_sink_format_listener: None,
227+ bus_filter: None,
228+ bus_graph_active: false,
229+ agc_controller: None,
230+ }
231+ }
232+
233+ /// hand over the bus filter + AGC controller so a rate change can
234+ /// rebuild + rebind both atomically. called once from `runtime`.
235+ pub fn install_filter_rebuild_handles(
236+ &mut self,
237+ filter: crate::pw::filter::Filter,
238+ agc: Rc<RefCell<crate::agc::AgcController>>,
239+ ) {
240+ self.bus_filter = Some(filter);
241+ self.agc_controller = Some(agc);
242+ }
243+
244+ /// drain queued [`PwCommand`]s then run a routing-link enforcement
245+ /// pass. called by the 50 ms timer in
246+ /// [`crate::pw::PwContext::run_until_signal`].
247+ pub fn drain_pw_commands(&mut self, back: &Rc<RefCell<Self>>) {
248+ while let Ok(cmd) = self.pw_command_rx.try_recv() {
249+ self.apply_pw_command(cmd, back);
250+ }
251+ self.apply_pending_routes();
252+ }
253+
254+ fn apply_pw_command(&mut self, cmd: PwCommand, back: &Rc<RefCell<Self>>) {
255+ match cmd {
256+ PwCommand::RouteStream {
257+ node_id,
258+ to,
259+ app_label,
260+ } => {
261+ let target_name = match to {
262+ Route::Processed => PROCESSED_SINK_NAME.to_owned(),
263+ Route::Bypass => {
264+ let Some(name) = self.daemon.lock().real_sink.name.clone() else {
265+ tracing::warn!(
266+ node_id,
267+ "route.stream bypass requested but no real sink known yet — skipping metadata write"
268+ );
269+ return;
270+ };
271+ name
272+ }
273+ };
274+ self.write_stream_target(node_id, &target_name, &app_label);
275+ self.enqueue_route(node_id, target_name, app_label, to);
276+ self.sync_bus_graph_links();
277+ }
278+ PwCommand::ReevaluateAll => {
279+ self.reevaluate_all();
280+ }
281+ PwCommand::RebuildFilter { sample_rate } => {
282+ self.rebuild_filter(sample_rate);
283+ }
284+ PwCommand::ReevaluateLayerA => {
285+ self.reevaluate_layer_a(back);
286+ }
287+ PwCommand::LayerAResetDeference { node_id } => {
288+ if let Some(managed) = self.managed_streams.get_mut(&node_id) {
289+ managed.controller.reset_deference();
290+ tracing::info!(node_id, "Layer A deference reset");
291+ } else {
292+ tracing::debug!(
293+ node_id,
294+ "Layer A reset requested for an unmanaged stream — ignoring"
295+ );
296+ }
297+ }
298+ }
299+ }
300+
301+ /// tear down + recreate the bus filter at `new_sample_rate` and
302+ /// rebind AGC. posted by the Format listener on a rate mismatch.
303+ /// ~50–100 ms audio gap during the swap.
304+ fn rebuild_filter(&mut self, new_sample_rate: u32) {
305+ let Some(agc) = self.agc_controller.clone() else {
306+ tracing::warn!(
307+ new_sample_rate,
308+ "filter rebuild requested but agc handle not installed yet"
309+ );
310+ return;
311+ };
312+ let current_rate = self.daemon.lock().filter_sample_rate;
313+ if current_rate == Some(new_sample_rate) {
314+ tracing::debug!(
315+ new_sample_rate,
316+ "filter rebuild requested but rate is already current — no-op"
317+ );
318+ return;
319+ }
320+ // snapshot DSP config under the lock; rebuild runs without it.
321+ let filter_init = {
322+ let s = self.daemon.lock();
323+ let effective = s.profiles.effective();
324+ crate::pw::filter::FilterInit {
325+ compressor: effective.build_compressor_config(),
326+ limiter: effective.build_limiter_config(),
327+ agc: headroom_dsp::AgcGainConfig::default(),
328+ agc_enabled: effective.agc.enabled,
329+ }
330+ };
331+ tracing::info!(
332+ old_rate = ?current_rate,
333+ new_rate = new_sample_rate,
334+ "rebuilding bus filter at new sample rate"
335+ );
336+ // clear the cached filter id BEFORE dropping the old filter:
337+ // if the new filter's `global_add` ever beats the old one's
338+ // remove, `try_capture_filter_playback`'s early-exit would skip
339+ // the new id and never re-link. stale pending entries are
340+ // harmless (apply_pending_routes no-ops on a missing id).
341+ let old_filter_id = self.filter_playback_id.take();
342+ if let Some(id) = old_filter_id {
343+ self.pending_routes.remove(&id);
344+ self.managed_route_links.remove(&id);
345+ }
346+ // drop the old filter before creating the new one (short silence).
347+ self.bus_filter = None;
348+ let bundle = match crate::pw::filter::Filter::create(
349+ &self.core,
350+ filter_init,
351+ new_sample_rate,
352+ ) {
353+ Ok(b) => b,
354+ Err(e) => {
355+ tracing::error!(
356+ error = %e,
357+ new_sample_rate,
358+ "filter rebuild failed; daemon will run without a filter until the next rate change"
359+ );
360+ return;
361+ }
362+ };
363+ {
364+ let mut s = self.daemon.lock();
365+ s.filter_control = Some(bundle.control.clone());
366+ s.filter_sample_rate = Some(bundle.sample_rate);
367+ }
368+ agc.borrow_mut().rebind(
369+ bundle.measurement_consumer,
370+ bundle.control,
371+ bundle.sample_rate,
372+ );
373+ self.bus_filter = Some(bundle.filter);
374+ // re-run routing to re-anchor links on the new filter's ports.
375+ self.reevaluate_all();
376+ }
377+
378+ /// true iff the default metadata has been bound.
379+ #[must_use]
380+ pub fn has_default_metadata(&self) -> bool {
381+ self.default_metadata.is_some()
382+ }
383+
384+ fn on_global(&mut self, global: &GlobalObject<&DictRef>, back: &Rc<RefCell<Self>>) {
385+ match &global.type_ {
386+ ObjectType::Metadata => self.try_bind_default_metadata(global, back),
387+ ObjectType::Node => {
388+ self.try_capture_processed_sink_id(global);
389+ self.try_capture_real_sink(global);
390+ self.try_capture_filter_playback(global);
391+ self.try_route_stream(global, back);
392+ }
393+ ObjectType::Port => self.try_capture_port(global),
394+ ObjectType::Link => self.try_capture_link(global),
395+ _ => {}
396+ }
397+ }
398+
399+ /// track a Link global + vigilance check: if it originates from a
400+ /// stream we route and lands off its declared target, destroy it
401+ /// now. WP links streams faster than our node callback fires, so
402+ /// this fast path backs up the slower `apply_pending_routes` retry.
403+ fn try_capture_link(&mut self, global: &GlobalObject<&DictRef>) {
404+ let Some(props) = &global.props else {
405+ tracing::debug!(link_id = global.id, "link global without props");
406+ return;
407+ };
408+ let dict: &DictRef = props;
409+ let parse = |k: &str| dict.get(k).and_then(|s| s.parse::<u32>().ok());
410+ let (Some(output_port), Some(input_port), Some(output_node), Some(input_node)) = (
411+ parse("link.output.port"),
412+ parse("link.input.port"),
413+ parse("link.output.node"),
414+ parse("link.input.node"),
415+ ) else {
416+ tracing::debug!(
417+ link_id = global.id,
418+ out_port = ?parse("link.output.port"),
419+ in_port = ?parse("link.input.port"),
420+ out_node = ?parse("link.output.node"),
421+ in_node = ?parse("link.input.node"),
422+ "link global with incomplete props"
423+ );
424+ return;
425+ };
426+
427+ let info = LinkInfo {
428+ output_port,
429+ input_port,
430+ output_node,
431+ input_node,
432+ };
433+ tracing::debug!(
434+ link_id = global.id,
435+ output_port,
436+ input_port,
437+ output_node,
438+ input_node,
439+ "captured link global"
440+ );
441+ self.links_by_id.insert(global.id, info);
442+ self.outbound_links_by_node
443+ .entry(output_node)
444+ .or_default()
445+ .push(global.id);
446+
447+ self.enforce_link_for_managed_stream(global.id, &info);
448+ }
449+
450+ /// destroy `link` if it leaves a routed stream for a *different*
451+ /// Audio/Sink than the declared target. links to non-sinks (Layer A
452+ /// taps) are left alone — Layer A owns those.
453+ fn enforce_link_for_managed_stream(&mut self, link_id: u32, info: &LinkInfo) {
454+ let intent = self.intent_for_node(info.output_node);
455+ let Some((target_sink_node_id, target_input_ports)) = intent else {
456+ return;
457+ };
458+ if info.input_node == target_sink_node_id
459+ && target_input_ports.iter().any(|p| *p == info.input_port)
460+ {
461+ return; // link lands on the intended target — keep
462+ }
463+ // If the destination isn't one of our routing targets,
464+ // leave it alone — it's likely a Layer A tap or some
465+ // other downstream consumer the daemon doesn't own.
466+ if !self.is_routing_target(info.input_node) {
467+ return;
468+ }
469+ match self.registry.destroy_global(link_id).into_result() {
470+ Ok(_) => tracing::debug!(
471+ link_id,
472+ output_node = info.output_node,
473+ input_node = info.input_node,
474+ "destroyed conflicting link for managed stream"
475+ ),
476+ Err(e) => tracing::warn!(
477+ link_id,
478+ output_node = info.output_node,
479+ error = ?e,
480+ "failed to destroy conflicting link"
481+ ),
482+ }
483+ }
484+
485+ /// resolve a routing-target name to its node id. targets are
486+ /// `Audio/Sink`s + the bus filter; the filter is special-cased so
487+ /// `sinks_by_name` stays sink-only.
488+ fn resolve_routing_target(&self, name: &str) -> Option<u32> {
489+ if name == FILTER_NODE_NAME {
490+ return self.filter_playback_id;
491+ }
492+ self.sinks_by_name.get(name).copied()
493+ }
494+
495+ /// does `node_id` belong to a routing target the daemon links into?
496+ /// (link-teardown vigilance: leave non-targets like Layer A taps.)
497+ fn is_routing_target(&self, node_id: u32) -> bool {
498+ if self.filter_playback_id == Some(node_id) {
499+ return true;
500+ }
501+ self.sinks_by_name.values().any(|&id| id == node_id)
502+ }
503+
504+ /// `(target_sink_node_id, target_input_port_ids)` if the daemon
505+ /// intends to route `source_node`. for the link-vigilance fast path.
506+ fn intent_for_node(&self, source_node: u32) -> Option<(u32, Vec<u32>)> {
507+ let target_name = if let Some(p) = self.pending_routes.get(&source_node) {
508+ p.target_sink_name.clone()
509+ } else if self.managed_route_links.contains_key(&source_node) {
510+ let s = self.daemon.lock();
511+ let entry = s.streams.get(&source_node)?;
512+ match entry.route {
513+ Route::Processed => PROCESSED_SINK_NAME.to_owned(),
514+ Route::Bypass => s.real_sink.name.clone()?,
515+ }
516+ } else {
517+ return None;
518+ };
519+ let target_node = self.resolve_routing_target(&target_name)?;
520+ let target_inputs: Vec<u32> = self
521+ .ports_by_node
522+ .get(&target_node)?
523+ .iter()
524+ .filter(|p| p.direction == PortDirection::In)
525+ .map(|p| p.port_id)
526+ .collect();
527+ if target_inputs.is_empty() {
528+ None
529+ } else {
530+ Some((target_node, target_inputs))
531+ }
532+ }
533+
534+ fn try_capture_port(&mut self, global: &GlobalObject<&DictRef>) {
535+ let Some(props) = &global.props else { return };
536+ let dict: &DictRef = props;
537+ let Some(node_id) = dict.get("node.id").and_then(|s| s.parse::<u32>().ok()) else {
538+ return;
539+ };
540+ let direction = match dict.get("port.direction") {
541+ Some("in") => PortDirection::In,
542+ Some("out") => PortDirection::Out,
543+ _ => return,
544+ };
545+ let ordinal = dict.get("port.id").and_then(|s| s.parse::<u32>().ok());
546+ let info = PortInfo {
547+ port_id: global.id,
548+ direction,
549+ ordinal,
550+ };
551+ let entry = self.ports_by_node.entry(node_id).or_default();
552+ entry.retain(|p| p.port_id != info.port_id);
553+ entry.push(info);
554+ // owning node so `on_global_remove` distinguishes port vs node
555+ // removal under id reuse.
556+ self.port_owner.insert(global.id, node_id);
557+ }
558+
559+ /// record non-processed `Audio/Sink` nodes in `sinks_by_name` and,
560+ /// if the name matches the real sink, populate its node_id.
561+ ///
562+ /// **cold-boot fallback (F4):** with no real sink known yet, adopt
563+ /// the first non-processed sink, else `real_sink.name` stays `None`
564+ /// forever and bypass routes log "no real sink known". a later
565+ /// `default.audio.sink` event refines it via `adopt_new_real_sink`.
566+ fn try_capture_real_sink(&mut self, global: &GlobalObject<&DictRef>) {
567+ let Some(props) = &global.props else { return };
568+ let dict: &DictRef = props;
569+ if dict.get("media.class") != Some("Audio/Sink") {
570+ return;
571+ }
572+ let Some(name) = dict.get("node.name") else {
573+ return;
574+ };
575+ if name == PROCESSED_SINK_NAME {
576+ return; // tracked elsewhere
577+ }
578+ let rate = dict.get("audio.rate").and_then(|s| s.parse::<u32>().ok());
579+ self.sinks_by_name.insert(name.to_owned(), global.id);
580+ let mut became_real_sink = false;
581+ {
582+ let mut s = self.daemon.lock();
583+ if s.real_sink.name.is_none() {
584+ tracing::info!(
585+ node_id = global.id,
586+ name,
587+ ?rate,
588+ "no preferred_real_sink yet; adopting first available Audio/Sink as fallback"
589+ );
590+ s.real_sink.name = Some(name.to_owned());
591+ s.real_sink.node_id = Some(global.id);
592+ s.real_sink.sample_rate = rate;
593+ // adopted sink is "ready" (matches the metadata path);
594+ // else status/GUI report it not-ready while linked.
595+ s.real_sink.ready = true;
596+ became_real_sink = true;
597+ } else if s.real_sink.name.as_deref() == Some(name) {
598+ // re-sighting the current real sink.
599+ s.real_sink.ready = true;
600+ if s.real_sink.node_id != Some(global.id) {
601+ tracing::info!(
602+ node_id = global.id,
603+ name,
604+ "resolved preferred_real_sink node id"
605+ );
606+ s.real_sink.node_id = Some(global.id);
607+ became_real_sink = true;
608+ }
609+ // refresh rate on re-sight; ALSA sinks first register
610+ // without `audio.rate` (filled by the Format listener).
611+ if rate.is_some() && s.real_sink.sample_rate != rate {
612+ tracing::info!(
613+ node_id = global.id,
614+ name,
615+ old_rate = ?s.real_sink.sample_rate,
616+ new_rate = ?rate,
617+ "real sink rate updated"
618+ );
619+ s.real_sink.sample_rate = rate;
620+ }
621+ }
622+ }
623+ // ALSA sinks carry `audio.rate` only in the Format param, not
624+ // the props dict; subscribe to pull it. only for the current
625+ // real sink so we don't accumulate proxies.
626+ if became_real_sink {
627+ self.install_real_sink_format_listener(global);
628+ self.write_policy_default_audio_sink();
629+ // F4 can surface the sink after filter capture (which then
630+ // skipped the output leg); pin now — order-independent with
631+ // `try_capture_filter_playback`.
632+ self.sync_bus_graph_links();
633+ }
634+ }
635+
636+ /// bind `sink_global` and subscribe to its `Format` param (the
637+ /// initial value is replayed on subscribe) to track
638+ /// `real_sink.sample_rate`. replaces a listener on a different node.
639+ fn install_real_sink_format_listener(&mut self, sink_global: &GlobalObject<&DictRef>) {
640+ let node_id = sink_global.id;
641+ if let Some((prev_id, _, _)) = &self.real_sink_format_listener {
642+ if *prev_id == node_id {
643+ return; // already bound, nothing to do
644+ }
645+ }
646+ let node = match self.registry.bind::<Node, _>(sink_global) {
647+ Ok(n) => n,
648+ Err(e) => {
649+ tracing::warn!(
650+ node_id,
651+ error = %e,
652+ "failed to bind real sink Node proxy; sample rate will fall back to default"
653+ );
654+ self.real_sink_format_listener = None;
655+ return;
656+ }
657+ };
658+ let daemon = self.daemon.clone();
659+ let listener = node
660+ .add_listener_local()
661+ .param(move |_seq, id, _index, _next, param_opt| {
662+ if id != ParamType::Format {
663+ return;
664+ }
665+ let Some(param) = param_opt else { return };
666+ let Some(rate) = extract_audio_rate(param) else {
667+ return;
668+ };
669+ let (need_rebuild, tx) = {
670+ let mut s = daemon.lock();
671+ if s.real_sink.sample_rate == Some(rate) {
672+ return;
673+ }
674+ tracing::info!(
675+ node_id,
676+ old_rate = ?s.real_sink.sample_rate,
677+ new_rate = rate,
678+ "real sink Format negotiated; updating sample_rate"
679+ );
680+ s.real_sink.sample_rate = Some(rate);
681+ // filter on a different rate → ask for a rebuild.
682+ let need = s.filter_sample_rate != Some(rate);
683+ (need, s.pw_command_tx.clone())
684+ };
685+ if !need_rebuild {
686+ return;
687+ }
688+ let Some(tx) = tx else {
689+ tracing::debug!(
690+ "no PipeWire command channel; filter rebuild deferred (test mode?)"
691+ );
692+ return;
693+ };
694+ if tx
695+ .send(PwCommand::RebuildFilter { sample_rate: rate })
696+ .is_err()
697+ {
698+ tracing::warn!(
699+ "PipeWire command channel closed; filter rate-match lost"
700+ );
701+ }
702+ })
703+ .register();
704+ node.subscribe_params(&[ParamType::Format]);
705+ self.real_sink_format_listener = Some((node_id, node, listener));
706+ }
707+
708+ /// capture `headroom-filter`'s global id. match on `node.name`
709+ /// alone — `pw_filter` publishes no `Stream/*` media class.
710+ fn try_capture_filter_playback(&mut self, global: &GlobalObject<&DictRef>) {
711+ if self.filter_playback_id.is_some() {
712+ return;
713+ }
714+ let Some(props) = &global.props else { return };
715+ let dict: &DictRef = props;
716+ if dict.get("node.name") != Some(FILTER_NODE_NAME) {
717+ return;
718+ }
719+ tracing::info!(node_id = global.id, "captured bus filter node id");
720+ self.filter_playback_id = Some(global.id);
721+ // not registered in `sinks_by_name` (that map is sink-only);
722+ // resolved via `resolve_routing_target`/`is_routing_target`.
723+
724+ // both link legs are enqueued idempotently; whichever of the
725+ // filter / processed-sink / real-sink surfaces last wins the
726+ // race. (the output leg via `pin_filter_to_real_sink` is also
727+ // called from the real-sink adoption paths.)
728+ self.sync_bus_graph_links();
729+ }
730+
731+ fn sync_bus_graph_links(&mut self) {
732+ let should_be_active = self.should_activate_bus_graph();
733+ if should_be_active {
734+ if !self.bus_graph_active {
735+ tracing::info!("processed bus graph activated");
736+ }
737+ self.bus_graph_active = true;
738+ self.enqueue_filter_input_link();
739+ self.pin_filter_to_real_sink();
740+ } else {
741+ self.drop_bus_graph_links();
742+ if self.bus_graph_active {
743+ tracing::info!("processed bus graph deactivated");
744+ }
745+ self.bus_graph_active = false;
746+ }
747+ }
748+
749+ fn should_activate_bus_graph(&self) -> bool {
750+ let s = self.daemon.lock();
751+ if s.profiles.bypass_global() {
752+ return false;
753+ }
754+ if s.profiles.effective().default_route.route == Route::Processed {
755+ return true;
756+ }
757+ s.streams.values().any(|r| r.route == Route::Processed)
758+ }
759+
760+ fn drop_bus_graph_links(&mut self) {
761+ let processed_id = { self.daemon.lock().processed_sink_id };
762+ if let Some(processed_id) = processed_id {
763+ self.pending_routes.remove(&processed_id);
764+ self.managed_route_links.remove(&processed_id);
765+ }
766+ if let Some(filter_id) = self.filter_playback_id {
767+ self.pending_routes.remove(&filter_id);
768+ self.managed_route_links.remove(&filter_id);
769+ }
770+ }
771+
772+ /// enqueue the `filter → real_sink` output link once both the
773+ /// filter and a real sink are known. idempotent — callable from any
774+ /// site that learns one half, regardless of arrival order; the
775+ /// intent survives until the (maybe-suspended) sink's ports surface.
776+ fn pin_filter_to_real_sink(&mut self) {
777+ let Some(filter_id) = self.filter_playback_id else {
778+ return;
779+ };
780+ let Some(name) = self.daemon.lock().real_sink.name.clone() else {
781+ return;
782+ };
783+ self.enqueue_route(filter_id, name, FILTER_NODE_NAME.to_owned(), Route::Bypass);
784+ }
785+
786+ /// enqueue the `processed.monitor → filter.in.*` link pair. source
787+ /// = processed sink (its `Out` ports = the monitor); target = the
788+ /// filter's `In` ports. paired by ordinal in `apply_pending_routes`.
789+ fn enqueue_filter_input_link(&mut self) {
790+ let processed_id = match self.daemon.lock().processed_sink_id {
791+ Some(id) => id,
792+ None => {
793+ tracing::debug!(
794+ "filter input link deferred: processed sink id not yet captured"
795+ );
796+ return;
797+ }
798+ };
799+ self.enqueue_route(
800+ processed_id,
801+ FILTER_NODE_NAME.to_owned(),
802+ "headroom-processed.monitor".to_owned(),
803+ Route::Processed,
804+ );
805+ }
806+
807+ fn try_bind_default_metadata(
808+ &mut self,
809+ global: &GlobalObject<&DictRef>,
810+ back: &Rc<RefCell<Self>>,
811+ ) {
812+ if self.default_metadata.is_some() {
813+ return;
814+ }
815+ let Some(props) = &global.props else { return };
816+ let dict: &DictRef = props;
817+ if dict.get("metadata.name") != Some("default") {
818+ return;
819+ }
820+ let md = match self.registry.bind::<Metadata, _>(global) {
821+ Ok(m) => m,
822+ Err(e) => {
823+ tracing::warn!(error = %e, "failed to bind default metadata");
824+ return;
825+ }
826+ };
827+ tracing::info!(global_id = global.id, "bound default metadata");
828+ // install the listener BEFORE our promotion write so it sees the
829+ // prior server state then our write, in order.
830+ let listener_back = back.clone();
831+ let listener = md
832+ .add_listener_local()
833+ .property(move |subject, key, _type, value| {
834+ // re-entrancy invariant: callbacks are serial on this loop
835+ // no sync pipewire round-trips while this `RefCell` borrow lives
836+ listener_back.borrow_mut().on_metadata_property(subject, key, value);
837+ 0
838+ })
839+ .register();
840+ self.default_metadata = Some(md);
841+ self._default_metadata_listener = Some(listener);
842+ self.write_policy_default_audio_sink();
843+ }
844+
845+ fn try_capture_processed_sink_id(&mut self, global: &GlobalObject<&DictRef>) {
846+ let Some(props) = &global.props else { return };
847+ let dict: &DictRef = props;
848+ if dict.get("node.name") != Some(PROCESSED_SINK_NAME) {
849+ return;
850+ }
851+ let mut s = self.daemon.lock();
852+ if s.processed_sink_id != Some(global.id) {
853+ tracing::info!(node_id = global.id, "captured headroom-processed node id");
854+ s.processed_sink_id = Some(global.id);
855+ }
856+ drop(s);
857+ // expose in `sinks_by_name` so routing resolves it to a node id.
858+ self.sinks_by_name
859+ .insert(PROCESSED_SINK_NAME.to_owned(), global.id);
860+ // registry replay order isn't guaranteed; if the filter came
861+ // first, retry the monitor → filter.in.* enqueue now.
862+ if self.filter_playback_id.is_some() {
863+ self.sync_bus_graph_links();
864+ }
865+ }
866+
867+ fn try_route_stream(
868+ &mut self,
869+ global: &GlobalObject<&DictRef>,
870+ back: &Rc<RefCell<Self>>,
871+ ) {
872+ let Some(props) = &global.props else { return };
873+ let dict: &DictRef = props;
874+ if dict.get("media.class") != Some("Stream/Output/Audio") {
875+ return;
876+ }
877+ // don't route the daemon's own streams (filter + taps) back in
878+ // — feedback loop. `node.dont-move` isn't always in the registry
879+ // view, so match on name prefix too.
880+ if dict
881+ .get("node.name")
882+ .is_some_and(|n| n.starts_with("headroom-filter") || n.starts_with("headroom-tap"))
883+ {
884+ tracing::trace!(node_id = global.id, "skipping headroom-internal stream");
885+ return;
886+ }
887+
888+ let info = build_node_info(global.id, dict);
889+
890+ // cache before routing so reapply paths can re-`evaluate`
891+ // without re-reading props; owned global lets the Layer A
892+ // reconcile path bind without a live callback. cleared in
893+ // `on_global_remove`.
894+ self.known_streams.insert(info.node_id, info.clone());
895+ self.stream_globals.insert(info.node_id, global.to_owned());
896+
897+ let app_label = info_app_label(&info);
898+ self.apply_bus_route(&info, &app_label);
899+
900+ // Layer A is orthogonal to the bus route — taps the source
901+ // wherever it routes.
902+ self.maybe_spawn_layer_a(&info, &app_label, back);
903+ }
904+
905+ /// apply the bus-routing decision for `info`. reads bypass +
906+ /// profile + real-sink name at call time, then enqueues a route or
907+ /// unmanages. no proxies touched while the lock is held.
908+ fn apply_bus_route(&mut self, info: &PwNodeInfo, app_label: &str) {
909+ let (decision, real_sink_name) = {
910+ let s = self.daemon.lock();
911+ let bypass = s.profiles.bypass_global();
912+ let d = routing::evaluate(info, s.profiles.effective(), bypass);
913+ (d, s.real_sink.name.clone())
914+ };
915+
916+ match decision {
917+ RoutingDecision::Route(Route::Processed) => {
918+ self.write_stream_target(info.node_id, PROCESSED_SINK_NAME, app_label);
919+ self.enqueue_route(
920+ info.node_id,
921+ PROCESSED_SINK_NAME.to_owned(),
922+ app_label.to_owned(),
923+ Route::Processed,
924+ );
925+ self.record_route(info.node_id, app_label.to_owned(), Route::Processed);
926+ self.sync_bus_graph_links();
927+ }
928+ RoutingDecision::Route(Route::Bypass) => {
929+ if let Some(name) = real_sink_name.as_deref() {
930+ self.write_stream_target(info.node_id, name, app_label);
931+ self.enqueue_route(
932+ info.node_id,
933+ name.to_owned(),
934+ app_label.to_owned(),
935+ Route::Bypass,
936+ );
937+ } else {
938+ // no real sink known yet (early boot); record the
939+ // route, leave the stream at PipeWire's default.
940+ tracing::warn!(
941+ node_id = info.node_id,
942+ app = app_label,
943+ "bypass route with no known real sink — leaving stream at PipeWire default"
944+ );
945+ }
946+ self.record_route(info.node_id, app_label.to_owned(), Route::Bypass);
947+ self.sync_bus_graph_links();
948+ }
949+ RoutingDecision::Skip => {
950+ // not a managed bus stream; drop its links + intent,
951+ // leave Layer A alone.
952+ tracing::trace!(node_id = info.node_id, "skip (not routable)");
953+ self.unmanage(info.node_id);
954+ self.sync_bus_graph_links();
955+ }
956+ }
957+ }
958+
959+ /// tear down bus-routing state for `node_id` (links via
960+ /// `object.linger = "false"`, pending intent, `state.streams`).
961+ /// Layer A entries untouched — keyed on source node, own lifecycle.
962+ fn unmanage(&mut self, node_id: u32) {
963+ self.pending_routes.remove(&node_id);
964+ self.managed_route_links.remove(&node_id);
965+ let mut s = self.daemon.lock();
966+ if s.streams.remove(&node_id).is_some() {
967+ tracing::debug!(node_id, "bus route unmanaged");
968+ }
969+ }
970+
971+ /// re-apply routing policy to every known stream (one
972+ /// `routing::evaluate` each). also re-asserts `default.audio.sink`
973+ /// for the current bypass state — what makes "bypass on" a real
974+ /// kill switch for apps that follow `default` rather than
975+ /// `target.object`.
976+ fn reevaluate_all(&mut self) {
977+ self.write_policy_default_audio_sink();
978+
979+ let snapshot: Vec<PwNodeInfo> = self.known_streams.values().cloned().collect();
980+ tracing::info!(streams = snapshot.len(), "reevaluating all known streams");
981+ for info in snapshot {
982+ let app_label = info_app_label(&info);
983+ self.apply_bus_route(&info, &app_label);
984+ }
985+ self.sync_bus_graph_links();
986+ }
987+
988+ /// spawn a Layer A tap + controller if the stream matches an
989+ /// enabled `[[per_app.rules]]` entry. no-op if already managed or
990+ /// unmatched; every no-op path logs *why* at debug. wiring is in
991+ /// [`Self::spawn_layer_a`].
992+ fn maybe_spawn_layer_a(
993+ &mut self,
994+ info: &PwNodeInfo,
995+ app_label: &str,
996+ back: &Rc<RefCell<Self>>,
997+ ) {
998+ if self.managed_streams.contains_key(&info.node_id) {
999+ tracing::debug!(
1000+ node_id = info.node_id,
1001+ app = app_label,
1002+ "Layer A spawn skipped: already managed"
1003+ );
1004+ return;
1005+ }
1006+ let eval = {
1007+ let s = self.daemon.lock();
1008+ app_level::evaluate(info, &s.profiles.effective().per_app)
1009+ };
1010+ let rule = match eval {
1011+ LayerAEval::Spawn(rule) => rule,
1012+ other => {
1013+ tracing::debug!(
1014+ node_id = info.node_id,
1015+ app = app_label,
1016+ reason = other.skip_reason(),
1017+ "Layer A spawn skipped"
1018+ );
1019+ return;
1020+ }
1021+ };
1022+ self.spawn_layer_a(info.node_id, rule, app_label, back);
1023+ }
1024+
1025+ /// create the tap, bind the source node (from the cached owned
1026+ /// global, so both the callback + reconcile paths can call this),
1027+ /// restore any persisted ceiling, register the managed stream.
1028+ fn spawn_layer_a(
1029+ &mut self,
1030+ node_id: u32,
1031+ rule: crate::profile::PerAppRule,
1032+ app_label: &str,
1033+ back: &Rc<RefCell<Self>>,
1034+ ) {
1035+ let block_dt_s = layer_a_block_dt_s(self.daemon.lock().real_sink.sample_rate);
1036+ let (tap, consumer) = match StreamTap::start(&self.core, node_id) {
1037+ Ok(pair) => pair,
1038+ Err(e) => {
1039+ tracing::warn!(
1040+ node_id,
1041+ app = app_label,
1042+ error = %e,
1043+ "Layer A tap start failed; stream will be left unmanaged (retry next drain)"
1044+ );
1045+ return;
1046+ }
1047+ };
1048+ let mut controller = AppLevelController::new(rule, block_dt_s);
1049+
1050+ // restore any persisted user_ceiling for this app; lookup
1051+ // before bind keeps the ordering below clean.
1052+ let persisted_ceiling = if app_label.is_empty() {
1053+ None
1054+ } else {
1055+ self.persisted_ceilings.get(app_label).copied()
1056+ };
1057+ if let Some(ceiling) = persisted_ceiling {
1058+ controller.restore_state(ceiling, std::time::Instant::now());
1059+ tracing::debug!(
1060+ node_id,
1061+ app = app_label,
1062+ ceiling,
1063+ "Layer A: restored persisted user_ceiling for new instance"
1064+ );
1065+ }
1066+
1067+ // bind → install_listener → write baseline → subscribe(Props),
1068+ // in that order. writing a known baseline (persisted ceiling or
1069+ // unity) BEFORE subscribing means the Props echo equals the
1070+ // controller's `last_written_lin`, so the echo check ignores it
1071+ // instead of misreading a stale/inherited value as a user
1072+ // gesture and locking the ceiling (the "hangs until I tweak it
1073+ // again" bug on re-enable). bind failure still registers the tap.
1074+ let baseline = persisted_ceiling.unwrap_or(1.0);
1075+ let (node, node_listener) = match self.stream_globals.get(&node_id) {
1076+ Some(global) => match self.registry.bind::<Node, _>(global) {
1077+ Ok(n) => {
1078+ let listener = install_param_listener(&n, node_id, back);
1079+ write_channel_volumes(&n, baseline);
1080+ n.subscribe_params(&[ParamType::Props]);
1081+ (Some(n), Some(listener))
1082+ }
1083+ Err(e) => {
1084+ tracing::warn!(
1085+ node_id,
1086+ error = %e,
1087+ "Layer A: failed to bind Node proxy; volume writes + deference will be skipped"
1088+ );
1089+ (None, None)
1090+ }
1091+ },
1092+ None => {
1093+ tracing::warn!(
1094+ node_id,
1095+ "Layer A: no cached global for source node; volume writes + deference skipped"
1096+ );
1097+ (None, None)
1098+ }
1099+ };
1100+ self.managed_streams.insert(
1101+ node_id,
1102+ ManagedStream {
1103+ tap,
1104+ controller,
1105+ measurement_consumer: consumer,
1106+ node,
1107+ node_listener,
1108+ links: Vec::new(),
1109+ app_label: app_label.to_owned(),
1110+ },
1111+ );
1112+ tracing::info!(node_id, app = app_label, "Layer A tap spawned");
1113+ if let Ok(event) = Event::new(
1114+ Topic::Routing,
1115+ "layer_a_attached",
1116+ &json!({ "node_id": node_id, "app": app_label }),
1117+ ) {
1118+ self.daemon.lock().broadcaster.publish(Topic::Routing, event);
1119+ }
1120+ }
1121+
1122+ /// self-healing: spawn a tap for any known stream that should be
1123+ /// managed but isn't, regardless of why it missed its one
1124+ /// `try_route_stream` spawn. runs every drain so a miss recovers in
1125+ /// one tick.
1126+ fn reconcile_layer_a(&mut self, back: &Rc<RefCell<Self>>) {
1127+ let per_app = self.daemon.lock().profiles.effective().per_app.clone();
1128+ if !per_app.enabled {
1129+ return; // master off
1130+ }
1131+ let candidates: Vec<PwNodeInfo> = self
1132+ .known_streams
1133+ .values()
1134+ .filter(|info| !self.managed_streams.contains_key(&info.node_id))
1135+ .cloned()
1136+ .collect();
1137+ for info in candidates {
1138+ if let LayerAEval::Spawn(rule) = app_level::evaluate(&info, &per_app) {
1139+ let app_label = info_app_label(&info);
1140+ tracing::debug!(
1141+ node_id = info.node_id,
1142+ app = app_label.as_str(),
1143+ "Layer A reconcile: re-attaching a stream that missed its spawn"
1144+ );
1145+ self.spawn_layer_a(info.node_id, rule, &app_label, back);
1146+ }
1147+ }
1148+ }
1149+
1150+ /// re-evaluate managed + known streams against `[per_app]`: tear
1151+ /// down taps that no longer match, spawn ones that now do. posted as
1152+ /// `PwCommand::ReevaluateLayerA` by the per-app / master setters.
1153+ fn reevaluate_layer_a(&mut self, back: &Rc<RefCell<Self>>) {
1154+ let per_app = self.daemon.lock().profiles.effective().per_app.clone();
1155+ let managed_ids: Vec<u32> = self.managed_streams.keys().copied().collect();
1156+ for node_id in managed_ids {
1157+ let Some(info) = self.known_streams.get(&node_id).cloned() else {
1158+ continue;
1159+ };
1160+ match app_level::evaluate(&info, &per_app) {
1161+ // still managed: push the (maybe new) rule into the live
1162+ // controller — thresholds change in place, no tap churn,
1163+ // ceiling + deference + envelope preserved.
1164+ LayerAEval::Spawn(rule) => {
1165+ if let Some(managed) = self.managed_streams.get_mut(&node_id) {
1166+ managed.controller.set_rule(rule);
1167+ }
1168+ }
1169+ // no longer managed: release the tap + gain.
1170+ other => {
1171+ tracing::info!(
1172+ node_id,
1173+ reason = other.skip_reason(),
1174+ "Layer A: stopping per-app management for this stream (per-app disabled \
1175+ for the app or globally)"
1176+ );
1177+ self.teardown_managed_stream(node_id, true);
1178+ }
1179+ }
1180+ }
1181+ self.reconcile_layer_a(back);
1182+ }
1183+
1184+ /// restore every managed stream's pre-management volume (ceiling or
1185+ /// unity) on graceful shutdown so attenuated apps aren't left
1186+ /// reduced. best-effort; caller pumps the loop to flush the writes.
1187+ pub fn restore_all_managed_volumes(&self) {
1188+ for (&node_id, managed) in &self.managed_streams {
1189+ let Some(node) = managed.node.as_ref() else {
1190+ continue;
1191+ };
1192+ let restore_to = managed.controller.user_ceiling_lin().unwrap_or(1.0);
1193+ write_channel_volumes(node, restore_to);
1194+ tracing::info!(
1195+ node_id,
1196+ restore_to,
1197+ app = managed.app_label.as_str(),
1198+ "restoring managed stream volume on shutdown"
1199+ );
1200+ }
1201+ }
1202+
1203+ /// tear down a managed Layer A stream: optionally restore volume
1204+ /// (so a policy-disable releases the gain), persist the ceiling for
1205+ /// the app's next instance, drop the tap, emit `layer_a_detached`.
1206+ /// `restore_volume = false` from `on_global_remove` (stream gone),
1207+ /// `true` from `reevaluate_layer_a` (policy disable).
1208+ fn teardown_managed_stream(&mut self, node_id: u32, restore_volume: bool) {
1209+ let Some(managed) = self.managed_streams.remove(&node_id) else {
1210+ return;
1211+ };
1212+ if restore_volume {
1213+ if let Some(node) = managed.node.as_ref() {
1214+ let restore_to = managed.controller.user_ceiling_lin().unwrap_or(1.0);
1215+ write_channel_volumes(node, restore_to);
1216+ tracing::debug!(
1217+ node_id,
1218+ restore_to,
1219+ "Layer A: restored stream volume on teardown"
1220+ );
1221+ }
1222+ }
1223+ if let Some(ceiling) = managed.controller.user_ceiling_lin() {
1224+ if !managed.app_label.is_empty() {
1225+ self.persisted_ceilings
1226+ .insert(managed.app_label.clone(), ceiling);
1227+ tracing::debug!(
1228+ node_id,
1229+ app = managed.app_label.as_str(),
1230+ ceiling,
1231+ "Layer A: persisted user_ceiling for next instance"
1232+ );
1233+ }
1234+ }
1235+ {
1236+ let mut s = self.daemon.lock();
1237+ s.layer_a.remove(&node_id);
1238+ if let Ok(event) = Event::new(
1239+ Topic::Routing,
1240+ "layer_a_detached",
1241+ &json!({ "node_id": node_id }),
1242+ ) {
1243+ s.broadcaster.publish(Topic::Routing, event);
1244+ }
1245+ }
1246+ tracing::info!(node_id, "Layer A tap torn down");
1247+ drop(managed);
1248+ }
1249+
1250+ /// drain each managed stream's measurement ring, advance its
1251+ /// controller, write `Props.channelVolumes` on change. also retries
1252+ /// explicit-link creation. called by the 5 ms timer in
1253+ /// [`crate::pw::PwContext::run_until_signal`].
1254+ pub fn drain_layer_a(&mut self, back: &Rc<RefCell<Self>>) {
1255+ self.attempt_pending_links();
1256+
1257+ // collect meter events to emit after the iter_mut borrow drops
1258+ // (broadcaster is behind the daemon mutex; avoid nested borrows).
1259+ let mut meters: Vec<(u32, String, f32, f32)> = Vec::new();
1260+ let mut snapshots: Vec<LayerASnapshot> = Vec::with_capacity(self.managed_streams.len());
1261+
1262+ let now = std::time::Instant::now();
1263+ for (&source_node_id, managed) in self.managed_streams.iter_mut() {
1264+ while let Ok(sample) = managed.measurement_consumer.pop() {
1265+ let Some(volume_lin) =
1266+ managed.controller.process_block(sample.peak, sample.mean_sq, now)
1267+ else {
1268+ continue;
1269+ };
1270+ let Some(node) = managed.node.as_ref() else {
1271+ tracing::trace!(
1272+ target_volume = volume_lin,
1273+ "Layer A wanted to write volume but no Node proxy was bound"
1274+ );
1275+ continue;
1276+ };
1277+ write_channel_volumes(node, volume_lin);
1278+ meters.push((
1279+ source_node_id,
1280+ managed.app_label.clone(),
1281+ volume_lin,
1282+ managed.controller.smoothed_reduction_db(),
1283+ ));
1284+ }
1285+ // advance envelopes through a silent gap (source suspension,
1286+ // e.g. Strawberry between tracks, stops the audio thread);
1287+ // else the gain freezes at the last value. no-op when
1288+ // measurements flow.
1289+ if let Some(volume_lin) = managed.controller.tick_silent(now) {
1290+ if let Some(node) = managed.node.as_ref() {
1291+ write_channel_volumes(node, volume_lin);
1292+ meters.push((
1293+ source_node_id,
1294+ managed.app_label.clone(),
1295+ volume_lin,
1296+ managed.controller.smoothed_reduction_db(),
1297+ ));
1298+ }
1299+ }
1300+
1301+ snapshots.push(LayerASnapshot {
1302+ node_id: source_node_id,
1303+ app: managed.app_label.clone(),
1304+ managed: true,
1305+ volume_lin: managed.controller.last_written_lin(),
1306+ reduction_db: managed.controller.smoothed_reduction_db(),
1307+ user_ceiling_lin: managed.controller.user_ceiling_lin(),
1308+ deferred: managed.controller.deferred(),
1309+ });
1310+ }
1311+
1312+ {
1313+ let mut s = self.daemon.lock();
1314+ for snap in snapshots {
1315+ s.layer_a.insert(snap.node_id, snap);
1316+ }
1317+ for (node_id, app, volume, reduction_db) in meters {
1318+ if let Ok(event) = Event::new(
1319+ Topic::Meters,
1320+ "layer_a_level",
1321+ &json!({
1322+ "node_id": node_id,
1323+ "app": app,
1324+ "volume_lin": volume,
1325+ "reduction_db": reduction_db,
1326+ }),
1327+ ) {
1328+ s.broadcaster.publish(Topic::Meters, event);
1329+ }
1330+ }
1331+ }
1332+
1333+ self.reconcile_layer_a(back);
1334+ }
1335+
1336+ /// for each managed stream lacking links, if both port sets are in
1337+ /// the cache, pair by ordinal and create one passive `Link` per
1338+ /// channel. idempotent.
1339+ fn attempt_pending_links(&mut self) {
1340+ // snapshot first so we don't hold &mut self across the
1341+ // link-factory calls (which need &self.core).
1342+ let pending: Vec<(u32, u32)> = self
1343+ .managed_streams
1344+ .iter()
1345+ .filter(|(_, m)| m.links.is_empty())
1346+ .map(|(&src_id, m)| (src_id, m.tap.tap_node_id()))
1347+ .collect();
1348+
1349+ for (source_node_id, tap_node_id) in pending {
1350+ if tap_node_id == 0 {
1351+ // Stream isn't bound yet; retry next tick.
1352+ continue;
1353+ }
1354+ let Some(src_outs) = collect_ports(
1355+ &self.ports_by_node,
1356+ source_node_id,
1357+ PortDirection::Out,
1358+ ) else {
1359+ continue;
1360+ };
1361+ let Some(tap_ins) =
1362+ collect_ports(&self.ports_by_node, tap_node_id, PortDirection::In)
1363+ else {
1364+ continue;
1365+ };
1366+
1367+ // Stereo only in v0 — pair by per-node ordinal. If counts
1368+ // mismatch, skip and retry (ports may still be arriving).
1369+ if src_outs.len() < 2 || tap_ins.len() < 2 {
1370+ continue;
1371+ }
1372+
1373+ let mut created = Vec::with_capacity(2);
1374+ let mut all_ok = true;
1375+ for (out, inp) in src_outs.iter().take(2).zip(tap_ins.iter().take(2)) {
1376+ match create_explicit_link(&self.core, out.port_id, inp.port_id) {
1377+ Ok(link) => created.push(link),
1378+ Err(e) => {
1379+ tracing::warn!(
1380+ source = source_node_id,
1381+ tap = tap_node_id,
1382+ out_port = out.port_id,
1383+ in_port = inp.port_id,
1384+ error = %e,
1385+ "Layer A explicit link creation failed; will retry next tick"
1386+ );
1387+ all_ok = false;
1388+ break;
1389+ }
1390+ }
1391+ }
1392+ if all_ok && !created.is_empty() {
1393+ if let Some(m) = self.managed_streams.get_mut(&source_node_id) {
1394+ tracing::info!(
1395+ source = source_node_id,
1396+ tap = tap_node_id,
1397+ links = created.len(),
1398+ "Layer A explicit passive links created"
1399+ );
1400+ m.links = created;
1401+ }
1402+ }
1403+ }
1404+ }
1405+
1406+ /// record routing intent for `node_id`; `apply_pending_routes`
1407+ /// later builds the explicit links. last intent wins.
1408+ fn enqueue_route(
1409+ &mut self,
1410+ node_id: u32,
1411+ target_sink_name: String,
1412+ app_label: String,
1413+ route: Route,
1414+ ) {
1415+ // already-correct links to the same sink are left alone (the
1416+ // apply pass no-ops), avoiding the 21-42 ms gap an
1417+ // unconditional rebuild would cost. only drop the proxies when
1418+ // the target actually changed.
1419+ let already_at_target = self
1420+ .managed_route_links
1421+ .get(&node_id)
1422+ .is_some_and(|m| m.target_sink_name == target_sink_name);
1423+ if !already_at_target {
1424+ self.managed_route_links.remove(&node_id);
1425+ }
1426+ self.pending_routes.insert(
1427+ node_id,
1428+ PendingRoute {
1429+ target_sink_name,
1430+ app_label,
1431+ route,
1432+ },
1433+ );
1434+ }
1435+
1436+ /// drain `pending_routes`: for each stream whose source + target
1437+ /// ports are both on the registry, tear down conflicting outbound
1438+ /// links and create the explicit ones. not-ready intents stay queued.
1439+ fn apply_pending_routes(&mut self) {
1440+ // snapshot keys: we mutate `managed_route_links` while iterating.
1441+ let candidates: Vec<u32> = self.pending_routes.keys().copied().collect();
1442+ if !candidates.is_empty() {
1443+ tracing::debug!(
1444+ pending = candidates.len(),
1445+ "apply_pending_routes pass"
1446+ );
1447+ }
1448+ for node_id in candidates {
1449+ let Some(intent) = self.pending_routes.get(&node_id).cloned() else {
1450+ continue;
1451+ };
1452+
1453+ let Some(target_node) = self.resolve_routing_target(&intent.target_sink_name) else {
1454+ tracing::debug!(
1455+ node_id,
1456+ target = intent.target_sink_name.as_str(),
1457+ "pending route: target not yet on registry"
1458+ );
1459+ continue; // target not yet on registry
1460+ };
1461+ let Some(src_outs) =
1462+ collect_ports(&self.ports_by_node, node_id, PortDirection::Out)
1463+ else {
1464+ tracing::debug!(node_id, "pending route: source has no output ports yet");
1465+ continue;
1466+ };
1467+ let Some(target_ins) =
1468+ collect_ports(&self.ports_by_node, target_node, PortDirection::In)
1469+ else {
1470+ tracing::debug!(node_id, target_node, "pending route: target has no input ports yet");
1471+ continue;
1472+ };
1473+ // pair by ordinal up to the narrower side (all N channels,
1474+ // so surround isn't truncated to stereo). mono (N=1) is
1475+ // intentionally left to WP's upmix adapter — 1→N fanout +
1476+ // stereo-link limiter semantics don't generalise, so
1477+ // `route.set` on a mono app is a hint, not enforcement
1478+ // (v0; fixed in the v1 multichannel pipeline).
1479+ let pair_count = src_outs.len().min(target_ins.len());
1480+ if pair_count < 2 {
1481+ tracing::debug!(
1482+ node_id,
1483+ src_outs = src_outs.len(),
1484+ target_ins = target_ins.len(),
1485+ "pending route: not enough ports for stereo+ pairing (mono left to WP)"
1486+ );
1487+ continue;
1488+ }
1489+ let want: Vec<(u32, u32)> = src_outs
1490+ .iter()
1491+ .take(pair_count)
1492+ .zip(target_ins.iter().take(pair_count))
1493+ .map(|(o, i)| (o.port_id, i.port_id))
1494+ .collect();
1495+ let want_set: std::collections::HashSet<(u32, u32)> = want.iter().copied().collect();
1496+
1497+ // 1) destroy outbound links to a *different* sink; leave
1498+ // non-sink links (Layer A taps) alone.
1499+ let existing: Vec<u32> = self
1500+ .outbound_links_by_node
1501+ .get(&node_id)
1502+ .cloned()
1503+ .unwrap_or_default();
1504+ for link_id in existing {
1505+ let Some(info) = self.links_by_id.get(&link_id).copied() else {
1506+ continue;
1507+ };
1508+ if want_set.contains(&(info.output_port, info.input_port)) {
1509+ continue; // already correct — keep
1510+ }
1511+ if !self.is_routing_target(info.input_node) {
1512+ continue; // probably a Layer A tap or similar
1513+ }
1514+ if let Err(e) = self.registry.destroy_global(link_id).into_result() {
1515+ tracing::warn!(
1516+ link_id,
1517+ node_id,
1518+ target = intent.target_sink_name.as_str(),
1519+ error = ?e,
1520+ "apply_pending_routes: destroy_global failed"
1521+ );
1522+ }
1523+ }
1524+
1525+ // 2) Create any missing wanted links.
1526+ let already_wanted: std::collections::HashSet<(u32, u32)> = self
1527+ .outbound_links_by_node
1528+ .get(&node_id)
1529+ .into_iter()
1530+ .flatten()
1531+ .filter_map(|id| self.links_by_id.get(id))
1532+ .map(|info| (info.output_port, info.input_port))
1533+ .collect();
1534+ let mut created: Vec<Link> = self
1535+ .managed_route_links
1536+ .remove(&node_id)
1537+ .map(|m| m.links)
1538+ .unwrap_or_default();
1539+ let mut all_ok = true;
1540+ for (out_port, in_port) in &want {
1541+ if already_wanted.contains(&(*out_port, *in_port)) {
1542+ continue;
1543+ }
1544+ match create_routing_link(&self.core, *out_port, *in_port) {
1545+ Ok(link) => created.push(link),
1546+ Err(e) => {
1547+ tracing::warn!(
1548+ node_id,
1549+ out_port,
1550+ in_port,
1551+ target = intent.target_sink_name.as_str(),
1552+ error = %e,
1553+ "apply_pending_routes: create_object failed; retry next tick"
1554+ );
1555+ all_ok = false;
1556+ break;
1557+ }
1558+ }
1559+ }
1560+ if !created.is_empty() {
1561+ self.managed_route_links.insert(
1562+ node_id,
1563+ ManagedRoute {
1564+ target_sink_name: intent.target_sink_name.clone(),
1565+ links: created,
1566+ },
1567+ );
1568+ }
1569+ if all_ok {
1570+ tracing::info!(
1571+ node_id,
1572+ app = intent.app_label.as_str(),
1573+ target = intent.target_sink_name.as_str(),
1574+ route = intent.route.as_str(),
1575+ "explicit routing link established"
1576+ );
1577+ self.pending_routes.remove(&node_id);
1578+ }
1579+ }
1580+ }
1581+
1582+ /// write `target.object = {"name":"<sink_name>"}` for `node_id`.
1583+ fn write_stream_target(&self, node_id: u32, sink_name: &str, app_label: &str) {
1584+ let Some(md) = &self.default_metadata else {
1585+ tracing::warn!(node_id, "no default metadata bound; cannot apply target.object");
1586+ return;
1587+ };
1588+ let value = format_sink_target_value(sink_name);
1589+ md.set_property(node_id, TARGET_OBJECT_KEY, Some(SPA_JSON_TYPE), Some(&value));
1590+ tracing::info!(node_id, app = app_label, target = sink_name, "routed");
1591+ }
1592+
1593+ /// write `default.audio.sink` (subject 0, system-wide).
1594+ fn write_default_audio_sink(&self, sink_name: &str) {
1595+ let Some(md) = &self.default_metadata else {
1596+ tracing::warn!("no default metadata bound; cannot write default.audio.sink");
1597+ return;
1598+ };
1599+ let value = format_sink_target_value(sink_name);
1600+ md.set_property(
1601+ METADATA_SUBJECT_GLOBAL,
1602+ DEFAULT_AUDIO_SINK_KEY,
1603+ Some(SPA_JSON_TYPE),
1604+ Some(&value),
1605+ );
1606+ tracing::info!(sink_name, "wrote default.audio.sink");
1607+ }
1608+
1609+ /// handle a `default` metadata property change; only
1610+ /// `default.audio.sink` matters.
1611+ fn on_metadata_property(&mut self, subject: u32, key: Option<&str>, value: Option<&str>) {
1612+ if subject != METADATA_SUBJECT_GLOBAL {
1613+ return;
1614+ }
1615+ if key != Some(DEFAULT_AUDIO_SINK_KEY) {
1616+ return;
1617+ }
1618+ let Some(raw) = value else {
1619+ // key removed; keep the last-known real sink for retargets.
1620+ tracing::warn!("default.audio.sink cleared on server side");
1621+ return;
1622+ };
1623+ let Some(name) = parse_default_sink_name(raw) else {
1624+ tracing::warn!(raw, "failed to parse default.audio.sink value");
1625+ return;
1626+ };
1627+ if name == PROCESSED_SINK_NAME {
1628+ // our own promotion echo.
1629+ tracing::debug!("default.audio.sink is headroom-processed (expected)");
1630+ return;
1631+ }
1632+ self.adopt_new_real_sink(name);
1633+ }
1634+
1635+ /// re-assert `default.audio.sink = headroom-processed`, capped at
1636+ /// `MAX_PER_WINDOW` per `WINDOW` so a WP that keeps rewriting our
1637+ /// value back can't hot-loop us. explicit links still enforce
1638+ /// routing whichever side wins the default.
1639+ fn reassert_default_processed(&mut self) {
1640+ // under default-bypass policy, stop fighting WP for the default so
1641+ // `default`-following apps land at the real sink — else
1642+ // "bypass on" wouldn't bypass for them.
1643+ if self.default_should_bypass() {
1644+ return;
1645+ }
1646+ const WINDOW: std::time::Duration = std::time::Duration::from_secs(1);
1647+ const MAX_PER_WINDOW: u32 = 10;
1648+ let now = std::time::Instant::now();
1649+ match &mut self.default_reassertion {
1650+ Some((started, n)) if now.duration_since(*started) < WINDOW => {
1651+ if *n >= MAX_PER_WINDOW {
1652+ tracing::debug!(
1653+ attempts = *n,
1654+ "default.audio.sink re-assertion budget exhausted for this window"
1655+ );
1656+ return;
1657+ }
1658+ *n += 1;
1659+ }
1660+ _ => {
1661+ self.default_reassertion = Some((now, 1));
1662+ }
1663+ }
1664+ self.write_default_audio_sink(PROCESSED_SINK_NAME);
1665+ }
1666+
1667+ fn write_policy_default_audio_sink(&self) {
1668+ if self.default_should_bypass() {
1669+ let real_sink_name = { self.daemon.lock().real_sink.name.clone() };
1670+ if let Some(name) = real_sink_name {
1671+ tracing::info!(
1672+ sink = name.as_str(),
1673+ "bypass default: setting default.audio.sink to real sink"
1674+ );
1675+ self.write_default_audio_sink(&name);
1676+ } else {
1677+ tracing::warn!(
1678+ "bypass default requested but no real sink known yet — leaving default.audio.sink alone"
1679+ );
1680+ }
1681+ } else {
1682+ self.write_default_audio_sink(PROCESSED_SINK_NAME);
1683+ }
1684+ }
1685+
1686+ fn default_should_bypass(&self) -> bool {
1687+ let s = self.daemon.lock();
1688+ s.profiles.bypass_global() || s.profiles.effective().default_route.route == Route::Bypass
1689+ }
1690+
1691+ /// update `preferred_real_sink`, retarget every bypass-routed
1692+ /// stream + the filter, re-assert headroom-processed as default.
1693+ fn adopt_new_real_sink(&mut self, new_sink_name: String) {
1694+ let (bypass_targets, resolved_node_id) = {
1695+ let mut s = self.daemon.lock();
1696+ let Some(targets) = s.apply_real_sink_change(&new_sink_name) else {
1697+ // sink unchanged, WP just moved default away; re-assert.
1698+ drop(s);
1699+ self.reassert_default_processed();
1700+ return;
1701+ };
1702+ // resolve node_id now if known; else try_capture_real_sink
1703+ // fills it on the next global.
1704+ let resolved = self.sinks_by_name.get(&new_sink_name).copied();
1705+ if let Some(id) = resolved {
1706+ s.real_sink.node_id = Some(id);
1707+ }
1708+ (targets, resolved)
1709+ };
1710+ tracing::info!(
1711+ sink = new_sink_name.as_str(),
1712+ node_id = ?resolved_node_id,
1713+ "preferred_real_sink updated"
1714+ );
1715+
1716+ for (node_id, app_label) in &bypass_targets {
1717+ self.write_stream_target(*node_id, &new_sink_name, app_label);
1718+ self.enqueue_route(
1719+ *node_id,
1720+ new_sink_name.clone(),
1721+ app_label.clone(),
1722+ Route::Bypass,
1723+ );
1724+ }
1725+ if !bypass_targets.is_empty() {
1726+ tracing::info!(
1727+ retargeted = bypass_targets.len(),
1728+ sink = new_sink_name.as_str(),
1729+ "retargeted bypass streams"
1730+ );
1731+ }
1732+
1733+ // retarget the filter (no `target.object` — we own its
1734+ // linking; WP wouldn't honour it for a pw_filter anyway).
1735+ self.sync_bus_graph_links();
1736+
1737+ // re-assert headroom-processed as default for new streams.
1738+ self.reassert_default_processed();
1739+
1740+ let event = Event::new(
1741+ Topic::Routing,
1742+ "real_sink_changed",
1743+ &json!({ "name": new_sink_name, "node_id": resolved_node_id }),
1744+ );
1745+ if let Ok(event) = event {
1746+ self.daemon.lock().broadcaster.publish(Topic::Routing, event);
1747+ }
1748+ }
1749+
1750+ fn record_route(&self, node_id: u32, app: String, route: Route) {
1751+ let mut s = self.daemon.lock();
1752+ s.streams.insert(
1753+ node_id,
1754+ RoutedStream {
1755+ node_id,
1756+ app: app.clone(),
1757+ route,
1758+ },
1759+ );
1760+ if let Ok(event) = Event::new(
1761+ Topic::Routing,
1762+ "stream_routed",
1763+ &json!({
1764+ "node_id": node_id,
1765+ "app": app,
1766+ "to": route.as_str(),
1767+ }),
1768+ ) {
1769+ s.broadcaster.publish(Topic::Routing, event);
1770+ }
1771+ }
1772+
1773+ fn on_global_remove(&mut self, node_id: u32) {
1774+ // best-effort cleanup over a mixed id namespace; missing-key
1775+ // removes are harmless. port cleanup is scoped via `port_owner`
1776+ // so a node removal isn't conflated with a port removal under
1777+ // id reuse (the old "retain by port_id across all nodes" could
1778+ // wipe a live node's ports).
1779+ if let Some(owner) = self.port_owner.remove(&node_id) {
1780+ if let Some(ports) = self.ports_by_node.get_mut(&owner) {
1781+ ports.retain(|p| p.port_id != node_id);
1782+ if ports.is_empty() {
1783+ self.ports_by_node.remove(&owner);
1784+ }
1785+ }
1786+ } else if let Some(ports) = self.ports_by_node.remove(&node_id) {
1787+ for p in ports {
1788+ self.port_owner.remove(&p.port_id);
1789+ }
1790+ }
1791+
1792+ // node_id may be a link global, or a node whose links we forget.
1793+ if let Some(info) = self.links_by_id.remove(&node_id) {
1794+ if let Some(v) = self.outbound_links_by_node.get_mut(&info.output_node) {
1795+ v.retain(|&id| id != node_id);
1796+ if v.is_empty() {
1797+ self.outbound_links_by_node.remove(&info.output_node);
1798+ }
1799+ }
1800+ }
1801+ self.outbound_links_by_node.remove(&node_id);
1802+ self.links_by_id
1803+ .retain(|_, info| info.output_node != node_id && info.input_node != node_id);
1804+
1805+ // stream gone — drop intent + cache so reapply doesn't route a
1806+ // dead node.
1807+ self.pending_routes.remove(&node_id);
1808+ self.managed_route_links.remove(&node_id);
1809+ self.known_streams.remove(&node_id);
1810+ self.stream_globals.remove(&node_id);
1811+
1812+ if self.filter_playback_id == Some(node_id) {
1813+ tracing::debug!(node_id, "filter playback removed from registry");
1814+ self.filter_playback_id = None;
1815+ }
1816+ self.sinks_by_name.retain(|name, &mut id| {
1817+ if id == node_id {
1818+ tracing::debug!(node_id, name, "real sink removed from registry");
1819+ let mut s = self.daemon.lock();
1820+ // clear BOTH name and node_id: nulling only node_id
1821+ // leaves the name pinned to a dead sink, so every
1822+ // bypass route queues forever against a stale target.
1823+ if s.real_sink.name.as_deref() == Some(name.as_str()) {
1824+ s.real_sink.name = None;
1825+ s.real_sink.node_id = None;
1826+ s.real_sink.sample_rate = None;
1827+ drop(s);
1828+ // the Format listener points at a dead node.
1829+ if let Some((prev_id, _, _)) = &self.real_sink_format_listener {
1830+ if *prev_id == node_id {
1831+ self.real_sink_format_listener = None;
1832+ }
1833+ }
1834+ } else if s.real_sink.node_id == Some(node_id) {
1835+ // defensive: id matched but name didn't.
1836+ s.real_sink.node_id = None;
1837+ }
1838+ false
1839+ } else {
1840+ true
1841+ }
1842+ });
1843+ // node gone, so don't restore volume — just persist the ceiling
1844+ // and tear down the tap.
1845+ self.teardown_managed_stream(node_id, false);
1846+ let mut s = self.daemon.lock();
1847+ let removed = s.streams.remove(&node_id);
1848+ if removed.is_some() {
1849+ tracing::debug!(node_id, "stream removed");
1850+ if let Ok(event) = Event::new(
1851+ Topic::Routing,
1852+ "stream_removed",
1853+ &json!({ "node_id": node_id }),
1854+ ) {
1855+ s.broadcaster.publish(Topic::Routing, event);
1856+ }
1857+ }
1858+ }
1859+}
1860+
1861+/// `param` listener forwarding external `channelVolumes` changes to
1862+/// the controller's `on_external_change` — the user-volume-deference
1863+/// loop.
1864+fn install_param_listener(
1865+ node: &Node,
1866+ source_node_id: u32,
1867+ back: &Rc<RefCell<RoutingState>>,
1868+) -> NodeListener {
1869+ let back = back.clone();
1870+ node.add_listener_local()
1871+ .param(move |_seq, id, _index, _next, param_opt| {
1872+ if id != ParamType::Props {
1873+ return;
1874+ }
1875+ let Some(param) = param_opt else { return };
1876+ let Some(new_volume) = extract_channel_volume(param) else {
1877+ return;
1878+ };
1879+ let mut state = back.borrow_mut();
1880+ let Some(managed) = state.managed_streams.get_mut(&source_node_id) else {
1881+ return;
1882+ };
1883+ managed.controller.on_external_change(new_volume);
1884+ tracing::debug!(
1885+ source = source_node_id,
1886+ new_volume,
1887+ user_ceiling = ?managed.controller.user_ceiling_lin(),
1888+ deferred = managed.controller.deferred(),
1889+ "Layer A observed external Props.channelVolumes change"
1890+ );
1891+ })
1892+ .register()
1893+}
1894+
1895+/// pull `SPA_FORMAT_AUDIO_rate` from a `Format` POD (ALSA sinks expose
1896+/// rate only here, not in the props dict). `None` if absent.
1897+fn extract_audio_rate(pod: &Pod) -> Option<u32> {
1898+ let bytes = pod.as_bytes();
1899+ let (_, value) = PodDeserializer::deserialize_any_from(bytes).ok()?;
1900+ let Value::Object(obj) = value else { return None };
1901+ if obj.id != ParamType::Format.as_raw() {
1902+ return None;
1903+ }
1904+ for prop in obj.properties {
1905+ if prop.key == libspa_sys::SPA_FORMAT_AUDIO_rate {
1906+ if let Value::Int(rate) = prop.value {
1907+ if rate > 0 {
1908+ return Some(rate as u32);
1909+ }
1910+ }
1911+ }
1912+ }
1913+ None
1914+}
1915+
1916+/// pull the first channel of `SPA_PROP_channelVolumes` from a `Props`
1917+/// POD. `None` if absent or the wrong shape.
1918+fn extract_channel_volume(pod: &Pod) -> Option<f32> {
1919+ let bytes = pod.as_bytes();
1920+ let (_, value) = PodDeserializer::deserialize_any_from(bytes).ok()?;
1921+ let Value::Object(obj) = value else { return None };
1922+ if obj.id != ParamType::Props.as_raw() {
1923+ return None;
1924+ }
1925+ for prop in obj.properties {
1926+ if prop.key == libspa_sys::SPA_PROP_channelVolumes {
1927+ if let Value::ValueArray(ValueArray::Float(values)) = prop.value {
1928+ return values.first().copied();
1929+ }
1930+ }
1931+ }
1932+ None
1933+}
1934+
1935+/// ports of a given direction owned by `node_id`, sorted by ordinal so
1936+/// pairs line up across source/tap calls.
1937+fn collect_ports(
1938+ cache: &HashMap<u32, Vec<PortInfo>>,
1939+ node_id: u32,
1940+ direction: PortDirection,
1941+) -> Option<Vec<PortInfo>> {
1942+ let ports = cache.get(&node_id)?;
1943+ let mut filtered: Vec<PortInfo> = ports
1944+ .iter()
1945+ .filter(|p| p.direction == direction)
1946+ .cloned()
1947+ .collect();
1948+ if filtered.is_empty() {
1949+ return None;
1950+ }
1951+ // ordinal 0 ↔ FL, 1 ↔ FR (PipeWire convention); port_id breaks
1952+ // ties for ports lacking `port.id`.
1953+ filtered.sort_by_key(|p| (p.ordinal.unwrap_or(u32::MAX), p.port_id));
1954+ Some(filtered)
1955+}
1956+
1957+/// passive `link-factory` link with explicit port ids. `passive` so the
1958+/// tap rides alongside without driving the source.
1959+fn create_explicit_link(core: &Core, output_port: u32, input_port: u32) -> Result<Link, pipewire::Error> {
1960+ let out_str = output_port.to_string();
1961+ let in_str = input_port.to_string();
1962+ let props = properties! {
1963+ "link.output.port" => out_str.as_str(),
1964+ "link.input.port" => in_str.as_str(),
1965+ "link.passive" => "true",
1966+ "object.linger" => "false",
1967+ };
1968+ core.create_object::<Link>("link-factory", &props)
1969+}
1970+
1971+/// active `link-factory` link that drives the sink — forces routing
1972+/// where WP's `target.object` is unreliable for already-linked streams.
1973+fn create_routing_link(core: &Core, output_port: u32, input_port: u32) -> Result<Link, pipewire::Error> {
1974+ let out_str = output_port.to_string();
1975+ let in_str = input_port.to_string();
1976+ let props = properties! {
1977+ "link.output.port" => out_str.as_str(),
1978+ "link.input.port" => in_str.as_str(),
1979+ "object.linger" => "false",
1980+ };
1981+ core.create_object::<Link>("link-factory", &props)
1982+}
1983+
1984+/// write `Props.channelVolumes = [vol, vol]` (stereo) to the node for
1985+/// Layer A attenuation. allocates a POD on the heap; not on the rt
1986+/// thread.
1987+fn write_channel_volumes(node: &Node, volume_lin: f32) {
1988+ let obj = PodObject {
1989+ type_: SpaTypes::ObjectParamProps.as_raw(),
1990+ id: ParamType::Props.as_raw(),
1991+ properties: vec![Property {
1992+ key: libspa_sys::SPA_PROP_channelVolumes,
1993+ flags: PropertyFlags::empty(),
1994+ value: Value::ValueArray(ValueArray::Float(vec![volume_lin, volume_lin])),
1995+ }],
1996+ };
1997+ let serialised = match PodSerializer::serialize(
1998+ std::io::Cursor::new(Vec::new()),
1999+ &Value::Object(obj),
2000+ ) {
2001+ Ok((cursor, _)) => cursor.into_inner(),
2002+ Err(e) => {
2003+ tracing::warn!(error = %e, "channelVolumes POD serialize failed");
2004+ return;
2005+ }
2006+ };
2007+ let Some(pod) = Pod::from_bytes(&serialised) else {
2008+ tracing::warn!("channelVolumes Pod::from_bytes returned None");
2009+ return;
2010+ };
2011+ node.set_param(ParamType::Props, 0, pod);
2012+ tracing::debug!(volume = volume_lin, "Layer A wrote channelVolumes");
2013+}
2014+
2015+fn info_app_label(info: &PwNodeInfo) -> String {
2016+ info.application_process_binary
2017+ .clone()
2018+ .or_else(|| info.application_name.clone())
2019+ .unwrap_or_default()
2020+}
2021+
2022+fn build_node_info(node_id: u32, dict: &DictRef) -> PwNodeInfo {
2023+ PwNodeInfo {
2024+ node_id,
2025+ media_class: dict.get("media.class").map(str::to_owned),
2026+ application_process_binary: dict.get("application.process.binary").map(str::to_owned),
2027+ application_name: dict.get("application.name").map(str::to_owned),
2028+ portal_app_id: dict
2029+ .get("pipewire.access.portal.app_id")
2030+ .map(str::to_owned),
2031+ media_role: dict.get("media.role").map(str::to_owned),
2032+ dont_move: dict.get("node.dont-move") == Some("true"),
2033+ audio_channels: dict.get("audio.channels").and_then(|s| s.parse::<u32>().ok()),
2034+ }
2035+}
2036+
2037+fn install_listener(registry: &Registry, state: Rc<RefCell<RoutingState>>) -> Listener {
2038+ let state_for_global = state.clone();
2039+ let state_for_remove = state;
2040+ registry
2041+ .add_listener_local()
2042+ .global(move |global| {
2043+ let back = state_for_global.clone();
2044+ state_for_global.borrow_mut().on_global(global, &back);
2045+ })
2046+ .global_remove(move |id| {
2047+ state_for_remove.borrow_mut().on_global_remove(id);
2048+ })
2049+ .register()
2050+}
2051+
2052+/// owns the registry, routing state, and listener. drop order matters:
2053+/// listener before registry (field order ensures it).
2054+pub struct RegistryWatcher {
2055+ _listener: Listener,
2056+ state: Rc<RefCell<RoutingState>>,
2057+ _registry: Rc<Registry>,
2058+}
2059+
2060+impl RegistryWatcher {
2061+ /// builds the IPC → PipeWire command channel and writes its sender
2062+ /// into `daemon.pw_command_tx` so IPC handlers can post from any
2063+ /// thread.
2064+ pub fn new(registry: Rc<Registry>, core: Core, daemon: SharedState) -> Self {
2065+ let (tx, rx) = crossbeam_channel::unbounded::<PwCommand>();
2066+ daemon.lock().pw_command_tx = Some(tx);
2067+ let state = Rc::new(RefCell::new(RoutingState::new(
2068+ daemon,
2069+ registry.clone(),
2070+ core,
2071+ rx,
2072+ )));
2073+ let listener = install_listener(&registry, state.clone());
2074+ Self {
2075+ _listener: listener,
2076+ state,
2077+ _registry: registry,
2078+ }
2079+ }
2080+
2081+ /// per-thread routing state; mostly for tests + instrumentation.
2082+ #[must_use]
2083+ pub fn state(&self) -> &Rc<RefCell<RoutingState>> {
2084+ &self.state
2085+ }
2086+}
Added file: crates/headroom-core/src/pw/sink.rs+77 additions
@@ -0,0 +1,77 @@
1+//! `headroom-processed` virtual sink (the bus filter captures from its
2+//! monitor; bypassed streams skip it).
3+
4+use pipewire::{core::Core, node::Node, properties::properties};
5+
6+use crate::error::DaemonError;
7+
8+/// stable, user-visible in `pavucontrol`, `pw-cli list-objects`, etc.
9+pub const NODE_NAME: &str = "headroom-processed";
10+
11+pub const NODE_DESCRIPTION: &str = "Headroom (processed)";
12+
13+pub struct VirtualSink {
14+ /// holds the sink alive on the server; drop destroys it. `None`
15+ /// until [`Self::create`].
16+ proxy: Option<Node>,
17+}
18+
19+impl VirtualSink {
20+ #[must_use]
21+ pub fn new() -> Self {
22+ Self { proxy: None }
23+ }
24+
25+ /// create the virtual sink. uses the generic `adapter` factory
26+ /// (always present) wrapping `support.null-audio-sink` (the SPA
27+ /// factory that yields a null sink with a monitor port).
28+ ///
29+ /// `sample_rate` → `audio.rate` so the sink clocks at the real
30+ /// sink's rate; otherwise it defaults to 48 kHz and the capture
31+ /// adapter resamples — that buffering across two drivers caused
32+ /// the per-quantum tremolo seen in soak.
33+ ///
34+ /// # Errors
35+ /// [`DaemonError::PipeWire`] if the server rejects create-object.
36+ pub fn create(&mut self, core: &Core, sample_rate: u32) -> Result<(), DaemonError> {
37+ let rate_str = sample_rate.to_string();
38+ let props = properties! {
39+ // SPA factory the adapter wraps → null sink with monitor.
40+ "factory.name" => "support.null-audio-sink",
41+ "node.name" => NODE_NAME,
42+ "node.description" => NODE_DESCRIPTION,
43+ // monitor we can capture from.
44+ "media.class" => "Audio/Sink",
45+ // stereo; >2ch bypasses entirely.
46+ "audio.position" => "FL,FR",
47+ // lock to real-sink rate — no resampling at monitor → filter.
48+ "audio.rate" => rate_str.as_str(),
49+ // follower, not driver: land both filter halves on the
50+ // real sink's driver.
51+ "node.passive" => "true",
52+ "node.suspend-on-idle" => "true",
53+ };
54+
55+ let proxy: Node = core
56+ .create_object("adapter", &props)
57+ .map_err(|e| DaemonError::pipewire(format!("create_object: {e}")))?;
58+
59+ self.proxy = Some(proxy);
60+ tracing::debug!(
61+ node.name = NODE_NAME,
62+ "create_object(adapter, factory.name=support.null-audio-sink) queued"
63+ );
64+ Ok(())
65+ }
66+
67+ #[must_use]
68+ pub fn is_created(&self) -> bool {
69+ self.proxy.is_some()
70+ }
71+}
72+
73+impl Default for VirtualSink {
74+ fn default() -> Self {
75+ Self::new()
76+ }
77+}
Added file: crates/headroom-core/src/routing.rs+308 additions
@@ -0,0 +1,308 @@
1+//! pure routing policy; pipewire-free so it unit-tests without the daemon
2+
3+use headroom_ipc::{Route, RouteRuleMatch};
4+
5+use crate::profile::Profile;
6+
7+/// subset of a pw node's props the routing engine needs.
8+#[derive(Debug, Clone, Default, PartialEq, Eq)]
9+pub struct PwNodeInfo {
10+ pub node_id: u32,
11+ /// e.g. `"Stream/Output/Audio"`, `"Audio/Sink"`
12+ pub media_class: Option<String>,
13+ /// kernel-sourced, highest reliability
14+ pub application_process_binary: Option<String>,
15+ /// client-set
16+ pub application_name: Option<String>,
17+ /// flatpak-set, trustworthy when present
18+ pub portal_app_id: Option<String>,
19+ /// rarely set
20+ pub media_role: Option<String>,
21+ /// `node.dont-move`; honoured by skipping routing entirely
22+ pub dont_move: bool,
23+ /// `None` if absent. `>2ch` forced to bypass: bus filter is stereo-only, so processing surround
24+ /// would drop channels or downmix unrequested.
25+ pub audio_channels: Option<u32>,
26+}
27+
28+impl PwNodeInfo {
29+ #[must_use]
30+ pub fn is_routable_playback(&self) -> bool {
31+ !self.dont_move && self.media_class.as_deref() == Some("Stream/Output/Audio")
32+ }
33+}
34+
35+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36+pub enum RoutingDecision {
37+ Route(Route),
38+ Skip,
39+}
40+
41+/// `Skip` if not routable playback. `bypass_global` overrides every rule. else first-match route,
42+/// falling back to the profile's `default_route`.
43+#[must_use]
44+pub fn evaluate(info: &PwNodeInfo, profile: &Profile, bypass_global: bool) -> RoutingDecision {
45+ if !info.is_routable_playback() {
46+ return RoutingDecision::Skip;
47+ }
48+ // real graph operation (4k explicit links to the real sink), not just a metadata write —
49+ // see PwCommand::ReevaluateAll + `set_global_bypass`
50+ if bypass_global {
51+ return RoutingDecision::Route(Route::Bypass);
52+ }
53+ // stereo-only bus filter: processing >2ch would drop channels or downmix unrequested. straight
54+ // to the real sink preserves the layout; pw's source-side adapter handles any downmix.
55+ if matches!(info.audio_channels, Some(ch) if ch > 2) {
56+ return RoutingDecision::Route(Route::Bypass);
57+ }
58+ for rule in &profile.rules {
59+ if matches(info, &rule.match_) {
60+ return RoutingDecision::Route(rule.route);
61+ }
62+ }
63+ RoutingDecision::Route(profile.default_route.route)
64+}
65+
66+/// true iff every present matcher field has a value equal to the node's. empty fields = don't care.
67+pub(crate) fn matches(info: &PwNodeInfo, m: &RouteRuleMatch) -> bool {
68+ let any_match = |needle: &Option<String>, hay: &[String]| -> bool {
69+ if hay.is_empty() {
70+ return true;
71+ }
72+ match needle {
73+ Some(s) => hay.iter().any(|h| h == s),
74+ None => false,
75+ }
76+ };
77+
78+ any_match(&info.application_process_binary, &m.process_binary)
79+ && any_match(&info.application_name, &m.application_name)
80+ && any_match(&info.portal_app_id, &m.portal_app_id)
81+ && any_match(&info.media_role, &m.media_role)
82+}
83+
84+#[cfg(test)]
85+mod tests {
86+ use super::*;
87+
88+ fn playback(binary: &str) -> PwNodeInfo {
89+ PwNodeInfo {
90+ node_id: 1,
91+ media_class: Some("Stream/Output/Audio".into()),
92+ application_process_binary: Some(binary.into()),
93+ ..Default::default()
94+ }
95+ }
96+
97+ #[test]
98+ fn non_playback_streams_are_skipped() {
99+ let mut info = playback("firefox");
100+ info.media_class = Some("Stream/Input/Audio".into());
101+ let profile = Profile::default_v0();
102+ assert_eq!(evaluate(&info, &profile, false), RoutingDecision::Skip);
103+ }
104+
105+ #[test]
106+ fn dont_move_opts_out() {
107+ let mut info = playback("firefox");
108+ info.dont_move = true;
109+ let profile = Profile::default_v0();
110+ assert_eq!(evaluate(&info, &profile, false), RoutingDecision::Skip);
111+ }
112+
113+ #[test]
114+ fn surround_streams_force_bypass_regardless_of_rule_match() {
115+ // The default profile routes `firefox` to processed. A 5.1
116+ // firefox stream (rare but valid — some browser content
117+ // declares surround) must still bypass: the bus filter is
118+ // stereo-only and the explicit-link path would otherwise
119+ // drop FC/LFE/SL/SR (surround contract).
120+ let mut info = playback("firefox");
121+ info.audio_channels = Some(6);
122+ let profile = Profile::default_v0();
123+ assert_eq!(
124+ evaluate(&info, &profile, false),
125+ RoutingDecision::Route(Route::Bypass)
126+ );
127+ }
128+
129+ #[test]
130+ fn stereo_and_mono_streams_follow_normal_rules() {
131+ // Sanity: the surround forcer only kicks in for >2ch.
132+ let profile = Profile::default_v0();
133+ for ch in [None, Some(1), Some(2)] {
134+ let mut info = playback("firefox");
135+ info.audio_channels = ch;
136+ assert_eq!(
137+ evaluate(&info, &profile, false),
138+ RoutingDecision::Route(Route::Processed),
139+ "channels={ch:?}"
140+ );
141+ }
142+ }
143+
144+ #[test]
145+ fn global_bypass_overrides_processed_rules() {
146+ let info = playback("firefox");
147+ let profile = Profile::default_v0();
148+ assert_eq!(
149+ evaluate(&info, &profile, true),
150+ RoutingDecision::Route(Route::Bypass)
151+ );
152+ }
153+
154+ #[test]
155+ fn application_name_only_rule_matches_stream_with_no_process_binary() {
156+ // The shape `route set` emits when expanded into an
157+ // `application_name`-keyed override. Verifies that a
158+ // stream missing `application.process.binary` (typical
159+ // of pw-cat, many CLI tools, some Flatpak wrappers) is
160+ // still matched by the user's intent.
161+ use headroom_ipc::{RouteRule, RouteRuleMatch};
162+ let mut profile = Profile::default_v0();
163+ // Override at the top of the rule list.
164+ profile.rules.insert(
165+ 0,
166+ RouteRule {
167+ match_: RouteRuleMatch {
168+ application_name: vec!["pw-cat".into()],
169+ ..Default::default()
170+ },
171+ route: Route::Bypass,
172+ },
173+ );
174+ // Stream advertises only application.name = "pw-cat".
175+ let info = PwNodeInfo {
176+ node_id: 9,
177+ media_class: Some("Stream/Output/Audio".into()),
178+ application_process_binary: None,
179+ application_name: Some("pw-cat".into()),
180+ ..Default::default()
181+ };
182+ assert_eq!(
183+ evaluate(&info, &profile, false),
184+ RoutingDecision::Route(Route::Bypass)
185+ );
186+ }
187+
188+ #[test]
189+ fn matches_bypass_rule_for_known_music_player() {
190+ let info = playback("mpv");
191+ let profile = Profile::default_v0();
192+ assert_eq!(
193+ evaluate(&info, &profile, false),
194+ RoutingDecision::Route(Route::Bypass)
195+ );
196+ }
197+
198+ #[test]
199+ fn matches_processed_rule_for_browser() {
200+ let info = playback("firefox");
201+ let profile = Profile::default_v0();
202+ assert_eq!(
203+ evaluate(&info, &profile, false),
204+ RoutingDecision::Route(Route::Processed)
205+ );
206+ }
207+
208+ #[test]
209+ fn falls_back_to_default_route_when_no_rule_matches() {
210+ let info = playback("some-obscure-binary");
211+ let profile = Profile::default_v0();
212+ // default_v0 has `default_route = Processed`.
213+ assert_eq!(
214+ evaluate(&info, &profile, false),
215+ RoutingDecision::Route(Route::Processed)
216+ );
217+ }
218+
219+ #[test]
220+ fn first_matching_rule_wins() {
221+ // Build a profile whose first rule says everything matches
222+ // → bypass, and second rule contradicts. First should win.
223+ let mut profile = Profile::default_v0();
224+ profile.rules.clear();
225+ profile.rules.push(headroom_ipc::RouteRule {
226+ match_: RouteRuleMatch {
227+ process_binary: vec!["firefox".into()],
228+ ..Default::default()
229+ },
230+ route: Route::Bypass,
231+ });
232+ profile.rules.push(headroom_ipc::RouteRule {
233+ match_: RouteRuleMatch {
234+ process_binary: vec!["firefox".into()],
235+ ..Default::default()
236+ },
237+ route: Route::Processed,
238+ });
239+ let info = playback("firefox");
240+ assert_eq!(
241+ evaluate(&info, &profile, false),
242+ RoutingDecision::Route(Route::Bypass)
243+ );
244+ }
245+
246+ #[test]
247+ fn empty_matcher_acts_as_wildcard() {
248+ let mut profile = Profile::default_v0();
249+ profile.rules.clear();
250+ profile.rules.push(headroom_ipc::RouteRule {
251+ match_: RouteRuleMatch::default(), // all fields empty
252+ route: Route::Bypass,
253+ });
254+ let info = playback("firefox");
255+ assert_eq!(
256+ evaluate(&info, &profile, false),
257+ RoutingDecision::Route(Route::Bypass)
258+ );
259+ }
260+
261+ #[test]
262+ fn multiple_match_fields_are_anded() {
263+ let mut profile = Profile::default_v0();
264+ profile.rules.clear();
265+ profile.rules.push(headroom_ipc::RouteRule {
266+ match_: RouteRuleMatch {
267+ process_binary: vec!["firefox".into()],
268+ media_role: vec!["Communication".into()],
269+ ..Default::default()
270+ },
271+ route: Route::Bypass,
272+ });
273+
274+ // process_binary matches but media_role doesn't (None on info).
275+ let info = playback("firefox");
276+ assert_ne!(
277+ evaluate(&info, &profile, false),
278+ RoutingDecision::Route(Route::Bypass)
279+ );
280+
281+ // Both match.
282+ let mut info2 = playback("firefox");
283+ info2.media_role = Some("Communication".into());
284+ assert_eq!(
285+ evaluate(&info2, &profile, false),
286+ RoutingDecision::Route(Route::Bypass)
287+ );
288+ }
289+
290+ #[test]
291+ fn portal_app_id_can_match_when_present() {
292+ let mut profile = Profile::default_v0();
293+ profile.rules.clear();
294+ profile.rules.push(headroom_ipc::RouteRule {
295+ match_: RouteRuleMatch {
296+ portal_app_id: vec!["com.discordapp.Discord".into()],
297+ ..Default::default()
298+ },
299+ route: Route::Processed,
300+ });
301+ let mut info = playback("DiscordWrapper");
302+ info.portal_app_id = Some("com.discordapp.Discord".into());
303+ assert_eq!(
304+ evaluate(&info, &profile, false),
305+ RoutingDecision::Route(Route::Processed)
306+ );
307+ }
308+}
Added file: crates/headroom-core/src/runtime.rs+173 additions
@@ -0,0 +1,173 @@
1+//! top-level orchestrator: wires pw layer, ipc, agc, routing; runs the main loop
2+
3+use std::cell::RefCell;
4+use std::rc::Rc;
5+
6+use headroom_ipc::{Event, Topic};
7+use serde_json::json;
8+
9+use crate::agc::{AgcController, AGC_TICK};
10+use crate::error::DaemonError;
11+use crate::ipc::IpcServer;
12+use crate::profile_store::ProfileStore;
13+use crate::profile_watcher::ProfileWatcher;
14+use crate::pw::filter::{Filter, FilterBundle, FilterInit};
15+use crate::pw::{block_termination_signals, PwContext};
16+use crate::state::{self, DaemonState, SharedState};
17+
18+/// blocks until shutdown (SIGTERM/SIGINT).
19+pub fn run(profiles: ProfileStore) -> Result<(), DaemonError> {
20+ // snapshot without draining: status/ipc keeps surfacing until next reload clears them
21+ let pending_warnings = profiles.warnings();
22+ let active_missing = profiles.is_active_missing().map(|s| s.to_owned());
23+ tracing::info!(
24+ profile = profiles.effective().name.as_str(),
25+ rules = profiles.effective().rules.len(),
26+ "starting headroom daemon"
27+ );
28+ for w in &pending_warnings {
29+ tracing::warn!(warning = %w, "profile store warning");
30+ }
31+ if let Some(name) = active_missing.as_deref() {
32+ tracing::warn!(missing = name, "selected profile missing; using built-in default");
33+ }
34+
35+ // block SIGTERM/SIGINT process-wide BEFORE spawning any threads, so every thread inherits
36+ // the blocked mask and pipewire's signalfd is the sole receiver. threads spawned before would
37+ // keep default disposition and dying on SIGTERM would skip our shutdown path. (the ipc accept
38+ // thread, added later in startup, tripped this once.)
39+ block_termination_signals()?;
40+
41+ // captured before `profiles` moves into shared state
42+ let watch_dir = profiles.primary_profile_watch_dir();
43+
44+ let daemon_state = state::shared(DaemonState::new(profiles));
45+
46+ // ipc first so its accept thread is ready before any pipewire work logs through it.
47+ // handle's Drop cleans the socket.
48+ let socket_path = headroom_ipc::default_socket_path()
49+ .ok_or_else(|| DaemonError::other("no default IPC socket path"))?;
50+ let _ipc = IpcServer::start(socket_path, daemon_state.clone())?;
51+
52+ // failure to install is non-fatal: manual `profile.reload` over ipc still works
53+ let _profile_watcher = match watch_dir {
54+ Some(dir) => match ProfileWatcher::start(dir, daemon_state.clone()) {
55+ Ok(watcher) => watcher,
56+ Err(e) => {
57+ tracing::warn!(error = %e, "profile file-watcher disabled");
58+ None
59+ }
60+ },
61+ None => None,
62+ };
63+
64+ let pw = PwContext::new()?;
65+ // processed sink and bus filter must run at the real sink's rate to avoid a rate-conversion
66+ // stage at the monitor → filter boundary. falls back to 48k if the real sink hasn't surfaced;
67+ // the Format-param listener rebuilds on the first observed rate change.
68+ let initial_rate = daemon_state
69+ .lock()
70+ .real_sink
71+ .sample_rate
72+ .unwrap_or(crate::pw::filter::DEFAULT_SAMPLE_RATE);
73+ tracing::info!(initial_rate, "creating processed sink + filter at real-sink-matched rate");
74+ pw.create_processed_sink(initial_rate)?;
75+
76+ // seed the dsp from the effective profile. FilterControl → DaemonState for live updates;
77+ // measurement consumer → agc controller.
78+ let filter_init = {
79+ let s = daemon_state.lock();
80+ let effective = s.profiles.effective();
81+ FilterInit {
82+ compressor: effective.build_compressor_config(),
83+ limiter: effective.build_limiter_config(),
84+ agc: headroom_dsp::AgcGainConfig::default(),
85+ agc_enabled: effective.agc.enabled,
86+ }
87+ };
88+
89+ let FilterBundle {
90+ filter,
91+ control: filter_control,
92+ measurement_consumer,
93+ bus_metrics,
94+ timing,
95+ sample_rate: filter_rate,
96+ } = Filter::create(pw.core(), filter_init, initial_rate)?;
97+ {
98+ let mut s = daemon_state.lock();
99+ s.filter_control = Some(filter_control.clone());
100+ s.filter_sample_rate = Some(filter_rate);
101+ }
102+
103+ // reads [agc] config each tick so profile.use takes effect next tick
104+ let agc_controller = AgcController::new(
105+ filter_rate,
106+ crate::pw::filter::CHANNELS,
107+ measurement_consumer,
108+ filter_control,
109+ daemon_state.clone(),
110+ bus_metrics,
111+ timing,
112+ )
113+ .map_err(DaemonError::from)?;
114+ let agc_controller = Rc::new(RefCell::new(agc_controller));
115+ let agc_timer = {
116+ let agc = agc_controller.clone();
117+ let timer = pw
118+ .main_loop()
119+ .loop_()
120+ .add_timer(move |_| agc.borrow_mut().tick());
121+ let _ = timer.update_timer(Some(AGC_TICK), Some(AGC_TICK));
122+ timer
123+ };
124+
125+ // matching Stream/Output/Audio nodes get `target.object` via the `default` metadata;
126+ // wireplumber moves them. bypassed streams point straight at preferred_real_sink the same way.
127+ pw.start_routing(daemon_state.clone())?;
128+
129+ // hand the filter + agc handle to routing state so the Format-param listener can ask the
130+ // registry thread to rebuild at a new rate via PwCommand::RebuildFilter. filter ownership
131+ // moves here; RoutingState drops it on shutdown via PwContext's drop order.
132+ if let Some(routing_state) = pw.routing_state() {
133+ routing_state
134+ .borrow_mut()
135+ .install_filter_rebuild_handles(filter, agc_controller.clone());
136+ } else {
137+ // start_routing succeeded so this shouldn't fire; keep the filter alive defensively
138+ tracing::warn!("routing_state unavailable post-start_routing; keeping filter local");
139+ }
140+
141+ publish_daemon_started(&daemon_state, &pending_warnings, active_missing.as_deref());
142+
143+ pw.run_until_signal()?;
144+
145+ // drop before exiting so they tear down deterministically alongside the pipewire context
146+ drop(agc_timer);
147+ drop(agc_controller);
148+
149+ publish_daemon_shutdown(&daemon_state);
150+
151+ tracing::info!("headroom daemon stopped");
152+ Ok(())
153+}
154+
155+fn publish_daemon_started(state: &SharedState, warnings: &[String], active_missing: Option<&str>) {
156+ if let Ok(event) = Event::new(
157+ Topic::Daemon,
158+ "started",
159+ &json!({
160+ "version": env!("CARGO_PKG_VERSION"),
161+ "warnings": warnings,
162+ "active_missing": active_missing,
163+ }),
164+ ) {
165+ state.lock().broadcaster.publish(Topic::Daemon, event);
166+ }
167+}
168+
169+fn publish_daemon_shutdown(state: &SharedState) {
170+ if let Ok(event) = Event::new(Topic::Daemon, "shutdown", &json!({})) {
171+ state.lock().broadcaster.publish(Topic::Daemon, event);
172+ }
173+}
Added file: crates/headroom-core/src/state.rs+166 additions
@@ -0,0 +1,166 @@
1+//! cross-thread daemon state; non-Send pw state stays on the pw thread in `pw::registry`
2+
3+use std::collections::HashMap;
4+use std::sync::Arc;
5+use std::time::Instant;
6+
7+use crossbeam_channel::Sender;
8+use parking_lot::Mutex;
9+
10+use headroom_ipc::{LayerASnapshot, Route, SinkInfo};
11+
12+use crate::ipc::broadcast::Broadcaster;
13+use crate::profile_store::ProfileStore;
14+use crate::pw::command::PwCommand;
15+use crate::pw::filter::FilterControl;
16+
17+/// per-stream routing decision the daemon has applied (or attempted).
18+#[derive(Debug, Clone)]
19+pub struct RoutedStream {
20+ pub node_id: u32,
21+ /// `application.process.binary`, else `application.name`, else empty
22+ pub app: String,
23+ pub route: Route,
24+}
25+
26+/// shared state behind a single mutex. lock held briefly; no nested locks, no awaits.
27+#[derive(Debug)]
28+pub struct DaemonState {
29+ pub started_at: Instant,
30+ pub profiles: ProfileStore,
31+ /// global id of `headroom-processed`; `None` until the registry surfaces it
32+ pub processed_sink_id: Option<u32>,
33+ /// rate the filter runs at. matches the real sink's native rate at last (re)build; drives
34+ /// status reporting + layer a's block-period. `None` only at very early boot.
35+ pub filter_sample_rate: Option<u32>,
36+ /// user's preferred hardware sink, kept fresh from `default.audio.sink`
37+ pub real_sink: SinkInfo,
38+ pub streams: HashMap<u32, RoutedStream>,
39+ /// layer a controller state mirrored from the pipewire thread's `managed_streams` each drain
40+ /// pass, so ipc threads can read it without reaching into the `Rc<RefCell>` pw-thread state.
41+ /// keyed by source node id; removed on teardown.
42+ pub layer_a: HashMap<u32, LayerASnapshot>,
43+ pub broadcaster: Broadcaster,
44+ /// `None` between startup and `Filter::create`, and in tests with no audio path. cloned under
45+ /// the daemon lock then dropped before pushing, so the lock is never held during an
46+ /// audio-thread hand-off.
47+ pub filter_control: Option<FilterControl>,
48+ /// commands that must run on the pipewire main-loop thread (e.g. `route.stream` metadata
49+ /// writes). `None` until `start_routing`. cloned + dropped before send so the lock is never
50+ /// held while crossbeam pushes.
51+ pub pw_command_tx: Option<Sender<PwCommand>>,
52+}
53+
54+impl DaemonState {
55+ #[must_use]
56+ pub fn new(profiles: ProfileStore) -> Self {
57+ Self {
58+ started_at: Instant::now(),
59+ profiles,
60+ processed_sink_id: None,
61+ filter_sample_rate: None,
62+ real_sink: SinkInfo::default(),
63+ streams: HashMap::new(),
64+ layer_a: HashMap::new(),
65+ broadcaster: Broadcaster::new(),
66+ filter_control: None,
67+ pw_command_tx: None,
68+ }
69+ }
70+
71+ /// apply a `default.audio.sink` change to `real_sink`, returning bypass-routed streams that
72+ /// need their `target.object` rewritten to follow the new sink. `None` on idempotent no-op.
73+ /// touches only in-memory state, so it's safe under the lock (pw writes happen after unlock).
74+ pub fn apply_real_sink_change(&mut self, new_name: &str) -> Option<Vec<(u32, String)>> {
75+ if self.real_sink.name.as_deref() == Some(new_name) {
76+ return None;
77+ }
78+ self.real_sink = SinkInfo {
79+ // node_id + sample_rate stay unknown; `try_capture_real_sink` resolves both when it
80+ // sees the matching `Audio/Sink` global. the routing path operates on name alone.
81+ node_id: None,
82+ name: Some(new_name.to_owned()),
83+ ready: true,
84+ sample_rate: None,
85+ };
86+ Some(
87+ self.streams
88+ .values()
89+ .filter(|r| r.route == Route::Bypass)
90+ .map(|r| (r.node_id, r.app.clone()))
91+ .collect(),
92+ )
93+ }
94+}
95+
96+pub type SharedState = Arc<Mutex<DaemonState>>;
97+
98+#[must_use]
99+pub fn shared(state: DaemonState) -> SharedState {
100+ Arc::new(Mutex::new(state))
101+}
102+
103+#[cfg(test)]
104+mod tests {
105+ use super::*;
106+ use crate::profile_store::ProfileStore;
107+
108+ fn state() -> DaemonState {
109+ DaemonState::new(ProfileStore::builtin())
110+ }
111+
112+ fn add_stream(s: &mut DaemonState, node_id: u32, app: &str, route: Route) {
113+ s.streams.insert(
114+ node_id,
115+ RoutedStream {
116+ node_id,
117+ app: app.into(),
118+ route,
119+ },
120+ );
121+ }
122+
123+ #[test]
124+ fn apply_real_sink_change_first_time_returns_empty_retarget_list() {
125+ let mut s = state();
126+ let to_retarget = s.apply_real_sink_change("alsa_output.usb-foo").unwrap();
127+ assert!(to_retarget.is_empty(), "no streams yet — nothing to retarget");
128+ assert_eq!(s.real_sink.name.as_deref(), Some("alsa_output.usb-foo"));
129+ assert!(s.real_sink.ready);
130+ }
131+
132+ #[test]
133+ fn apply_real_sink_change_returns_bypass_streams_only() {
134+ let mut s = state();
135+ // Seed: two streams routed, one bypass, one processed.
136+ add_stream(&mut s, 100, "mpv", Route::Bypass);
137+ add_stream(&mut s, 101, "firefox", Route::Processed);
138+ let mut retarget = s.apply_real_sink_change("alsa_output.usb-foo").unwrap();
139+ retarget.sort_by_key(|(id, _)| *id);
140+ assert_eq!(retarget.len(), 1);
141+ assert_eq!(retarget[0].0, 100);
142+ assert_eq!(retarget[0].1, "mpv");
143+ }
144+
145+ #[test]
146+ fn apply_real_sink_change_idempotent_on_same_name() {
147+ let mut s = state();
148+ add_stream(&mut s, 100, "mpv", Route::Bypass);
149+ assert!(s.apply_real_sink_change("alsa_output.usb-foo").is_some());
150+ assert!(s.apply_real_sink_change("alsa_output.usb-foo").is_none());
151+ }
152+
153+ #[test]
154+ fn apply_real_sink_change_returns_targets_on_subsequent_switches() {
155+ let mut s = state();
156+ add_stream(&mut s, 100, "mpv", Route::Bypass);
157+ add_stream(&mut s, 101, "ardour", Route::Bypass);
158+ s.apply_real_sink_change("speakers").unwrap();
159+ let mut t = s.apply_real_sink_change("headphones").unwrap();
160+ t.sort_by_key(|(id, _)| *id);
161+ assert_eq!(t.len(), 2);
162+ assert_eq!(t[0].0, 100);
163+ assert_eq!(t[1].0, 101);
164+ assert_eq!(s.real_sink.name.as_deref(), Some("headphones"));
165+ }
166+}
Added file: crates/pipewire-filter/Cargo.toml+19 additions
@@ -0,0 +1,19 @@
1+[package]
2+name = "pipewire-filter"
3+description = "automatic loudness and per-app volume control (pipewire)"
4+version.workspace = true
5+edition.workspace = true
6+license.workspace = true
7+homepage.workspace = true
8+repository.workspace = true
9+authors.workspace = true
10+
11+# pipewire-rs 0.8 has no safe `pw_filter` wrapper + headroom-core
12+# forbids unsafe; the unsafe FFI lives here.
13+
14+[dependencies]
15+pipewire = { workspace = true }
16+pipewire-sys = { workspace = true }
17+libspa = { workspace = true }
18+libspa-sys = { workspace = true }
19+thiserror = { workspace = true }
Added file: crates/pipewire-filter/src/error.rs+18 additions
@@ -0,0 +1,18 @@
1+//! error type for the [`pipewire-filter`](crate) crate.
2+
3+#[derive(Debug, thiserror::Error)]
4+pub enum FilterError {
5+ /// `pw_filter_new` returned NULL — alloc failure or, since we own
6+ /// every arg, an internal bug.
7+ #[error("pw_filter_new returned NULL")]
8+ CreationFailed,
9+
10+ /// `pw_filter_add_port` returned NULL — malformed props or format POD.
11+ #[error("pw_filter_add_port returned NULL")]
12+ AddPortFailed,
13+
14+ /// `pw_filter_connect` returned a negative error code.
15+ #[error("pw_filter_connect failed: {0}")]
16+ ConnectFailed(std::io::Error),
17+
18+}
Added file: crates/pipewire-filter/src/lib.rs+613 additions
@@ -0,0 +1,613 @@
1+//! minimal safe wrapper around `pw_filter` (pipewire-rs 0.8 has no
2+//! `Filter`). mirrors `pipewire::stream`; lives in its own crate so
3+//! the unsafe FFI stays out of `headroom-core`'s `forbid(unsafe_code)`.
4+//! every `unsafe` block carries a `// SAFETY:` comment.
5+//!
6+//! drop order invariant: [`Filter`] must outlive its [`FilterListener`]
7+//! — otherwise a trampoline could recover a freed `Box`. not encoded in
8+//! the types (same as pipewire-rs `Stream`); callers drop the listener
9+//! first.
10+
11+#![warn(missing_docs)]
12+#![warn(clippy::missing_safety_doc)]
13+
14+pub mod error;
15+
16+use std::ffi::CString;
17+use std::marker::PhantomData;
18+use std::mem;
19+use std::os::raw::c_void;
20+use std::pin::Pin;
21+use std::ptr::NonNull;
22+
23+use pipewire::{
24+ core::Core,
25+ properties::Properties,
26+};
27+
28+pub use error::FilterError;
29+pub use libspa::utils::Direction;
30+
31+/// state of a [`Filter`], from the C `pw_filter_state` enum.
32+#[derive(Debug, Clone, PartialEq, Eq)]
33+pub enum FilterState {
34+ /// `PW_FILTER_STATE_ERROR`. carries the optional error string.
35+ Error(String),
36+ /// `PW_FILTER_STATE_UNCONNECTED`.
37+ Unconnected,
38+ /// `PW_FILTER_STATE_CONNECTING`.
39+ Connecting,
40+ /// `PW_FILTER_STATE_PAUSED`.
41+ Paused,
42+ /// `PW_FILTER_STATE_STREAMING`.
43+ Streaming,
44+}
45+
46+impl FilterState {
47+ /// decode the C enum + optional error string into [`FilterState`].
48+ ///
49+ /// # Safety
50+ /// `error` must be NULL or a NUL-terminated C string valid until
51+ /// return (PipeWire guarantees both for the callback's duration).
52+ unsafe fn from_raw(state: pipewire_sys::pw_filter_state, error: *const std::os::raw::c_char) -> Self {
53+ match state {
54+ pipewire_sys::pw_filter_state_PW_FILTER_STATE_UNCONNECTED => Self::Unconnected,
55+ pipewire_sys::pw_filter_state_PW_FILTER_STATE_CONNECTING => Self::Connecting,
56+ pipewire_sys::pw_filter_state_PW_FILTER_STATE_PAUSED => Self::Paused,
57+ pipewire_sys::pw_filter_state_PW_FILTER_STATE_STREAMING => Self::Streaming,
58+ _ => {
59+ let msg = if error.is_null() {
60+ String::new()
61+ } else {
62+ // SAFETY: documented above; PipeWire guarantees a
63+ // valid NUL-terminated string for the call's
64+ // duration.
65+ std::ffi::CStr::from_ptr(error)
66+ .to_string_lossy()
67+ .into_owned()
68+ };
69+ Self::Error(msg)
70+ }
71+ }
72+ }
73+}
74+
75+/// flags accepted by [`Filter::connect`]. mirrors `enum pw_filter_flags`.
76+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
77+pub struct FilterFlags(pipewire_sys::pw_filter_flags);
78+
79+impl FilterFlags {
80+ /// `PW_FILTER_FLAG_NONE`.
81+ pub const NONE: Self = Self(pipewire_sys::pw_filter_flags_PW_FILTER_FLAG_NONE);
82+ /// `PW_FILTER_FLAG_INACTIVE`.
83+ pub const INACTIVE: Self = Self(pipewire_sys::pw_filter_flags_PW_FILTER_FLAG_INACTIVE);
84+ /// `PW_FILTER_FLAG_DRIVER`.
85+ pub const DRIVER: Self = Self(pipewire_sys::pw_filter_flags_PW_FILTER_FLAG_DRIVER);
86+ /// `PW_FILTER_FLAG_RT_PROCESS` — process on the rt data thread.
87+ pub const RT_PROCESS: Self = Self(pipewire_sys::pw_filter_flags_PW_FILTER_FLAG_RT_PROCESS);
88+ /// `PW_FILTER_FLAG_CUSTOM_LATENCY`.
89+ pub const CUSTOM_LATENCY: Self =
90+ Self(pipewire_sys::pw_filter_flags_PW_FILTER_FLAG_CUSTOM_LATENCY);
91+
92+ /// OR two flag sets.
93+ #[must_use]
94+ pub const fn union(self, other: Self) -> Self {
95+ Self(self.0 | other.0)
96+ }
97+
98+ /// raw bits for `pw_filter_connect`.
99+ #[must_use]
100+ pub const fn bits(self) -> pipewire_sys::pw_filter_flags {
101+ self.0
102+ }
103+}
104+
105+impl std::ops::BitOr for FilterFlags {
106+ type Output = Self;
107+ fn bitor(self, rhs: Self) -> Self {
108+ self.union(rhs)
109+ }
110+}
111+
112+/// flags accepted by [`Filter::add_port`]. mirrors
113+/// `enum pw_filter_port_flags`.
114+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
115+pub struct PortFlags(pipewire_sys::pw_filter_port_flags);
116+
117+impl PortFlags {
118+ /// `PW_FILTER_PORT_FLAG_NONE`.
119+ pub const NONE: Self = Self(pipewire_sys::pw_filter_port_flags_PW_FILTER_PORT_FLAG_NONE);
120+ /// `PW_FILTER_PORT_FLAG_MAP_BUFFERS` — mmap so `data.data` is
121+ /// directly readable/writable.
122+ pub const MAP_BUFFERS: Self =
123+ Self(pipewire_sys::pw_filter_port_flags_PW_FILTER_PORT_FLAG_MAP_BUFFERS);
124+
125+ /// OR two flag sets.
126+ #[must_use]
127+ pub const fn union(self, other: Self) -> Self {
128+ Self(self.0 | other.0)
129+ }
130+
131+ /// raw bits for `pw_filter_add_port`.
132+ #[must_use]
133+ pub const fn bits(self) -> pipewire_sys::pw_filter_port_flags {
134+ self.0
135+ }
136+}
137+
138+impl std::ops::BitOr for PortFlags {
139+ type Output = Self;
140+ fn bitor(self, rhs: Self) -> Self {
141+ self.union(rhs)
142+ }
143+}
144+
145+/// owns a `*mut pw_filter`. drop calls `pw_filter_destroy`.
146+///
147+/// not `Send`/`Sync`: `pw_filter` is bound to its owning main loop,
148+/// like `pw_stream`.
149+pub struct Filter {
150+ ptr: NonNull<pipewire_sys::pw_filter>,
151+ /// keeps the Core alive while this filter exists.
152+ _core: Core,
153+ /// `!Send`/`!Sync` even where `NonNull<_>` is `Send`.
154+ _not_send: PhantomData<*mut ()>,
155+}
156+
157+impl Filter {
158+ /// create an unconnected filter. `properties` is consumed (PipeWire
159+ /// takes ownership of the `pw_properties`).
160+ ///
161+ /// # Errors
162+ /// [`FilterError::CreationFailed`] if `pw_filter_new` returns NULL.
163+ pub fn new(core: &Core, name: &str, properties: Properties) -> Result<Self, FilterError> {
164+ let c_name = CString::new(name).expect("filter name contains a NUL byte");
165+ // SAFETY:
166+ // - `core.as_raw_ptr()` returns the live `*mut pw_core` that
167+ // `Core` keeps alive for the lifetime of the reference.
168+ // - `c_name.as_ptr()` is valid through this expression.
169+ // - `properties.into_raw()` consumes ownership; PipeWire
170+ // must free the `pw_properties` (it does: per filter.h
171+ // "ownership is taken"). We must NOT free it ourselves;
172+ // `into_raw` is exactly the API for that handoff.
173+ let ptr = unsafe {
174+ pipewire_sys::pw_filter_new(core.as_raw_ptr(), c_name.as_ptr(), properties.into_raw())
175+ };
176+ let ptr = NonNull::new(ptr).ok_or(FilterError::CreationFailed)?;
177+ Ok(Self {
178+ ptr,
179+ _core: core.clone(),
180+ _not_send: PhantomData,
181+ })
182+ }
183+
184+ fn as_raw_ptr(&self) -> *mut pipewire_sys::pw_filter {
185+ self.ptr.as_ptr()
186+ }
187+
188+ /// add a port. `params` are borrowed PODs (initial format hint);
189+ /// `properties` is consumed (as [`Self::new`]). `port_data_size`
190+ /// is 0 — the rt callback recovers state from the listener box.
191+ ///
192+ /// # Errors
193+ /// [`FilterError::AddPortFailed`] if `pw_filter_add_port` returns
194+ /// NULL.
195+ pub fn add_port(
196+ &self,
197+ direction: Direction,
198+ flags: PortFlags,
199+ properties: Properties,
200+ params: &mut [&libspa::pod::Pod],
201+ ) -> Result<PortData, FilterError> {
202+ // SAFETY:
203+ // - `self.as_raw_ptr()` is valid for the lifetime of `self`.
204+ // - `direction.as_raw()` is one of SPA_DIRECTION_INPUT /
205+ // SPA_DIRECTION_OUTPUT.
206+ // - `properties.into_raw()` hands ownership over; PipeWire
207+ // frees on filter destruction.
208+ // - `params` is `&mut [&Pod]`. `Pod` is `#[repr(transparent)]`
209+ // over `spa_pod`, so `&Pod` and `*const spa_pod` have the
210+ // same layout. The cast pattern is the one pipewire-rs
211+ // uses for `pw_stream_connect` (stream.rs:170).
212+ // - `params` is not stored; PipeWire copies whatever it needs
213+ // from the PODs before returning.
214+ let port_data = unsafe {
215+ pipewire_sys::pw_filter_add_port(
216+ self.as_raw_ptr(),
217+ direction.as_raw(),
218+ flags.bits(),
219+ 0,
220+ properties.into_raw(),
221+ params.as_mut_ptr().cast(),
222+ params.len() as u32,
223+ )
224+ };
225+ let port_data = NonNull::new(port_data).ok_or(FilterError::AddPortFailed)?;
226+ Ok(PortData { ptr: port_data })
227+ }
228+
229+ /// connect the filter for processing. `params` are optional
230+ /// filter-level format hints (headroom passes none; negotiation is
231+ /// per-port).
232+ ///
233+ /// # Errors
234+ /// [`FilterError::ConnectFailed`] if `pw_filter_connect` returns a
235+ /// negative result code.
236+ pub fn connect(
237+ &self,
238+ flags: FilterFlags,
239+ params: &mut [&libspa::pod::Pod],
240+ ) -> Result<(), FilterError> {
241+ // SAFETY: same argument-validity rationale as `add_port`. The
242+ // params slice can be empty — pipewire-rs's `Stream::connect`
243+ // accepts the same.
244+ let rc = unsafe {
245+ pipewire_sys::pw_filter_connect(
246+ self.as_raw_ptr(),
247+ flags.bits(),
248+ params.as_mut_ptr().cast(),
249+ params.len() as u32,
250+ )
251+ };
252+ if rc < 0 {
253+ let errno = -rc;
254+ return Err(FilterError::ConnectFailed(std::io::Error::from_raw_os_error(
255+ errno,
256+ )));
257+ }
258+ Ok(())
259+ }
260+
261+ /// node id assigned by the server; non-zero once connected + acked.
262+ #[must_use]
263+ pub fn node_id(&self) -> u32 {
264+ // SAFETY: `self.as_raw_ptr()` is valid for the lifetime of
265+ // `self`. `pw_filter_get_node_id` is documented as a simple
266+ // getter, no side effects.
267+ unsafe { pipewire_sys::pw_filter_get_node_id(self.as_raw_ptr()) }
268+ }
269+
270+ /// begin registering a listener. `user_data` is moved into the
271+ /// listener box, reachable from every callback as `&mut D`.
272+ /// finalise with [`ListenerBuilder::register`].
273+ pub fn add_local_listener_with_user_data<D>(
274+ &self,
275+ user_data: D,
276+ ) -> ListenerBuilder<'_, D> {
277+ ListenerBuilder {
278+ filter: self,
279+ callbacks: ListenerCallbacks::with_user_data(user_data),
280+ }
281+ }
282+}
283+
284+impl std::fmt::Debug for Filter {
285+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286+ f.debug_struct("Filter")
287+ .field("node_id", &self.node_id())
288+ .finish()
289+ }
290+}
291+
292+impl Drop for Filter {
293+ fn drop(&mut self) {
294+ // SAFETY: `self.ptr` was returned by `pw_filter_new` and has
295+ // not been freed since. `pw_filter_destroy` is the documented
296+ // cleanup function. Note: callers are expected to drop any
297+ // associated `FilterListener` first; otherwise the listener's
298+ // `spa_hook::remove` runs against a freed list. We can't
299+ // express that constraint in the borrow checker without
300+ // making the listener literally borrow the filter, which
301+ // pipewire-rs `Stream` also chooses not to do.
302+ unsafe { pipewire_sys::pw_filter_destroy(self.as_raw_ptr()) }
303+ }
304+}
305+
306+/// opaque port handle from [`Filter::add_port`]; rt callbacks move
307+/// audio via [`Self::dequeue_buffer`] + the [`Buffer`] RAII.
308+///
309+/// `Send` so the listener user-data struct can own port handles, but
310+/// not `Sync`: `pw_filter_dequeue_buffer` writes a per-port lockless
311+/// ring, so concurrent same-port calls aren't documented-safe. all
312+/// real use is single-threaded in the rt callback.
313+pub struct PortData {
314+ ptr: NonNull<c_void>,
315+}
316+
317+// SAFETY: the underlying `*mut c_void` points into the `pw_filter`
318+// allocation, which lives until `pw_filter_destroy`. The Filter
319+// owns the lifetime; the documented drop order (listener before
320+// filter) ensures that PortData inside the listener's user-data
321+// box never outlives the filter. Move-only ownership is the right
322+// model — there must be exactly one logical owner of a port
323+// handle, and the RT-callback closure is where that owner lives.
324+unsafe impl Send for PortData {}
325+
326+impl PortData {
327+ fn as_raw_ptr(&self) -> *mut c_void {
328+ self.ptr.as_ptr()
329+ }
330+
331+ /// dequeue the next buffer (fresh data on input ports, blank on
332+ /// output ports). `None` if the queue is empty. the returned
333+ /// [`Buffer`] queues itself back on drop.
334+ pub fn dequeue_buffer(&self) -> Option<Buffer<'_>> {
335+ // SAFETY: `as_raw_ptr` returns the live port handle; PipeWire
336+ // returns either a valid `*mut pw_buffer` or NULL. The
337+ // returned pointer is owned by PipeWire — we only borrow it
338+ // for the realtime callback's duration. The `Buffer` RAII
339+ // hands it back via `pw_filter_queue_buffer` on drop.
340+ let raw = unsafe { pipewire_sys::pw_filter_dequeue_buffer(self.as_raw_ptr()) };
341+ let raw = NonNull::new(raw)?;
342+ Some(Buffer {
343+ buf: raw,
344+ port: self,
345+ })
346+ }
347+}
348+
349+/// RAII handle for a dequeued buffer. drop calls `pw_filter_queue_buffer`.
350+pub struct Buffer<'p> {
351+ buf: NonNull<pipewire_sys::pw_buffer>,
352+ port: &'p PortData,
353+}
354+
355+impl Buffer<'_> {
356+ /// borrow the buffer's `spa_data` slice (mirrors pipewire-rs
357+ /// `Buffer::datas_mut`).
358+ pub fn datas_mut(&mut self) -> &mut [libspa::buffer::Data] {
359+ // SAFETY: `pw_buffer.buffer` points at a `spa_buffer` that
360+ // PipeWire owns for the duration of this callback. The same
361+ // invariant pipewire-rs relies on in `Buffer::datas_mut`. If
362+ // `n_datas == 0` or `datas == NULL` we return an empty slice
363+ // rather than dereferencing.
364+ unsafe {
365+ let pw_buf = self.buf.as_ptr();
366+ let spa_buf = (*pw_buf).buffer;
367+ if spa_buf.is_null() {
368+ return &mut [];
369+ }
370+ let n_datas = (*spa_buf).n_datas;
371+ let datas_ptr = (*spa_buf).datas;
372+ if n_datas == 0 || datas_ptr.is_null() {
373+ return &mut [];
374+ }
375+ // `libspa::buffer::Data` is `#[repr(transparent)]` over
376+ // `spa_sys::spa_data`, so a `*mut spa_data` is layout-
377+ // compatible with `*mut Data`.
378+ let datas = datas_ptr.cast::<libspa::buffer::Data>();
379+ std::slice::from_raw_parts_mut(datas, n_datas as usize)
380+ }
381+ }
382+}
383+
384+impl Drop for Buffer<'_> {
385+ fn drop(&mut self) {
386+ // SAFETY: `self.buf` was obtained from
387+ // `pw_filter_dequeue_buffer` on `self.port` and has not been
388+ // queued elsewhere (this is the only path that consumes it).
389+ // The `&PortData` borrow keeps the port alive at least until
390+ // this call returns.
391+ unsafe {
392+ pipewire_sys::pw_filter_queue_buffer(self.port.as_raw_ptr(), self.buf.as_ptr());
393+ }
394+ }
395+}
396+
397+// -- Listener machinery ------------------------------------------------------
398+
399+type ProcessCb<D> = dyn FnMut(&mut D, *mut libspa_sys::spa_io_position);
400+type StateChangedCb<D> = dyn FnMut(&mut D, FilterState, FilterState);
401+type ParamChangedCb<D> = dyn FnMut(&mut D, *mut c_void, u32, Option<&libspa::pod::Pod>);
402+
403+/// user data + per-event closures, behind a `Box` whose raw pointer is
404+/// the trampoline's `data` argument.
405+struct ListenerCallbacks<D> {
406+ user_data: D,
407+ process: Option<Box<ProcessCb<D>>>,
408+ state_changed: Option<Box<StateChangedCb<D>>>,
409+ param_changed: Option<Box<ParamChangedCb<D>>>,
410+}
411+
412+impl<D> ListenerCallbacks<D> {
413+ fn with_user_data(user_data: D) -> Self {
414+ Self {
415+ user_data,
416+ process: None,
417+ state_changed: None,
418+ param_changed: None,
419+ }
420+ }
421+
422+ /// build the C `pw_filter_events` vtable + heap-boxed callbacks.
423+ /// only wires events whose closure is set.
424+ fn into_raw(self) -> (Pin<Box<pipewire_sys::pw_filter_events>>, Box<Self>) {
425+ let callbacks = Box::new(self);
426+
427+ // SAFETY notes for the trampolines below:
428+ // - `data` is the `*mut c_void` we hand to
429+ // `pw_filter_add_listener`. It is the raw pointer to the
430+ // `Box<ListenerCallbacks<D>>`. The box is reclaimed by the
431+ // `FilterListener` on drop, so during the listener's
432+ // lifetime the pointer remains valid.
433+ // - We rebuild a `&mut ListenerCallbacks<D>` from `data`,
434+ // NOT a `Box<_>`. We must not double-free.
435+ // - PipeWire serialises callbacks for a single filter on a
436+ // single thread (data thread for RT events, main loop
437+ // otherwise) so the unique borrow is sound.
438+
439+ unsafe extern "C" fn on_process<D>(
440+ data: *mut c_void,
441+ position: *mut libspa_sys::spa_io_position,
442+ ) {
443+ // SAFETY: per the block comment above.
444+ let state = unsafe { &mut *(data as *mut ListenerCallbacks<D>) };
445+ if let Some(cb) = &mut state.process {
446+ cb(&mut state.user_data, position);
447+ }
448+ }
449+
450+ unsafe extern "C" fn on_state_changed<D>(
451+ data: *mut c_void,
452+ old: pipewire_sys::pw_filter_state,
453+ new: pipewire_sys::pw_filter_state,
454+ error: *const std::os::raw::c_char,
455+ ) {
456+ // SAFETY: per the block comment above.
457+ let state = unsafe { &mut *(data as *mut ListenerCallbacks<D>) };
458+ if let Some(cb) = &mut state.state_changed {
459+ // SAFETY for `new`: error is documented to either be
460+ // NULL or a valid NUL-terminated C string owned by
461+ // the filter.
462+ let new = unsafe { FilterState::from_raw(new, error) };
463+ // `error` only describes the *new* state; passing it
464+ // to the `old` decode would misattribute the message
465+ // if a future PipeWire enum value falls through to
466+ // the `_` arm.
467+ let old = unsafe { FilterState::from_raw(old, std::ptr::null()) };
468+ cb(&mut state.user_data, old, new);
469+ }
470+ }
471+
472+ unsafe extern "C" fn on_param_changed<D>(
473+ data: *mut c_void,
474+ port_data: *mut c_void,
475+ id: u32,
476+ param: *const libspa_sys::spa_pod,
477+ ) {
478+ // SAFETY: per the block comment above.
479+ let state = unsafe { &mut *(data as *mut ListenerCallbacks<D>) };
480+ if let Some(cb) = &mut state.param_changed {
481+ let param_ref = if param.is_null() {
482+ None
483+ } else {
484+ // SAFETY: PipeWire owns the POD for the call's
485+ // duration. `Pod::from_raw` only borrows.
486+ Some(unsafe { libspa::pod::Pod::from_raw(param) })
487+ };
488+ cb(&mut state.user_data, port_data, id, param_ref);
489+ }
490+ }
491+
492+ // SAFETY: `mem::zeroed` produces an all-NULL `pw_filter_events`
493+ // — every callback field is `Option<unsafe extern "C" fn ...>`
494+ // which is layout-equivalent to a nullable function pointer.
495+ // We then fill in the fields we want and leave the rest NULL,
496+ // which is exactly what PipeWire expects (it skips NULL slots).
497+ let events = unsafe {
498+ let mut events: Pin<Box<pipewire_sys::pw_filter_events>> = Box::pin(mem::zeroed());
499+ events.version = pipewire_sys::PW_VERSION_FILTER_EVENTS;
500+ if callbacks.process.is_some() {
501+ events.process = Some(on_process::<D>);
502+ }
503+ if callbacks.state_changed.is_some() {
504+ events.state_changed = Some(on_state_changed::<D>);
505+ }
506+ if callbacks.param_changed.is_some() {
507+ events.param_changed = Some(on_param_changed::<D>);
508+ }
509+ events
510+ };
511+
512+ (events, callbacks)
513+ }
514+}
515+
516+/// builder from [`Filter::add_local_listener_with_user_data`]. install
517+/// closures, then [`Self::register`].
518+#[must_use = "Listener builders do nothing until .register() is called"]
519+pub struct ListenerBuilder<'f, D> {
520+ filter: &'f Filter,
521+ callbacks: ListenerCallbacks<D>,
522+}
523+
524+impl<D> ListenerBuilder<'_, D> {
525+ /// set the rt process callback.
526+ pub fn process<F>(mut self, callback: F) -> Self
527+ where
528+ F: FnMut(&mut D, *mut libspa_sys::spa_io_position) + 'static,
529+ {
530+ self.callbacks.process = Some(Box::new(callback));
531+ self
532+ }
533+
534+ /// set the state-changed callback.
535+ pub fn state_changed<F>(mut self, callback: F) -> Self
536+ where
537+ F: FnMut(&mut D, FilterState, FilterState) + 'static,
538+ {
539+ self.callbacks.state_changed = Some(Box::new(callback));
540+ self
541+ }
542+
543+ /// set the param-changed callback. `port_data` is NULL for
544+ /// filter-level changes, else the per-port handle; the `Pod` is
545+ /// borrowed for the call.
546+ pub fn param_changed<F>(mut self, callback: F) -> Self
547+ where
548+ F: FnMut(&mut D, *mut c_void, u32, Option<&libspa::pod::Pod>) + 'static,
549+ {
550+ self.callbacks.param_changed = Some(Box::new(callback));
551+ self
552+ }
553+
554+ /// register the listener; drop the returned [`FilterListener`] to
555+ /// unregister.
556+ ///
557+ /// # Errors
558+ /// never (`pw_filter_add_listener` is `void`); `Result` kept for
559+ /// parity with pipewire-rs `Stream::register`.
560+ pub fn register(self) -> Result<FilterListener<D>, FilterError> {
561+ let (events, data) = self.callbacks.into_raw();
562+ // SAFETY:
563+ // - `Box::into_raw` consumes the box, leaving the heap
564+ // allocation alive. We reclaim it inside the
565+ // `FilterListener` on drop.
566+ // - The events table is `Box::pin`ned; the raw `&` returned
567+ // by `events.as_ref().get_ref()` is stable for as long as
568+ // the listener holds the pinned box (the listener owns it).
569+ // - The spa_hook is zero-initialised and handed to PipeWire
570+ // to populate.
571+ let (listener, data) = unsafe {
572+ let listener: Box<libspa_sys::spa_hook> = Box::new(mem::zeroed());
573+ let raw_listener = Box::into_raw(listener);
574+ let raw_data = Box::into_raw(data);
575+ pipewire_sys::pw_filter_add_listener(
576+ self.filter.as_raw_ptr(),
577+ raw_listener,
578+ events.as_ref().get_ref(),
579+ raw_data.cast(),
580+ );
581+ (Box::from_raw(raw_listener), Box::from_raw(raw_data))
582+ };
583+ Ok(FilterListener {
584+ listener,
585+ _events: events,
586+ _data: data,
587+ })
588+ }
589+}
590+
591+/// owns the spa_hook + heap-boxed callbacks; drop unhooks the listener.
592+/// must outlive any callback — drop this *before* the [`Filter`], else
593+/// `pw_filter_destroy` could fire a trampoline against a freed hook.
594+pub struct FilterListener<D> {
595+ listener: Box<libspa_sys::spa_hook>,
596+ /// pinned: PipeWire keeps a pointer into this allocation.
597+ _events: Pin<Box<pipewire_sys::pw_filter_events>>,
598+ /// the trampoline's `data` box; kept alive for the listener's life.
599+ _data: Box<ListenerCallbacks<D>>,
600+}
601+
602+impl<D> Drop for FilterListener<D> {
603+ fn drop(&mut self) {
604+ // SAFETY: `self.listener` is the spa_hook PipeWire wrote into
605+ // during `pw_filter_add_listener`. `hook::remove` consumes the
606+ // hook by value; we hand it a copy from the box, then the box
607+ // itself is freed by the auto-generated Drop. The original
608+ // hook in `self.listener` is now invalid but no further code
609+ // reads it.
610+ let hook = *self.listener;
611+ libspa::utils::hook::remove(hook);
612+ }
613+}

show truncated diff