Commit graph

5 commits

Author SHA1 Message Date
atagen
5c769a1226 fix: close audio-gap on unchanged ReevaluateAll; reset compressor on enable
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`.
2026-05-21 19:42:12 +10:00
atagen
03edb17180 F6: honour compressor.enabled in the DSP
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.
2026-05-21 18:19:32 +10:00
atagen
79e4baedd0 4g: bus meters publishing + housekeeping
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).
2026-05-21 10:29:38 +10:00
atagen
fcf421b94c stage 6: per-app 2026-05-20 23:49:58 +10:00
atagen
ca1910de60 stage 2 2026-05-19 16:33:09 +10:00