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:
parent
79e4baedd0
commit
e528a98417
8 changed files with 1283 additions and 31 deletions
|
|
@ -19,6 +19,9 @@ headroom-core = { workspace = true }
|
|||
headroom-ipc = { workspace = true }
|
||||
|
||||
clap = { workspace = true }
|
||||
crossbeam-channel = { workspace = true }
|
||||
crossterm = { workspace = true }
|
||||
ratatui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
810
crates/headroom-cli/src/tui.rs
Normal file
810
crates/headroom-cli/src/tui.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue