Commit 65250d1c123c
tarball24 files changed+7909-0
@@ -0,0 +1,55 @@
1
+[package]2
+name = "headroom-core"3
+description = "automatic loudness and per-app volume control (pipewire)"4
+version.workspace = true5
+edition.workspace = true6
+license.workspace = true7
+homepage.workspace = true8
+repository.workspace = true9
+authors.workspace = true10
+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` API30
+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` callback42
+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@@ -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 knob13
+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` work16
+const TICK_BUF_SAMPLES: usize = 8192;17
+18
+/// "no usable measurement yet" — `ebur128` before its window fills, or during silence. published19
+/// 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 transition31
+ last_enabled: bool,32
+ last_short_term_lufs: f32,33
+ /// last successfully queued limiter program loudness34
+ last_program_lufs: Option<f32>,35
+ bus_metrics: SharedBusMetrics,36
+ /// wraps freely37
+ 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_lufs91
+ }92
+93
+ #[must_use]94
+ pub fn current_target_db(&self) -> f32 {95
+ self.smoothed_target_db96
+ }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 lock101
+ 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 on113
+ 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.enabled120
+ && short_term > cfg.silence_threshold_lufs121
+ && 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 rises127
+ 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_control135
+ .set_agc_target_db(self.smoothed_target_db);136
+ }137
+138
+ // limiter sees post-agc loudness; ebur128 measures pre-agc139
+ 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
+ None144
+ };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 Hz166
+ 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→playback201
+ // ring drained/stuffed within a quantum (the "tremolo every quantum" mechanism). warn so202
+ // 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-free214
+ 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-finite256
+ // 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 reducing270
+ 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 == 0297
+ }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 rtrbs316
+ /// whose producers were just dropped (sends would fail), so swap handles + rebuild ebur128 at317
+ /// 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 it406
+ // 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 silence425
+ agc.tick();426
+ // ebur128 may report -inf or values below the silence427
+ // 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 window438
+ // (~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 pushed444
+ // 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
+ state516
+ .lock()517
+ .profiles518
+ .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 too538
+ // 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
+}@@ -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
+}@@ -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
+ id61
+ }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 the69
+ /// 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 interested100
+ /// 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 for199
+ // 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 + one210
+ // overflow event after we've drained. Subscriber needs the211
+ // Daemon topic on its interest list to receive overflow212
+ // 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 the228
+ // 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> = got238
+ .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 = got249
+ .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 dying280
+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
+}@@ -0,0 +1,146 @@
1
+//! per-connection handler. each client spawns a writer thread (owns2
+//! 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't5
+//! 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::BrokenPipe118
+ ) =>119
+ {120
+ return121
+ }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 so129
+ // 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 gone144
+ }145
+ }146
+}@@ -0,0 +1,13 @@
1
+//! daemon IPC server (server side of `IPC.md`). one blocking thread per2
+//! 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 the12
+/// `profile.reload` op.13
+pub(crate) use ops::execute_reload;@@ -0,0 +1,1166 @@
1
+//! op dispatch + handlers. each locks state briefly and returns a2
+//! [`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 ops55
+// ---------------------------------------------------------------------------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: s78
+ .streams79
+ .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> = s116
+ .profiles117
+ .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: s150
+ .streams151
+ .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 ops200
+// ---------------------------------------------------------------------------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
+/// # Errors234
+/// 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 tx350
+ .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) ops394
+// ---------------------------------------------------------------------------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 tx438
+ .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::Internal464
+ }465
+ };466
+ err(id, code, e.to_string())467
+}468
+469
+// ---------------------------------------------------------------------------470
+// Helpers471
+// ---------------------------------------------------------------------------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
+// Tests529
+// ---------------------------------------------------------------------------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 warning606
+ // 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
+ warnings636
+ .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 ops758
+ // -----------------------------------------------------------------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<_> = rules820
+ .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 = s846
+ .profiles847
+ .effective()848
+ .rules849
+ .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 must876
+ // 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 = s893
+ .profiles894
+ .effective()895
+ .rules896
+ .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 the995
+ // 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 for1023
+ // 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
+ } = cmd1132
+ 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 still1143
+ // 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
+}@@ -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.059
+"#,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
+ consumer77
+}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
+ rx83
+}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
+ rx91
+}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
+ out99
+}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_cmds272
+ .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_cmds291
+ .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::Profile383
+ && 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::Profile401
+ && 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
+ events429
+ .iter()430
+ .any(|event| event.topic == Topic::Routing && event.event == "rule_changed"),431
+ "expected route rule change event, got {events:?}"432
+ );433
+}@@ -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 => state89
+ .filter_control90
+ .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
+}@@ -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 accept35
+ /// thread. stale sockets are unlinked; a reachable path means36
+ /// another daemon is running → [`DaemonError::Other`].37
+ ///38
+ /// # Errors39
+ /// - [`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 = client219
+ .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 profile226
+ /// on disk, switch to it via `profile.use`, and confirm that an227
+ /// 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
+ client267
+ .route_set("obs", Route::Bypass)268
+ .expect("route.set obs");269
+ client270
+ .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 = routes283
+ .rules284
+ .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
+}@@ -0,0 +1,36 @@
1
+//! daemon core: pipewire main loop, filter pipeline, registry, routing, agc, ipc2
+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 watches29
+/// 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
+}@@ -0,0 +1,169 @@
1
+//! bus meter snapshot shared audio-thread → agc; audio side writes via try_lock, never blocks2
+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 reducing11
+ pub compressor_gr_db: f32,12
+ /// min of soft and hard gain, in dB13
+ 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 condition16
+ pub limiter_hard_gr_db: f32,17
+ /// peak the limiter saw on its input (not the bounded output); for tuning soft-tier headroom18
+ 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 the29
+/// 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 snapshot38
+ pub last_spike_at_call: AtomicU64,39
+ /// samples zero-filled because the capture→playback ring was empty. non-zero delta = bug40
+ /// (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-thread45
+ 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
+}@@ -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 a4
+//! [`PwCommand`] drained by a 50 ms timer source instead. ~50 ms5
+//! worst-case dispatch — do NOT route anything spike-reactive here6
+//! (Layer A `channelVolumes`, rt gain reduction); that breaks the §4.57
+//! reaction-time contract. filter DSP updates bypass this via8
+//! [`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's24
+ /// Format listener sees a rate the filter isn't running at (ALSA25
+ /// sinks publish rate only via Format, not the props dict → initial26
+ /// 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
+}@@ -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 explicit23
+/// monitor → input and output → real-sink links — wireplumber does24
+/// 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`] overrides28
+/// 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 controller40
+/// 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. structural44
+/// limiter changes (oversample, lookahead) can't apply live — see45
+/// [`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` resets56
+ SetProgramLoudnessLufs(Option<f32>),57
+}58
+59
+/// cheap-to-clone handle for pushing [`AudioCmd`]s into the running60
+/// 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
+ false77
+ }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 mono126
+/// (`format.dsp = "32 bit float mono audio"`), one per channel per127
+/// direction — canonical `pw_filter` shape; explicit links pair128
+/// 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 dropped136
+ /// 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 audio147
+/// 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, or170
+ /// [`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 routing179
+ /// layer builds the `processed.monitor → filter.in.*` and180
+ /// `filter.out.* → real_sink` links explicitly. filter sits in181
+ /// `Paused` until both land.182
+ ///183
+ /// # Errors184
+ /// [`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 FIR201
+ // 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 = filter216
+ .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
+ filter237
+ .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 the262
+/// 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
+ filter276
+ .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-cased281
+/// 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, wireplumber295
+ // 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-thread422
+ 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 = state456
+ .measurement_dropped457
+ .saturating_add(measurement_dropped);458
+ }459
+460
+ // `frames < in_frames` shouldn't happen (output maxsize is sized461
+ // by `clock.quantum-limit` ≫ typical quanta); recorded as a462
+ // regression signal.463
+ if frames < in_frames {464
+ let dropped_frames = in_frames - frames;465
+ state466
+ .timing467
+ .record_dropped((dropped_frames * CHANNELS as usize) as u64);468
+ }469
+470
+ // `try_lock` so we never block on a daemon-thread reader; a471
+ // 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 the493
+/// 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 u64519
+ }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_idx553
+}554
+555
+/// mono input data as f32 samples; misalignment is counted off-thread556
+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
+ None572
+ }573
+ }574
+}575
+576
+#[cfg(test)]577
+mod tests {578
+ //! audio-thread leg (apply_audio_cmd) + control-side send leg579
+ //! (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 place643
+ 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().oversample656
+ );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, drain684
+ // 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
+}@@ -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.…"}`) into13
+/// a sink name. `None` on anything unrecognised — ignore rather than14
+/// 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) sink25
+ // names, so escape embedded quotes even though pipewire's never26
+ // 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
+}@@ -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 receive26
+/// them instead of the kernel's default (terminate).27
+///28
+/// **must be called before any thread is spawned** — threads spawned29
+/// beforehand keep the unblocked mask and die on signal before the30
+/// signalfd reads.31
+///32
+/// # Errors33
+/// [`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 be52
+ /// inside the main loop for the roundtrip).53
+ sink: RefCell<VirtualSink>,54
+ /// registry watcher + routing engine; `None` until55
+ /// [`Self::start_routing`].56
+ routing: RefCell<Option<RegistryWatcher>>,57
+}58
+59
+impl PwContext {60
+ /// init PipeWire, create the main loop/context, connect, and block61
+ /// termination signals (see [`block_termination_signals`]).62
+ ///63
+ /// # Errors64
+ /// [`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
+ /// # Errors89
+ /// [`DaemonError::PipeWire`] if obtaining the registry fails.90
+ pub fn start_routing(&self, daemon: SharedState) -> Result<(), DaemonError> {91
+ let registry = self92
+ .core93
+ .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_loop104
+ }105
+106
+ #[must_use]107
+ pub fn core(&self) -> &Core {108
+ &self.core109
+ }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 be119
+ /// called before [`Self::run_until_signal`].120
+ ///121
+ /// # Errors122
+ /// [`DaemonError::PipeWire`] if `create_object` fails, the123
+ /// `support.null-audio-sink` factory isn't available, or the124
+ /// 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 = self139
+ .core140
+ .sync(0)141
+ .map_err(|e| DaemonError::pipewire(format!("core.sync: {e}")))?;142
+143
+ let _listener = self144
+ .core145
+ .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
+ /// # Errors163
+ /// [`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 = self168
+ .main_loop169
+ .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 = self178
+ .main_loop179
+ .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) — see187
+ // `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
+ timer201
+ })202
+ };203
+204
+ // drain Layer A measurement rings + issue `Props.channelVolumes`205
+ // writes. 5 ms keeps detection-to-write inside one quantum at206
+ // typical 21 ms quanta207
+ 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
+ timer220
+ })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 before229
+ // 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
+}@@ -0,0 +1,2086 @@
1
+//! PipeWire registry subscription + routing decisions. watches new2
+//! globals (metadata, nodes, ports, links), routes `Stream/Output/Audio`3
+//! per the active profile, tracks `preferred_real_sink`, and owns the4
+//! 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 may45
+/// 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 real49
+/// 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 wire56
+/// `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 channel63
+ /// 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 a74
+/// WP-created link that conflicts with the declared route — WP only75
+/// honours `target.object` at connect time, so the daemon owns the76
+/// 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`, resolved88
+/// 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 were98
+/// built for. keeping them alive lets `enqueue_route` skip a99
+/// 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 here112
+/// 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 the121
+ /// sink-side target for the processed monitor and the source-side122
+ /// 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 a133
+ /// port removal from a node removal under PipeWire's id reuse —134
+ /// the old "retain by port_id across all nodes" pass could wipe a135
+ /// 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; see145
+ /// [`ManagedRoute`].146
+ managed_route_links: HashMap<u32, ManagedRoute>,147
+ /// `default.audio.sink` re-assertion budget so a hostile WP can't148
+ /// hot-loop us. `(window_start, attempts)`.149
+ default_reassertion: Option<(std::time::Instant, u32)>,150
+ /// `PwNodeInfo` per routable stream, so bypass/profile reapply can151
+ /// 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 path154
+ /// runs on the drain timer (no live `global`) but `bind` only reads155
+ /// 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 user160
+ /// 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 its163
+ /// 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 swap166
+ /// it atomically on a rate change.167
+ bus_filter: Option<crate::pw::filter::Filter>,168
+ /// whether the processed-sink monitor -> filter -> real-sink graph169
+ /// should currently be linked.170
+ bus_graph_active: bool,171
+ /// slow AGC controller, so a rebuild can rebind it. shared with the172
+ /// 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` if184
+ /// 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 the192
+ /// 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 can234
+ /// 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 enforcement245
+ /// pass. called by the 50 ms timer in246
+ /// [`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
+ name272
+ }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` and302
+ /// 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's338
+ // remove, `try_capture_filter_playback`'s early-exit would skip339
+ // the new id and never re-link. stale pending entries are340
+ // 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 a400
+ /// stream we route and lands off its declared target, destroy it401
+ /// now. WP links streams faster than our node callback fires, so402
+ /// 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_node443
+ .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 A452
+ /// 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_id459
+ && target_input_ports.iter().any(|p| *p == info.input_port)460
+ {461
+ return; // link lands on the intended target — keep462
+ }463
+ // If the destination isn't one of our routing targets,464
+ // leave it alone — it's likely a Layer A tap or some465
+ // 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 are486
+ /// `Audio/Sink`s + the bus filter; the filter is special-cased so487
+ /// `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 daemon505
+ /// 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> = self521
+ .ports_by_node522
+ .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
+ None529
+ } 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 node555
+ // 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, adopt563
+ /// the first non-processed sink, else `real_sink.name` stays `None`564
+ /// forever and bypass routes log "no real sink known". a later565
+ /// `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 elsewhere577
+ }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 register610
+ // 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, not624
+ // the props dict; subscribe to pull it. only for the current625
+ // 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 then630
+ // skipped the output leg); pin now — order-independent with631
+ // `try_capture_filter_playback`.632
+ self.sync_bus_graph_links();633
+ }634
+ }635
+636
+ /// bind `sink_global` and subscribe to its `Format` param (the637
+ /// initial value is replayed on subscribe) to track638
+ /// `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 do644
+ }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 = node660
+ .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 tx695
+ .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 the725
+ // filter / processed-sink / real-sink surfaces last wins the726
+ // race. (the output leg via `pin_filter_to_real_sink` is also727
+ // 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 the773
+ /// filter and a real sink are known. idempotent — callable from any774
+ /// site that learns one half, regardless of arrival order; the775
+ /// 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. source787
+ /// = processed sink (its `Out` ports = the monitor); target = the788
+ /// 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 the829
+ // prior server state then our write, in order.830
+ let listener_back = back.clone();831
+ let listener = md832
+ .add_listener_local()833
+ .property(move |subject, key, _type, value| {834
+ // re-entrancy invariant: callbacks are serial on this loop835
+ // no sync pipewire round-trips while this `RefCell` borrow lives836
+ listener_back.borrow_mut().on_metadata_property(subject, key, value);837
+ 0838
+ })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_name859
+ .insert(PROCESSED_SINK_NAME.to_owned(), global.id);860
+ // registry replay order isn't guaranteed; if the filter came861
+ // 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 in878
+ // — feedback loop. `node.dont-move` isn't always in the registry879
+ // view, so match on name prefix too.880
+ if dict881
+ .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 A892
+ // reconcile path bind without a live callback. cleared in893
+ // `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 source901
+ // 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 or907
+ /// 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 the939
+ // 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 via960
+ /// `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 (one972
+ /// `routing::evaluate` each). also re-asserts `default.audio.sink`973
+ /// for the current bypass state — what makes "bypass on" a real974
+ /// kill switch for apps that follow `default` rather than975
+ /// `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 an989
+ /// enabled `[[per_app.rules]]` entry. no-op if already managed or990
+ /// unmatched; every no-op path logs *why* at debug. wiring is in991
+ /// [`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 owned1026
+ /// 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; lookup1051
+ // before bind keeps the ordering below clean.1052
+ let persisted_ceiling = if app_label.is_empty() {1053
+ None1054
+ } 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 or1069
+ // unity) BEFORE subscribing means the Props echo equals the1070
+ // controller's `last_written_lin`, so the echo check ignores it1071
+ // instead of misreading a stale/inherited value as a user1072
+ // gesture and locking the ceiling (the "hangs until I tweak it1073
+ // 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 be1123
+ /// managed but isn't, regardless of why it missed its one1124
+ /// `try_route_stream` spawn. runs every drain so a miss recovers in1125
+ /// 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 off1130
+ }1131
+ let candidates: Vec<PwNodeInfo> = self1132
+ .known_streams1133
+ .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]`: tear1151
+ /// down taps that no longer match, spawn ones that now do. posted as1152
+ /// `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 live1162
+ // 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 or1185
+ /// unity) on graceful shutdown so attenuated apps aren't left1186
+ /// 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 volume1204
+ /// (so a policy-disable releases the gain), persist the ceiling for1205
+ /// 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_ceilings1226
+ .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 its1251
+ /// controller, write `Props.channelVolumes` on change. also retries1252
+ /// explicit-link creation. called by the 5 ms timer in1253
+ /// [`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 drops1258
+ // (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 when1288
+ // 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 in1337
+ /// the cache, pair by ordinal and create one passive `Link` per1338
+ /// channel. idempotent.1339
+ fn attempt_pending_links(&mut self) {1340
+ // snapshot first so we don't hold &mut self across the1341
+ // link-factory calls (which need &self.core).1342
+ let pending: Vec<(u32, u32)> = self1343
+ .managed_streams1344
+ .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 counts1368
+ // 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 (the1416
+ // apply pass no-ops), avoiding the 21-42 ms gap an1417
+ // unconditional rebuild would cost. only drop the proxies when1418
+ // the target actually changed.1419
+ let already_at_target = self1420
+ .managed_route_links1421
+ .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 + target1437
+ /// ports are both on the registry, tear down conflicting outbound1438
+ /// 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 registry1460
+ };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) is1475
+ // intentionally left to WP's upmix adapter — 1→N fanout +1476
+ // stereo-link limiter semantics don't generalise, so1477
+ // `route.set` on a mono app is a hint, not enforcement1478
+ // (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_outs1490
+ .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; leave1498
+ // non-sink links (Layer A taps) alone.1499
+ let existing: Vec<u32> = self1500
+ .outbound_links_by_node1501
+ .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 — keep1510
+ }1511
+ if !self.is_routing_target(info.input_node) {1512
+ continue; // probably a Layer A tap or similar1513
+ }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)> = self1527
+ .outbound_links_by_node1528
+ .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> = self1535
+ .managed_route_links1536
+ .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; only1610
+ /// `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 at1636
+ /// `MAX_PER_WINDOW` per `WINDOW` so a WP that keeps rewriting our1637
+ /// value back can't hot-loop us. explicit links still enforce1638
+ /// routing whichever side wins the default.1639
+ fn reassert_default_processed(&mut self) {1640
+ // under default-bypass policy, stop fighting WP for the default so1641
+ // `default`-following apps land at the real sink — else1642
+ // "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::Bypass1689
+ }1690
+1691
+ /// update `preferred_real_sink`, retarget every bypass-routed1692
+ /// 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_sink1703
+ // 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 its1734
+ // 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-key1775
+ // removes are harmless. port cleanup is scoped via `port_owner`1776
+ // so a node removal isn't conflated with a port removal under1777
+ // id reuse (the old "retain by port_id across all nodes" could1778
+ // 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_id1803
+ .retain(|_, info| info.output_node != node_id && info.input_node != node_id);1804
+1805
+ // stream gone — drop intent + cache so reapply doesn't route a1806
+ // 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_id1821
+ // leaves the name pinned to a dead sink, so every1822
+ // 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
+ false1839
+ } else {1840
+ true1841
+ }1842
+ });1843
+ // node gone, so don't restore volume — just persist the ceiling1844
+ // 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 to1862
+/// the controller's `on_external_change` — the user-volume-deference1863
+/// 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 expose1896
+/// 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
+ None1914
+}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
+ None1933
+}1934
+1935
+/// ports of a given direction owned by `node_id`, sorted by ordinal so1936
+/// 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> = ports1944
+ .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 breaks1952
+ // 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 the1958
+/// 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 routing1972
+/// 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 for1985
+/// Layer A attenuation. allocates a POD on the heap; not on the rt1986
+/// 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_binary2017
+ .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: dict2029
+ .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
+ registry2041
+ .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 sender2062
+ /// into `daemon.pw_command_tx` so IPC handlers can post from any2063
+ /// 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(®istry, 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.state2085
+ }2086
+}@@ -0,0 +1,77 @@
1
+//! `headroom-processed` virtual sink (the bus filter captures from its2
+//! 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` factory26
+ /// (always present) wrapping `support.null-audio-sink` (the SPA27
+ /// factory that yields a null sink with a monitor port).28
+ ///29
+ /// `sample_rate` → `audio.rate` so the sink clocks at the real30
+ /// sink's rate; otherwise it defaults to 48 kHz and the capture31
+ /// adapter resamples — that buffering across two drivers caused32
+ /// the per-quantum tremolo seen in soak.33
+ ///34
+ /// # Errors35
+ /// [`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 the50
+ // real sink's driver.51
+ "node.passive" => "true",52
+ "node.suspend-on-idle" => "true",53
+ };54
+55
+ let proxy: Node = core56
+ .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
+}@@ -0,0 +1,308 @@
1
+//! pure routing policy; pipewire-free so it unit-tests without the daemon2
+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 reliability14
+ pub application_process_binary: Option<String>,15
+ /// client-set16
+ pub application_name: Option<String>,17
+ /// flatpak-set, trustworthy when present18
+ pub portal_app_id: Option<String>,19
+ /// rarely set20
+ pub media_role: Option<String>,21
+ /// `node.dont-move`; honoured by skipping routing entirely22
+ pub dont_move: bool,23
+ /// `None` if absent. `>2ch` forced to bypass: bus filter is stereo-only, so processing surround24
+ /// 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. straight54
+ // 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.1116
+ // firefox stream (rare but valid — some browser content117
+ // declares surround) must still bypass: the bus filter is118
+ // stereo-only and the explicit-link path would otherwise119
+ // 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 an157
+ // `application_name`-keyed override. Verifies that a158
+ // stream missing `application.process.binary` (typical159
+ // of pw-cat, many CLI tools, some Flatpak wrappers) is160
+ // 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 matches222
+ // → 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 empty252
+ 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
+}@@ -0,0 +1,173 @@
1
+//! top-level orchestrator: wires pw layer, ipc, agc, routing; runs the main loop2
+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 them21
+ 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 inherits36
+ // the blocked mask and pipewire's signalfd is the sole receiver. threads spawned before would37
+ // keep default disposition and dying on SIGTERM would skip our shutdown path. (the ipc accept38
+ // thread, added later in startup, tripped this once.)39
+ block_termination_signals()?;40
+41
+ // captured before `profiles` moves into shared state42
+ 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 works53
+ 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
+ None59
+ }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-conversion66
+ // 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_state69
+ .lock()70
+ .real_sink71
+ .sample_rate72
+ .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 tick104
+ 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 = pw118
+ .main_loop()119
+ .loop_()120
+ .add_timer(move |_| agc.borrow_mut().tick());121
+ let _ = timer.update_timer(Some(AGC_TICK), Some(AGC_TICK));122
+ timer123
+ };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 the130
+ // registry thread to rebuild at a new rate via PwCommand::RebuildFilter. filter ownership131
+ // moves here; RoutingState drops it on shutdown via PwContext's drop order.132
+ if let Some(routing_state) = pw.routing_state() {133
+ routing_state134
+ .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 defensively138
+ 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 context146
+ 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
+}@@ -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 empty22
+ 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 it32
+ pub processed_sink_id: Option<u32>,33
+ /// rate the filter runs at. matches the real sink's native rate at last (re)build; drives34
+ /// 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 drain40
+ /// 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 under45
+ /// the daemon lock then dropped before pushing, so the lock is never held during an46
+ /// 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` metadata49
+ /// writes). `None` until `start_routing`. cloned + dropped before send so the lock is never50
+ /// 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 that72
+ /// 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 it80
+ // 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.streams88
+ .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
+}@@ -0,0 +1,19 @@
1
+[package]2
+name = "pipewire-filter"3
+description = "automatic loudness and per-app volume control (pipewire)"4
+version.workspace = true5
+edition.workspace = true6
+license.workspace = true7
+homepage.workspace = true8
+repository.workspace = true9
+authors.workspace = true10
+11
+# pipewire-rs 0.8 has no safe `pw_filter` wrapper + headroom-core12
+# 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 }@@ -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 own6
+ /// 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
+}@@ -0,0 +1,613 @@
1
+//! minimal safe wrapper around `pw_filter` (pipewire-rs 0.8 has no2
+//! `Filter`). mirrors `pipewire::stream`; lives in its own crate so3
+//! 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 in8
+//! the types (same as pipewire-rs `Stream`); callers drop the listener9
+//! 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
+ /// # Safety50
+ /// `error` must be NULL or a NUL-terminated C string valid until51
+ /// 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 a63
+ // valid NUL-terminated string for the call's64
+ // 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.0102
+ }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`]. mirrors113
+/// `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` is121
+ /// 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.0135
+ }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 (PipeWire159
+ /// takes ownership of the `pw_properties`).160
+ ///161
+ /// # Errors162
+ /// [`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` that167
+ // `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; PipeWire170
+ // must free the `pw_properties` (it does: per filter.h171
+ // "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
+ /// # Errors193
+ /// [`FilterError::AddPortFailed`] if `pw_filter_add_port` returns194
+ /// 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; PipeWire207
+ // frees on filter destruction.208
+ // - `params` is `&mut [&Pod]`. `Pod` is `#[repr(transparent)]`209
+ // over `spa_pod`, so `&Pod` and `*const spa_pod` have the210
+ // same layout. The cast pattern is the one pipewire-rs211
+ // uses for `pw_stream_connect` (stream.rs:170).212
+ // - `params` is not stored; PipeWire copies whatever it needs213
+ // 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 optional230
+ /// filter-level format hints (headroom passes none; negotiation is231
+ /// per-port).232
+ ///233
+ /// # Errors234
+ /// [`FilterError::ConnectFailed`] if `pw_filter_connect` returns a235
+ /// 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`. The242
+ // 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 of265
+ // `self`. `pw_filter_get_node_id` is documented as a simple266
+ // 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 the271
+ /// 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 has295
+ // not been freed since. `pw_filter_destroy` is the documented296
+ // cleanup function. Note: callers are expected to drop any297
+ // associated `FilterListener` first; otherwise the listener's298
+ // `spa_hook::remove` runs against a freed list. We can't299
+ // express that constraint in the borrow checker without300
+ // making the listener literally borrow the filter, which301
+ // 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 move307
+/// audio via [`Self::dequeue_buffer`] + the [`Buffer`] RAII.308
+///309
+/// `Send` so the listener user-data struct can own port handles, but310
+/// not `Sync`: `pw_filter_dequeue_buffer` writes a per-port lockless311
+/// ring, so concurrent same-port calls aren't documented-safe. all312
+/// 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 Filter319
+// owns the lifetime; the documented drop order (listener before320
+// filter) ensures that PortData inside the listener's user-data321
+// box never outlives the filter. Move-only ownership is the right322
+// model — there must be exactly one logical owner of a port323
+// 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 on332
+ /// output ports). `None` if the queue is empty. the returned333
+ /// [`Buffer`] queues itself back on drop.334
+ pub fn dequeue_buffer(&self) -> Option<Buffer<'_>> {335
+ // SAFETY: `as_raw_ptr` returns the live port handle; PipeWire336
+ // returns either a valid `*mut pw_buffer` or NULL. The337
+ // returned pointer is owned by PipeWire — we only borrow it338
+ // for the realtime callback's duration. The `Buffer` RAII339
+ // 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-rs357
+ /// `Buffer::datas_mut`).358
+ pub fn datas_mut(&mut self) -> &mut [libspa::buffer::Data] {359
+ // SAFETY: `pw_buffer.buffer` points at a `spa_buffer` that360
+ // PipeWire owns for the duration of this callback. The same361
+ // invariant pipewire-rs relies on in `Buffer::datas_mut`. If362
+ // `n_datas == 0` or `datas == NULL` we return an empty slice363
+ // 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)]` over376
+ // `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 from387
+ // `pw_filter_dequeue_buffer` on `self.port` and has not been388
+ // queued elsewhere (this is the only path that consumes it).389
+ // The `&PortData` borrow keeps the port alive at least until390
+ // 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 is404
+/// 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 to429
+ // `pw_filter_add_listener`. It is the raw pointer to the430
+ // `Box<ListenerCallbacks<D>>`. The box is reclaimed by the431
+ // `FilterListener` on drop, so during the listener's432
+ // 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 a436
+ // single thread (data thread for RT events, main loop437
+ // 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 be460
+ // NULL or a valid NUL-terminated C string owned by461
+ // the filter.462
+ let new = unsafe { FilterState::from_raw(new, error) };463
+ // `error` only describes the *new* state; passing it464
+ // to the `old` decode would misattribute the message465
+ // if a future PipeWire enum value falls through to466
+ // 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
+ None483
+ } else {484
+ // SAFETY: PipeWire owns the POD for the call's485
+ // 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
+ events510
+ };511
+512
+ (events, callbacks)513
+ }514
+}515
+516
+/// builder from [`Filter::add_local_listener_with_user_data`]. install517
+/// 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) -> Self527
+ where528
+ F: FnMut(&mut D, *mut libspa_sys::spa_io_position) + 'static,529
+ {530
+ self.callbacks.process = Some(Box::new(callback));531
+ self532
+ }533
+534
+ /// set the state-changed callback.535
+ pub fn state_changed<F>(mut self, callback: F) -> Self536
+ where537
+ F: FnMut(&mut D, FilterState, FilterState) + 'static,538
+ {539
+ self.callbacks.state_changed = Some(Box::new(callback));540
+ self541
+ }542
+543
+ /// set the param-changed callback. `port_data` is NULL for544
+ /// filter-level changes, else the per-port handle; the `Pod` is545
+ /// borrowed for the call.546
+ pub fn param_changed<F>(mut self, callback: F) -> Self547
+ where548
+ F: FnMut(&mut D, *mut c_void, u32, Option<&libspa::pod::Pod>) + 'static,549
+ {550
+ self.callbacks.param_changed = Some(Box::new(callback));551
+ self552
+ }553
+554
+ /// register the listener; drop the returned [`FilterListener`] to555
+ /// unregister.556
+ ///557
+ /// # Errors558
+ /// never (`pw_filter_add_listener` is `void`); `Result` kept for559
+ /// 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 heap564
+ // allocation alive. We reclaim it inside the565
+ // `FilterListener` on drop.566
+ // - The events table is `Box::pin`ned; the raw `&` returned567
+ // by `events.as_ref().get_ref()` is stable for as long as568
+ // the listener holds the pinned box (the listener owns it).569
+ // - The spa_hook is zero-initialised and handed to PipeWire570
+ // 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`], else593
+/// `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 into605
+ // during `pw_filter_add_listener`. `hook::remove` consumes the606
+ // hook by value; we hand it a copy from the box, then the box607
+ // itself is freed by the auto-generated Drop. The original608
+ // hook in `self.listener` is now invalid but no further code609
+ // reads it.610
+ let hook = *self.listener;611
+ libspa::utils::hook::remove(hook);612
+ }613
+}