atagen

headroom

public
Star0
Watch0

Commit 249c28030ddd

tarball

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

4 files changed+1342-0
layer-a: per-app level control
Added file: crates/headroom-core/benches/app_level.rs+72 additions
@@ -0,0 +1,72 @@
1+//! microbench: one `AppLevelController::process_block` call
2+
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 gate
36+ // 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 takes
51+ // 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);
Added file: crates/headroom-core/src/app_level.rs+1000 additions
@@ -0,0 +1,1000 @@
1+//! per-app level control (layer a): pure controller logic, pipewire-free
2+
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 synthetic
13+// `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 amplify
19+/// floor noise ~1000×, peg max-cut, and lock at mute even after unmute. −40 dB is below any
20+/// 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 fully
24+/// 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 can
28+/// arrive after the controller wrote B (param events lag/reorder under 10 Hz writes), so A is
29+/// 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 lifetime
37+ 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 gate
44+ 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 the
58+ /// 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.rule
82+ }
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_lin
101+ }
102+
103+ #[must_use]
104+ pub fn deferred(&self) -> bool {
105+ self.deferred
106+ }
107+
108+ /// always `>= 0`; `0` means no cut.
109+ #[must_use]
110+ pub fn smoothed_reduction_db(&self) -> f32 {
111+ self.smoothed_reduction_db
112+ }
113+
114+ #[must_use]
115+ pub fn last_written_lin(&self) -> f32 {
116+ self.last_written_lin
117+ }
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 canonical
122+ 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 the
133+ /// source, so the tap reads post-attenuation. uncompensated, any reduction looks like the
134+ /// source got quieter → envelope releases → freezes gain at the user's slider. divide
135+ /// 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_FLOOR
156+ (peak_lin, mean_sq_lin)
157+ };
158+ let decision = self
159+ .envelopes
160+ .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 value
168+ 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_TOLERANCE
201+ || self
202+ .recent_writes
203+ .iter()
204+ .any(|&w| (w - v).abs() < ECHO_TOLERANCE)
205+ }
206+
207+ /// advance envelopes through silent periods, then decide once. without this, a suspended source
208+ /// (pw stops delivering buffers — Strawberry between tracks) leaves the envelopes pinned at the
209+ /// 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 normally
223+ }
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-circuit
227+ self.envelopes.reset();
228+ self.smoothed_reduction_db = 0.0;
229+ } else {
230+ // bounded by the branch above, so the u32 truncation is safe
231+ 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 pass
237+ 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 until
242+ /// [`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 per
265+ /// 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 locks
267+ /// 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::RuleDisabled
323+ };
324+ }
325+ }
326+ if per_app.default_enabled {
327+ return LayerAEval::Spawn(default_rule());
328+ }
329+ LayerAEval::NoMatch
330+}
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 converge
395+ 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 live
414+ // controller via set_rule (no tap churn). The new thresholds
415+ // 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 it
419+ // 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 the
448+ // 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_dt
452+ 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 kick
479+ 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 a
486+ // specific reduction, let it write, then nudge inputs by an
487+ // amount that produces a sub-`WRITE_DB_THRESHOLD` change at
488+ // 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 only
504+ // active gate, then feed an essentially-identical input. The
505+ // smoothed reduction barely budges, so the dB diff against
506+ // 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 below
525+ // 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!(c
548+ .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 baseline
578+ // BEFORE subscribing to Props (spawn_layer_a). A fresh
579+ // controller's `last_written_lin` is 1.0, so the initial echo
580+ // of that baseline is recognised as our own write — no spurious
581+ // ceiling, no deference, so management restarts cleanly instead
582+ // 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 controller
593+ // 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 user
595+ // gesture, so an un-suppressed 0.5 echo locks a bogus 0.5
596+ // ceiling — exactly the "value hangs until I tweak it" symptom
597+ // 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 after
607+ // last_written has moved to 0.35. With only a single
608+ // last_written comparison, 0.31 would be misread as a user-set
609+ // ceiling and (sitting below the content's natural target)
610+ // permanently clamp the stream. The recent-writes window
611+ // 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 and
628+ // 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 ticks
641+ // -----------------------------------------------------------------
642+
643+ /// Run enough warm-up blocks (with the given input) to converge the
644+ /// 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+ t
657+ }
658+
659+ #[test]
660+ fn gain_compensation_recovers_pre_attenuation_signal() {
661+ // Source true peak = 0 dBFS, applied gain = 0.5 (so the tap
662+ // would measure 0.5). With compensation enabled, the envelope
663+ // must see the pre-attenuation 0 dBFS — i.e. the controller's
664+ // computed reduction must match what an uncompensated 0 dBFS
665+ // 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 write
668+ 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 → compensation
687+ // must NOT amplify. Feed a small post-attenuation peak that
688+ // would blow up to clipping if divided by 0.005, and verify
689+ // 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_FLOOR
692+ 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 the
695+ // −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_db
700+ );
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-op
710+ // (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 what
720+ // 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 at
730+ // the cap during full-scale input, so a few hundred ms of
731+ // release isn't enough — the RMS envelope sees the
732+ // compensation-amplified mean_sq and takes ~rms_window × 4–5
733+ // to drop below threshold. Use a multi-second idle that
734+ // 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_db
746+ );
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 triggers
757+ // 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_db
766+ );
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 write
774+ // back up toward unity (or user_ceiling). Idle is measured
775+ // since the last *process_block call*, not the last write —
776+ // in steady state the controller keeps consuming measurements
777+ // 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 → target
793+ // computes to 1.0. Past the rate-limit window so the write
794+ // 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 restores
808+ // a persisted ceiling, then the param listener fires with
809+ // the (now-overwritten) channelVolumes. The echo must be
810+ // 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 the
845+ // 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.5
849+ 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 (whether
864+ // via the write above or because steady state already pinned
865+ // 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_lin
870+ );
871+ }
872+
873+ // -----------------------------------------------------------------
874+ // Rule matching
875+ // -----------------------------------------------------------------
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::MasterOff
886+ );
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::RuleDisabled
925+ );
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::NoMatch
954+ );
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 single
961+ // gate that decides whether a known-but-unmanaged stream gets a
962+ // 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+}
Modified file: crates/headroom-core/src/pw/registry.rs+27 additions
@@ -171,6 +171,8 @@ pub struct RoutingState {
171171 /// slow AGC controller, so a rebuild can rebind it. shared with the
172172 /// 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 drain
175+ 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 stream
199+ 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 hz
1264+ 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 drops
12581269 // (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 listener
18791906 let mut state = back.borrow_mut();
18801907 let Some(managed) = state.managed_streams.get_mut(&source_node_id) else {
18811908 return;
Added file: crates/headroom-core/src/pw/tap.rs+243 additions
@@ -0,0 +1,243 @@
1+//! per-app Layer A analysis tap. an input `pw_stream` siblings the
2+//! managed source; the rt callback computes per-block peak/mean_sq and
3+//! pushes a [`MeasurementSample`] into a per-tap rtrb for the daemon-side
4+//! controller. links are built explicitly by the registry watcher (see
5+//! 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 a
32+/// 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-thread
49+ format_errors: Arc<AtomicU64>,
50+}
51+
52+/// one per-app Layer A tap. the explicit per-channel links are owned
53+/// 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+ /// # Errors
65+ /// [`DaemonError::PipeWire`] on stream construction / connection
66+ /// 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 = stream
89+ .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+ stream
111+ .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 via
115+ // pw-cli). connect still creates our input ports from the
116+ // declared format — what the registry watcher's explicit
117+ // 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 locks
126+ // 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_id
155+ }
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 errors
164+ #[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) so
172+ // PipeWire negotiates the source's rate. hardcoding 48 kHz would
173+ // wedge 44.1 kHz sources at Paused. block period varies with the
174+ // 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+ .0
186+ .into_inner();
187+ Ok(bytes)
188+}
189+
190+/// rt `process` callback. allocation-free, guarded by
191+/// [`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 slicing
212+ 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-thread
217+ 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 state
236+ .producer
237+ .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+}