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

@ -6,6 +6,8 @@
#![forbid(unsafe_code)]
mod tui;
use std::path::PathBuf;
use std::process::ExitCode;
@ -66,12 +68,19 @@ enum Cmd {
/// Reload profile files from disk.
Reload,
/// Subscribe to event topics and print as line-delimited JSON.
/// Live monitor. Defaults to a full-screen TUI; `--json` falls back
/// to the line-delimited JSON stream that previous versions
/// produced (useful for scripting and tests).
Monitor {
/// Topics to subscribe to (comma-separated).
/// Defaults to `meters` if none given.
/// Topics to subscribe to (comma-separated). Only honoured with
/// `--json`; the TUI always subscribes to all four event topics.
#[arg(value_delimiter = ',', default_value = "meters")]
topics: Vec<MonitorTopic>,
/// Emit one JSON event per line on stdout instead of drawing
/// the TUI.
#[arg(long)]
json: bool,
},
}
@ -170,13 +179,28 @@ fn init_tracing() {
fn run() -> Result<(), CliError> {
let cli = Cli::parse();
init_tracing();
// TUI takes over the terminal; don't let `tracing` scribble on top
// of it. The JSON-mode monitor also benefits from a quieter stderr.
let tui_mode = matches!(&cli.cmd, Cmd::Monitor { json: false, .. });
if !tui_mode {
init_tracing();
}
match cli.cmd {
Cmd::Daemon => {
headroom_core::run().map_err(|e| CliError::Daemon(e.to_string()))?;
Ok(())
}
Cmd::Monitor { json: false, .. } => {
// Connect on the main thread so the initial `status` /
// `route.list` round-trips happen before we enter raw mode.
let client = match cli.socket.as_deref() {
Some(p) => Client::connect_at(p)?,
None => Client::connect()?,
};
tui::run(client).map_err(CliError::Tui)
}
cmd => with_client(cli.socket.as_deref(), |c| dispatch(c, cmd)),
}
}
@ -247,18 +271,23 @@ fn dispatch(client: &mut Client, cmd: Cmd) -> Result<(), CliError> {
let reloaded = client.profile_reload()?;
println!("reloaded: {reloaded:?}");
}
Cmd::Monitor { topics } => {
let pw_topics: Vec<Topic> = topics.iter().copied().map(Topic::from).collect();
client.subscribe(&pw_topics)?;
loop {
let ev = client.next_event()?;
println!(
"{} {}/{} {}",
chrono_like_now(),
ev.topic,
ev.event,
serde_json::to_string(&ev.data)?,
);
Cmd::Monitor { topics, json } => {
if json {
let pw_topics: Vec<Topic> =
topics.iter().copied().map(Topic::from).collect();
client.subscribe(&pw_topics)?;
loop {
let ev = client.next_event()?;
println!(
"{} {}/{} {}",
chrono_like_now(),
ev.topic,
ev.event,
serde_json::to_string(&ev.data)?,
);
}
} else {
unreachable!("TUI monitor is dispatched before `with_client`")
}
}
}
@ -276,6 +305,9 @@ enum CliError {
#[error("json: {0}")]
Json(#[from] serde_json::Error),
#[error("tui: {0}")]
Tui(tui::TuiError),
#[error("{0}")]
Other(String),
}

View file

@ -0,0 +1,810 @@
//! `headroom monitor` TUI. Subscribes to `meters`, `routing`,
//! `profile`, and `daemon`, renders bus DSP gauges + loudness +
//! per-stream routing + status header.
//!
//! Architecture: the main thread owns the terminal and the draw loop.
//! A reader thread owns the `Client` and forwards each subscription
//! event over a crossbeam channel. On quit the main thread restores
//! the terminal and exits; the reader thread is reaped by the OS.
//! (A CLI binary doesn't need a graceful reader shutdown — the kernel
//! tears the UnixStream down on process exit.)
use std::collections::BTreeMap;
use std::io;
use std::thread;
use std::time::{Duration, Instant};
use crossbeam_channel::{select, tick, unbounded, Receiver};
use crossterm::event::{self, Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
use headroom_client::{Client, ClientError};
use headroom_ipc::{
DaemonEvent, Event, LayerALevel, MeterTick, ProfileEvent, Route, RoutingEvent, Status,
StreamRoute, Topic,
};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, Borders, Cell, Gauge, Paragraph, Row, Table},
Frame, Terminal,
};
/// Errors specific to the TUI subcommand.
#[derive(Debug, thiserror::Error)]
pub enum TuiError {
#[error("client: {0}")]
Client(#[from] ClientError),
#[error("terminal: {0}")]
Io(#[from] io::Error),
}
/// Entry point — owns the connected client through initial RPCs, then
/// hands it off to the reader thread and enters the draw loop.
pub fn run(mut client: Client) -> Result<(), TuiError> {
// Subscribe + initial state, all on the main thread before the
// terminal goes into raw mode. Any error here bubbles cleanly.
let topics = [Topic::Meters, Topic::Routing, Topic::Profile, Topic::Daemon];
client.subscribe(&topics)?;
let status = client.status()?;
let route_list = client.route_list()?;
// Spawn reader.
let (tx, rx) = unbounded::<Msg>();
let reader_handle = thread::Builder::new()
.name("headroom-monitor-rx".into())
.spawn(move || reader_loop(client, tx))
.map_err(TuiError::Io)?;
// Terminal up.
let mut terminal = ratatui::init();
let outcome = draw_loop(&mut terminal, status, route_list, rx);
ratatui::restore();
// Detach the reader: process exit (or the dropped channel) will
// tear the connection down. We don't need its result.
drop(reader_handle);
outcome
}
// ---------------------------------------------------------------------------
// Reader thread
// ---------------------------------------------------------------------------
enum Msg {
Event(Event),
Disconnected(String),
}
fn reader_loop(mut client: Client, tx: crossbeam_channel::Sender<Msg>) {
loop {
match client.next_event() {
Ok(ev) => {
if tx.send(Msg::Event(ev)).is_err() {
return;
}
}
Err(e) => {
let _ = tx.send(Msg::Disconnected(e.to_string()));
return;
}
}
}
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
struct UiState {
daemon_version: String,
profile: String,
bypass: bool,
/// Daemon uptime as of connect, plus our local elapsed.
base_uptime_s: u64,
connected_at: Instant,
default_route: Route,
streams: BTreeMap<u32, StreamRoute>,
/// Per-stream Layer A state. Presence = tap attached; the inner
/// `Option<f32>` is the latest smoothed reduction in dB (None
/// until the first `meters/layer_a_level` event arrives).
layer_a: BTreeMap<u32, Option<f32>>,
meters: Option<MeterTick>,
/// Wall-clock instant the last meter tick arrived. Used to show
/// staleness if the audio thread stops feeding the AGC.
last_meter_at: Option<Instant>,
overflow_total: u64,
last_error: Option<String>,
disconnected: Option<String>,
}
impl UiState {
fn new(status: Status, route_list: headroom_ipc::RouteList) -> Self {
let mut streams = BTreeMap::new();
for s in route_list.current {
streams.insert(s.node_id, s);
}
// Streams reported on `status` superset; merge.
for s in status.streams.iter() {
streams.entry(s.node_id).or_insert_with(|| s.clone());
}
Self {
daemon_version: status.version,
profile: status.profile,
bypass: status.bypass,
base_uptime_s: status.uptime_s,
connected_at: Instant::now(),
default_route: route_list.default_route,
streams,
layer_a: BTreeMap::new(),
meters: None,
last_meter_at: None,
overflow_total: 0,
last_error: None,
disconnected: None,
}
}
fn uptime_s(&self) -> u64 {
self.base_uptime_s
.saturating_add(self.connected_at.elapsed().as_secs())
}
fn apply_event(&mut self, ev: Event) {
match ev.topic {
Topic::Meters if ev.event == "tick" => {
if let Ok(m) = serde_json::from_value::<MeterTick>(ev.data) {
self.meters = Some(m);
self.last_meter_at = Some(Instant::now());
}
}
Topic::Meters if ev.event == "layer_a_level" => {
if let Ok(l) = serde_json::from_value::<LayerALevel>(ev.data) {
self.layer_a.insert(l.node_id, Some(l.reduction_db));
}
}
Topic::Routing => {
if let Ok(re) = serde_json::from_value::<RoutingEvent>(routing_payload(&ev)) {
match re {
RoutingEvent::StreamRouted { node_id, app, to } => {
self.streams.insert(
node_id,
StreamRoute {
node_id,
app,
route: to,
},
);
}
RoutingEvent::StreamRemoved { node_id } => {
self.streams.remove(&node_id);
self.layer_a.remove(&node_id);
}
RoutingEvent::LayerAAttached { node_id, .. } => {
// Mark managed; reduction unknown until the
// first `layer_a_level` event lands.
self.layer_a.entry(node_id).or_insert(None);
}
RoutingEvent::LayerADetached { node_id } => {
self.layer_a.remove(&node_id);
}
RoutingEvent::RuleChanged => { /* TUI doesn't display rules */ }
_ => {}
}
}
}
Topic::Profile => {
if let Ok(ProfileEvent::Changed { name, .. }) =
serde_json::from_value::<ProfileEvent>(profile_payload(&ev))
{
self.profile = name;
}
}
Topic::Daemon => {
if let Ok(de) = serde_json::from_value::<DaemonEvent>(daemon_payload(&ev)) {
match de {
DaemonEvent::Overflow {
lost, total_lost, ..
} => {
self.overflow_total = total_lost.max(self.overflow_total + lost as u64);
}
DaemonEvent::Error { code, message } => {
self.last_error = Some(format!("{code}: {message}"));
}
DaemonEvent::Shutdown => {
self.disconnected = Some("daemon shutdown".into());
}
DaemonEvent::Started { version } => {
self.daemon_version = version;
}
_ => {}
}
}
}
_ => {}
}
}
}
/// The wire frame carries `{event, topic, data}` — the typed enum lives
/// inside `data` but is `#[serde(tag = "event")]`, so we re-inject the
/// event name to make serde happy. Same dance for the other topics.
fn routing_payload(ev: &Event) -> serde_json::Value {
inject_event(&ev.event, &ev.data)
}
fn profile_payload(ev: &Event) -> serde_json::Value {
inject_event(&ev.event, &ev.data)
}
fn daemon_payload(ev: &Event) -> serde_json::Value {
inject_event(&ev.event, &ev.data)
}
fn inject_event(event: &str, data: &serde_json::Value) -> serde_json::Value {
let mut obj = match data {
serde_json::Value::Object(m) => m.clone(),
_ => serde_json::Map::new(),
};
obj.insert("event".into(), serde_json::Value::String(event.to_string()));
serde_json::Value::Object(obj)
}
// ---------------------------------------------------------------------------
// Draw loop
// ---------------------------------------------------------------------------
fn draw_loop<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
status: Status,
route_list: headroom_ipc::RouteList,
rx: Receiver<Msg>,
) -> Result<(), TuiError> {
let mut state = UiState::new(status, route_list);
// 10 Hz redraw floor so uptime + staleness counters tick even when
// there are no events flowing.
let ticker = tick(Duration::from_millis(100));
let input_rx = spawn_input_thread();
loop {
terminal.draw(|f| draw(f, &state))?;
select! {
recv(rx) -> msg => match msg {
Ok(Msg::Event(ev)) => state.apply_event(ev),
Ok(Msg::Disconnected(reason)) => {
state.disconnected = Some(reason);
// Final paint, then linger briefly so the user sees
// the disconnected banner.
terminal.draw(|f| draw(f, &state))?;
thread::sleep(Duration::from_millis(800));
return Ok(());
}
Err(_) => return Ok(()),
},
recv(input_rx) -> msg => match msg {
Ok(InputMsg::Quit) => return Ok(()),
Ok(InputMsg::Other) => {}
Err(_) => return Ok(()),
},
recv(ticker) -> _ => {}
}
}
}
// ---------------------------------------------------------------------------
// Input thread
// ---------------------------------------------------------------------------
enum InputMsg {
Quit,
Other,
}
fn spawn_input_thread() -> Receiver<InputMsg> {
let (tx, rx) = unbounded::<InputMsg>();
thread::Builder::new()
.name("headroom-monitor-input".into())
.spawn(move || loop {
// Block on the next terminal event; crossterm's read() is
// a blocking syscall against stdin.
let Ok(ev) = event::read() else { return };
let msg = match ev {
CtEvent::Key(k) if is_quit(&k) => InputMsg::Quit,
CtEvent::Key(_) | CtEvent::Resize(_, _) => InputMsg::Other,
_ => continue,
};
if tx.send(msg).is_err() {
return;
}
})
.expect("spawn input thread");
rx
}
fn is_quit(k: &KeyEvent) -> bool {
matches!(k.code, KeyCode::Char('q') | KeyCode::Esc)
|| (k.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(k.code, KeyCode::Char('c') | KeyCode::Char('C')))
}
// ---------------------------------------------------------------------------
// Drawing
// ---------------------------------------------------------------------------
fn draw(f: &mut Frame, state: &UiState) {
let area = f.area();
let outer = Block::default()
.borders(Borders::ALL)
.title(Span::styled(
" headroom monitor ",
Style::default().add_modifier(Modifier::BOLD),
))
.title_top(Line::from(header_status(state)).right_aligned())
.title_bottom(Line::from(footer_text(state)).right_aligned());
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(6), // bus gauges
Constraint::Length(5), // loudness
Constraint::Min(4), // streams table
])
.split(inner);
draw_bus(f, chunks[0], state);
draw_loudness(f, chunks[1], state);
draw_streams(f, chunks[2], state);
}
fn header_status(state: &UiState) -> Vec<Span<'static>> {
let bypass_span = if state.bypass {
Span::styled(
" BYPASS ",
Style::default().fg(Color::Black).bg(Color::Yellow),
)
} else {
Span::styled(" processed ", Style::default().fg(Color::Green))
};
vec![
Span::raw(" profile: "),
Span::styled(state.profile.clone(), Style::default().bold()),
Span::raw(" "),
bypass_span,
Span::raw(format!(
" v{} uptime {} ",
state.daemon_version,
fmt_uptime(state.uptime_s())
)),
]
}
fn footer_text(state: &UiState) -> Vec<Span<'static>> {
let mut parts: Vec<Span> = vec![
Span::raw(" q/Esc/Ctrl-C quit "),
Span::styled("·", Style::default().fg(Color::DarkGray)),
Span::raw(" subscribed: meters routing profile daemon "),
];
if state.overflow_total > 0 {
parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
parts.push(Span::styled(
format!(" dropped: {} ", state.overflow_total),
Style::default().fg(Color::Yellow),
));
}
if let Some(err) = &state.last_error {
parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
parts.push(Span::styled(
format!(" daemon error: {err} "),
Style::default().fg(Color::Red),
));
}
if let Some(reason) = &state.disconnected {
parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
parts.push(Span::styled(
format!(" disconnected: {reason} "),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
));
}
parts
}
fn draw_bus(f: &mut Frame, area: Rect, state: &UiState) {
let block = Block::default()
.borders(Borders::ALL)
.title(" bus dsp ");
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner);
let m = state.meters;
draw_gauge_row(
f,
rows[0],
GaugeRow {
label: "AGC target",
value: m.map(|t| t.agc_gain_db),
min: -12.0,
max: 12.0,
unit: "dB",
color: Color::Cyan,
},
);
draw_gauge_row(
f,
rows[1],
GaugeRow {
label: "Compressor GR",
value: m.map(|t| t.compressor_gr_db),
min: -24.0,
max: 0.0,
unit: "dB",
color: Color::Magenta,
},
);
draw_gauge_row(
f,
rows[2],
GaugeRow {
label: "Limiter GR",
value: m.map(|t| t.limiter_gr_db),
min: -24.0,
max: 0.0,
unit: "dB",
color: Color::Red,
},
);
draw_gauge_row(
f,
rows[3],
GaugeRow {
label: "True peak",
value: m.map(|t| t.true_peak_dbtp),
min: -60.0,
max: 3.0,
unit: "dBTP",
color: Color::Green,
},
);
}
struct GaugeRow<'a> {
label: &'a str,
value: Option<f32>,
min: f32,
max: f32,
unit: &'a str,
color: Color,
}
/// One labeled gauge row: `LABEL VALUE [████░░░░] min..max`.
fn draw_gauge_row(f: &mut Frame, area: Rect, row: GaugeRow<'_>) {
let GaugeRow {
label,
value,
min,
max,
unit,
color,
} = row;
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(16),
Constraint::Length(14),
Constraint::Min(8),
Constraint::Length(14),
])
.split(area);
f.render_widget(Paragraph::new(format!(" {label}")), cols[0]);
let value_str = value
.map(|v| format!("{v:+7.2} {unit}"))
.unwrap_or_else(|| " -- ".to_string());
f.render_widget(
Paragraph::new(value_str).alignment(Alignment::Right),
cols[1],
);
let pct = match value {
Some(v) => {
let clamped = v.clamp(min, max);
((clamped - min) / (max - min)).clamp(0.0, 1.0) as f64
}
None => 0.0,
};
let gauge = Gauge::default()
.gauge_style(Style::default().fg(color))
.ratio(pct)
.label("");
f.render_widget(gauge, cols[2]);
f.render_widget(
Paragraph::new(format!("{min:.0}..{max:.0} ")).alignment(Alignment::Right),
cols[3],
);
}
fn draw_loudness(f: &mut Frame, area: Rect, state: &UiState) {
let block = Block::default()
.borders(Borders::ALL)
.title(" loudness (BS.1770) ");
let inner = block.inner(area);
f.render_widget(block, area);
let staleness = state
.last_meter_at
.map(|t| t.elapsed())
.unwrap_or(Duration::ZERO);
let stale = staleness > Duration::from_millis(500);
let (mom, st, intg) = match state.meters {
Some(m) => (Some(m.momentary_lufs), Some(m.shortterm_lufs), Some(m.integrated_lufs)),
None => (None, None, None),
};
let lines = vec![
lufs_line("Momentary (400 ms)", mom, stale),
lufs_line("Short-term (3 s)", st, stale),
lufs_line("Integrated (gated)", intg, stale),
];
f.render_widget(Paragraph::new(lines), inner);
}
fn lufs_line(label: &str, v: Option<f32>, stale: bool) -> Line<'static> {
let val = match v {
Some(x) if x > headroom_core::agc::LOUDNESS_FLOOR_LUFS + 0.5 => {
format!("{x:+7.2} LUFS")
}
Some(_) => " -- LUFS".to_string(),
None => " -- LUFS".to_string(),
};
let style = if stale {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
};
Line::from(vec![
Span::raw(format!(" {label:<24}")),
Span::styled(val, style),
])
}
fn draw_streams(f: &mut Frame, area: Rect, state: &UiState) {
let title = format!(
" streams ({}) — default: {} ",
state.streams.len(),
state.default_route
);
let block = Block::default().borders(Borders::ALL).title(title);
let header = Row::new(vec!["node", "app", "route", "layer A"])
.style(Style::default().add_modifier(Modifier::BOLD));
let rows: Vec<Row> = state
.streams
.values()
.map(|s| {
let route_cell = match s.route {
Route::Processed => Cell::from("processed").style(Style::default().fg(Color::Green)),
Route::Bypass => Cell::from("bypass").style(Style::default().fg(Color::Yellow)),
};
let la_cell = match state.layer_a.get(&s.node_id) {
Some(Some(db)) => Cell::from(format!("{db:+5.1} dB"))
.style(Style::default().fg(Color::Magenta)),
Some(None) => Cell::from("attached")
.style(Style::default().fg(Color::DarkGray)),
None => Cell::from("").style(Style::default().fg(Color::DarkGray)),
};
Row::new(vec![
Cell::from(s.node_id.to_string()),
Cell::from(s.app.clone()),
route_cell,
la_cell,
])
})
.collect();
let widths = [
Constraint::Length(8),
Constraint::Min(20),
Constraint::Length(12),
Constraint::Length(10),
];
let table = Table::new(rows, widths).header(header).block(block);
f.render_widget(table, area);
}
fn fmt_uptime(s: u64) -> String {
let h = s / 3600;
let m = (s % 3600) / 60;
let sec = s % 60;
if h > 0 {
format!("{h}h{m:02}m{sec:02}s")
} else if m > 0 {
format!("{m}m{sec:02}s")
} else {
format!("{sec}s")
}
}
#[cfg(test)]
mod tests {
use super::*;
use headroom_ipc::{Sinks, Status};
fn empty_state() -> UiState {
let status = Status {
version: "test".into(),
protocol: 1,
uptime_s: 0,
profile: "default".into(),
bypass: false,
sinks: Sinks::default(),
streams: vec![],
warnings: vec![],
};
let route_list = headroom_ipc::RouteList {
rules: vec![],
current: vec![],
default_route: Route::Processed,
};
UiState::new(status, route_list)
}
#[test]
fn meter_tick_event_updates_state() {
let mut state = empty_state();
let tick = MeterTick {
momentary_lufs: -19.3,
shortterm_lufs: -20.1,
integrated_lufs: -19.8,
true_peak_dbtp: -1.4,
gain_reduction_db: -2.1,
compressor_gr_db: -0.8,
limiter_gr_db: -1.3,
agc_gain_db: 0.5,
};
let ev = Event::new(Topic::Meters, "tick", &tick).unwrap();
state.apply_event(ev);
let got = state.meters.expect("meters set");
assert!((got.momentary_lufs - tick.momentary_lufs).abs() < f32::EPSILON);
assert!((got.true_peak_dbtp - tick.true_peak_dbtp).abs() < f32::EPSILON);
assert!(state.last_meter_at.is_some());
}
#[test]
fn stream_removed_prunes_state() {
let mut state = empty_state();
// Insert via stream_routed first.
state.apply_event(
Event::new(
Topic::Routing,
"stream_routed",
&serde_json::json!({ "node_id": 7, "app": "x", "to": "processed" }),
)
.unwrap(),
);
state.apply_event(
Event::new(
Topic::Routing,
"layer_a_attached",
&serde_json::json!({ "node_id": 7, "app": "x" }),
)
.unwrap(),
);
assert!(state.streams.contains_key(&7));
assert!(state.layer_a.contains_key(&7));
state.apply_event(
Event::new(
Topic::Routing,
"stream_removed",
&serde_json::json!({ "node_id": 7 }),
)
.unwrap(),
);
assert!(!state.streams.contains_key(&7));
assert!(!state.layer_a.contains_key(&7));
}
#[test]
fn layer_a_level_updates_reduction() {
let mut state = empty_state();
state.apply_event(
Event::new(
Topic::Routing,
"layer_a_attached",
&serde_json::json!({ "node_id": 11, "app": "loud-app" }),
)
.unwrap(),
);
assert_eq!(state.layer_a.get(&11), Some(&None));
state.apply_event(
Event::new(
Topic::Meters,
"layer_a_level",
&serde_json::json!({
"node_id": 11,
"app": "loud-app",
"volume_lin": 0.256_f32,
"reduction_db": -11.8_f32,
}),
)
.unwrap(),
);
let r = state.layer_a.get(&11).copied().flatten().unwrap();
assert!((r - -11.8).abs() < 1e-4);
}
#[test]
fn routing_event_inserts_stream() {
let mut state = empty_state();
let ev = Event::new(
Topic::Routing,
"stream_routed",
&serde_json::json!({
"node_id": 42,
"app": "firefox",
"to": "bypass",
}),
)
.unwrap();
state.apply_event(ev);
let s = state.streams.get(&42).expect("stream tracked");
assert_eq!(s.app, "firefox");
assert_eq!(s.route, Route::Bypass);
}
#[test]
fn profile_changed_updates_active() {
let mut state = empty_state();
let ev = Event::new(
Topic::Profile,
"changed",
&serde_json::json!({
"name": "night",
"previous": "default",
}),
)
.unwrap();
state.apply_event(ev);
assert_eq!(state.profile, "night");
}
#[test]
fn daemon_overflow_accumulates() {
let mut state = empty_state();
let ev = Event::new(
Topic::Daemon,
"overflow",
&serde_json::json!({
"lost_topic": "meters",
"lost": 3u32,
"total_lost": 5u64,
}),
)
.unwrap();
state.apply_event(ev);
assert_eq!(state.overflow_total, 5);
}
#[test]
fn fmt_uptime_buckets() {
assert_eq!(fmt_uptime(5), "5s");
assert_eq!(fmt_uptime(75), "1m15s");
assert_eq!(fmt_uptime(3725), "1h02m05s");
}
}