Commit graph

28 commits

Author SHA1 Message Date
atagen
591cac7662 tui: add profile picker 2026-05-24 19:20:32 +10:00
atagen
28aa099e80 profiles: ship an extended set of default profiles 2026-05-24 19:20:29 +10:00
atagen
c8c221ba45 fix: deserialize makeup_db = "auto" in profiles
`MakeupGain` was a derived `#[serde(untagged)]` enum, and an untagged
unit variant can't be deserialized from the string `"auto"` under the
`toml` crate (untagged matches unit variants against `null`, not a
name). So every profile carrying `makeup_db = "auto"` — including the
shipped default/night/speech — silently failed to parse and was
skipped by `scan_dir_into`, falling back to the builtin default.

Hand-roll Serialize/Deserialize: number → `Db`, case-insensitive
`"auto"` → `Auto`, bogus string is a hard error. Serializes back to a
lowercase `"auto"` token.
2026-05-24 19:20:25 +10:00
atagen
7797f60128 fix: further layer A (per-app) glitches 2026-05-24 18:12:31 +10:00
atagen
2978318019 fix: layer A freeze 2026-05-22 16:38:49 +10:00
atagen
efb0c0f746 fix: kill bus-filter tremolo via larger ring + scheduling hints
Soak testing surfaced a continuous per-quantum tremolo on the
processed audio path. `pw-cli info` on the filter streams showed
`clock.quantum-limit = "8192"` (frames) — exactly matching the
ring's 16 384-sample capacity. With max buffer == ring capacity
there is zero headroom: each callback the capture pushed a full
buffer's worth into a half-empty ring (dropping the overflow) and
the very next playback callback found the ring under-filled
(zero-filling the deficit). At steady state ~32 k samples/sec
were each dropped on capture and zero-filled on playback —
audible as ~23 Hz amplitude modulation on whatever was playing.

Confirmed by plumbing `samples_starved` / `samples_dropped` (which
existed in `PlaybackState` / `CaptureState` but were never read)
through a shared `PlaybackTiming` so the AGC tick can log per-tick
deltas. Both counters climbed in lockstep, both idle and active —
the lockstep being the signature of buffer-size > ring-size.

Mitigation:
  * Bump `RING_CAPACITY` 16 384 → 65 536 (4× the documented
    max buffer × CHANNELS). Adds ~340 ms average ring latency,
    which is bad for competitive gaming but acceptable as a
    hold-the-line measure.
  * Set `node.latency = "256/48000"` on both filter halves so
    PipeWire targets a small buffer. The hint is advisory and
    the system's quantum-limit ceiling still wins, but it costs
    nothing and helps on systems with less aggressive defaults.
  * Set `node.link-group` on both halves to the same value —
    standard for `module-loopback`'s paired-stream pattern.
  * Set `audio.rate` and `node.passive = true` on the processed
    sink so it runs at the real sink's rate and follows the same
    driver. Eliminates a redundant resampler at the monitor →
    filter boundary and lands every headroom node under one
    driver (confirmed via pw-top: all show as `+` followers of
    Mbox).
  * Re-order runtime startup so the initial sample rate is
    computed before either the processed sink or the filter is
    built; both now boot at the same rate.

What this does not do: fix the underlying architectural issue.
The two `pw_stream`s communicate via an rtrb ring with no
PipeWire-graph dependency edge between them, so producer/consumer
ordering within a quantum isn't enforced — the fix is to switch
to a single `pw_filter` node (input + output ports on the same
node, ordering by construction). That rewrite is the next move;
this commit just gets the soak unblocked.
2026-05-22 10:04:45 +10:00
atagen
716290c3bf fix(Q5): drop retain pre-pass in apply_route_overrides
Codex audit of 4c39ecd flagged that the `retain` step would silently
prune any base-profile rule whose single-field shape coincided with
the overlay's emit pattern. `materialize` is already stateless — it
reserialises the base profile fresh from `pick_base` on every call,
so overlay rules can't accumulate across consecutive `set_route`
calls. The retain bought nothing and risked dropping user-authored
rules; prepending alone makes the overlay win first-match iteration.

Adds a regression test that loads a user profile carrying a rule
whose shape matches what the overlay emits, then sets an override
for the same app and asserts the user rule survives alongside the
prepended overlay rules.
2026-05-21 21:30:06 +10:00
atagen
4c39ecd5d2 fix: route.set matches application_name too, not just process_binary
The `route.set <app> <route>` overlay used to emit a single
`RouteRule` keyed on `process_binary`. The matcher ANDs across
non-empty fields, so a stream that didn't advertise
`application.process.binary` would miss the rule even though
its `application.name` was a perfect match. pw-cat is the
canonical hit — it sets `application.name = "pw-cat"` and
`node.name = "pw-cat"` but leaves `process.binary` unset
entirely. The same goes for several Electron and Flatpak
wrappers where the wrapping process eats the binary name.

`apply_route_overrides` now emits TWO rules per override, one
keyed on each identity field, with the same route. PipeWire
iterates rules in order and returns on first match, so the
effect is an OR across `process_binary` and `application_name`
for the single override — exactly the "match by whatever name
the stream advertises" intent of the CLI verb.

Why two rules and not "loosen the matcher to OR these two
fields": the matcher's AND-across-fields is load-bearing for
profile-author rules like `{process_binary: ["firefox"],
media_role: ["voice"]}` (match firefox-with-voice-role only).
Loosening the matcher would silently break those. Two
single-field rules with the same route preserve the original
semantics and add zero risk.

`is_single_app_rule_for_any` (the retain pre-pass that drops
old override rules before re-emitting) extends to recognise
the application_name-only variant too, so re-setting or
unsetting an override leaves no residual rules.

Tests
  - `profile_store::tests::set_route_emits_both_process_binary_and_application_name_rules`
    asserts both variants exist after `set_route`.
  - `profile_store::tests::set_route_then_unset_leaves_no_residual_rules`
    catches the matching retain-pre-pass regression that would
    have leaked rules on unset.
  - `routing::tests::application_name_only_rule_matches_stream_with_no_process_binary`
    proves a stream with `application.name = "pw-cat"` and no
    `process.binary` actually matches the application_name-keyed
    rule path. 194 tests pass (was 191; +3 for the new
    coverage); clippy clean.

Live verification

  Daemon up, pw-cat → headroom-processed (default rule).
  `headroom route set pw-cat bypass`: pw-cat's link snaps to
  `Mbox:playback_FL` within one drain tick (~50 ms); status
  reports `route: bypass`. Layer A tap survives the transition
  intact. `headroom route unset pw-cat`: snaps back to
  `headroom-processed:playback_FL`. Both transitions are
  audibly clean against the F2 audio-gap mitigation from
  `5c769a1`.
2026-05-21 21:12:23 +10:00
atagen
ab02df23fe filter rate matching C: live rebuild when real-sink rate changes
Closes the cold-boot + hot-swap gap A+B left open. When the real
sink's Format-param listener fires with a rate that doesn't match
the filter's currently-running rate, the daemon now rebuilds the
filter atomically and rebinds the slow AGC controller to the new
measurement ring + FilterControl.

What triggers a rebuild
  - Cold-boot against an ALSA sink. `audio.rate` isn't in the
    props dict, so the registry-capture path falls back to 48 kHz
    and creates the filter at that rate. Tens of ms later the
    Format listener fires with the real rate (say 96 kHz). If
    different from the filter's current rate, post
    `PwCommand::RebuildFilter`.
  - Hot-swap. User runs `wpctl set-default <other-sink>` and the
    new sink has a different native rate. `adopt_new_real_sink`
    swaps the Format listener; the next param event from the new
    node's negotiated Format triggers the same rebuild path.

What the rebuild does
  - Snapshots `FilterInit` from the active profile under the
    daemon lock, then drops the lock before touching PipeWire.
  - Drops the old `Filter` (RAII tears down the two pw_streams
    + their listeners), then calls `Filter::create` at the new
    rate. ~50–100 ms audio gap on the processed path during the
    swap.
  - Updates `daemon.filter_control` + `daemon.filter_sample_rate`
    under the lock.
  - `AgcController::rebind(new_consumer, new_control, new_rate)`
    swaps the AGC's view atomically and rebuilds its `ebur128`
    instance at the new rate.
  - Runs `reevaluate_all` so any explicit links anchored at the
    old filter's now-gone ports get re-pinned to the new
    processed-sink ports on the next drain tick.

Plumbing
  - New `PwCommand::RebuildFilter { sample_rate }`.
  - `RoutingState` gains `bus_filter: Option<Filter>` (filter
    ownership moves from `runtime::run`'s local into routing
    state so the registry thread can swap it) and
    `agc_controller: Option<Rc<RefCell<AgcController>>>` so the
    rebuild can call `rebind` on the slow loop.
  - `RoutingState::install_filter_rebuild_handles` is called once
    from `runtime` after `start_routing` + `AgcController::new`.
  - `PwContext::routing_state()` accessor exposes the
    `Rc<RefCell<RoutingState>>` so runtime can install the
    handles without threading them through `start_routing`'s
    signature.
  - The Format listener computes `need_rebuild = filter_sample_rate
    != Some(new_rate)` under the daemon lock, then sends the
    `RebuildFilter` command on `daemon.pw_command_tx` if needed.

What doesn't change
  - Steady-state: when the daemon boots and the rate hasn't
    moved, no rebuild fires. The no-rebuild path is the common
    case for users whose hardware is 48 kHz native; nothing about
    their setup gets touched.
  - Layer A taps: orthogonal to the bus path. The rebuild doesn't
    touch `managed_streams`; existing taps keep their links.

Verified

  - 191 tests still pass; clippy clean.
  - Cold-boot against the dev Mbox (48 kHz native): filter
    creates at 48 k, Format listener fires ~22 ms later
    detecting 48 k → `need_rebuild = false` → no rebuild posted.
    Status reports `processed.sample_rate = 48000`. The
    no-rebuild path is the one most users will hit.
  - Live rebuild against a non-48 kHz sink: not exercised in
    this commit (I can't reliably fabricate a non-48 kHz null
    sink via `pw-cli load-module` in the shell — same limitation
    8d hit). The user's 96 kHz motherboard, once they activate
    its card profile and set it as default, is the next test
    target.
2026-05-21 20:51:11 +10:00
atagen
86d00c43d1 filter rate matching A+B: runtime-parameterised rate at boot
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.
2026-05-21 20:43:55 +10:00
atagen
4a80a16d79 docs: explain why mono streams aren't link-enforced
The `pair_count < 2` early-return in `apply_pending_routes` looked
arbitrary from the outside (and Codex+self-review both flagged it
as a possible bug). It's actually a deliberate choice: WP's
source-side upmix adapter handles mono → stereo cleanly today,
and broadcasting one source port to N target ports via link-factory
fanout requires the limiter's stereo-link semantics and the
BS.1770 multichannel weights to make sense for N=1 — neither
generalises trivially. The proper fix lives in the v1
multichannel pipeline.

Replaces the old "PipeWire's adapter is responsible for any
downmix" comment with the actual reasoning + the contract caveat
(`route.set` on a mono app won't move it; the metadata write is
a hint, not enforcement) so a future contributor doesn't
accidentally "fix" it without weighing the trade-offs.

No code change beyond the comment + the debug-log message.
2026-05-21 19:50:11 +10:00
atagen
ec49206660 fix(F4): clear real_sink.name when the adopted sink departs
Codex audit of the F1-F6 sweep flagged this. The fallback path in
`try_capture_real_sink` adopts the first non-processed Audio/Sink
when no real sink is known; if that sink later disconnects (USB
DAC pulled, Bluetooth peer drops), `on_global_remove`'s
`sinks_by_name.retain` was clearing `s.real_sink.node_id` but
leaving `s.real_sink.name` set to the departed name. Symptoms:

  - `apply_pending_routes` then logs "target sink not yet on
    registry" for every bypass route and queues forever — `name`
    no longer resolves through `sinks_by_name`.
  - `adopt_new_real_sink` from the metadata listener would
    normally rescue this, but WP only re-fires `default.audio.sink`
    on actual changes; if the user's default is unchanged in WP's
    view (because the departed sink wasn't WP's pick), no event
    arrives.

The retain-callback now clears both `name` and `node_id` when the
removed node's name matches `real_sink.name`. The F4 fallback will
then pick a replacement from the next non-processed Audio/Sink the
registry surfaces, or a fresh metadata event will set a specific
choice — either path recovers cleanly.

Codex's other finding (theoretical duplicate-link creation in
multi-channel apply_pending_routes when the link listener lags
behind the drain timer within a single tick) is real-but-unlikely:
the listener fires within microseconds on the same event loop;
drain ticks are 50 ms apart, so the listener always catches up
before the next drain. The unchanged-target gap-mitigation from
`5c769a1` also reduces exposure — most ReevaluateAll passes don't
hit the create loop at all now. Filed as a "watch but don't fix"
note inline in the routing follow-up memory.

190 tests pass; clippy clean.
2026-05-21 19:44:49 +10:00
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
04a005e1cd F4: real-sink discovery fallback closes the cold-boot race
Codex flagged a real-but-rare race in `try_bind_default_metadata`:
the daemon installs the metadata listener then immediately writes
`default.audio.sink = headroom-processed`, relying on PipeWire to
deliver the prior value to the listener before our write. In
practice this works (pw-metadata replays current state to a
freshly-installed listener), but two failure modes leak through:

  1. **Prior daemon left the world dirty.** If a previous daemon
     run set default to `headroom-processed` and didn't restore
     before exiting, the listener replays "headroom-processed" —
     `on_metadata_property` recognises that as our own promotion
     and returns early. `real_sink.name` is never captured.
     Bypass routes log "no real sink known" forever.
  2. **No replay event.** If the listener doesn't fire for any
     reason — broken PipeWire, an event-bus hiccup,
     pipewire-rs's `add_listener_local` racing with our write —
     same outcome.

Fix: instead of trying to win the listener race
(pipewire-rs has no synchronous metadata getter, and `Core::sync`
needs an async done-callback we don't have plumbing for), make
`try_capture_real_sink` self-heal. When `real_sink.name` is still
`None` and we see *any* non-processed `Audio/Sink` on the registry,
adopt it as the fallback real sink. A subsequent
`default.audio.sink` event will refine the choice via the existing
`adopt_new_real_sink` path if WP picks something else.

This is a belt-and-braces patch — the listener path stays the
primary mechanism, the fallback only kicks in when that path
hasn't produced a name. In steady-state (the common case where
listener replay works) it changes nothing.

Verified

  Cold start with PipeWire's `default.audio.sink` set to the
  Mbox: daemon logs `preferred_real_sink updated sink=Mbox`
  via the listener path; the fallback's
  `adopting first available Audio/Sink as fallback` log does
  not fire. No regression for the steady state.

  188 tests pass; clippy clean at -D warnings --all-targets.
2026-05-21 18:43:02 +10:00
atagen
0e718abe27 F2: reapply routing on profile / rule changes
Codex flagged that `profile use`, `profile reload`, `route set`, and
`route unset` updated overlay state and (sometimes) propagated DSP
configs but never asked the registry thread to re-route existing
streams. The new policy only applied to *future* connections;
anything already routed kept its old explicit links until the app
disconnected.

The plumbing was actually already in place from F1 — the bypass
toggle posted `PwCommand::ReevaluateAll`, the registry handled it,
and `reevaluate_all` iterated the `known_streams` cache. This
commit is just the missing call sites: a `post_reevaluate(state)`
helper that reads `state.pw_command_tx` and sends
`ReevaluateAll`, called after each of the four mutating IPC ops.
`execute_reload` (which the profile-watcher also calls) gets the
post too, so editing a TOML on disk now re-routes live streams.

Tests

  All 188 still pass; clippy clean.

Live verification

  Sine flowing through `headroom-processed` while the daemon is
  on the `layer-a-test` profile (default_route = processed):
  - `headroom profile use bypass-all` → pw-cat's explicit link
    flips from processed → Mbox within ~50 ms (one drain tick).
  - `headroom profile use layer-a-test` → flips back to
    processed.
  - Layer A tap link survives both transitions (orthogonal,
    unaffected by bus rerouting — same invariant as F1).

Adjacent issue noted (not in F2 scope)

  `headroom route set <app> <route>` only writes the rule's
  `process_binary` field. Streams that don't advertise
  `application.process.binary` (pw-cat is one) can't be matched
  by this single-field rule even though they have an
  `application.name`. The fix is either to widen `route.set` into
  a smarter "match by app label" verb (which would either need a
  new OR-across-fields matcher kind or a CLI flag to pick which
  field) or to teach the materialiser to produce both
  process_binary AND application_name rules with the same name,
  with the matcher then OR'd. Either way it's a separate UX bug;
  filed as a follow-up.
2026-05-21 18:40:02 +10:00
atagen
e0c23ec459 F1: make bypass on a real kill switch
Codex flagged that `bypass.set` only flipped `bypass_global` in
profile state and never touched the graph: `try_route_stream`
returned Skip but the daemon kept re-asserting
`default.audio.sink = headroom-processed`, so apps following
default still landed in the processor, and already-managed streams
kept their explicit links to the processed sink. The "kill switch"
killed nothing.

What the bypass now actually does

  Three coupled effects, applied atomically by a single
  `PwCommand::ReevaluateAll` post from the IPC handler:

  1. **Routing decision flips.** `routing::evaluate` learned to
     short-circuit to `Route(Bypass)` for every routable playback
     stream when `bypass_global=true`. Surround's pre-existing
     `>2ch -> Bypass` rule still applies; both share the same
     output and pick up the same explicit-link machinery from 4k.

  2. **Existing managed streams get re-routed.** A new
     `known_streams: HashMap<u32, PwNodeInfo>` cache in
     `RoutingState` (populated on `try_route_stream`, cleared in
     `on_global_remove`) lets `reevaluate_all` iterate every
     stream we've ever seen and re-run the decision. The
     extracted `apply_bus_route` runs the same enqueue / unmanage
     logic the registry callback uses, so the live-arrival path
     and the bypass-toggle path stay in lockstep.

  3. **`default.audio.sink` flips to the real sink.** Inside
     `reevaluate_all`, the daemon writes default to the real sink
     name under bypass, and back to `headroom-processed` when
     bypass clears. The `reassert_default_processed` rate-limiter
     is gated on bypass so we don't keep fighting WP for a sink
     we no longer want as default. Apps that route to "default"
     (which is most legacy code paths and a lot of GTK/Qt
     widgets) now actually skip the processor under bypass.

Adjacent cleanups that fell out

  - `try_route_stream` no longer carries the bypass branch
    inline. The split — registry callback inserts cache + calls
    `apply_bus_route` + maybe spawns Layer A — keeps the
    re-evaluation path free of the `&GlobalObject` it doesn't
    have. Layer A spawning stays at first-see time as before;
    streams that arrived before the daemon doesn't get a
    retroactive tap, which is fine since Layer A is orthogonal
    to bus routing and tap creation requires the registry global.
  - `RoutingDecision::Skip` now properly tears down any prior
    bus state (`unmanage()` drops the Link proxies and removes
    the IPC-visible `state.streams` entry).
  - `PwCommand::ReevaluateAll` is a generic re-evaluation
    trigger; F2 will reuse it for profile / rule changes.

Tests

  - `routing::evaluate` signature picked up a `bypass_global:
    bool` arg; 11 unit tests updated to pass `false`.
  - ops::tests' `let PwCommand::RouteStream { .. } = cmd;` is
    now `let ... else { panic!(..) }` (the enum is no longer
    single-variant). 188 tests pass; clippy clean.

Live verification

  A/B/A against a 1 kHz sine `--target headroom-processed`:
  - bypass off (baseline): pw-cat → headroom-processed:playback;
    default.audio.sink = headroom-processed.
  - bypass on: pw-cat → Mbox:playback (the explicit link to
    processed is gone, a new explicit link to the real sink is
    in place); default.audio.sink = the Mbox.
  - bypass off (back): pw-cat → headroom-processed:playback;
    default.audio.sink = headroom-processed.
  - Layer A tap link stays attached through both transitions —
    orthogonal as designed.
2026-05-21 18:32:43 +10:00
atagen
3427ec56fc F3: force-bypass surround streams; generalise N-channel pairing
Codex flagged that routing was channel-blind and the explicit-link
pairer hardcoded `take(2)`. For a 5.1 stream the consequences
depended on the route decision: Route::Processed silently dropped
the centre, LFE, and both surround channels (only FL/FR linked to
the stereo processed sink); Route::Bypass to a 5.1-capable real
sink had the destruction pass kill 4 of 6 links because they
weren't in the 2-pair `want_set`. Either way the user lost
channels.

PLAN §12 already documented the intent ("anything >2ch is routed
directly to the real sink, bypass behaviour, regardless of profile
rule") but the code didn't honour it. This commit makes the
contract load-bearing.

Changes

  - `PwNodeInfo` gains `audio_channels: Option<u32>`, populated
    in `build_node_info` from the stream's `audio.channels`
    property. `None` for clients that don't advertise (older PW,
    odd toolkits) — those fall through to normal rule evaluation
    on the assumption they're stereo or mono.
  - `routing::evaluate` short-circuits to `Route(Bypass)` when
    `audio_channels > 2`, ahead of rule matching. The bus filter
    is F32 stereo by construction, so this is the only honest
    answer: forcing surround into the processed path either drops
    channels or invents an unrequested downmix.
  - `apply_pending_routes`' link pairing generalised from
    `take(2)` to `take(min(src_outs.len(), target_ins.len()))`.
    Stereo → stereo is unchanged (`min(2, 2) = 2`); 5.1 → 5.1
    real sink now pairs all six channels; 5.1 → stereo real sink
    pairs two (PipeWire's source-side adapter does the downmix,
    which is its job, not ours). The destruction pass already
    only nukes links to *known sinks*, so taps + non-sink
    consumers stay untouched as before.
  - PLAN §12 updated: the surround bullet now describes enforced
    behaviour rather than aspirational documentation.

Tests

  - `routing::tests::surround_streams_force_bypass_regardless_of_rule_match`
    — a 6-channel stream matching the default profile's "browser
    is processed" rule must still bypass.
  - `routing::tests::stereo_and_mono_streams_follow_normal_rules`
    — confirms the forcer only triggers for `>2ch` (None, Some(1),
    Some(2) all flow through to the rule).

  188 tests pass; clippy clean at -D warnings --all-targets.

Live regression check (stereo 1 kHz sine into processed): 51
non-floor meter ticks over 3 s, bus DSP path still flowing,
integrated LUFS around -28. Stereo path unaffected by the
generalised pairing.
2026-05-21 18:24:01 +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
d52cd6db3b 8e: playback callback timing instrumentation + spike investigation
Adds a lock-free `PlaybackTiming` struct (atomics: call_count,
sum_us, max_us, spike_count, last_spike_us, last_spike_at_call)
shared between the bus filter's `playback_process` callback (RT
thread, writes) and the AGC controller (daemon thread, reads).
The audio thread wraps each inner call in
`Instant::now()` ... `state.timing.record(elapsed)` — wait-free,
no allocation. The AGC tick samples the snapshot once per second
and logs at WARN when new spikes have landed since the previous
sample, DEBUG otherwise. `#[global_allocator]` declaration in
`headroom-cli` now sits behind `cfg(debug_assertions)` so release
builds compile cleanly (assert_no_alloc strips `AllocDisabler`
under its default `disable_release` feature).

Spike investigation outcome

  PLAN §11 follow-up noted: ~240 μs steady state, ~2 ms BUSY
  spikes at ~10 s cadence. My ~3 min capture of a 1 kHz sine
  routed through processed (release build) showed:

  - Steady state ~2180 μs / call
  - Max climbed slowly: 2186 → 2222 → 2606 → 2655 → 2812 μs over
    ~1 min (1.3× steady-state, well within the per-quantum budget)
  - Callback rate ~4 Hz, implying the Mbox is negotiating a large
    quantum (~12k frames per call vs the 1024-frame baseline
    PLAN §4.7 measured). Per-frame DSP cost is identical to the
    original budget; the longer wall-clock is just the longer
    quantum

  No clear ~10 s-cadence outlier pattern reproduced. The system
  is comfortably inside budget (~2.2 ms / 250 ms quantum ≈ 1% of
  one core). Without an audible artefact or a reproducible
  failure mode I'm not chasing the original spike further; the
  instrumentation stays so future regressions are visible at
  WARN level. `SPIKE_THRESHOLD_US = 5000` is comfortably above
  steady-state at both small and large quanta, so only real
  outliers trip the log.

Verified

  185 tests pass; clippy clean at -D warnings --all-targets.
  Release build runs sine playback continuously for >3 min with
  no assert_no_alloc abort, no panic, no spike warning. Debug
  build (with assert_no_alloc active) likewise stable across
  thousands of audio callbacks (revalidated as part of the
  release-build comparison).
2026-05-21 16:42:46 +10:00
atagen
9220143db7 8a: assert_no_alloc on audio-thread callbacks
Wraps the three audio-thread `process` callbacks
(`capture_process`, `playback_process`, `tap_process`) with
`assert_no_alloc::assert_no_alloc(|| inner(...))`. The
`headroom-cli` binary installs `AllocDisabler` as `#[global_allocator]`
so any allocation inside one of those blocks during debug builds
aborts the process with "memory allocation of N bytes failed".

Each callback was renamed to `*_inner` to keep the thin wrapper
function pointer stable for pipewire-rs's `process(fn_ptr)`.

`assert_no_alloc`'s `disable_release` is on by default — release
builds get the system allocator unwrapped and the macros become
no-ops, so the audio thread pays zero runtime cost in production.

Verified

  Positive smoke (5 s of 1 kHz sine through processed): daemon
  stays up across thousands of capture/playback/tap callbacks. No
  abort. Audio threads are alloc-free as designed.

  Negative smoke (temporarily inserted `Vec::with_capacity(1024)`
  inside `capture_process_inner`): daemon aborts (SIGABRT, exit
  134) on the first audio callback with the expected
  "memory allocation of 1024 bytes failed" stderr message —
  confirming the harness is wired correctly and not silently a
  no-op. Sanity-check alloc reverted before commit.

  185 tests pass; clippy clean at -D warnings --all-targets.
2026-05-21 16:21:53 +10:00
atagen
8af6dff98d 4l: filter.playback through 4k enforcement + sticky default.audio.sink
Two follow-ups from 4k's commit body, both surfaced by the same
smoke-test setup.

filter.playback through 4k

  `try_capture_filter_playback` and the bypass-retarget pass in
  `adopt_new_real_sink` now call `enqueue_route` on top of the
  existing `write_stream_target`. Without that, WirePlumber was
  fanning the filter's output port to *both* the real sink (the
  intended target) and `headroom-processed:playback` — a feedback
  loop where the filter's output flowed back into the processed
  sink, then through monitor → filter capture → DSP → filter
  playback again.

  Plumbing 4k for the filter required two small tweaks elsewhere:

  - `enforce_link_for_managed_stream` and `apply_pending_routes`
    were destroying every non-target outbound link from a managed
    source. That included Layer A passive tap links, which sent
    Layer A's own retry loop into a create/destroy fight with this
    code. Both paths now skip links whose destination isn't a
    known Audio/Sink, so only WP-created sink links get torn down.

  - The processed sink is now also recorded in `sinks_by_name`
    (previously skipped because it's "tracked elsewhere" in
    `processed_sink_id`). `apply_pending_routes` resolves the
    target by name, so it needed processed visible here to handle
    Route::Processed.

Sticky default.audio.sink

  `adopt_new_real_sink` previously short-circuited via
  `apply_real_sink_change` when the real sink name hadn't changed
  — which meant the *first* time WP rewrote `default.audio.sink`
  away from `headroom-processed` we'd re-assert, but on every
  subsequent rewrite to the same Mbox value we'd skip out before
  reaching the re-assert call at the bottom of the function. WP
  won 1-0 after the first round.

  Fixed by hoisting the re-assertion into a dedicated method
  (`reassert_default_processed`) with a per-second attempt cap (10
  per second), called both from the idempotent early-exit path
  and from the end of the full retarget path. The cap is what
  keeps a hostile WP policy from pulling us into a hot loop — at
  10 Hz we tolerate a brief metadata storm, then back off for the
  remainder of the window.

Verified

  185 tests still pass; clippy clean at -D warnings --all-targets.

  Live smoke against a running PipeWire/WP:
  - `pw-metadata` confirms `default.audio.sink` settles on
    `headroom-processed` after daemon startup (daemon wrote 3
    times in ~30 ms, WP yielded; metadata then stayed put).
  - `pw-link` confirms `headroom-filter.playback:output_{FL,FR}`
    has exactly one outbound link each — to the Mbox playback
    ports — with no link back to processed:playback.
  - Sine-into-processed regression still passes: 59/59
    meter ticks above the floor, momentary_lufs around -28, true
    peak around -21 dBTP — bus DSP chain still processing
    end-to-end after the filter's link surface was tightened.
2026-05-21 15:58:18 +10:00
atagen
df8af6c4d2 4k: routing establishes explicit links, not just target.object
Phase 5 smoke-tested the monitor TUI and surfaced that the bus DSP
never sees signal: bus meters stay at the LUFS floor / -200 dBTP
even when `headroom status` reports a stream as route=processed.
The root cause is in routing, not the TUI.

Why writing target.object alone wasn't enough

  The daemon's routing engine wrote `target.object` on the stream
  node and relied on WirePlumber to (re-)link the stream to the
  declared sink. That works for streams the daemon creates itself
  (`headroom-filter.playback`): the `pw_stream` carries
  target.object at connect time, before WP sees the node global,
  so WP's first linking decision honours it.

  For external clients (pw-cat, Strawberry) the order is reversed:
  WP links the stream the instant the node global appears,
  *before* the daemon's registry callback fires
  `try_route_stream`. The metadata write that follows is a no-op
  for routing — WP doesn't re-link in response to a target.object
  change on an already-linked node. Verified manually: writing
  target.object on a live stream + severing its bad link did NOT
  cause WP to relink to the declared target. WP just left the
  stream unrouted.

What this commit changes

  RoutingState now tracks `Link` registry globals (`links_by_id` +
  `outbound_links_by_node` reverse index) and Audio/Sink globals
  by name (`sinks_by_name` now also carries `headroom-processed`,
  not just the real-hardware sinks). On every routing decision —
  `try_route_stream`, `apply_pw_command(RouteStream)`, and the
  bypass-retarget pass inside `adopt_new_real_sink` — the daemon
  also enqueues a `PendingRoute` for the source node.

  Two enforcement paths:

  - **Fast vigilance** in `try_capture_link`: when WP creates a
    new link out of a managed stream that lands on a different
    Audio/Sink, the daemon calls `registry.destroy_global(link_id)`
    immediately. Links to non-sinks (Layer A taps, other
    downstream consumers) are left alone — Layer A owns those.

  - **50 ms drain loop** in `apply_pending_routes`: for each
    pending route, once the source's output ports and the target
    sink's input ports are visible on the registry, the daemon
    destroys any remaining outbound link landing on the wrong
    sink and creates the desired link via `link-factory` (new
    `create_routing_link` helper — non-passive variant of the
    existing `create_explicit_link` Layer A uses). The owned
    `Link` proxies live in `managed_route_links` keyed by source
    node id; dropping them tears the links down via
    `object.linger = "false"`.

  `target.object` writes are kept (cheap hint that helps fresh
  pw_streams and documents intent) but are no longer the source
  of truth.

Verified

  All 185 tests still pass; clippy clean at -D warnings
  --all-targets.

  Live smoke (pw-cat /dev/zero of a 1 kHz sine at -20 dBFS into
  `--target headroom-processed`):
  - Before: pw-cat:output → Mbox:playback directly; bus meters
    pinned at floor, integrated_lufs = -200, true_peak = -200.
  - After: `routed pw-cat → headroom-processed` followed within
    50 ms by `explicit routing link established`; pw-link confirms
    pw-cat:output → headroom-processed:playback (+ the Layer A
    tap link, preserved). Bus meters show momentary -28 → -16
    LUFS, true_peak around -34 to -19 dBTP, compressor GR -2.6 dB,
    limiter GR -6.7 dB — i.e. the bus DSP chain is processing
    signal end-to-end for the first time.
  - Layer A tap creation logs exactly once (vs. the
    create/destroy fighting loop the first cut had before
    `enforce_link_for_managed_stream` learned to skip non-sink
    destinations).

Known limits not addressed here

  - `default.audio.sink` reassertion by WP. The daemon still
    writes `default.audio.sink = headroom-processed` but WP's
    session policy may rewrite it back. With explicit links, this
    is now mostly cosmetic — new streams whose target.object
    matches headroom-processed will be routed correctly via the
    same enforcement path even if default is something else. The
    metadata side will be tightened later if it turns out to
    matter operationally.

  - A spurious filter.playback → processed:playback feedback link
    still appears in the live graph (the bus filter's own output
    being linked back to its sink). Suspected source: a leftover
    rule on the filter node. To investigate separately; doesn't
    currently affect signal flow because filter capture sees
    signal from the real producer.
2026-05-21 15:36:15 +10:00
atagen
e528a98417 5: monitor TUI + wire fill-ins
`headroom monitor` becomes a full-screen ratatui TUI by default;
the previous behaviour (line-delimited JSON, useful for scripts and
tests) is preserved behind --json.

5 — Monitor TUI

  New `crates/headroom-cli/src/tui.rs` (~700 lines incl. tests).
  Main thread does subscribe + initial status() + route_list() before
  entering raw mode, so connect errors surface as clean stderr
  messages instead of corrupting the terminal. A reader thread owns
  the headroom_client::Client and forwards each subscription event
  through a crossbeam channel; an input thread blocks on
  event::read() and forwards keys (q / Esc / Ctrl-C) through a
  second channel; the main thread `select!`s both plus a 10 Hz
  ticker (so uptime + staleness display advance even when no
  events are flowing). On quit the OS reaps the reader; a CLI tool
  doesn't need a graceful UnixStream shutdown.

  Layout: outer block carries the profile / version / uptime in the
  top-right title and a footer with subscribed topics + an overflow /
  error / disconnected banner when relevant. Inside: bus DSP gauges
  (AGC target, compressor GR, limiter GR, true peak), a loudness
  panel (momentary / short-term / integrated, greyed when stale),
  and a streams table with route + Layer A reduction column.

Wire types caught up to the daemon

  `headroom-ipc::RoutingEvent` gained `StreamRemoved`,
  `LayerAAttached`, `LayerADetached` variants — these are events the
  daemon already publishes (registry.rs §pw) but that
  weren't typed in the proto. Without `StreamRemoved` the TUI would
  accumulate departed streams forever; without the Layer A pair the
  per-stream column couldn't track tap state.

  New `LayerALevel` struct types the `meters/layer_a_level` payload
  (node_id, app, volume_lin, reduction_db).

  `headroom_core::agc::LOUDNESS_FLOOR_LUFS` is now `pub` — it's
  published as-is in MeterTick.*_lufs fields when ebur128 has no
  useful measurement yet, so clients need it to render "no
  measurement" without hard-coding `-200.0`.

Toolchain notes

  ratatui and crossterm pinned to =0.28.1. Newer ratatui pulls in
  `instability` 0.3.12 + `darling` 0.23 which need rustc 1.88+; the
  project pins 1.86 via rust-toolchain.toml. Lockfile also pins
  `instability` to 0.3.7 and `darling` to 0.20.10 (older patches that
  still build on 1.86).

Verified

  185 tests passing (was 178: +5 for TUI event mapping +
  fmt_uptime, +2 for stream_removed / layer_a_level handling).
  Clippy clean at -D warnings --all-targets.

  Live smoke: daemon emits routing/{stream_routed, stream_removed,
  layer_a_attached, layer_a_detached} and meters/{tick, layer_a_level}
  in shapes that round-trip cleanly through the new typed enums.
  TUI binary survives raw-mode init + initial RPCs + subscription
  against a live daemon.

Known unrelated daemon gap (to be fixed next): pre-existing streams
aren't actually re-linked when the daemon writes target.object —
WirePlumber updates metadata but doesn't tear the old link down or
create a new one into the processed sink. Bus DSP path therefore
sees silence even when status reports route=processed. Not Phase 5;
addressed separately.
2026-05-21 13:35:27 +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
9edd809416 stage 4 (a–d): IPC server, ops, broadcast
Phase 4 first four checkpoints — daemon now serves the wire protocol
specified in IPC.md and broadcasts events to subscribers.

  4a IPC server skeleton
     UnixListener at $XDG_RUNTIME_DIR/headroom/control.sock, accept
     thread, per-connection thread, hello-on-connect, codec
     round-trip, 0600 perms with stale-socket detection. Caught and
     fixed a sigprocmask ordering bug: block SIGTERM/SIGINT
     process-wide BEFORE the IPC accept thread spawns, otherwise it
     inherits the unblocked mask and the signal takes the default
     disposition before pipewire's signalfd can read it.

  4b Read-only ops + shared state
     Arc<Mutex<DaemonState>> (parking_lot) for cross-thread daemon
     state. RoutingState moved off Rc<RefCell<>>-only and reads
     profile from the shared lock. Captures the headroom-processed
     node id via the registry. Implements: status, profile.list,
     profile.show, route.list, setting.get (serde-roundtrip dotted
     lookup), setting.list (flattened).

  4c Mutating ops
     profile.use (idempotent no-op until 4e ships the disk loader),
     profile.reload (empty list till 4e), route.set/unset with
     single-app user-rule replace semantics, setting.set with serde
     round-trip type-safety, bypass.set. CLI fix:
     allow_hyphen_values so 'headroom set foo.bar -0.5' works.

  4d Subscriptions + broadcast
     Per-connection split into reader thread + writer thread, joined
     by a bounded crossbeam_channel<ServerFrame>(64). Broadcaster in
     DaemonState fans out events via try_send; bounded queues drop
     on overflow with per-(subscriber, topic) counters and a
     daemon::overflow flush event piggybacked onto the next
     successful publish.

     Live events wired: daemon::started, daemon::shutdown,
     routing::rule_changed, routing::stream_routed,
     routing::stream_removed. CLI 'monitor [topics]' command
     subscribes by topic list.

Workspace deps unchanged; uses already-declared crossbeam-channel,
parking_lot. Sinks/SinkInfo gained Default derives.

Tests: 97 passing (28 dsp, 20 ipc, 45 core, 4 client). Clippy clean
at default level under -D warnings.

Remaining Phase 4 punch-list (recommended order):
  4e profile TOML loader + hot reload (notify-debouncer-mini)
  4h preferred_real_sink tracking
  4i target.object routing reliability on real WirePlumber
  4f slow AGC loop with ebur128
  4g meters publishing
  4j auto-promote to default sink (optional flag)
2026-05-19 23:14:18 +10:00
atagen
ae83310772 stage 3: daemon core
Phase 3 — bring up the daemon end-to-end through six checkpoints:

  3a Module skeleton (error, profile, routing, runtime, pw/*)
  3b Pure routing engine + 13 tests (no PipeWire dep)
  3c PwContext: main loop, sigprocmask-block SIGTERM/SIGINT before
     add_signal_local so signalfd actually picks them up
  3d headroom-processed virtual sink via the adapter factory with
     factory.name=support.null-audio-sink
  3e Filter: two pw_streams (capture from monitor / playback to real
     sink) with an rtrb SPSC ring between them. DSP chain
     (Compressor → two-tier Limiter) runs in the playback callback.
     Allocation-free; #![forbid(unsafe_code)] preserved via
     bytemuck::try_cast_slice for the byte↔f32 reinterpretation.
  3f Registry watcher binds the default metadata, evaluates new
     Stream/Output/Audio nodes against profile rules, writes
     target.object for processed routes. Self-stream guard skips
     anything whose node.name starts with 'headroom-filter'.

Workspace deps added: pipewire = { features = ["v0_3_44"] } for the
modern TARGET_OBJECT key, libspa, rtrb, nix (sigprocmask), bytemuck.

Tests: 65 passing (28 dsp, 20 ipc, 4 client, 13 core). Clippy clean
at default level under -D warnings.

PLAN.md §5 renumbered to fix stale subsection labels (was 4.1–4.4
from before the per-app insertion).

Known limitations punted to Phase 4 (documented in commit history
and team memory):
  - WirePlumber doesn't always honor late target.object writes once
    a stream is already linked (timing race).
  - preferred_real_sink dynamic tracking stubbed.
  - No auto-promote of headroom-processed to system default.
  - application.process.binary occasionally arrives in late metadata
    updates after the global registers; routing logs show '?' until
    we add a re-read.
2026-05-19 22:15:49 +10:00
atagen
ca1910de60 stage 2 2026-05-19 16:33:09 +10:00