No description
Find a file
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
contrib/systemd 7: packaging — systemd user unit + Nix modules + README 2026-05-21 17:00:25 +10:00
crates fix: close audio-gap on unchanged ReevaluateAll; reset compressor on enable 2026-05-21 19:42:12 +10:00
docs stage 2 2026-05-19 16:33:09 +10:00
nix 7: packaging — systemd user unit + Nix modules + README 2026-05-21 17:00:25 +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 8a: assert_no_alloc on audio-thread callbacks 2026-05-21 16:21:53 +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 7: packaging — systemd user unit + Nix modules + README 2026-05-21 17:00:25 +10:00
IPC.md stage 2 2026-05-19 16:33:09 +10:00
PLAN.md F5: document the limiter's rate-leakage caveat in PLAN §3.1 2026-05-21 18:43:58 +10:00
README.md 7: packaging — systemd user unit + Nix modules + README 2026-05-21 17:00:25 +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.
  • Layer A per-app level control (peak + RMS detector → smoothed channelVolumes writes) 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.