Drops the FILTER_SAMPLE_RATE const dependency from the filter's
creation path so the audio thread can run at whatever rate the
real sink negotiates, not unconditionally 48 kHz. Closes one of
the two output-edge resamples PLAN §3.1's F5 caveat called out
— content matching the real-sink rate now passes through the
limiter without an output-side resample elevating its true peaks.
Phase A (foundation)
- `Filter::create(core, init, sample_rate)` takes the rate as a
runtime parameter. `DEFAULT_SAMPLE_RATE` keeps 48 kHz as the
fallback constant; `FILTER_SAMPLE_RATE` is kept as a
back-compat alias.
- `build_format_pod_bytes(sample_rate)` parameterised so the SPA
Format the filter advertises matches the chosen rate.
- `FilterBundle.sample_rate` exposed so the AGC controller and
`runtime` can size their own state.
- New `LimiterConfig::sanitize_for_rate(sample_rate)` caps the
oversample factor so the internal (post-upsample) rate stays
≤ 192 kHz: 48 k base → 4× = 192 k; 96 k → 2× = 192 k; 192 k
→ 1× = 192 k. Keeps the FIR cost from doubling each time the
base rate doubles, with negligible loss of true-peak detection
quality at high base rates (the signal already has plenty of
bandwidth). Two regression tests lock the math in.
Phase B (data plumbing)
- `SinkInfo` (wire-level) gains an optional `sample_rate`
field. `headroom status` now reports the processed sink's
running rate and the real sink's native rate — useful for
debugging "did the daemon actually match my hardware?"
without resorting to `pw-link`.
- `state::RealSink.sample_rate` populated by the registry
watcher from two sources:
- The `audio.rate` property (many virtual sinks expose it).
- A `Format`-param listener bound to the real sink's `Node`
proxy (ALSA sinks only expose the rate in the negotiated
Format, not in their property dict). New
`install_real_sink_format_listener` mirrors the
channelVolumes-listener pattern Layer A already uses.
Listener cleaned up in `on_global_remove` when the real
sink departs.
- `state::DaemonState.filter_sample_rate` mirrors the bus
filter's currently-running rate; surfaced in `status`.
- Layer A's block-period constant becomes a runtime function
(`layer_a_block_dt_s(sample_rate)`) so 96 k / 192 k hardware
gets correctly-scaled controller time-constants.
Known gap: filter created at boot uses whatever rate is known at
that moment. For ALSA sinks the Format listener fires ~tens of ms
*after* the registry capture — by which time the filter is
already created at the fallback rate. The next commit (Phase C)
rebuilds the filter when the listener delivers a rate different
from what the filter is running at.
Verified
- 191 tests pass (was 189; +2 for the new
`sanitize_for_rate` cases); clippy clean at -D warnings
--all-targets.
- Live: cold-boot against a 48 kHz Mbox shows
`status.sinks.processed.sample_rate = 48000` +
`status.sinks.real.sample_rate = 48000`, daemon log records
"creating filter at real-sink-matched rate initial_rate=48000"
and "real sink Format negotiated; updating sample_rate
new_rate=48000" within ~55 ms of each other. For sinks where
`audio.rate` IS in props (some virtual sinks) the rate is
captured before filter creation.
Two self-review follow-ups from the F1/F2 commits surfaced by an
audio-correctness pass over `244367c..HEAD`. Both are low-risk,
high-signal fixes — the kind that prevent users complaining about
"weird little blips" when changing profiles or unmuting the
compressor.
## Audio-gap on `PwCommand::ReevaluateAll`
`enqueue_route` used to unconditionally drop
`managed_route_links[node_id]` before the next 50 ms drain tick
rebuilt. With `object.linger="false"` on the link-factory props,
dropping the `Link` proxy destroys the actual graph link
immediately. Result: every profile reload / route set / route
unset / bypass toggle caused a 21–42 ms audio dropout on every
already-correctly-routed stream — even when nothing about the
stream's routing had actually changed.
`managed_route_links` now carries the target sink name alongside
the `Link` proxies (new `ManagedRoute` struct: `target_sink_name`
+ `links`). `enqueue_route` only drops when the target name
differs from the stored one; the unchanged case leaves the live
links intact, and `apply_pending_routes`' destroy/create loop sees
its `want_set` already satisfied and exits as a no-op.
Live verification: pw-cat /tmp/sine streaming through processed,
issue `route set firefox bypass` (rule that doesn't touch pw-cat).
Before this fix the link IDs would flip; after, link IDs 83 + 122
stayed identical across `reevaluating all known streams streams=1`
in the daemon log. Listener-visible gap goes from one quantum to
zero.
The path that *does* change target (real bypass toggle, real-sink
hot-swap, a rule edit that flipped the stream's decision) still
drops + rebuilds — the gap there is unavoidable without a
core-sync barrier or a "transition through both old and new
links" choreography. That's acceptable: the user explicitly
asked for the route change in those cases.
## Compressor envelope reset across `enabled` transition
F6 made `compressor.enabled = false` actually skip processing,
but didn't touch the envelope or RMS state — which kept ticking
forward during enabled periods, sat stale during disabled
periods, and then bled out via release on the first re-enable.
With long release times this meant up to ~100 ms of artificial
gain reduction after switching from a `transparent` profile back
to a compressing one, for no acoustic reason.
`Compressor::set_config` now detects the `disabled → enabled`
transition and resets `envelope_db`, `rms_state`, and
`last_gr_db` so the compressor starts from a clean state — same
behaviour as a freshly-constructed `Compressor::new(...)`.
Same-enabled transitions (parameter tweaks while enabled, or
no-op `set_config` while disabled) leave the envelope alone, so
live tweaks still don't pop.
Regression test
`compressor::tests::enable_transition_resets_stale_envelope`
winds the envelope hot, toggles disable+enable via two
`set_config` calls, then asserts the next quiet sample produces
zero GR. Without the reset that assertion would fail by ~5+ dB.
## Verified
190 tests pass (+1 for the envelope reset; +0 for the link
fix — exercised by live-smoke since it's about side-effect
timing not value); clippy clean at `-D warnings --all-targets`.
The profile schema accepted `[compressor] enabled = false` (and the
`transparent` and `bypass-all` profiles set it) but the flag was
parsed and dropped — `build_compressor_config()` never threaded
it through to `CompressorConfig`, and `Compressor::process_frame`
had no enable branch. Result: the "compressor and AGC bypassed"
claim in `transparent.toml`'s description was a lie; the
compressor ran on every sample regardless of the profile knob.
Surfaced by Codex's review of the project.
Changes
- `headroom_dsp::CompressorConfig` gains `pub enabled: bool`
(default true). `Compressor::process_frame` early-returns
`(left, right)` and resets `last_gr_db = 0.0` when disabled,
so bus meters / `gain_reduction_db()` report the truthful
"compressor off" state instead of the stale last value.
- `headroom_core::profile::Profile::build_compressor_config`
threads `self.compressor.enabled` into the materialised
`CompressorConfig`. Live profile reload picks this up
automatically — the next `set_config` push from
`setting.set` / `profile.use` flips the audio thread.
- Regression unit test `disabled_compressor_passes_signal_through_unchanged`:
drive a -6 dBFS sine that would compress hard with enabled +
aggressive thresholds, assert output equals input exactly and
GR is zero.
What this does NOT change
- **Limiter has no `enabled` flag** and intentionally remains
always-on. It is the daemon's hard contract (the -0.1 dBTP
ceiling on the processed route, advertised in the README and
in PLAN §3). Users who don't want limiting should route
bypass; the `bypass-all.toml` profile's own comment confirms
the limiter is "still configured as a fail-safe in case a
stream lands on the processed sink anyway."
Verified
186 tests pass (+1 for the disable path); clippy clean at
-D warnings --all-targets.
Live A/B against `pw-cat /tmp/sine` (-6 dBFS sine into
processed): default profile compresses at -4.5 dB GR;
`headroom profile use transparent` flips to 0.00 dB GR
exactly on the next meter tick.
Closes the last gap before Phase 5's monitor TUI: per-app meter
events already publish on the meters topic via the registry watcher;
bus-level DSP meters now also publish.
4g — Bus meters
headroom_core::meters::BusMetrics is an Arc<parking_lot::Mutex<...>>
snapshot owned by the playback callback (try_lock; skip on
contention) and read by the AGC controller on each 50 ms tick.
Carries: compressor GR, limiter total/soft/hard GR, true peak. The
AGC controller combines these with its ebur128 readings (momentary,
short-term, integrated) and the current smoothed AGC target, then
publishes a headroom_ipc::MeterTick on Topic::Meters.
Publish cadence honours profile.meters.publish_hz, capped at the
AGC tick rate (20 Hz). Lower publish_hz throttles to every Nth
tick.
Mode::I added to the AGC's EbuR128 so loudness_global() is
available without a second ebur128 instance. Bounded cost — a
histogram walk per call, <=20 Hz.
LUFS values are sanitised to a -200.0 dB floor via
finite_or_floor() — ebur128 returns -inf (not Err) for "no usable
measurement yet," and non-finite f32 can't survive JSON
serialisation (serde_json renders as null).
Housekeeping shipped alongside
headroom-client moved from [dependencies] to [dev-dependencies] in
headroom-core — it's only used inside ipc::server's tests. Verified
by full clippy + test run; production builds no longer pull it in.
Pre-existing clippy nits cleared (limiter.rs x5, app_level.rs,
ipc/ops.rs, pw/filter.rs). All field_reassign_with_default or
assign_op_pattern in test code; stage-6 commit ran clippy without
--all-targets so these slipped through.
Verified
178 tests passing (28 dsp + 48 dsp + 20 ipc + 106 core including
+2 new meters tests + 4 client). Clippy clean at default level with
-D warnings --all-targets.
Smoke test: monitor meters subscription receives 20 Hz MeterTick
events with the expected JSON shape (all fields finite).