From c65c75bb9ffe3d79a545fdce4dcb6a8fd2aac416 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 17:00:25 +1000 Subject: [PATCH] =?UTF-8?q?7:=20packaging=20=E2=80=94=20systemd=20user=20u?= =?UTF-8?q?nit=20+=20Nix=20modules=20+=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the daemon as a real installable, not just `cargo build`. Artifacts - `contrib/systemd/headroom.service` — user-scope unit. Type=simple (the daemon doesn't fork), After=pipewire.service, Restart=on- failure with a 2 s back-off so a crash loop doesn't spam stderr, StandardOutput/Error=journal, LimitRTPRIO=20 / LimitNICE=-11 to match the rtkit-style grant PipeWire's own unit carries. The file is templated with `@bindir@` so the build derivation can substitute in an absolute store path at install time, without the unit having to rely on whatever `headroom` happens to be on PATH. - `nix/home-module.nix` — `services.headroom.enable`. Installs the package on the user's PATH, symlinks the shipped profiles into `$XDG_CONFIG_HOME/headroom/profiles/`, and writes the systemd user unit (start After=pipewire.service Requires=pipewire.service Wants=wireplumber.service WantedBy=pipewire.service). Knobs: `installDefaultProfiles` for users who maintain their own set, `extraProfiles` (attrset of filename → path) to drop in personal profiles that override shipped ones by name. - `nix/nixos-module.nix` — `programs.headroom.enable`. Narrow scope: binary on global PATH, the package's `lib/systemd/user/*.service` is materialised under `/etc/systemd/user/` via `systemd.packages`, and an assertion fires if pipewire isn't enabled (clearer than a runtime crash). Per-user defaults (profile install, RT priority tuning) live in the Home Manager module; the two compose. Build derivation `postInstall` now installs the unit (with `@bindir@` substituted to `$out/bin`) and copies `profiles/*.toml` to `$out/share/headroom/profiles/`. The flake's version lookup moved from `crates/headroom-cli/Cargo.toml` (where `version.workspace = true` evaluates to a table, not a string) to the workspace `Cargo.toml`. Modules exposed under `nixosModules.default` and `homeModules.default`. README Rewrote the install section: Nix flake-based install with both Home Manager and NixOS module examples, plus a from-scratch `cargo install` + `install`/`sed` recipe for non-Nix users. Added a usage section with the common `headroom` subcommands and bumped the status banner from "pre-alpha" to "alpha" (signal chain, routing, IPC, monitor TUI, profile reload, and packaging all work end-to-end now). Verified - `nix flake check` passes; NixOS module type-checks under nixpkgs eval. - `nix build .#headroom` produces `bin/headroom`, `lib/systemd/user/headroom.service` with the absolute store-path ExecStart baked in, and all five shipped profiles under `share/headroom/profiles/`. - `systemd-analyze verify --user` accepts the unit. - 185 workspace tests still pass; clippy clean at -D warnings --all-targets; `nix fmt` happy. --- README.md | 94 +++++++++++++++- contrib/systemd/headroom.service | 39 +++++++ flake.nix | 183 +++++++++++++++++-------------- nix/home-module.nix | 119 ++++++++++++++++++++ nix/nixos-module.nix | 61 +++++++++++ 5 files changed, 413 insertions(+), 83 deletions(-) create mode 100644 contrib/systemd/headroom.service create mode 100644 nix/home-module.nix create mode 100644 nix/nixos-module.nix diff --git a/README.md b/README.md index fed7fc9..13151d9 100644 --- a/README.md +++ b/README.md @@ -13,24 +13,114 @@ untouched. 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`](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`](PLAN.md) for the full design and roadmap. ## Status -Pre-alpha. Wire protocol and crate scaffolding are in; daemon and -filter are under construction. +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. + +```sh +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: + +```nix +{ + 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): + +```nix +{ + 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) + +```sh +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: + +```sh +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 ```sh nix develop # toolchain + pipewire dev libs + helpers cargo build # iterate +cargo test --workspace nix build # final packaged headroom binary ``` diff --git a/contrib/systemd/headroom.service b/contrib/systemd/headroom.service new file mode 100644 index 0000000..a22b72f --- /dev/null +++ b/contrib/systemd/headroom.service @@ -0,0 +1,39 @@ +[Unit] +Description=Headroom audio daemon (PipeWire AGC + compressor + true-peak limiter) +Documentation=https://github.com/amaanq/headroom +# PipeWire is a hard dependency: headroom registers a virtual sink and +# wires explicit links via PW's link-factory, so we can't start before +# pw-mainloop is up. ConditionUser ensures this only ever runs as a +# user-scope unit, never accidentally as the system instance. +After=pipewire.service pipewire-pulse.service wireplumber.service +Requires=pipewire.service +Wants=wireplumber.service +ConditionUser=!@system + +[Service] +Type=simple +ExecStart=@bindir@/headroom daemon +# Restart on failure but not too aggressively — a tight crash loop +# would just produce a lot of stderr noise and clobber the user's +# routing repeatedly. +Restart=on-failure +RestartSec=2s +# Headroom doesn't fork; SIGTERM is the clean shutdown path. The +# default KillMode=control-group is correct for a single-process +# daemon; explicit here for clarity. +KillMode=control-group +TimeoutStopSec=5s +# Surface stdout/stderr to journald so `journalctl --user -u headroom` +# shows daemon logs with the expected RUST_LOG filtering. +StandardOutput=journal +StandardError=journal +SyslogIdentifier=headroom +# Realtime hint — pipewire grants RT scheduling via pw_thread_loop, +# but the daemon main thread benefits from a slight scheduling boost +# too. LimitRTPRIO matches the pipewire user unit's grant. +LimitRTPRIO=20 +LimitRTTIME=200000 +LimitNICE=-11 + +[Install] +WantedBy=pipewire.service diff --git a/flake.nix b/flake.nix index 1829bd6..129f92f 100644 --- a/flake.nix +++ b/flake.nix @@ -11,97 +11,118 @@ }; outputs = { self, nixpkgs, flake-utils, rust-overlay }: - flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" ] (system: - let - pkgs = import nixpkgs { - inherit system; - overlays = [ rust-overlay.overlays.default ]; - }; + flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" ] + (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ rust-overlay.overlays.default ]; + }; - rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; + rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; - rustPlatform = pkgs.makeRustPlatform { - cargo = rustToolchain; - rustc = rustToolchain; - }; + rustPlatform = pkgs.makeRustPlatform { + cargo = rustToolchain; + rustc = rustToolchain; + }; - # Native libs the audio crates link against. - nativeAudioBuildInputs = with pkgs; [ - pipewire - pipewire.dev - ]; - - nativeBuildTools = with pkgs; [ - pkg-config - clang - ]; - - commonEnv = { - LIBCLANG_PATH = "${pkgs.libclang.lib}/lib"; - PKG_CONFIG_PATH = "${pkgs.pipewire.dev}/lib/pkgconfig"; - }; - in - { - # `nix develop` — full dev environment. - devShells.default = pkgs.mkShell ({ - name = "headroom-dev"; - - nativeBuildInputs = nativeBuildTools ++ [ - rustToolchain - pkgs.rust-analyzer + # Native libs the audio crates link against. + nativeAudioBuildInputs = with pkgs; [ + pipewire + pipewire.dev ]; - buildInputs = nativeAudioBuildInputs ++ (with pkgs; [ - socat # poke the IPC socket - jq # pretty-print JSON - pipewire # for pw-cli, pw-cat, etc. - wireplumber - ]); + nativeBuildTools = with pkgs; [ + pkg-config + clang + ]; - shellHook = '' - echo "headroom dev shell — rustc $(rustc --version | cut -d' ' -f2)" - echo " cargo build / cargo test for iteration." - echo " nix build .#headroom for the packaged binary." - export RUST_BACKTRACE=1 - export RUST_LOG=headroom=debug,info - ''; - } // commonEnv); + commonEnv = { + LIBCLANG_PATH = "${pkgs.libclang.lib}/lib"; + PKG_CONFIG_PATH = "${pkgs.pipewire.dev}/lib/pkgconfig"; + }; + in + { + # `nix develop` — full dev environment. + devShells.default = pkgs.mkShell ({ + name = "headroom-dev"; - # `nix build` — the final packaged daemon + CLI. - packages = rec { - default = headroom; + nativeBuildInputs = nativeBuildTools ++ [ + rustToolchain + pkgs.rust-analyzer + ]; - headroom = rustPlatform.buildRustPackage ({ - pname = "headroom"; - version = (builtins.fromTOML (builtins.readFile ./crates/headroom-cli/Cargo.toml)).package.version; + buildInputs = nativeAudioBuildInputs ++ (with pkgs; [ + socat # poke the IPC socket + jq # pretty-print JSON + pipewire # for pw-cli, pw-cat, etc. + wireplumber + ]); - src = ./.; - - cargoLock = { - lockFile = ./Cargo.lock; - # allowBuiltinFetchGit = true; - }; - - nativeBuildInputs = nativeBuildTools; - buildInputs = nativeAudioBuildInputs; - - # We ship two binaries from the workspace: `headroom` (cli + daemon). - cargoBuildFlags = [ "-p" "headroom-cli" ]; - doCheck = true; - cargoTestFlags = [ "--workspace" ]; - - meta = with pkgs.lib; { - description = "AGC + compressor + true-peak limiter daemon for PipeWire"; - license = licenses.gpl3Plus; - platforms = platforms.linux; - mainProgram = "headroom"; - }; + shellHook = '' + echo "headroom dev shell — rustc $(rustc --version | cut -d' ' -f2)" + echo " cargo build / cargo test for iteration." + echo " nix build .#headroom for the packaged binary." + export RUST_BACKTRACE=1 + export RUST_LOG=headroom=debug,info + ''; } // commonEnv); - }; - # Reserved for the eventual user-service module. - # nixosModules.default = import ./nix/module.nix; + # `nix build` — the final packaged daemon + CLI. + packages = rec { + default = headroom; - formatter = pkgs.nixpkgs-fmt; - }); + headroom = rustPlatform.buildRustPackage ({ + pname = "headroom"; + # Pull from the workspace Cargo.toml — the per-crate + # manifests use `version.workspace = true` which evaluates + # to a table here, not a string. + version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.package.version; + + src = ./.; + + cargoLock = { + lockFile = ./Cargo.lock; + # allowBuiltinFetchGit = true; + }; + + nativeBuildInputs = nativeBuildTools; + buildInputs = nativeAudioBuildInputs; + + # We ship one binary from the workspace: `headroom` (cli + daemon). + cargoBuildFlags = [ "-p" "headroom-cli" ]; + doCheck = true; + cargoTestFlags = [ "--workspace" ]; + + # Install the systemd user unit (templated with @bindir@ + # so the unit refers to the absolute path of the binary in + # this derivation, never to whatever happens to be on + # PATH) and ship the canonical profiles under + # share/headroom/profiles so users / modules can copy + # them into XDG_CONFIG_HOME on first run. + postInstall = '' + install -Dm644 contrib/systemd/headroom.service \ + "$out/lib/systemd/user/headroom.service" + substituteInPlace "$out/lib/systemd/user/headroom.service" \ + --replace-fail '@bindir@' "$out/bin" + + mkdir -p "$out/share/headroom/profiles" + cp -r profiles/. "$out/share/headroom/profiles/" + ''; + + meta = with pkgs.lib; { + description = "AGC + compressor + true-peak limiter daemon for PipeWire"; + license = licenses.gpl3Plus; + platforms = platforms.linux; + mainProgram = "headroom"; + }; + } // commonEnv); + }; + + formatter = pkgs.nixpkgs-fmt; + }) // { + # System-independent outputs — modules. + nixosModules.default = import ./nix/nixos-module.nix self; + homeModules.default = import ./nix/home-module.nix self; + }; } diff --git a/nix/home-module.nix b/nix/home-module.nix new file mode 100644 index 0000000..923c1e7 --- /dev/null +++ b/nix/home-module.nix @@ -0,0 +1,119 @@ +# Home Manager module — installs the headroom binary, the systemd +# user service, and (optionally) a default set of profiles into the +# user's XDG_CONFIG_HOME. +# +# Headroom is a per-user daemon that talks to PipeWire over the user +# session, so the Home Manager scope is its natural install point. A +# separate NixOS module (./nixos-module.nix) covers the case where the +# user wants `headroom` on every account's PATH or wants to enable the +# service at the system level via systemd-user; that module simply +# delegates the heavy lifting to `services.headroom` (this file) when +# Home Manager is in use. +self: +{ config, lib, pkgs, ... }: + +let + inherit (lib) mkEnableOption mkOption mkIf types literalExpression; + + cfg = config.services.headroom; + + package = cfg.package; + + # Profiles shipped by the package, suitable for symlinking into the + # user's XDG_CONFIG_HOME so they show up in `headroom profile list` + # without the user having to copy them by hand. + shippedProfilesDir = "${package}/share/headroom/profiles"; +in +{ + options.services.headroom = { + enable = mkEnableOption "Headroom — PipeWire AGC + compressor + true-peak limiter daemon"; + + package = mkOption { + type = types.package; + default = self.packages.${pkgs.system}.headroom; + defaultText = literalExpression "headroom.packages.\${pkgs.system}.headroom"; + description = '' + The headroom package to install. Override to pin a local + build (e.g. `path:/home/me/code/headroom`) when iterating. + ''; + }; + + installDefaultProfiles = mkOption { + type = types.bool; + default = true; + description = '' + Symlink the profiles shipped with the package into + `$XDG_CONFIG_HOME/headroom/profiles/`. Disable if you + maintain your own profile set and don't want the shipped + ones cluttering `headroom profile list`. + ''; + }; + + extraProfiles = mkOption { + type = types.attrsOf types.path; + default = { }; + example = literalExpression '' + { + "studio.toml" = ./profiles/studio.toml; + } + ''; + description = '' + Additional profile TOML files to drop into the user's + profile directory, keyed by filename. Overrides any + identically-named shipped profile. + ''; + }; + }; + + config = mkIf cfg.enable { + home.packages = [ package ]; + + # Symlink shipped profiles + any user-provided extras into the + # user's XDG_CONFIG_HOME. The daemon's profile watcher + # (notify-debouncer-mini) treats symlinks identically to + # regular files, so this is transparent. + xdg.configFile = lib.mkMerge [ + (mkIf cfg.installDefaultProfiles ( + lib.mapAttrs' + (name: _: lib.nameValuePair "headroom/profiles/${name}" { + source = "${shippedProfilesDir}/${name}"; + }) + (builtins.readDir shippedProfilesDir) + )) + (lib.mapAttrs' + (name: path: lib.nameValuePair "headroom/profiles/${name}" { + source = path; + }) + cfg.extraProfiles) + ]; + + # systemd user unit. The unit shipped by the package already + # carries the right ExecStart with an absolute path baked in, + # so we just symlink it into the user's services directory and + # let Home Manager start it via its systemd-user machinery. + systemd.user.services.headroom = { + Unit = { + Description = "Headroom audio daemon (PipeWire AGC + compressor + true-peak limiter)"; + Documentation = "https://github.com/amaanq/headroom"; + After = [ "pipewire.service" "pipewire-pulse.service" "wireplumber.service" ]; + Requires = [ "pipewire.service" ]; + Wants = [ "wireplumber.service" ]; + }; + Service = { + Type = "simple"; + ExecStart = "${package}/bin/headroom daemon"; + Restart = "on-failure"; + RestartSec = "2s"; + StandardOutput = "journal"; + StandardError = "journal"; + SyslogIdentifier = "headroom"; + LimitRTPRIO = 20; + LimitRTTIME = 200000; + LimitNICE = -11; + }; + Install = { + WantedBy = [ "pipewire.service" ]; + }; + }; + }; +} diff --git a/nix/nixos-module.nix b/nix/nixos-module.nix new file mode 100644 index 0000000..df06016 --- /dev/null +++ b/nix/nixos-module.nix @@ -0,0 +1,61 @@ +# NixOS module — system-wide install. Headroom itself is a user-scope +# daemon (it talks to the user's PipeWire session), so this module's +# job is narrow: +# +# 1. Make the `headroom` binary available on every login's PATH. +# 2. Drop the systemd user unit into the system-wide location so a +# user can `systemctl --user enable --now headroom` without first +# having to use Home Manager. +# 3. Ensure the standard audio stack (PipeWire + WirePlumber) is +# enabled, since headroom can't function without them. +# +# For per-user defaults — activeProfile, shipped-profile install, +# RT-priority tuning — use the Home Manager module +# (`homeModules.default`) instead. The two compose. +self: +{ config, lib, pkgs, ... }: + +let + inherit (lib) mkEnableOption mkOption mkIf types literalExpression; + + cfg = config.programs.headroom; +in +{ + options.programs.headroom = { + enable = mkEnableOption "Headroom — PipeWire AGC + compressor + true-peak limiter daemon"; + + package = mkOption { + type = types.package; + default = self.packages.${pkgs.system}.headroom; + defaultText = literalExpression "headroom.packages.\${pkgs.system}.headroom"; + description = '' + The headroom package to install system-wide. + ''; + }; + }; + + config = mkIf cfg.enable { + # Binary + manpages (when we have them) on the global PATH. + environment.systemPackages = [ cfg.package ]; + + # Make the shipped systemd user unit discoverable by `systemctl + # --user`. Setting `packages` here is the canonical NixOS way to + # install user-scope unit files from a package — it materialises + # `/etc/systemd/user/headroom.service` pointing at the package's + # `lib/systemd/user/headroom.service`. + systemd.packages = [ cfg.package ]; + + # Headroom requires PipeWire; refuse to evaluate the module if + # the user enabled headroom but not pipewire, with a pointer + # rather than a confusing runtime failure. + assertions = [ + { + assertion = config.services.pipewire.enable; + message = '' + programs.headroom.enable requires services.pipewire.enable = true; + headroom is a PipeWire-only daemon. + ''; + } + ]; + }; +}