No description
Find a file
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
crates 4l: filter.playback through 4k enforcement + sticky default.audio.sink 2026-05-21 15:58:18 +10:00
docs stage 2 2026-05-19 16:33:09 +10:00
profiles stage 2 2026-05-19 16:33:09 +10:00
.gitignore stage 2 2026-05-19 16:33:09 +10:00
Cargo.lock 5: monitor TUI + wire fill-ins 2026-05-21 13:35:27 +10:00
Cargo.toml 5: monitor TUI + wire fill-ins 2026-05-21 13:35:27 +10:00
flake.lock stage 2 2026-05-19 16:33:09 +10:00
flake.nix stage 2 2026-05-19 16:33:09 +10:00
IPC.md stage 2 2026-05-19 16:33:09 +10:00
PLAN.md stage 6: per-app 2026-05-20 23:49:58 +10:00
README.md stage 6: per-app 2026-05-20 23:49:58 +10:00
rust-toolchain.toml stage 2 2026-05-19 16:33:09 +10:00

headroom

AGC + compressor + true-peak limiter daemon for PipeWire, in Rust.

Headroom puts a per-application audio safety net between noisy sources (browsers, voice chat, random video) and your speakers, while leaving the things you don't want compressed (music players, games, DAWs) untouched.

  • Hard 0.1 dBTP ceiling on the processed route, with proper inter-sample-peak handling, enforced inline so the contract holds regardless of control-plane state. Streams routed bypass ride the real sink directly and are not in scope of the contract — that's the trade-off that makes the per-app exclusion useful.
  • Per-app exclusion with profile-driven rules.
  • Single binary daemon + CLI, controlled over a Unix-domain socket with a documented JSON wire protocol (see IPC.md).
  • First-party Rust crate (headroom-client) for programmatic use; third-party clients (Qt panels, status bars, …) target the wire protocol directly.

See PLAN.md for the full design and roadmap.

Status

Pre-alpha. Wire protocol and crate scaffolding are in; daemon and filter are under construction.

Building

nix develop          # toolchain + pipewire dev libs + helpers
cargo build          # iterate
nix build            # final packaged headroom binary

License

GPL-3.0-or-later for the daemon and CLI. headroom-dsp and headroom-ipc are MPL-2.0 so they can be reused by non-GPL plugin hosts and clients.