fix: further layer A (per-app) glitches

This commit is contained in:
atagen 2026-05-24 18:12:31 +10:00
parent 2978318019
commit 7797f60128
16 changed files with 1589 additions and 155 deletions

View file

@ -52,6 +52,10 @@ enum Cmd {
#[command(subcommand)]
Route(RouteCmd),
/// Per-application level control (Layer A).
#[command(subcommand)]
PerApp(PerAppCmd),
/// Get a setting value from the active profile.
Get {
/// Dotted setting key.
@ -156,6 +160,34 @@ enum RouteCmd {
},
}
#[derive(Debug, Subcommand)]
enum PerAppCmd {
/// Show per-app Layer A state for currently-managed streams.
Status {
/// Emit the snapshot list as JSON instead of a table.
#[arg(long)]
json: bool,
},
/// Enable the Layer A master switch (persisted).
On,
/// Disable the Layer A master switch (persisted).
Off,
/// Enable or disable Layer A for a specific app (persisted).
Set {
/// Application identifier (process_binary or application_name).
app: String,
/// `on` or `off`.
#[arg(value_enum)]
state: BypassState,
},
/// Clear a managed stream's deference lock so the controller
/// resumes normal level control.
Reset {
/// PipeWire node id of the managed stream.
node_id: u32,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum BypassState {
On,
@ -265,6 +297,47 @@ fn dispatch(client: &mut Client, cmd: Cmd) -> Result<(), CliError> {
client.route_stream(node_id, to.into())?;
}
Cmd::PerApp(PerAppCmd::Status { json }) => {
let list = client.layer_a_list()?;
if json {
println!("{}", serde_json::to_string_pretty(&list)?);
} else if list.is_empty() {
println!("no streams under Layer A management");
} else {
println!(
"{:<8} {:<24} {:>10} {:>9} {:>9}",
"node", "app", "reduction", "ceiling", "deferred"
);
for s in &list {
let app = if s.app.len() > 24 {
format!("{}", &s.app[..23])
} else {
s.app.clone()
};
let ceiling = s
.user_ceiling_lin
.map(|c| format!("{c:.2}"))
.unwrap_or_else(|| "".to_string());
println!(
"{:<8} {:<24} {:>8.1}dB {:>9} {:>9}",
s.node_id, app, s.reduction_db, ceiling, s.deferred
);
}
}
}
Cmd::PerApp(PerAppCmd::On) => {
client.per_app_master(true)?;
}
Cmd::PerApp(PerAppCmd::Off) => {
client.per_app_master(false)?;
}
Cmd::PerApp(PerAppCmd::Set { app, state }) => {
client.per_app_set(&app, matches!(state, BypassState::On))?;
}
Cmd::PerApp(PerAppCmd::Reset { node_id }) => {
client.layer_a_reset(node_id)?;
}
Cmd::Get { key } => {
let v = client.setting_get(&key)?;
println!("{}", serde_json::to_string(&v)?);

View file

@ -18,8 +18,8 @@ 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,
DaemonEvent, Event, LayerALevel, LayerASnapshot, MeterTick, ProfileEvent, Route, RoutingEvent,
Status, StreamRoute, Topic,
};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
@ -49,6 +49,12 @@ pub fn run(mut client: Client) -> Result<(), TuiError> {
let status = client.status()?;
let route_list = client.route_list()?;
// The blocking client is single-connection: the reader thread will
// own `client` for the event stream, so open a *second* connection
// for control (request/response ops issued on keypress). Same
// socket the event client connected to.
let mut control = Client::connect_at(client.socket_path())?;
// Spawn reader.
let (tx, rx) = unbounded::<Msg>();
let reader_handle = thread::Builder::new()
@ -58,7 +64,7 @@ pub fn run(mut client: Client) -> Result<(), TuiError> {
// Terminal up.
let mut terminal = ratatui::init();
let outcome = draw_loop(&mut terminal, status, route_list, rx);
let outcome = draw_loop(&mut terminal, status, route_list, rx, &mut control);
ratatui::restore();
// Detach the reader: process exit (or the dropped channel) will
@ -101,6 +107,8 @@ struct UiState {
daemon_version: String,
profile: String,
bypass: bool,
/// Layer A master switch (per-app level control enabled globally).
per_app_master: bool,
/// Daemon uptime as of connect, plus our local elapsed.
base_uptime_s: u64,
connected_at: Instant,
@ -110,6 +118,13 @@ struct UiState {
/// `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>>,
/// Richer per-stream Layer A snapshots (ceiling, deferred,
/// managed), refreshed by polling `per-app.list` on the ticker.
/// Feeds the detail line; the table column still uses `layer_a`.
la_snapshots: BTreeMap<u32, LayerASnapshot>,
/// Currently-selected stream node id (for row actions). Resolved
/// against `streams` at draw time; falls back to the first row.
selected: Option<u32>,
meters: Option<MeterTick>,
/// Wall-clock instant the last meter tick arrived. Used to show
/// staleness if the audio thread stops feeding the AGC.
@ -129,15 +144,27 @@ impl UiState {
for s in status.streams.iter() {
streams.entry(s.node_id).or_insert_with(|| s.clone());
}
// Seed Layer A snapshots from the initial status so the detail
// line + table are populated before the first poll.
let mut la_snapshots = BTreeMap::new();
let mut layer_a = BTreeMap::new();
for snap in status.layer_a {
layer_a.insert(snap.node_id, Some(snap.reduction_db));
la_snapshots.insert(snap.node_id, snap);
}
let selected = streams.keys().next().copied();
Self {
daemon_version: status.version,
profile: status.profile,
bypass: status.bypass,
per_app_master: status.per_app,
base_uptime_s: status.uptime_s,
connected_at: Instant::now(),
default_route: route_list.default_route,
streams,
layer_a: BTreeMap::new(),
layer_a,
la_snapshots,
selected,
meters: None,
last_meter_at: None,
overflow_total: 0,
@ -151,6 +178,37 @@ impl UiState {
.saturating_add(self.connected_at.elapsed().as_secs())
}
/// Ordered list of stream node ids (matches the streams table row
/// order — `BTreeMap` keys, ascending).
fn ordered_nodes(&self) -> Vec<u32> {
self.streams.keys().copied().collect()
}
/// The effectively-selected node id: `selected` when it's still a
/// live stream, else the first row, else `None` (no streams).
fn effective_selection(&self) -> Option<u32> {
match self.selected {
Some(id) if self.streams.contains_key(&id) => Some(id),
_ => self.streams.keys().next().copied(),
}
}
/// Move the selection by `delta` rows (negative = up). No-op when
/// there are no streams.
fn move_selection(&mut self, delta: isize) {
let nodes = self.ordered_nodes();
if nodes.is_empty() {
self.selected = None;
return;
}
let cur = self
.effective_selection()
.and_then(|id| nodes.iter().position(|&n| n == id))
.unwrap_or(0) as isize;
let next = (cur + delta).rem_euclid(nodes.len() as isize) as usize;
self.selected = Some(nodes[next]);
}
fn apply_event(&mut self, ev: Event) {
match ev.topic {
Topic::Meters if ev.event == "tick" => {
@ -180,6 +238,7 @@ impl UiState {
RoutingEvent::StreamRemoved { node_id } => {
self.streams.remove(&node_id);
self.layer_a.remove(&node_id);
self.la_snapshots.remove(&node_id);
}
RoutingEvent::LayerAAttached { node_id, .. } => {
// Mark managed; reduction unknown until the
@ -188,6 +247,7 @@ impl UiState {
}
RoutingEvent::LayerADetached { node_id } => {
self.layer_a.remove(&node_id);
self.la_snapshots.remove(&node_id);
}
RoutingEvent::RuleChanged => { /* TUI doesn't display rules */ }
_ => {}
@ -258,12 +318,17 @@ fn draw_loop<B: ratatui::backend::Backend>(
status: Status,
route_list: headroom_ipc::RouteList,
rx: Receiver<Msg>,
control: &mut Client,
) -> 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();
// Poll the richer Layer A snapshot (ceiling / deferred / managed)
// roughly once a second — live `layer_a_level` events already feed
// the table column; the snapshot fills in the detail line.
let mut poll_ticks: u32 = 0;
loop {
terminal.draw(|f| draw(f, &state))?;
@ -283,21 +348,102 @@ fn draw_loop<B: ratatui::backend::Backend>(
},
recv(input_rx) -> msg => match msg {
Ok(InputMsg::Quit) => return Ok(()),
Ok(InputMsg::Other) => {}
Ok(InputMsg::Key(k)) => handle_key(&mut state, control, k),
Ok(InputMsg::Redraw) => {}
Err(_) => return Ok(()),
},
recv(ticker) -> _ => {}
recv(ticker) -> _ => {
poll_ticks = poll_ticks.wrapping_add(1);
if poll_ticks % 10 == 0 {
poll_layer_a(&mut state, control);
}
}
}
}
}
/// Pull the current Layer A snapshot list from the control connection
/// and refresh the detail-line state. Errors are surfaced in the
/// footer rather than fatal — the event stream keeps the rest of the
/// UI live.
fn poll_layer_a(state: &mut UiState, control: &mut Client) {
match control.layer_a_list() {
Ok(list) => {
state.la_snapshots = list.into_iter().map(|s| (s.node_id, s)).collect();
}
Err(e) => {
state.last_error = Some(format!("layer-a poll: {e}"));
}
}
}
/// Apply a keypress: navigation + global toggles + per-row actions.
/// Control ops are issued synchronously on the (separate) control
/// connection; failures land in the footer.
fn handle_key(state: &mut UiState, control: &mut Client, k: KeyEvent) {
match k.code {
KeyCode::Char('j') | KeyCode::Down => state.move_selection(1),
KeyCode::Char('k') | KeyCode::Up => state.move_selection(-1),
KeyCode::Char('b') => {
let target = !state.bypass;
match control.bypass_set(target) {
Ok(()) => state.bypass = target,
Err(e) => state.last_error = Some(format!("bypass: {e}")),
}
}
KeyCode::Char('p') => {
let target = !state.per_app_master;
match control.per_app_master(target) {
Ok(()) => state.per_app_master = target,
Err(e) => state.last_error = Some(format!("per-app master: {e}")),
}
}
KeyCode::Char('r') | KeyCode::Enter => {
let Some(node) = state.effective_selection() else { return };
let Some(cur) = state.streams.get(&node).map(|s| s.route) else { return };
let to = match cur {
Route::Processed => Route::Bypass,
Route::Bypass => Route::Processed,
};
match control.route_stream(node, to) {
Ok(()) => {
if let Some(s) = state.streams.get_mut(&node) {
s.route = to;
}
}
Err(e) => state.last_error = Some(format!("route: {e}")),
}
}
KeyCode::Char('a') => {
let Some(node) = state.effective_selection() else { return };
let Some(app) = state.streams.get(&node).map(|s| s.app.clone()) else { return };
if app.is_empty() {
state.last_error = Some("per-app: selected stream has no app label".into());
return;
}
let managed = state.la_snapshots.get(&node).is_some_and(|s| s.managed);
if let Err(e) = control.per_app_set(&app, !managed) {
state.last_error = Some(format!("per-app set: {e}"));
}
}
KeyCode::Char('x') => {
let Some(node) = state.effective_selection() else { return };
if let Err(e) = control.layer_a_reset(node) {
state.last_error = Some(format!("reset: {e}"));
}
}
_ => {}
}
}
// ---------------------------------------------------------------------------
// Input thread
// ---------------------------------------------------------------------------
enum InputMsg {
Quit,
Other,
Key(KeyEvent),
Redraw,
}
fn spawn_input_thread() -> Receiver<InputMsg> {
@ -310,7 +456,8 @@ fn spawn_input_thread() -> Receiver<InputMsg> {
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,
CtEvent::Key(k) => InputMsg::Key(k),
CtEvent::Resize(_, _) => InputMsg::Redraw,
_ => continue,
};
if tx.send(msg).is_err() {
@ -350,12 +497,14 @@ fn draw(f: &mut Frame, state: &UiState) {
Constraint::Length(6), // bus gauges
Constraint::Length(5), // loudness
Constraint::Min(4), // streams table
Constraint::Length(3), // layer A detail (selected stream)
])
.split(inner);
draw_bus(f, chunks[0], state);
draw_loudness(f, chunks[1], state);
draw_streams(f, chunks[2], state);
draw_layer_a_detail(f, chunks[3], state);
}
fn header_status(state: &UiState) -> Vec<Span<'static>> {
@ -367,11 +516,18 @@ fn header_status(state: &UiState) -> Vec<Span<'static>> {
} else {
Span::styled(" processed ", Style::default().fg(Color::Green))
};
let per_app_span = if state.per_app_master {
Span::styled(" per-app ", Style::default().fg(Color::Cyan))
} else {
Span::styled(" per-app off ", Style::default().fg(Color::DarkGray))
};
vec![
Span::raw(" profile: "),
Span::styled(state.profile.clone(), Style::default().bold()),
Span::raw(" "),
bypass_span,
Span::raw(" "),
per_app_span,
Span::raw(format!(
" v{} uptime {} ",
state.daemon_version,
@ -381,10 +537,21 @@ fn header_status(state: &UiState) -> Vec<Span<'static>> {
}
fn footer_text(state: &UiState) -> Vec<Span<'static>> {
let sep = || Span::styled("·", Style::default().fg(Color::DarkGray));
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 "),
Span::raw(" j/k select "),
sep(),
Span::raw(" r route "),
sep(),
Span::raw(" a per-app "),
sep(),
Span::raw(" x reset "),
sep(),
Span::raw(" b bypass "),
sep(),
Span::raw(" p per-app "),
sep(),
Span::raw(" q quit "),
];
if state.overflow_total > 0 {
parts.push(Span::styled("·", Style::default().fg(Color::DarkGray)));
@ -589,13 +756,15 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &UiState) {
);
let block = Block::default().borders(Borders::ALL).title(title);
let header = Row::new(vec!["node", "app", "route", "layer A"])
let header = Row::new(vec!["", "node", "app", "route", "per-app"])
.style(Style::default().add_modifier(Modifier::BOLD));
let selected = state.effective_selection();
let rows: Vec<Row> = state
.streams
.values()
.map(|s| {
let is_sel = selected == Some(s.node_id);
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)),
@ -607,16 +776,24 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &UiState) {
.style(Style::default().fg(Color::DarkGray)),
None => Cell::from("").style(Style::default().fg(Color::DarkGray)),
};
Row::new(vec![
let marker = if is_sel { "" } else { " " };
let row = Row::new(vec![
Cell::from(marker),
Cell::from(s.node_id.to_string()),
Cell::from(s.app.clone()),
route_cell,
la_cell,
])
]);
if is_sel {
row.style(Style::default().add_modifier(Modifier::REVERSED))
} else {
row
}
})
.collect();
let widths = [
Constraint::Length(2),
Constraint::Length(8),
Constraint::Min(20),
Constraint::Length(12),
@ -626,6 +803,64 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &UiState) {
f.render_widget(table, area);
}
/// Read-only Layer A detail for the currently-selected stream:
/// managed flag, smoothed reduction, user ceiling, deference lock.
fn draw_layer_a_detail(f: &mut Frame, area: Rect, state: &UiState) {
let block = Block::default()
.borders(Borders::ALL)
.title(" per-app level (selected) ");
let inner = block.inner(area);
f.render_widget(block, area);
let line = match state.effective_selection() {
None => Line::from(Span::styled(
" no stream selected",
Style::default().fg(Color::DarkGray),
)),
Some(node) => {
let app = state
.streams
.get(&node)
.map(|s| s.app.clone())
.unwrap_or_default();
match state.la_snapshots.get(&node) {
Some(snap) => {
let ceiling = snap
.user_ceiling_lin
.map(|c| format!("{c:.2}"))
.unwrap_or_else(|| "".to_string());
let deferred = if snap.deferred {
Span::styled("deferred", Style::default().fg(Color::Yellow))
} else {
Span::styled("active", Style::default().fg(Color::Green))
};
Line::from(vec![
Span::raw(format!(" node {node} {app} ")),
Span::styled(
if snap.managed { "managed" } else { "unmanaged" },
Style::default().fg(if snap.managed {
Color::Cyan
} else {
Color::DarkGray
}),
),
Span::raw(format!(
" reduction {:+.1} dB ceiling {ceiling} ",
snap.reduction_db
)),
deferred,
])
}
None => Line::from(Span::styled(
format!(" node {node} {app} not managed per-app"),
Style::default().fg(Color::DarkGray),
)),
}
}
};
f.render_widget(Paragraph::new(line), inner);
}
fn fmt_uptime(s: u64) -> String {
let h = s / 3600;
let m = (s % 3600) / 60;
@ -651,8 +886,10 @@ mod tests {
uptime_s: 0,
profile: "default".into(),
bypass: false,
per_app: false,
sinks: Sinks::default(),
streams: vec![],
layer_a: vec![],
warnings: vec![],
};
let route_list = headroom_ipc::RouteList {