`headroom monitor` becomes a full-screen ratatui TUI by default;
the previous behaviour (line-delimited JSON, useful for scripts and
tests) is preserved behind --json.
5 — Monitor TUI
New `crates/headroom-cli/src/tui.rs` (~700 lines incl. tests).
Main thread does subscribe + initial status() + route_list() before
entering raw mode, so connect errors surface as clean stderr
messages instead of corrupting the terminal. A reader thread owns
the headroom_client::Client and forwards each subscription event
through a crossbeam channel; an input thread blocks on
event::read() and forwards keys (q / Esc / Ctrl-C) through a
second channel; the main thread `select!`s both plus a 10 Hz
ticker (so uptime + staleness display advance even when no
events are flowing). On quit the OS reaps the reader; a CLI tool
doesn't need a graceful UnixStream shutdown.
Layout: outer block carries the profile / version / uptime in the
top-right title and a footer with subscribed topics + an overflow /
error / disconnected banner when relevant. Inside: bus DSP gauges
(AGC target, compressor GR, limiter GR, true peak), a loudness
panel (momentary / short-term / integrated, greyed when stale),
and a streams table with route + Layer A reduction column.
Wire types caught up to the daemon
`headroom-ipc::RoutingEvent` gained `StreamRemoved`,
`LayerAAttached`, `LayerADetached` variants — these are events the
daemon already publishes (registry.rs §pw) but that
weren't typed in the proto. Without `StreamRemoved` the TUI would
accumulate departed streams forever; without the Layer A pair the
per-stream column couldn't track tap state.
New `LayerALevel` struct types the `meters/layer_a_level` payload
(node_id, app, volume_lin, reduction_db).
`headroom_core::agc::LOUDNESS_FLOOR_LUFS` is now `pub` — it's
published as-is in MeterTick.*_lufs fields when ebur128 has no
useful measurement yet, so clients need it to render "no
measurement" without hard-coding `-200.0`.
Toolchain notes
ratatui and crossterm pinned to =0.28.1. Newer ratatui pulls in
`instability` 0.3.12 + `darling` 0.23 which need rustc 1.88+; the
project pins 1.86 via rust-toolchain.toml. Lockfile also pins
`instability` to 0.3.7 and `darling` to 0.20.10 (older patches that
still build on 1.86).
Verified
185 tests passing (was 178: +5 for TUI event mapping +
fmt_uptime, +2 for stream_removed / layer_a_level handling).
Clippy clean at -D warnings --all-targets.
Live smoke: daemon emits routing/{stream_routed, stream_removed,
layer_a_attached, layer_a_detached} and meters/{tick, layer_a_level}
in shapes that round-trip cleanly through the new typed enums.
TUI binary survives raw-mode init + initial RPCs + subscription
against a live daemon.
Known unrelated daemon gap (to be fixed next): pre-existing streams
aren't actually re-linked when the daemon writes target.object —
WirePlumber updates metadata but doesn't tear the old link down or
create a new one into the processed sink. Bus DSP path therefore
sees silence even when status reports route=processed. Not Phase 5;
addressed separately.
Phase 4 first four checkpoints — daemon now serves the wire protocol
specified in IPC.md and broadcasts events to subscribers.
4a IPC server skeleton
UnixListener at $XDG_RUNTIME_DIR/headroom/control.sock, accept
thread, per-connection thread, hello-on-connect, codec
round-trip, 0600 perms with stale-socket detection. Caught and
fixed a sigprocmask ordering bug: block SIGTERM/SIGINT
process-wide BEFORE the IPC accept thread spawns, otherwise it
inherits the unblocked mask and the signal takes the default
disposition before pipewire's signalfd can read it.
4b Read-only ops + shared state
Arc<Mutex<DaemonState>> (parking_lot) for cross-thread daemon
state. RoutingState moved off Rc<RefCell<>>-only and reads
profile from the shared lock. Captures the headroom-processed
node id via the registry. Implements: status, profile.list,
profile.show, route.list, setting.get (serde-roundtrip dotted
lookup), setting.list (flattened).
4c Mutating ops
profile.use (idempotent no-op until 4e ships the disk loader),
profile.reload (empty list till 4e), route.set/unset with
single-app user-rule replace semantics, setting.set with serde
round-trip type-safety, bypass.set. CLI fix:
allow_hyphen_values so 'headroom set foo.bar -0.5' works.
4d Subscriptions + broadcast
Per-connection split into reader thread + writer thread, joined
by a bounded crossbeam_channel<ServerFrame>(64). Broadcaster in
DaemonState fans out events via try_send; bounded queues drop
on overflow with per-(subscriber, topic) counters and a
daemon::overflow flush event piggybacked onto the next
successful publish.
Live events wired: daemon::started, daemon::shutdown,
routing::rule_changed, routing::stream_routed,
routing::stream_removed. CLI 'monitor [topics]' command
subscribes by topic list.
Workspace deps unchanged; uses already-declared crossbeam-channel,
parking_lot. Sinks/SinkInfo gained Default derives.
Tests: 97 passing (28 dsp, 20 ipc, 45 core, 4 client). Clippy clean
at default level under -D warnings.
Remaining Phase 4 punch-list (recommended order):
4e profile TOML loader + hot reload (notify-debouncer-mini)
4h preferred_real_sink tracking
4i target.object routing reliability on real WirePlumber
4f slow AGC loop with ebur128
4g meters publishing
4j auto-promote to default sink (optional flag)
Phase 3 — bring up the daemon end-to-end through six checkpoints:
3a Module skeleton (error, profile, routing, runtime, pw/*)
3b Pure routing engine + 13 tests (no PipeWire dep)
3c PwContext: main loop, sigprocmask-block SIGTERM/SIGINT before
add_signal_local so signalfd actually picks them up
3d headroom-processed virtual sink via the adapter factory with
factory.name=support.null-audio-sink
3e Filter: two pw_streams (capture from monitor / playback to real
sink) with an rtrb SPSC ring between them. DSP chain
(Compressor → two-tier Limiter) runs in the playback callback.
Allocation-free; #![forbid(unsafe_code)] preserved via
bytemuck::try_cast_slice for the byte↔f32 reinterpretation.
3f Registry watcher binds the default metadata, evaluates new
Stream/Output/Audio nodes against profile rules, writes
target.object for processed routes. Self-stream guard skips
anything whose node.name starts with 'headroom-filter'.
Workspace deps added: pipewire = { features = ["v0_3_44"] } for the
modern TARGET_OBJECT key, libspa, rtrb, nix (sigprocmask), bytemuck.
Tests: 65 passing (28 dsp, 20 ipc, 4 client, 13 core). Clippy clean
at default level under -D warnings.
PLAN.md §5 renumbered to fix stale subsection labels (was 4.1–4.4
from before the per-app insertion).
Known limitations punted to Phase 4 (documented in commit history
and team memory):
- WirePlumber doesn't always honor late target.object writes once
a stream is already linked (timing race).
- preferred_real_sink dynamic tracking stubbed.
- No auto-promote of headroom-processed to system default.
- application.process.binary occasionally arrives in late metadata
updates after the global registers; routing logs show '?' until
we add a re-read.