fix: further layer A (per-app) glitches
This commit is contained in:
parent
2978318019
commit
7797f60128
16 changed files with 1589 additions and 155 deletions
|
|
@ -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)?);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue