5: monitor TUI + wire fill-ins

`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.
This commit is contained in:
atagen 2026-05-21 13:35:27 +10:00
parent 79e4baedd0
commit e528a98417
8 changed files with 1283 additions and 31 deletions

View file

@ -13,9 +13,9 @@ mod proto;
pub use codec::{Codec, DEFAULT_MAX_FRAME_BYTES, MIN_MAX_FRAME_BYTES};
pub use error::{Error, ErrorCode, ProtoError};
pub use proto::{
DaemonEvent, Event, HelloData, MeterTick, Op, ProfileEvent, ProfileInfo, Request, Response,
ResponsePayload, Route, RouteList, RouteRule, RouteRuleMatch, RoutingEvent, ServerFrame,
SinkInfo, Sinks, Status, StreamRoute, Topic,
DaemonEvent, Event, HelloData, LayerALevel, MeterTick, Op, ProfileEvent, ProfileInfo, Request,
Response, ResponsePayload, Route, RouteList, RouteRule, RouteRuleMatch, RoutingEvent,
ServerFrame, SinkInfo, Sinks, Status, StreamRoute, Topic,
};
/// Wire-protocol version. Bumped only on incompatible changes.

View file

@ -525,10 +525,49 @@ pub enum RoutingEvent {
/// Route assigned.
to: Route,
},
/// A stream tracked by the routing engine went away (its
/// PipeWire node disappeared). Clients should drop any state
/// indexed by `node_id`.
StreamRemoved {
/// Node id of the departed stream.
node_id: u32,
},
/// A Layer A (per-app level control) tap was attached to a
/// stream — the daemon will start managing its
/// `Props.channelVolumes` and publishing `meters/layer_a_level`
/// events for it.
LayerAAttached {
/// Node id of the managed stream.
node_id: u32,
/// Application identifier.
app: String,
},
/// A Layer A tap was torn down (typically because the stream
/// went away). Clients should drop Layer A state for `node_id`.
LayerADetached {
/// Node id whose tap was torn down.
node_id: u32,
},
/// A persistent rule was added, replaced, or removed.
RuleChanged,
}
/// `meters/layer_a_level` payload — published when the per-app
/// (Layer A) level controller writes a new `channelVolumes` value to
/// a managed stream.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LayerALevel {
/// Source PipeWire node id.
pub node_id: u32,
/// Application identifier.
pub app: String,
/// Linear volume that was written (1.0 = unity).
pub volume_lin: f32,
/// Smoothed gain reduction the controller currently asserts, in
/// dB. ≤ 0 dB when reducing.
pub reduction_db: f32,
}
/// `daemon` topic events.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]