No description
Find a file
atagen 86d00c43d1 filter rate matching A+B: runtime-parameterised rate at boot
Drops the FILTER_SAMPLE_RATE const dependency from the filter's
creation path so the audio thread can run at whatever rate the
real sink negotiates, not unconditionally 48 kHz. Closes one of
the two output-edge resamples PLAN §3.1's F5 caveat called out
— content matching the real-sink rate now passes through the
limiter without an output-side resample elevating its true peaks.

Phase A (foundation)
  - `Filter::create(core, init, sample_rate)` takes the rate as a
    runtime parameter. `DEFAULT_SAMPLE_RATE` keeps 48 kHz as the
    fallback constant; `FILTER_SAMPLE_RATE` is kept as a
    back-compat alias.
  - `build_format_pod_bytes(sample_rate)` parameterised so the SPA
    Format the filter advertises matches the chosen rate.
  - `FilterBundle.sample_rate` exposed so the AGC controller and
    `runtime` can size their own state.
  - New `LimiterConfig::sanitize_for_rate(sample_rate)` caps the
    oversample factor so the internal (post-upsample) rate stays
    ≤ 192 kHz: 48 k base → 4× = 192 k; 96 k → 2× = 192 k; 192 k
    → 1× = 192 k. Keeps the FIR cost from doubling each time the
    base rate doubles, with negligible loss of true-peak detection
    quality at high base rates (the signal already has plenty of
    bandwidth). Two regression tests lock the math in.

Phase B (data plumbing)
  - `SinkInfo` (wire-level) gains an optional `sample_rate`
    field. `headroom status` now reports the processed sink's
    running rate and the real sink's native rate — useful for
    debugging "did the daemon actually match my hardware?"
    without resorting to `pw-link`.
  - `state::RealSink.sample_rate` populated by the registry
    watcher from two sources:
      - The `audio.rate` property (many virtual sinks expose it).
      - A `Format`-param listener bound to the real sink's `Node`
        proxy (ALSA sinks only expose the rate in the negotiated
        Format, not in their property dict). New
        `install_real_sink_format_listener` mirrors the
        channelVolumes-listener pattern Layer A already uses.
        Listener cleaned up in `on_global_remove` when the real
        sink departs.
  - `state::DaemonState.filter_sample_rate` mirrors the bus
    filter's currently-running rate; surfaced in `status`.
  - Layer A's block-period constant becomes a runtime function
    (`layer_a_block_dt_s(sample_rate)`) so 96 k / 192 k hardware
    gets correctly-scaled controller time-constants.

Known gap: filter created at boot uses whatever rate is known at
that moment. For ALSA sinks the Format listener fires ~tens of ms
*after* the registry capture — by which time the filter is
already created at the fallback rate. The next commit (Phase C)
rebuilds the filter when the listener delivers a rate different
from what the filter is running at.

Verified

  - 191 tests pass (was 189; +2 for the new
    `sanitize_for_rate` cases); clippy clean at -D warnings
    --all-targets.
  - Live: cold-boot against a 48 kHz Mbox shows
    `status.sinks.processed.sample_rate = 48000` +
    `status.sinks.real.sample_rate = 48000`, daemon log records
    "creating filter at real-sink-matched rate initial_rate=48000"
    and "real sink Format negotiated; updating sample_rate
    new_rate=48000" within ~55 ms of each other. For sinks where
    `audio.rate` IS in props (some virtual sinks) the rate is
    captured before filter creation.
2026-05-21 20:43:55 +10:00
contrib/systemd 7: packaging — systemd user unit + Nix modules + README 2026-05-21 17:00:25 +10:00
crates filter rate matching A+B: runtime-parameterised rate at boot 2026-05-21 20:43:55 +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.