diff --git a/PLAN.md b/PLAN.md index 7d3e62b..c2b5374 100644 --- a/PLAN.md +++ b/PLAN.md @@ -831,7 +831,10 @@ builds and any CI go through `nix build`. ## 11. Phased implementation -The phases are roughly token-of-work units, not calendar weeks. +The phases are roughly token-of-work units, not calendar weeks. **All +planned phases (0–8) are done as of 2026-05-21**; this section is +preserved as historical context + a reading guide to the commit log. +See [[headroom-project]] in team memory for the per-commit ledger. **Phase 0 — scaffolding.** Flake, workspace, crate skeletons, README, PLAN/IPC docs. *(done as part of this commit)* @@ -891,33 +894,23 @@ Sub-stages used in commits / TODOs: ### Tracked follow-ups (carried past their sub-stage) Items deliberately deferred from earlier sub-stages so they don't get -lost. Pick up by name when the phase that consumes them lands. +lost. Pick up by name when the trigger that gates them fires. - **Ephemeral overlay mutations.** *(4e follow-up.)* All `route.set` / `setting.set` changes are persisted to `overlay.toml`. A `--ephemeral` flag (or `--volatile`) on the CLI for one-shot tweaks that don't outlive the daemon was considered and dropped from v0 for simplicity. Revisit if real users ask for it; the store-level - change is a flag on the setter methods. -- **Filter playback BUSY spikes (periodic, ~10 s cadence).** *(6c - manual smoke finding, 2026-05.)* On a quiet system with AGC and - per-app both off, the filter's `playback_process` BUSY - occasionally spikes from its ~240 μs steady-state to ~2.0 ms, - correlating with output-sink WAIT spikes of similar size. No - audible impact (sub-quantum at 21 ms). The ~10 s cadence rules - out sliding-max worst-case (which would be input-pattern-driven, - not periodic) and Layer A (the spikes persist with `per_app.enabled - = false`). Suspects with 10 s clocks somewhere: WirePlumber session - policy heartbeat, PipeWire internal graph re-eval, or system-level - scheduling (CPU governor, kernel housekeeping). Diagnostic for - Phase 8: timestamp the playback callback, log when its measured - duration crosses ~1 ms; correlate with `journalctl`, - `wireplumber --verbose`, and `pw-dump` snapshots taken around the - spike. If we can't attribute it to PipeWire-side reschedule and - it's something we can fix in our callback, the candidate - workaround is to break the limiter's per-block work into smaller - chunks (cap allocations / pops / branches per call) for more - predictable timing. + change is a flag on the setter methods. **Dormant** — no user has + asked through Phase 8. +- ~~**Filter playback BUSY spikes (periodic, ~10 s cadence).**~~ + **Closed in 8e (`d52cd6d`).** The instrumentation added by 8e + did not reproduce the ~8×-baseline outlier pattern in a ~3 min + release-build capture; steady state was ~2.2 ms / call at this + hardware's quantum with max growing only to 1.3× baseline. + `PlaybackTiming` stays so future regressions surface at WARN. + Original observation may have been a transient WP/PW housekeeping + artefact under a different config; no actionable code change. - **Sub-millisecond dispatch primitive for spike-reactive writes.** *(Phase 6 optimisation, downgraded from prerequisite.)* The 4i `PwCommand` channel uses a 50 ms polling timer, fine for @@ -1042,18 +1035,68 @@ If those three say "fine," the §4.1 promise is upheld in practice and 6c is acceptance-tested. `jack_iodelay` and other true-round-trip tools are overkill. -**Phase 7 — Packaging.** systemd user unit, install paths, default -profile install, basic NixOS module. +**Phase 7 — Packaging.** *Done — `c65c75b`.* `contrib/systemd/headroom.service` +(user-scope, Type=simple, After=pipewire.service, Restart=on-failure, +journald, LimitRTPRIO=20). The package's `postInstall` substitutes +the unit's `@bindir@` placeholder with an absolute store path and +copies `profiles/*.toml` to `share/headroom/profiles/`. Two Nix +modules: `nixosModules.default` (`programs.headroom.enable` — +binary on global PATH + `systemd.packages` for `systemctl --user` +discovery + hard assertion on `services.pipewire.enable`) and +`homeModules.default` (`services.headroom.enable` — symlinks +shipped profiles into `$XDG_CONFIG_HOME/headroom/profiles/`, +`extraProfiles` attrset for per-user overrides, writes the systemd +user unit). README rewritten with install + usage sections. -**Phase 8 — Hardening.** Latency budget verification on real hardware, -Bluetooth-handoff edge case, profile-reload while audio is flowing, -multi-rate hardware, allocation-tracer sweep with -`assert_no_alloc` in debug. +**Phase 8 — Hardening.** *Done — `9220143` + `d52cd6d` + verification.* +- **8a — `assert_no_alloc` on audio-thread callbacks (`9220143`).** + `#[global_allocator] AllocDisabler` in `headroom-cli/src/main.rs` + behind `cfg(debug_assertions)` (release strips it via the crate's + default `disable_release`). The three RT callbacks + (`capture_process`, `playback_process`, `tap_process`) wrap their + body in `assert_no_alloc(|| inner(...))`. Verified by a deliberate + `Vec::with_capacity` injection → SIGABRT on first audio callback; + reverted before commit. Audio thread proven alloc-free under + multi-thousand-callback live load. +- **8b — live profile-reload under signal flow (verification only).** + Edit `$XDG_CONFIG_HOME/headroom/profiles/.toml` while a + sine plays: notify-debouncer-mini fires, `ProfileStore::reload` + runs, `setting.set` propagates via `FilterControl`'s rtrb to the + audio thread. Compressor GR went 0 → −9.3 dB ≈ 1 s after edit + and back to 0 after restore; 180 meter ticks over 9 s with max + inter-tick gap = exact 50.0 ms (the AGC period). No glitches. +- **8c — sink hotplug / default-sink change (verification only).** + `wpctl set-default ` while daemon runs: + `on_metadata_property` fires, `adopt_new_real_sink` runs, + filter.playback re-pinned via 4k explicit-link enforcement, + `routing/real_sink_changed` emitted on the wire. Bounces back + cleanly. +- **8d — multi-rate hardware (partial / deferred).** Filter is + hardcoded F32 stereo @ 48 kHz; PipeWire's link layer inserts a + resampler at the filter.playback → real-sink edge when rates + differ; bus DSP stays at 48 kHz internally. Architecture is + sound; real-hardware validation (USB DAC at 96k etc.) deferred + until available. +- **8e — playback callback timing instrumentation (`d52cd6d`).** + Lock-free `PlaybackTiming` atomics in `meters.rs`; AGC controller + drains once per second and logs at WARN above + `SPIKE_THRESHOLD_US = 5000`. The original ~10 s-cadence ~8× + spike pattern from §11 follow-ups *did not reproduce* in a ~3 min + release-build capture; steady state 2.2 ms / call at ~4 Hz, + max climbed to only 1.3× baseline. Instrumentation kept so + future regressions surface. --- ## 12. Risks & open questions +These are the original v0 design risks — still useful as a checklist +for new contributors. Phase 4k/4l/8c have exercised the routing / +hotplug / default-sink branches; the bullets below are unchanged +since several of them remain live concerns for non-NixOS distros +and multi-rate hardware. See [[headroom-project]] in team memory +for current status per risk. + - **WirePlumber re-linking on device hotplug.** When a Bluetooth headset connects, WP re-evaluates linking. Headroom must re-pin its routed streams. Tractable; the registry events surface this.