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.
|
||
|---|---|---|
| contrib/systemd | ||
| crates | ||
| docs | ||
| nix | ||
| 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.
- Layer A per-app level control (peak + RMS detector → smoothed
channelVolumeswrites) for taming individual streams without touching the bus path. Zero added signal-path latency; safe to use on bypass-routed streams. - 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. - Live profile reload — edit a TOML file in
$XDG_CONFIG_HOME/headroom/profiles/and the daemon picks up changes within ~500 ms; the audio thread doesn't glitch.
See PLAN.md for the full design and roadmap.
Status
Alpha. The signal chain (AGC, compressor, two-tier limiter, Layer A
per-app), the routing engine (explicit-link enforcement, sink hotplug,
sticky default sink), the IPC server with topic subscriptions, the
headroom monitor TUI, and live profile reload all work end-to-end.
Packaging exposes a systemd user unit and Nix modules. What's missing
is real-world soak time on multi-rate / Bluetooth setups and other
distros' init systems.
Installing
Nix (flake)
This repo is a flake; the daemon plus its systemd user unit and the canonical profiles are exposed as a package.
nix run github:amaanq/headroom -- daemon # one-shot run
nix profile install github:amaanq/headroom # add to $PATH
For Home Manager, add the flake as an input and enable the module:
{
inputs.headroom.url = "github:amaanq/headroom";
# In your Home Manager configuration:
imports = [ inputs.headroom.homeModules.default ];
services.headroom.enable = true;
}
The module symlinks the shipped profiles into
$XDG_CONFIG_HOME/headroom/profiles/, drops the systemd user unit
into the user's services dir, and the unit starts after PipeWire and
WirePlumber come up. services.headroom.extraProfiles lets you add
your own.
For NixOS (system-wide binary install + systemd-user discovery):
{
inputs.headroom.url = "github:amaanq/headroom";
# In your NixOS configuration:
imports = [ inputs.headroom.nixosModules.default ];
programs.headroom.enable = true;
}
Then any user can systemctl --user enable --now headroom.
Other distros (manual)
cargo install --path crates/headroom-cli # or: cargo build --release
# Profiles
mkdir -p ~/.config/headroom/profiles
cp profiles/*.toml ~/.config/headroom/profiles/
# systemd user unit (edit the ExecStart path to point at your binary)
install -Dm644 contrib/systemd/headroom.service \
~/.config/systemd/user/headroom.service
sed -i "s|@bindir@|$(dirname "$(command -v headroom)")|" \
~/.config/systemd/user/headroom.service
systemctl --user daemon-reload
systemctl --user enable --now headroom
Usage
Once the daemon is running:
headroom status # JSON snapshot — sinks, streams, active profile
headroom profile list # available profiles
headroom profile use night # activate one
headroom monitor # full-screen TUI (bus gauges + per-stream)
headroom monitor --json meters # line-delimited JSON, for scripting
headroom route set firefox processed
headroom set compressor.threshold_db -28
headroom bypass on # kill switch — straight to the real sink
See headroom --help for the full surface.
Building
nix develop # toolchain + pipewire dev libs + helpers
cargo build # iterate
cargo test --workspace
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.