Commit 249c28030ddd
tarball4 files changed+1342-0
@@ -0,0 +1,72 @@
1
+//! microbench: one `AppLevelController::process_block` call2
+3
+use std::time::{Duration, Instant};4
+5
+use criterion::{black_box, criterion_group, criterion_main, Criterion};6
+use headroom_core::app_level::AppLevelController;7
+use headroom_core::profile::{DeferPolicy, PerAppRule};8
+use headroom_ipc::RouteRuleMatch;9
+10
+const BLOCK_DT_S: f32 = 1024.0 / 48_000.0;11
+12
+fn aggressive_rule() -> PerAppRule {13
+ PerAppRule {14
+ match_: RouteRuleMatch::default(),15
+ enabled: true,16
+ peak_threshold_db: -6.0,17
+ rms_target_db: -20.0,18
+ max_cut_db: 12.0,19
+ peak_attack_ms: 5.0,20
+ peak_release_ms: 500.0,21
+ rms_window_ms: 1500.0,22
+ smoother_ms: 30.0,23
+ write_db_threshold: 0.5,24
+ min_write_interval_ms: 100.0,25
+ defer_to_user: DeferPolicy::Ceiling,26
+ }27
+}28
+29
+fn bench_process_block(c: &mut Criterion) {30
+ let mut ctrl = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);31
+ // Hot signal: 0 dBFS peak, ~-3 dB RMS.32
+ let peak = 1.0_f32;33
+ let mean_sq = 0.25_f32;34
+35
+ // Time advances at one block per call to keep the rate-limit gate36
+ // behaviour realistic — it'd otherwise be `now` reused every iter.37
+ let mut t = Instant::now();38
+ let step = Duration::from_millis(21);39
+40
+ let mut group = c.benchmark_group("app_level_controller");41
+ group.bench_function("process_block_hot_signal", |b| {42
+ b.iter(|| {43
+ t += step;44
+ let v = ctrl.process_block(black_box(peak), black_box(mean_sq), t);45
+ black_box(v);46
+ });47
+ });48
+49
+ // A second variant where the signal is below all thresholds —50
+ // this exercises the "no write" fast path the controller takes51
+ // most of the time on a quiet system.52
+ let mut quiet_ctrl = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);53
+ let quiet_peak = 0.01_f32;54
+ let quiet_mean_sq = 0.0001_f32;55
+ let mut t2 = Instant::now();56
+ group.bench_function("process_block_quiet_signal", |b| {57
+ b.iter(|| {58
+ t2 += step;59
+ let v = quiet_ctrl.process_block(60
+ black_box(quiet_peak),61
+ black_box(quiet_mean_sq),62
+ t2,63
+ );64
+ black_box(v);65
+ });66
+ });67
+68
+ group.finish();69
+}70
+71
+criterion_group!(benches, bench_process_block);72
+criterion_main!(benches);@@ -0,0 +1,1000 @@
1
+//! per-app level control (layer a): pure controller logic, pipewire-free2
+3
+use std::collections::VecDeque;4
+use std::time::{Duration, Instant};5
+6
+use headroom_dsp::{LevelDecision, LevelEnvelopes, LevelEnvelopesConfig};7
+8
+use crate::profile::{DeferPolicy, PerAppRule, PerAppSection};9
+use crate::routing;10
+use crate::routing::PwNodeInfo;11
+12
+// knob defaults live on `PerAppRule`; these are only the fallback for the synthetic13
+// `default_enabled` rule.14
+const FALLBACK_WRITE_DB_THRESHOLD: f32 = 0.5;15
+const FALLBACK_MIN_WRITE_INTERVAL_MS: f32 = 100.0;16
+const FALLBACK_SMOOTHER_MS: f32 = 30.0;17
+18
+/// below this gain we skip compensation: a muted stream (`last_written_lin ≈ 0`) would amplify19
+/// floor noise ~1000×, peg max-cut, and lock at mute even after unmute. −40 dB is below any20
+/// realistic `max_cut_db` yet above noise-floor amplification.21
+const GAIN_COMPENSATION_FLOOR: f32 = 0.01;22
+23
+/// cap on synthetic silent blocks per `tick_silent` pass; past this the envelopes have fully24
+/// released anyway, so short-circuit to `reset()` rather than spin.25
+const MAX_SILENT_CATCHUP_BLOCKS: u32 = 500;26
+27
+/// echo-suppression window. comparing only `last_written_lin` is racy: an echo of write A can28
+/// arrive after the controller wrote B (param events lag/reorder under 10 Hz writes), so A is29
+/// misread as a user ceiling and self-locks the stream. 16 ≈ 1.6 s at the 100 ms min interval.30
+const ECHO_HISTORY: usize = 16;31
+32
+/// looser than 1e-4 to absorb the f32 round-trip through pipewire's pod (de)serialization.33
+const ECHO_TOLERANCE: f32 = 1e-3;34
+35
+pub struct AppLevelController {36
+ /// stored by value so the controller is detached from the profile lifetime37
+ rule: PerAppRule,38
+ envelopes: LevelEnvelopes,39
+ /// single-pole, alpha from `rule.smoother_ms`40
+ smoothed_reduction_db: f32,41
+ smoother_alpha: f32,42
+ min_write_interval: Duration,43
+ /// `1.0` until a write goes out, so the first real change passes the gate44
+ last_written_lin: f32,45
+ last_write_at: Option<Instant>,46
+ /// `Some` triggers ceiling-mode deference (clamp our writes)47
+ user_ceiling_lin: Option<f32>,48
+ /// strict-mode lock: stops writes until [`Self::reset_deference`]49
+ deferred: bool,50
+ /// last real-audio measurement (not synthetic silence); drives [`Self::tick_silent`]51
+ last_measurement_at: Option<Instant>,52
+ /// see [`ECHO_HISTORY`]53
+ recent_writes: VecDeque<f32>,54
+}55
+56
+impl AppLevelController {57
+ /// `block_dt_s` = expected period between [`Self::process_block`] calls (pw quantum at the58
+ /// stream's rate); derives envelope alphas.59
+ #[must_use]60
+ pub fn new(rule: PerAppRule, block_dt_s: f32) -> Self {61
+ let envelopes = LevelEnvelopes::new(level_cfg_from_rule(&rule), block_dt_s);62
+ let smoother_alpha = anti_bounce_alpha(rule.smoother_ms, block_dt_s);63
+ let min_write_interval = Duration::from_millis(rule.min_write_interval_ms.max(0.0) as u64);64
+ Self {65
+ rule,66
+ envelopes,67
+ smoothed_reduction_db: 0.0,68
+ smoother_alpha,69
+ min_write_interval,70
+ last_written_lin: 1.0,71
+ last_write_at: None,72
+ user_ceiling_lin: None,73
+ deferred: false,74
+ last_measurement_at: None,75
+ recent_writes: VecDeque::with_capacity(ECHO_HISTORY),76
+ }77
+ }78
+79
+ #[must_use]80
+ pub fn rule(&self) -> &PerAppRule {81
+ &self.rule82
+ }83
+84
+ /// envelope state survives the swap; smoother + rate-limit pick up the new values immediately.85
+ pub fn set_rule(&mut self, rule: PerAppRule) {86
+ self.envelopes.set_config(level_cfg_from_rule(&rule));87
+ self.smoother_alpha = anti_bounce_alpha(rule.smoother_ms, self.envelopes.block_dt_s());88
+ self.min_write_interval = Duration::from_millis(rule.min_write_interval_ms.max(0.0) as u64);89
+ self.rule = rule;90
+ }91
+92
+ /// recompute alphas after a pw quantum change.93
+ pub fn set_block_dt(&mut self, dt_s: f32) {94
+ self.envelopes.set_block_dt(dt_s);95
+ self.smoother_alpha = anti_bounce_alpha(self.rule.smoother_ms, dt_s);96
+ }97
+98
+ #[must_use]99
+ pub fn user_ceiling_lin(&self) -> Option<f32> {100
+ self.user_ceiling_lin101
+ }102
+103
+ #[must_use]104
+ pub fn deferred(&self) -> bool {105
+ self.deferred106
+ }107
+108
+ /// always `>= 0`; `0` means no cut.109
+ #[must_use]110
+ pub fn smoothed_reduction_db(&self) -> f32 {111
+ self.smoothed_reduction_db112
+ }113
+114
+ #[must_use]115
+ pub fn last_written_lin(&self) -> f32 {116
+ self.last_written_lin117
+ }118
+119
+ #[must_use]120
+ pub fn last_decision(&self) -> LevelDecision {121
+ // can't borrow the envelope's stored decision (by-value); smoothed_reduction_db is canonical122
+ LevelDecision {123
+ peak_reduction_db: 0.0,124
+ rms_reduction_db: 0.0,125
+ total_reduction_db: self.smoothed_reduction_db,126
+ }127
+ }128
+129
+ /// `Some(new_volume_lin)` if a write is warranted now, else `None` (sub-threshold,130
+ /// rate-limited, or deferred).131
+ ///132
+ /// gain compensation: pw's adapter applies `channelVolumes` *before* the audio leaves the133
+ /// source, so the tap reads post-attenuation. uncompensated, any reduction looks like the134
+ /// source got quieter → envelope releases → freezes gain at the user's slider. divide135
+ /// peak/mean_sq by `last_written_lin` (and its square) to recover pre-attenuation signal.136
+ pub fn process_block(137
+ &mut self,138
+ peak_lin: f32,139
+ mean_sq_lin: f32,140
+ now: Instant,141
+ ) -> Option<f32> {142
+ if !self.rule.enabled || self.deferred {143
+ return None;144
+ }145
+ self.process_envelopes(peak_lin, mean_sq_lin);146
+ self.last_measurement_at = Some(now);147
+ self.decide_write(now)148
+ }149
+150
+ fn process_envelopes(&mut self, peak_lin: f32, mean_sq_lin: f32) {151
+ let g = self.last_written_lin;152
+ let (recovered_peak, recovered_mean_sq) = if g >= GAIN_COMPENSATION_FLOOR {153
+ (peak_lin / g, mean_sq_lin / (g * g))154
+ } else {155
+ // below the floor (≈ muted): pass through, see GAIN_COMPENSATION_FLOOR156
+ (peak_lin, mean_sq_lin)157
+ };158
+ let decision = self159
+ .envelopes160
+ .process_block(recovered_peak, recovered_mean_sq);161
+ self.smoothed_reduction_db +=162
+ self.smoother_alpha * (decision.total_reduction_db - self.smoothed_reduction_db);163
+ }164
+165
+ fn decide_write(&mut self, now: Instant) -> Option<f32> {166
+ let mut target_lin = headroom_dsp::util::db_to_lin(-self.smoothed_reduction_db);167
+ // ceiling-mode deference: never above the user's value168
+ if let Some(ceiling) = self.user_ceiling_lin {169
+ if target_lin > ceiling {170
+ target_lin = ceiling;171
+ }172
+ }173
+ target_lin = target_lin.clamp(0.0, 1.0);174
+175
+ let diff_db = lin_diff_db(target_lin, self.last_written_lin);176
+ if diff_db < self.rule.write_db_threshold {177
+ return None;178
+ }179
+ if let Some(prev) = self.last_write_at {180
+ if now.duration_since(prev) < self.min_write_interval {181
+ return None;182
+ }183
+ }184
+ self.last_written_lin = target_lin;185
+ self.last_write_at = Some(now);186
+ self.note_write(target_lin);187
+ Some(target_lin)188
+ }189
+190
+ /// remember a written volume so its later param-listener echo is recognised as ours.191
+ fn note_write(&mut self, v: f32) {192
+ if self.recent_writes.len() == ECHO_HISTORY {193
+ self.recent_writes.pop_front();194
+ }195
+ self.recent_writes.push_back(v);196
+ }197
+198
+ /// true if `v` is an echo of our own write (within [`ECHO_TOLERANCE`]), not a user adjustment.199
+ fn is_own_echo(&self, v: f32) -> bool {200
+ (v - self.last_written_lin).abs() < ECHO_TOLERANCE201
+ || self202
+ .recent_writes203
+ .iter()204
+ .any(|&w| (w - v).abs() < ECHO_TOLERANCE)205
+ }206
+207
+ /// advance envelopes through silent periods, then decide once. without this, a suspended source208
+ /// (pw stops delivering buffers — Strawberry between tracks) leaves the envelopes pinned at the209
+ /// last value and applies stale attenuation on resume.210
+ pub fn tick_silent(&mut self, now: Instant) -> Option<f32> {211
+ if !self.rule.enabled || self.deferred {212
+ return None;213
+ }214
+ let last = self.last_measurement_at?;215
+ let block_dt_s = self.envelopes.block_dt_s();216
+ if block_dt_s <= 0.0 {217
+ return None;218
+ }219
+ let block_dt = Duration::from_secs_f32(block_dt_s);220
+ let elapsed = now.saturating_duration_since(last);221
+ if elapsed < block_dt {222
+ return None; // source is producing normally223
+ }224
+ let n_blocks_f = elapsed.as_secs_f32() / block_dt_s;225
+ if n_blocks_f > MAX_SILENT_CATCHUP_BLOCKS as f32 {226
+ // long silence — envelopes fully released anyway; short-circuit227
+ self.envelopes.reset();228
+ self.smoothed_reduction_db = 0.0;229
+ } else {230
+ // bounded by the branch above, so the u32 truncation is safe231
+ let n_blocks = n_blocks_f as u32;232
+ for _ in 0..n_blocks {233
+ self.process_envelopes(0.0, 0.0);234
+ }235
+ }236
+ // count synthetic silence as a measurement so we don't re-tick next pass237
+ self.last_measurement_at = Some(now);238
+ self.decide_write(now)239
+ }240
+241
+ /// ceiling mode caps our writes at the user's value; strict mode stops adjustment until242
+ /// [`Self::reset_deference`].243
+ pub fn on_external_change(&mut self, new_volume_lin: f32) {244
+ // matches a recent write → our own echo, not a user change (window, not just last_written,245
+ // to catch delayed/reordered echoes that would otherwise self-lock the stream)246
+ if self.is_own_echo(new_volume_lin) {247
+ return;248
+ }249
+ match self.rule.defer_to_user {250
+ DeferPolicy::Ceiling => {251
+ self.user_ceiling_lin = Some(new_volume_lin.clamp(0.0, 1.0));252
+ }253
+ DeferPolicy::Strict => {254
+ self.deferred = true;255
+ }256
+ }257
+ }258
+259
+ pub fn reset_deference(&mut self) {260
+ self.user_ceiling_lin = None;261
+ self.deferred = false;262
+ }263
+264
+ /// seed a persisted ceiling when an app respawns its stream (Strawberry makes a fresh node per265
+ /// track). without it, the inherited `channelVolumes` (often our own prior attenuated value)266
+ /// hits a fresh controller at `last_written_lin = 1.0`, is misread as a user change, and locks267
+ /// the ceiling at the daemon's reduced value.268
+ pub fn restore_state(&mut self, ceiling_lin: f32, now: Instant) {269
+ let v = ceiling_lin.clamp(0.0, 1.0);270
+ self.user_ceiling_lin = Some(v);271
+ self.last_written_lin = v;272
+ self.last_write_at = Some(now);273
+ self.note_write(v);274
+ }275
+}276
+277
+/// non-`Spawn` variants distinguish *why* a stream is unmanaged (config vs bug) for spawn-skip logs.278
+#[derive(Debug, Clone, PartialEq)]279
+pub enum LayerAEval {280
+ MasterOff,281
+ NotPlayback,282
+ NoMatch,283
+ RuleDisabled,284
+ Spawn(PerAppRule),285
+}286
+287
+impl LayerAEval {288
+ #[must_use]289
+ pub fn rule(self) -> Option<PerAppRule> {290
+ match self {291
+ LayerAEval::Spawn(rule) => Some(rule),292
+ _ => None,293
+ }294
+ }295
+296
+ #[must_use]297
+ pub fn skip_reason(&self) -> &'static str {298
+ match self {299
+ LayerAEval::MasterOff => "per_app master disabled",300
+ LayerAEval::NotPlayback => "not a routable playback stream",301
+ LayerAEval::NoMatch => "no matching rule",302
+ LayerAEval::RuleDisabled => "matching rule disabled",303
+ LayerAEval::Spawn(_) => "spawn",304
+ }305
+ }306
+}307
+308
+/// orthogonal to `routing::evaluate` (the bus-routing sibling)309
+#[must_use]310
+pub fn evaluate(info: &PwNodeInfo, per_app: &PerAppSection) -> LayerAEval {311
+ if !per_app.enabled {312
+ return LayerAEval::MasterOff;313
+ }314
+ if !info.is_routable_playback() {315
+ return LayerAEval::NotPlayback;316
+ }317
+ for rule in &per_app.rules {318
+ if routing::matches(info, &rule.match_) {319
+ return if rule.enabled {320
+ LayerAEval::Spawn(rule.clone())321
+ } else {322
+ LayerAEval::RuleDisabled323
+ };324
+ }325
+ }326
+ if per_app.default_enabled {327
+ return LayerAEval::Spawn(default_rule());328
+ }329
+ LayerAEval::NoMatch330
+}331
+332
+fn default_rule() -> PerAppRule {333
+ let cfg = LevelEnvelopesConfig::default();334
+ PerAppRule {335
+ match_: headroom_ipc::RouteRuleMatch::default(),336
+ enabled: true,337
+ peak_threshold_db: cfg.peak_threshold_db,338
+ rms_target_db: cfg.rms_target_db,339
+ max_cut_db: cfg.max_cut_db,340
+ peak_attack_ms: cfg.peak_attack_ms,341
+ peak_release_ms: cfg.peak_release_ms,342
+ rms_window_ms: cfg.rms_window_ms,343
+ smoother_ms: FALLBACK_SMOOTHER_MS,344
+ write_db_threshold: FALLBACK_WRITE_DB_THRESHOLD,345
+ min_write_interval_ms: FALLBACK_MIN_WRITE_INTERVAL_MS,346
+ defer_to_user: DeferPolicy::default(),347
+ }348
+}349
+350
+fn level_cfg_from_rule(rule: &PerAppRule) -> LevelEnvelopesConfig {351
+ LevelEnvelopesConfig {352
+ peak_threshold_db: rule.peak_threshold_db,353
+ rms_target_db: rule.rms_target_db,354
+ max_cut_db: rule.max_cut_db,355
+ peak_attack_ms: rule.peak_attack_ms,356
+ peak_release_ms: rule.peak_release_ms,357
+ rms_window_ms: rule.rms_window_ms,358
+ }359
+}360
+361
+fn anti_bounce_alpha(time_ms: f32, block_dt_s: f32) -> f32 {362
+ if block_dt_s <= 0.0 || time_ms <= 0.0 {363
+ return 1.0;364
+ }365
+ let block_rate = 1.0 / block_dt_s;366
+ headroom_dsp::util::time_to_alpha(time_ms, block_rate)367
+}368
+369
+fn lin_diff_db(a: f32, b: f32) -> f32 {370
+ let a = a.max(1e-6);371
+ let b = b.max(1e-6);372
+ (20.0 * (a / b).log10()).abs()373
+}374
+375
+#[cfg(test)]376
+mod tests {377
+ use super::*;378
+ use crate::profile::PerAppRule;379
+ use headroom_dsp::util::db_to_lin;380
+ use headroom_ipc::RouteRuleMatch;381
+382
+ /// 1024-frame quantum @ 48 kHz.383
+ const BLOCK_DT_S: f32 = 1024.0 / 48_000.0;384
+385
+ fn aggressive_rule() -> PerAppRule {386
+ PerAppRule {387
+ match_: RouteRuleMatch::default(),388
+ enabled: true,389
+ peak_threshold_db: -6.0,390
+ rms_target_db: -20.0,391
+ max_cut_db: 12.0,392
+ peak_attack_ms: 5.0,393
+ peak_release_ms: 500.0,394
+ rms_window_ms: 200.0, // shorter so tests converge395
+ smoother_ms: FALLBACK_SMOOTHER_MS,396
+ write_db_threshold: FALLBACK_WRITE_DB_THRESHOLD,397
+ min_write_interval_ms: FALLBACK_MIN_WRITE_INTERVAL_MS,398
+ defer_to_user: DeferPolicy::Ceiling,399
+ }400
+ }401
+402
+ fn playback_info(binary: &str) -> PwNodeInfo {403
+ PwNodeInfo {404
+ node_id: 1,405
+ media_class: Some("Stream/Output/Audio".into()),406
+ application_process_binary: Some(binary.into()),407
+ ..Default::default()408
+ }409
+ }410
+411
+ #[test]412
+ fn set_rule_swaps_thresholds_preserving_deference() {413
+ // reevaluate_layer_a pushes a profile's new rule into a live414
+ // controller via set_rule (no tap churn). The new thresholds415
+ // must take effect while user_ceiling / deferred state survive.416
+ let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);417
+ assert!((c.rule().peak_threshold_db - -6.0).abs() < 1e-6);418
+ // Establish a user ceiling (Ceiling policy) so we can check it419
+ // survives the rule swap.420
+ c.on_external_change(0.4);421
+ assert_eq!(c.user_ceiling_lin(), Some(0.4));422
+423
+ let mut new_rule = aggressive_rule();424
+ new_rule.peak_threshold_db = -18.0;425
+ new_rule.max_cut_db = 6.0;426
+ c.set_rule(new_rule);427
+428
+ assert!((c.rule().peak_threshold_db - -18.0).abs() < 1e-6);429
+ assert!((c.rule().max_cut_db - 6.0).abs() < 1e-6);430
+ // Runtime deference state is preserved across the swap.431
+ assert_eq!(c.user_ceiling_lin(), Some(0.4));432
+ }433
+434
+ #[test]435
+ fn disabled_rule_returns_no_write() {436
+ let mut rule = aggressive_rule();437
+ rule.enabled = false;438
+ let mut c = AppLevelController::new(rule, BLOCK_DT_S);439
+ let now = Instant::now();440
+ assert!(c.process_block(db_to_lin(0.0), 1.0, now).is_none());441
+ }442
+443
+ #[test]444
+ fn first_write_after_settling_emits_volume_below_unity() {445
+ let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);446
+ let now = Instant::now();447
+ // Drive a hot signal until the envelopes settle and the448
+ // anti-bounce smoother converges.449
+ let mut last = None;450
+ for i in 0..1000 {451
+ let t = now + Duration::from_millis(i as u64 * 21); // ~block_dt452
+ if let Some(v) = c.process_block(db_to_lin(0.0), db_to_lin(-3.0).powi(2), t) {453
+ last = Some(v);454
+ }455
+ }456
+ let v = last.expect("controller should issue at least one write");457
+ assert!(v < 1.0, "expected sub-unity volume, got {v}");458
+ assert!(v > 0.0);459
+ }460
+461
+ #[test]462
+ fn rate_limit_blocks_back_to_back_writes() {463
+ let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);464
+ let t0 = Instant::now();465
+ // Drive convergence first so a write happens.466
+ let mut wrote = false;467
+ for i in 0..200 {468
+ let t = t0 + Duration::from_millis(i as u64 * 21);469
+ if c.process_block(db_to_lin(0.0), db_to_lin(-3.0).powi(2), t).is_some() {470
+ wrote = true;471
+ break;472
+ }473
+ }474
+ assert!(wrote, "first write expected during convergence");475
+ // Immediately after the write, force a different reduction —476
+ // the rate limit must suppress any further write within 100 ms.477
+ let t1 = c.last_write_at.unwrap() + Duration::from_millis(10);478
+ c.smoothed_reduction_db += 6.0; // synthetic kick479
+ let v = c.process_block(db_to_lin(0.0), db_to_lin(-3.0).powi(2), t1);480
+ assert!(v.is_none(), "rate limit should have blocked the follow-up write");481
+ }482
+483
+ #[test]484
+ fn threshold_blocks_microscopic_changes() {485
+ // Strategy: drive the controller to a steady state at a486
+ // specific reduction, let it write, then nudge inputs by an487
+ // amount that produces a sub-`WRITE_DB_THRESHOLD` change at488
+ // the smoothed output. The threshold gate must suppress.489
+ let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);490
+ let t0 = Instant::now();491
+ // 0 dBFS peak → 6 dB cut requested by the peak path.492
+ let hot_peak = db_to_lin(0.0);493
+ let hot_mean_sq = db_to_lin(-3.0).powi(2);494
+495
+ // Burn in until convergence.496
+ let mut last_write_t = t0;497
+ for i in 0..2_000 {498
+ let t = t0 + Duration::from_millis(i as u64 * 21);499
+ if c.process_block(hot_peak, hot_mean_sq, t).is_some() {500
+ last_write_t = t;501
+ }502
+ }503
+ // Move past the rate limit window so the threshold is the only504
+ // active gate, then feed an essentially-identical input. The505
+ // smoothed reduction barely budges, so the dB diff against506
+ // last_written_lin must stay under WRITE_DB_THRESHOLD.507
+ let t_after = last_write_t + Duration::from_millis(500);508
+ let v = c.process_block(hot_peak * 1.001, hot_mean_sq * 1.001, t_after);509
+ assert!(510
+ v.is_none(),511
+ "near-identical input should fall inside the threshold dead band, got {v:?}"512
+ );513
+ }514
+515
+ #[test]516
+ fn ceiling_mode_caps_target_at_user_value() {517
+ let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);518
+ // User pulls the slider down to 0.6 externally.519
+ c.on_external_change(0.6);520
+ assert_eq!(c.user_ceiling_lin(), Some(0.6));521
+ let mut last = None;522
+ let t0 = Instant::now();523
+ // No signal yet — proposed reduction is 0 → target is unity →524
+ // but ceiling forces it down to 0.6 → expect a write below525
+ // unity even with no detection activity.526
+ for i in 0..400 {527
+ let t = t0 + Duration::from_millis(i as u64 * 21);528
+ if let Some(v) = c.process_block(0.0, 0.0, t) {529
+ last = Some(v);530
+ }531
+ }532
+ let v = last.expect("should write at least once to reach ceiling");533
+ assert!((v - 0.6).abs() < 0.01, "expected ~0.6, got {v}");534
+ }535
+536
+ #[test]537
+ fn strict_mode_stops_writes_after_external_change() {538
+ let mut rule = aggressive_rule();539
+ rule.defer_to_user = DeferPolicy::Strict;540
+ let mut c = AppLevelController::new(rule, BLOCK_DT_S);541
+ c.on_external_change(0.7);542
+ assert!(c.deferred());543
+ let t = Instant::now();544
+ // Drive a hot signal — strict deference must not write.545
+ for i in 0..400 {546
+ let t = t + Duration::from_millis(i as u64 * 21);547
+ assert!(c548
+ .process_block(db_to_lin(0.0), db_to_lin(-3.0).powi(2), t)549
+ .is_none());550
+ }551
+ }552
+553
+ #[test]554
+ fn reset_deference_clears_strict_lock() {555
+ let mut rule = aggressive_rule();556
+ rule.defer_to_user = DeferPolicy::Strict;557
+ let mut c = AppLevelController::new(rule, BLOCK_DT_S);558
+ c.on_external_change(0.7);559
+ assert!(c.deferred());560
+ c.reset_deference();561
+ assert!(!c.deferred());562
+ assert!(c.user_ceiling_lin().is_none());563
+ }564
+565
+ #[test]566
+ fn ignores_external_change_that_matches_our_write() {567
+ let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);568
+ c.last_written_lin = 0.5;569
+ c.on_external_change(0.5);570
+ // Should not register as external — no ceiling, no defer.571
+ assert!(c.user_ceiling_lin().is_none());572
+ assert!(!c.deferred());573
+ }574
+575
+ #[test]576
+ fn reenable_unity_baseline_echo_does_not_lock_ceiling() {577
+ // On per-app re-enable, the registry writes a unity baseline578
+ // BEFORE subscribing to Props (spawn_layer_a). A fresh579
+ // controller's `last_written_lin` is 1.0, so the initial echo580
+ // of that baseline is recognised as our own write — no spurious581
+ // ceiling, no deference, so management restarts cleanly instead582
+ // of hanging at the previous session's value.583
+ let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);584
+ assert!((c.last_written_lin() - 1.0).abs() < 1e-6);585
+ c.on_external_change(1.0);586
+ assert!(c.user_ceiling_lin().is_none());587
+ assert!(!c.deferred());588
+ }589
+590
+ #[test]591
+ fn stale_attenuation_echo_without_baseline_locks_ceiling() {592
+ // Documents *why* the baseline write matters: a fresh controller593
+ // can't tell the node's stale value (the daemon's own prior cut,594
+ // left over from a just-torn-down session) from a real user595
+ // gesture, so an un-suppressed 0.5 echo locks a bogus 0.5596
+ // ceiling — exactly the "value hangs until I tweak it" symptom597
+ // the spawn-time unity write prevents.598
+ let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);599
+ c.on_external_change(0.5);600
+ assert_eq!(c.user_ceiling_lin(), Some(0.5));601
+ }602
+603
+ #[test]604
+ fn delayed_echo_of_earlier_write_is_not_a_user_ceiling() {605
+ // Regression for the self-lock bug: the controller wrote 0.31,606
+ // then 0.35; the echo of the *earlier* 0.31 arrives after607
+ // last_written has moved to 0.35. With only a single608
+ // last_written comparison, 0.31 would be misread as a user-set609
+ // ceiling and (sitting below the content's natural target)610
+ // permanently clamp the stream. The recent-writes window611
+ // recognises 0.31 as ours.612
+ let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);613
+ c.note_write(0.31);614
+ c.last_written_lin = 0.31;615
+ c.note_write(0.35);616
+ c.last_written_lin = 0.35;617
+ c.on_external_change(0.31);618
+ assert!(619
+ c.user_ceiling_lin().is_none(),620
+ "a delayed echo of our own write must not become a ceiling"621
+ );622
+ assert!(!c.deferred());623
+ }624
+625
+ #[test]626
+ fn genuine_user_change_after_writes_still_registers() {627
+ // A value the controller never wrote is a real user action and628
+ // must still take effect, even with a full write history.629
+ let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);630
+ for v in [0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.35, 0.31] {631
+ c.note_write(v);632
+ c.last_written_lin = v;633
+ }634
+ // 0.55 was never written.635
+ c.on_external_change(0.55);636
+ assert_eq!(c.user_ceiling_lin(), Some(0.55));637
+ }638
+639
+ // -----------------------------------------------------------------640
+ // Gain compensation + silent ticks641
+ // -----------------------------------------------------------------642
+643
+ /// Run enough warm-up blocks (with the given input) to converge the644
+ /// envelopes + smoother, returning the controller in steady state.645
+ fn warm_to_steady(646
+ c: &mut AppLevelController,647
+ peak: f32,648
+ mean_sq: f32,649
+ start: Instant,650
+ ) -> Instant {651
+ let mut t = start;652
+ for _ in 0..2_000 {653
+ let _ = c.process_block(peak, mean_sq, t);654
+ t += Duration::from_millis(21);655
+ }656
+ t657
+ }658
+659
+ #[test]660
+ fn gain_compensation_recovers_pre_attenuation_signal() {661
+ // Source true peak = 0 dBFS, applied gain = 0.5 (so the tap662
+ // would measure 0.5). With compensation enabled, the envelope663
+ // must see the pre-attenuation 0 dBFS — i.e. the controller's664
+ // computed reduction must match what an uncompensated 0 dBFS665
+ // input produces on a fresh controller.666
+ let mut compensated = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);667
+ compensated.last_written_lin = 0.5; // simulate prior write668
+ let _ = warm_to_steady(&mut compensated, 0.5, 0.5_f32.powi(2), Instant::now());669
+670
+ let mut baseline = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);671
+ // No prior write — compensation is a no-op since last_written = 1.0.672
+ let _ = warm_to_steady(&mut baseline, 1.0, 1.0, Instant::now());673
+674
+ let diff = (compensated.smoothed_reduction_db - baseline.smoothed_reduction_db).abs();675
+ assert!(676
+ diff < 0.1,677
+ "compensated controller should see the same effective signal as the baseline at full scale; \678
+ compensated={}, baseline={}",679
+ compensated.smoothed_reduction_db,680
+ baseline.smoothed_reduction_db,681
+ );682
+ }683
+684
+ #[test]685
+ fn gain_compensation_disabled_below_floor() {686
+ // last_written_lin below GAIN_COMPENSATION_FLOOR → compensation687
+ // must NOT amplify. Feed a small post-attenuation peak that688
+ // would blow up to clipping if divided by 0.005, and verify689
+ // the envelopes don't spike accordingly.690
+ let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);691
+ c.last_written_lin = 0.005; // below GAIN_COMPENSATION_FLOOR692
+ let now = Instant::now();693
+ let _ = warm_to_steady(&mut c, 0.01, 0.01_f32.powi(2), now);694
+ // Without compensation, 0.01 peak = −40 dB, well under the695
+ // −6 dB threshold → smoothed reduction stays ≈ 0.696
+ assert!(697
+ c.smoothed_reduction_db < 1.0,698
+ "below-floor compensation should pass-through; got {} dB",699
+ c.smoothed_reduction_db700
+ );701
+ }702
+703
+ #[test]704
+ fn tick_silent_is_noop_with_recent_measurement() {705
+ let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);706
+ let t = Instant::now();707
+ // Establish a recent measurement.708
+ c.process_block(0.5, 0.25, t);709
+ // tick_silent within the block window must be a no-op710
+ // (returns None, smoothed_reduction unchanged).711
+ let before = c.smoothed_reduction_db;712
+ let out = c.tick_silent(t + Duration::from_millis(1));713
+ assert!(out.is_none());714
+ assert!((c.smoothed_reduction_db - before).abs() < 1e-6);715
+ }716
+717
+ #[test]718
+ fn tick_silent_is_noop_without_prior_measurement() {719
+ // Controller has never seen a real measurement → no idea what720
+ // wall-clock the envelopes are anchored to → must skip.721
+ let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);722
+ let now = Instant::now();723
+ let out = c.tick_silent(now + Duration::from_secs(60));724
+ assert!(out.is_none());725
+ }726
+727
+ #[test]728
+ fn tick_silent_releases_envelope_over_extended_idle() {729
+ // Aggressive max_cut + gain compensation pegs both paths at730
+ // the cap during full-scale input, so a few hundred ms of731
+ // release isn't enough — the RMS envelope sees the732
+ // compensation-amplified mean_sq and takes ~rms_window × 4–5733
+ // to drop below threshold. Use a multi-second idle that734
+ // matches Strawberry's actual between-track pause.735
+ let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);736
+ let t = Instant::now();737
+ let after_warm = warm_to_steady(&mut c, 1.0, 1.0, t);738
+ let reduced = c.smoothed_reduction_db;739
+ assert!(reduced > 1.0, "expected sustained reduction, got {reduced}");740
+741
+ let _ = c.tick_silent(after_warm + Duration::from_secs(3));742
+ assert!(743
+ c.smoothed_reduction_db < reduced - 0.5,744
+ "tick_silent should release the envelope; before={reduced}, after={}",745
+ c.smoothed_reduction_db746
+ );747
+ }748
+749
+ #[test]750
+ fn tick_silent_long_idle_short_circuits_to_full_release() {751
+ let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);752
+ let t = Instant::now();753
+ let after_warm = warm_to_steady(&mut c, 1.0, 1.0, t);754
+ assert!(c.smoothed_reduction_db > 1.0);755
+756
+ // > MAX_SILENT_CATCHUP_BLOCKS × block_dt of silence triggers757
+ // the reset shortcut.758
+ let long_gap = Duration::from_secs_f32(759
+ (MAX_SILENT_CATCHUP_BLOCKS as f32 + 100.0) * BLOCK_DT_S,760
+ );761
+ let _ = c.tick_silent(after_warm + long_gap);762
+ assert!(763
+ c.smoothed_reduction_db.abs() < 1e-6,764
+ "long silence should reset envelopes; got {}",765
+ c.smoothed_reduction_db766
+ );767
+ }768
+769
+ #[test]770
+ fn tick_silent_writes_volume_back_up_when_envelope_releases() {771
+ // Setup: signal was loud, controller wrote a reduced volume.772
+ // Source then pauses indefinitely. After enough silent ticks,773
+ // smoothed_reduction → 0 and the controller should write774
+ // back up toward unity (or user_ceiling). Idle is measured775
+ // since the last *process_block call*, not the last write —776
+ // in steady state the controller keeps consuming measurements777
+ // but stops writing once target == last_written.778
+ let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);779
+ let t = Instant::now();780
+ let mut last_block_t = t;781
+ for i in 0..2_000 {782
+ let bt = t + Duration::from_millis(i as u64 * 21);783
+ let _ = c.process_block(1.0, 1.0, bt);784
+ last_block_t = bt;785
+ }786
+ let written = c.last_written_lin;787
+ assert!(788
+ written < 1.0,789
+ "controller should have written sub-unity volume during convergence; got {written}"790
+ );791
+792
+ // Long silence → reset shortcut → envelopes at zero → target793
+ // computes to 1.0. Past the rate-limit window so the write794
+ // can fire.795
+ let later = last_block_t + Duration::from_secs(20);796
+ let out = c.tick_silent(later);797
+ let v = out.expect("tick_silent should fire a write back toward unity");798
+ assert!(799
+ v > written,800
+ "tick_silent write must raise volume; before={written}, after={v}"801
+ );802
+ assert!((v - 1.0).abs() < 0.05, "expected ~1.0, got {v}");803
+ }804
+805
+ #[test]806
+ fn restore_state_seeds_ceiling_and_suppresses_first_echo() {807
+ // Simulates the spawn path: the new managed_stream restores808
+ // a persisted ceiling, then the param listener fires with809
+ // the (now-overwritten) channelVolumes. The echo must be810
+ // recognized as ours, not misattributed as a user change.811
+ let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);812
+ let now = Instant::now();813
+ c.restore_state(0.7, now);814
+ assert_eq!(c.user_ceiling_lin(), Some(0.7));815
+ assert!((c.last_written_lin() - 0.7).abs() < 1e-6);816
+ // Now simulate PipeWire echoing the just-written 0.7.817
+ c.on_external_change(0.7);818
+ // Ceiling must not change; the echo was recognized.819
+ assert_eq!(c.user_ceiling_lin(), Some(0.7));820
+ assert!(!c.deferred());821
+ }822
+823
+ #[test]824
+ fn restore_state_does_not_block_genuine_user_changes_afterwards() {825
+ let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);826
+ c.restore_state(0.7, Instant::now());827
+ // User actually adjusts to 0.5 in pavucontrol.828
+ c.on_external_change(0.5);829
+ assert_eq!(c.user_ceiling_lin(), Some(0.5));830
+ }831
+832
+ #[test]833
+ fn restore_state_clamps_out_of_range_inputs() {834
+ let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);835
+ c.restore_state(1.5, Instant::now());836
+ assert!((c.last_written_lin() - 1.0).abs() < 1e-6);837
+ let mut c2 = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);838
+ c2.restore_state(-0.2, Instant::now());839
+ assert!((c2.last_written_lin() - 0.0).abs() < 1e-6);840
+ }841
+842
+ #[test]843
+ fn tick_silent_respects_user_ceiling() {844
+ // Same as above but with a user ceiling set; after release the845
+ // controller must clamp the write at the ceiling.846
+ let mut c = AppLevelController::new(aggressive_rule(), BLOCK_DT_S);847
+ let t = Instant::now();848
+ c.on_external_change(0.5); // user_ceiling = 0.5849
+ let mut last_block_t = t;850
+ for i in 0..2_000 {851
+ let bt = t + Duration::from_millis(i as u64 * 21);852
+ let _ = c.process_block(1.0, 1.0, bt);853
+ last_block_t = bt;854
+ }855
+ // Long silence — envelopes release.856
+ let later = last_block_t + Duration::from_secs(20);857
+ if let Some(v) = c.tick_silent(later) {858
+ assert!(859
+ (v - 0.5).abs() < 0.01,860
+ "tick_silent write must clamp at user_ceiling; got {v}"861
+ );862
+ }863
+ // After release, last_written must be at the ceiling (whether864
+ // via the write above or because steady state already pinned865
+ // it there).866
+ assert!(867
+ (c.last_written_lin - 0.5).abs() < 0.01,868
+ "expected last_written ≈ 0.5, got {}",869
+ c.last_written_lin870
+ );871
+ }872
+873
+ // -----------------------------------------------------------------874
+ // Rule matching875
+ // -----------------------------------------------------------------876
+877
+ #[test]878
+ fn evaluate_returns_master_off_when_layer_a_disabled() {879
+ let per_app = PerAppSection {880
+ enabled: false,881
+ ..Default::default()882
+ };883
+ assert_eq!(884
+ evaluate(&playback_info("firefox"), &per_app),885
+ LayerAEval::MasterOff886
+ );887
+ }888
+889
+ #[test]890
+ fn evaluate_returns_matching_rule() {891
+ let per_app = PerAppSection {892
+ enabled: true,893
+ default_enabled: false,894
+ rules: vec![PerAppRule {895
+ match_: RouteRuleMatch {896
+ process_binary: vec!["firefox".into()],897
+ ..Default::default()898
+ },899
+ ..aggressive_rule()900
+ }],901
+ };902
+ let r = evaluate(&playback_info("firefox"), &per_app)903
+ .rule()904
+ .expect("match");905
+ assert_eq!(r.peak_threshold_db, aggressive_rule().peak_threshold_db);906
+ }907
+908
+ #[test]909
+ fn evaluate_returns_rule_disabled_for_disabled_matching_rule() {910
+ let per_app = PerAppSection {911
+ enabled: true,912
+ default_enabled: false,913
+ rules: vec![PerAppRule {914
+ match_: RouteRuleMatch {915
+ process_binary: vec!["spotify".into()],916
+ ..Default::default()917
+ },918
+ enabled: false,919
+ ..aggressive_rule()920
+ }],921
+ };922
+ assert_eq!(923
+ evaluate(&playback_info("spotify"), &per_app),924
+ LayerAEval::RuleDisabled925
+ );926
+ }927
+928
+ #[test]929
+ fn evaluate_returns_default_rule_when_default_enabled_and_no_match() {930
+ let per_app = PerAppSection {931
+ enabled: true,932
+ default_enabled: true,933
+ rules: vec![],934
+ };935
+ let r = evaluate(&playback_info("unmatched"), &per_app)936
+ .rule()937
+ .expect("default");938
+ // Default rule honours LevelEnvelopesConfig::default().939
+ let cfg = LevelEnvelopesConfig::default();940
+ assert!((r.peak_threshold_db - cfg.peak_threshold_db).abs() < 1e-6);941
+ assert_eq!(r.defer_to_user, DeferPolicy::default());942
+ }943
+944
+ #[test]945
+ fn evaluate_returns_no_match_for_unmatched_when_default_off() {946
+ let per_app = PerAppSection {947
+ enabled: true,948
+ default_enabled: false,949
+ rules: vec![],950
+ };951
+ assert_eq!(952
+ evaluate(&playback_info("unmatched"), &per_app),953
+ LayerAEval::NoMatch954
+ );955
+ }956
+957
+ #[test]958
+ fn spawn_predicate_matches_only_spawn_variant() {959
+ // Mirrors the reconciliation predicate in `RoutingState`:960
+ // `matches!(evaluate(..), LayerAEval::Spawn(_))` is the single961
+ // gate that decides whether a known-but-unmanaged stream gets a962
+ // tap. Confirm it's true exactly when a controller should run.963
+ let per_app = PerAppSection {964
+ enabled: true,965
+ default_enabled: false,966
+ rules: vec![PerAppRule {967
+ match_: RouteRuleMatch {968
+ process_binary: vec!["firefox".into()],969
+ ..Default::default()970
+ },971
+ ..aggressive_rule()972
+ }],973
+ };974
+ let should_manage = |info: &PwNodeInfo| {975
+ matches!(evaluate(info, &per_app), LayerAEval::Spawn(_))976
+ };977
+ assert!(should_manage(&playback_info("firefox")));978
+ assert!(!should_manage(&playback_info("unmatched")));979
+ }980
+981
+ #[test]982
+ fn skip_reason_strings_are_distinct() {983
+ assert_eq!(LayerAEval::MasterOff.skip_reason(), "per_app master disabled");984
+ assert_eq!(LayerAEval::NoMatch.skip_reason(), "no matching rule");985
+ assert_eq!(LayerAEval::RuleDisabled.skip_reason(), "matching rule disabled");986
+ assert_eq!(LayerAEval::NotPlayback.skip_reason(), "not a routable playback stream");987
+ }988
+989
+ #[test]990
+ fn evaluate_skips_non_playback_streams() {991
+ let mut info = playback_info("firefox");992
+ info.media_class = Some("Stream/Input/Audio".into());993
+ let per_app = PerAppSection {994
+ enabled: true,995
+ default_enabled: true,996
+ rules: vec![],997
+ };998
+ assert_eq!(evaluate(&info, &per_app), LayerAEval::NotPlayback);999
+ }1000
+}@@ -171,6 +171,8 @@ pub struct RoutingState {
171171
/// slow AGC controller, so a rebuild can rebind it. shared with the172172
/// AGC timer in `runtime`; the main loop serialises borrow_mut.173173
agc_controller: Option<Rc<RefCell<crate::agc::AgcController>>>,174
+ /// throttles tap format-error logs off the 5 ms drain175
+ layer_a_drain_count: u64,174176
}175177
176178
/// per-stream Layer A bundle: tap + controller + measurement consumer.@@ -193,6 +195,8 @@ struct ManagedStream {
193195
#[allow(dead_code)]194196
links: Vec<Link>,195197
app_label: String,198
+ /// last tap format-error total logged for this stream199
+ last_logged_format_errors: u64,196200
}197201
198202
impl RoutingState {@@ -227,6 +231,7 @@ impl RoutingState {
227231
bus_filter: None,228232
bus_graph_active: false,229233
agc_controller: None,234
+ layer_a_drain_count: 0,230235
}231236
}232237
@@ -1107,6 +1112,7 @@ impl RoutingState {
11071112
node_listener,11081113
links: Vec::new(),11091114
app_label: app_label.to_owned(),1115
+ last_logged_format_errors: 0,11101116
},11111117
);11121118
tracing::info!(node_id, app = app_label, "Layer A tap spawned");@@ -1254,6 +1260,11 @@ impl RoutingState {
12541260
pub fn drain_layer_a(&mut self, back: &Rc<RefCell<Self>>) {12551261
self.attempt_pending_links();12561262
1263
+ // 5 ms drain cadence -> about 1 hz1264
+ const FORMAT_ERR_LOG_EVERY: u64 = 200;1265
+ self.layer_a_drain_count = self.layer_a_drain_count.wrapping_add(1);1266
+ let log_format_errors = self.layer_a_drain_count % FORMAT_ERR_LOG_EVERY == 0;1267
+12571268
// collect meter events to emit after the iter_mut borrow drops12581269
// (broadcaster is behind the daemon mutex; avoid nested borrows).12591270
let mut meters: Vec<(u32, String, f32, f32)> = Vec::new();@@ -1298,6 +1309,21 @@ impl RoutingState {
12981309
}12991310
}13001311
1312
+ if log_format_errors {1313
+ let total = managed.tap.format_error_count();1314
+ let new = total.saturating_sub(managed.last_logged_format_errors);1315
+ if new > 0 {1316
+ managed.last_logged_format_errors = total;1317
+ tracing::warn!(1318
+ source = source_node_id,1319
+ app = %managed.app_label,1320
+ new_format_errors = new,1321
+ total_format_errors = total,1322
+ "Layer A tap skipped short/misaligned audio buffer(s)"1323
+ );1324
+ }1325
+ }1326
+13011327
snapshots.push(LayerASnapshot {13021328
node_id: source_node_id,13031329
app: managed.app_label.clone(),@@ -1876,6 +1902,7 @@ fn install_param_listener(
18761902
let Some(new_volume) = extract_channel_volume(param) else {18771903
return;18781904
};1905
+ // same re-entrancy invariant as the metadata listener18791906
let mut state = back.borrow_mut();18801907
let Some(managed) = state.managed_streams.get_mut(&source_node_id) else {18811908
return;@@ -0,0 +1,243 @@
1
+//! per-app Layer A analysis tap. an input `pw_stream` siblings the2
+//! managed source; the rt callback computes per-block peak/mean_sq and3
+//! pushes a [`MeasurementSample`] into a per-tap rtrb for the daemon-side4
+//! controller. links are built explicitly by the registry watcher (see5
+//! the `connect` note below).6
+7
+use pipewire::{8
+ core::Core,9
+ keys,10
+ properties::properties,11
+ spa::{12
+ param::{13
+ audio::{AudioFormat, AudioInfoRaw},14
+ ParamType,15
+ },16
+ pod::{serialize::PodSerializer, Object, Pod, Value},17
+ utils::{Direction, SpaTypes},18
+ },19
+ stream::{Stream, StreamFlags, StreamListener},20
+};21
+use std::sync::atomic::{AtomicU64, Ordering};22
+use std::sync::Arc;23
+24
+use rtrb::{Consumer, Producer, RingBuffer};25
+26
+use crate::error::DaemonError;27
+28
+/// v0 stereo only.29
+const TAP_CHANNELS: u32 = 2;30
+31
+/// per-tap measurement ring, in [`MeasurementSample`]s. ~1.3 s at a32
+/// 21 ms quantum.33
+const TAP_RING_CAPACITY: usize = 64;34
+35
+/// one block's analysis output. 8 bytes; `Copy`.36
+#[derive(Debug, Clone, Copy)]37
+pub struct MeasurementSample {38
+ /// `max(|x|)`.39
+ pub peak: f32,40
+ /// `Σ(x²)/N`.41
+ pub mean_sq: f32,42
+}43
+44
+struct TapState {45
+ producer: Producer<MeasurementSample>,46
+ /// ring-full drops; harmless (controller time constants are seconds).47
+ drops: u64,48
+ /// rt format errors logged off-thread49
+ format_errors: Arc<AtomicU64>,50
+}51
+52
+/// one per-app Layer A tap. the explicit per-channel links are owned53
+/// by the `ManagedStream` wrapping this tap (see `pw::registry`).54
+pub struct StreamTap {55
+ stream: Stream,56
+ _listener: StreamListener<TapState>,57
+ source_node_id: u32,58
+ format_errors: Arc<AtomicU64>,59
+}60
+61
+impl StreamTap {62
+ /// spawn a tap on `source_node_id`.63
+ ///64
+ /// # Errors65
+ /// [`DaemonError::PipeWire`] on stream construction / connection66
+ /// failure.67
+ pub fn start(68
+ core: &Core,69
+ source_node_id: u32,70
+ ) -> Result<(Self, Consumer<MeasurementSample>), DaemonError> {71
+ let (producer, consumer) = RingBuffer::<MeasurementSample>::new(TAP_RING_CAPACITY);72
+ let format_errors = Arc::new(AtomicU64::new(0));73
+74
+ let node_name = format!("headroom-tap.{source_node_id}");75
+ let stream_name = format!("headroom-tap-{source_node_id}");76
+ let props = properties! {77
+ *keys::MEDIA_TYPE => "Audio",78
+ *keys::MEDIA_CATEGORY => "Capture",79
+ *keys::MEDIA_ROLE => "DSP",80
+ *keys::NODE_NAME => node_name.as_str(),81
+ *keys::NODE_DESCRIPTION => "Headroom Layer A analysis tap",82
+ *keys::NODE_DONT_RECONNECT => "true",83
+ "node.dont-move" => "true",84
+ };85
+ let stream = Stream::new(core, &stream_name, props)86
+ .map_err(|e| DaemonError::pipewire(format!("tap stream new: {e}")))?;87
+88
+ let listener = stream89
+ .add_local_listener_with_user_data(TapState {90
+ producer,91
+ drops: 0,92
+ format_errors: format_errors.clone(),93
+ })94
+ .process(tap_process)95
+ .state_changed(move |_stream_ref, _data, old, new| {96
+ tracing::debug!(97
+ source = source_node_id,98
+ ?old,99
+ ?new,100
+ "Layer A tap state change"101
+ );102
+ })103
+ .register()104
+ .map_err(|e| DaemonError::pipewire(format!("tap register: {e}")))?;105
+106
+ let format_bytes = build_format_pod_bytes()?;107
+ let format_pod = Pod::from_bytes(&format_bytes)108
+ .ok_or_else(|| DaemonError::pipewire("Pod::from_bytes"))?;109
+ let mut params: [&Pod; 1] = [format_pod];110
+ stream111
+ .connect(112
+ Direction::Input,113
+ // no target: WP won't wire `Stream/Output → Stream/Input`,114
+ // so passing the source id makes no link (confirmed via115
+ // pw-cli). connect still creates our input ports from the116
+ // declared format — what the registry watcher's explicit117
+ // link-factory step needs.118
+ None,119
+ StreamFlags::MAP_BUFFERS | StreamFlags::RT_PROCESS,120
+ &mut params,121
+ )122
+ .map_err(|e| DaemonError::pipewire(format!("tap connect: {e}")))?;123
+124
+ // without `AUTOCONNECT` the stream stays inactive after connect;125
+ // `set_active(true)` lifts Paused → Streaming once format locks126
+ // in (process only fires in Streaming).127
+ if let Err(e) = stream.set_active(true) {128
+ tracing::warn!(129
+ source = source_node_id,130
+ error = %e,131
+ "tap set_active failed; stream will stay Paused and no samples will flow"132
+ );133
+ }134
+135
+ tracing::info!(136
+ source = source_node_id,137
+ "Layer A tap stream connected to source; awaiting Streaming state"138
+ );139
+140
+ Ok((141
+ Self {142
+ stream,143
+ _listener: listener,144
+ source_node_id,145
+ format_errors,146
+ },147
+ consumer,148
+ ))149
+ }150
+151
+ /// node id of the *source* stream this tap observes.152
+ #[must_use]153
+ pub fn source_node_id(&self) -> u32 {154
+ self.source_node_id155
+ }156
+157
+ /// node id PipeWire assigned to *this* tap's stream; 0 until bound.158
+ #[must_use]159
+ pub fn tap_node_id(&self) -> u32 {160
+ self.stream.node_id()161
+ }162
+163
+ /// total rt format errors164
+ #[must_use]165
+ pub fn format_error_count(&self) -> u64 {166
+ self.format_errors.load(Ordering::Relaxed)167
+ }168
+}169
+170
+fn build_format_pod_bytes() -> Result<Vec<u8>, DaemonError> {171
+ // rate left unset (libspa omits SPA_FORMAT_AUDIO_rate when 0) so172
+ // PipeWire negotiates the source's rate. hardcoding 48 kHz would173
+ // wedge 44.1 kHz sources at Paused. block period varies with the174
+ // source quantum; the controller's alpha math handles it.175
+ let mut info = AudioInfoRaw::new();176
+ info.set_format(AudioFormat::F32LE);177
+ info.set_channels(TAP_CHANNELS);178
+ let obj = Object {179
+ type_: SpaTypes::ObjectParamFormat.as_raw(),180
+ id: ParamType::EnumFormat.as_raw(),181
+ properties: info.into(),182
+ };183
+ let bytes = PodSerializer::serialize(std::io::Cursor::new(Vec::new()), &Value::Object(obj))184
+ .map_err(|e| DaemonError::pipewire(format!("tap format pod: {e}")))?185
+ .0186
+ .into_inner();187
+ Ok(bytes)188
+}189
+190
+/// rt `process` callback. allocation-free, guarded by191
+/// [`assert_no_alloc::assert_no_alloc`] in debug builds.192
+fn tap_process(stream: &pipewire::stream::StreamRef, state: &mut TapState) {193
+ assert_no_alloc::assert_no_alloc(|| tap_process_inner(stream, state));194
+}195
+196
+fn tap_process_inner(stream: &pipewire::stream::StreamRef, state: &mut TapState) {197
+ let Some(mut buffer) = stream.dequeue_buffer() else {198
+ return;199
+ };200
+ let datas = buffer.datas_mut();201
+ let Some(data) = datas.first_mut() else {202
+ return;203
+ };204
+ let n_bytes = data.chunk().size() as usize;205
+ if n_bytes == 0 {206
+ return;207
+ }208
+ let Some(byte_slice) = data.data() else {209
+ return;210
+ };211
+ // chunk size is producer metadata; clamp before slicing212
+ let n = n_bytes.min(byte_slice.len());213
+ let samples: &[f32] = match bytemuck::try_cast_slice::<u8, f32>(&byte_slice[..n]) {214
+ Ok(s) => s,215
+ Err(_) => {216
+ // count on the rt path; log off-thread217
+ state.format_errors.fetch_add(1, Ordering::Relaxed);218
+ return;219
+ }220
+ };221
+ if samples.is_empty() {222
+ return;223
+ }224
+ let mut peak = 0.0_f32;225
+ let mut sumsq = 0.0_f32;226
+ for &s in samples {227
+ let a = s.abs();228
+ if a > peak {229
+ peak = a;230
+ }231
+ sumsq += s * s;232
+ }233
+ let mean_sq = sumsq / samples.len() as f32;234
+235
+ if state236
+ .producer237
+ .push(MeasurementSample { peak, mean_sq })238
+ .is_err()239
+ {240
+ // ring full — drop silently; a missed block is harmless.241
+ state.drops = state.drops.saturating_add(1);242
+ }243
+}