No description
Find a file
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
contrib/systemd 7: packaging — systemd user unit + Nix modules + README 2026-05-21 17:00:25 +10:00
crates F1: make bypass on a real kill switch 2026-05-21 18:32:43 +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 F3: force-bypass surround streams; generalise N-channel pairing 2026-05-21 18:24:01 +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.