No description
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.
|
||
|---|---|---|
| crates | ||
| docs | ||
| profiles | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| flake.lock | ||
| flake.nix | ||
| IPC.md | ||
| PLAN.md | ||
| README.md | ||
| rust-toolchain.toml | ||
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
bypassride 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.