hydra

Terminal replacement for Loopback — virtual audio devices and routing on macOS, from a ratatui TUI.
Log | Files | Refs | README | LICENSE

app.rs (24121B)


      1 //! TUI application state and the actions that mutate it. Rendering lives in `ui.rs`.
      2 
      3 use hydra_ipc::{AudioApp, AudioDevice, Command, Response, RouteSummary};
      4 
      5 use crate::client;
      6 use crate::theme::Theme;
      7 
      8 /// Default gain for a new route. Core Audio process taps attenuate the captured signal
      9 /// (~-20 dB observed), so a fresh route at unity is far too quiet to be usable; ~10x makeup
     10 /// gain restores roughly the source level. Tunable per-route with +/- in the TUI.
     11 const DEFAULT_GAIN: f32 = 10.0;
     12 
     13 /// Whether we're currently talking to the daemon.
     14 #[derive(Debug, Clone)]
     15 pub enum Connection {
     16     Connected { protocol: u32 },
     17     Disconnected { reason: String },
     18 }
     19 
     20 /// A modal text-entry prompt. Both flows are "type a name, ⏎ to confirm, esc to cancel";
     21 /// the kind decides what the confirmed text does.
     22 #[derive(Debug, Clone, PartialEq, Eq)]
     23 pub enum PromptKind {
     24     /// Rename the Hydra virtual device.
     25     RenameDevice,
     26     /// Save the current routing as a named preset.
     27     SavePreset,
     28 }
     29 
     30 /// Which pane has keyboard focus.
     31 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
     32 pub enum Focus {
     33     Apps,
     34     Routes,
     35 }
     36 
     37 pub struct App {
     38     pub connection: Connection,
     39     /// All audio processes from the daemon (already kind-sorted).
     40     pub apps: Vec<AudioApp>,
     41     /// When false (default), background daemons (kind 2) are hidden from the app list,
     42     /// so you see real apps (Spotify, browsers, DAWs) not the system-helper wall.
     43     pub show_all_apps: bool,
     44     /// PIDs the user has marked (space) to combine into one route.
     45     pub marked: std::collections::BTreeSet<i32>,
     46     pub routes: Vec<RouteSummary>,
     47     /// Decaying peak-hold per route id, for the meter's "peak hold" tick. Updated each
     48     /// refresh: jump up to a new peak instantly, decay slowly otherwise.
     49     pub peak_hold: std::collections::HashMap<String, f32>,
     50     /// Output devices a route can target (output_channels > 0), e.g. speakers or "Hydra".
     51     pub outputs: Vec<AudioDevice>,
     52     /// Index into `outputs` of the currently chosen route target.
     53     pub output_sel: usize,
     54     /// Input-capable devices (mics, line-in, interfaces) — sources for hardware routing.
     55     pub inputs: Vec<AudioDevice>,
     56     /// When `Some`, the input-source picker is open: (input device names, selected index).
     57     pub input_picker: Option<(Vec<String>, usize)>,
     58     pub focus: Focus,
     59     pub app_sel: usize,
     60     pub route_sel: usize,
     61     pub status: String,
     62     /// When `Some`, a modal text prompt is open: (what it's for, in-progress text).
     63     pub prompt: Option<(PromptKind, String)>,
     64     /// When `Some`, the presets overlay is open: (preset names, selected index).
     65     pub presets: Option<(Vec<String>, usize)>,
     66     /// The active theme (swappable live via the theme picker).
     67     pub theme: Theme,
     68     /// When `Some`, the theme picker is open: (theme names, selected index).
     69     pub theme_picker: Option<(Vec<String>, usize)>,
     70     pub should_quit: bool,
     71 }
     72 
     73 impl App {
     74     pub fn new() -> Self {
     75         let mut app = App {
     76             connection: Connection::Disconnected { reason: "connecting…".into() },
     77             apps: Vec::new(),
     78             show_all_apps: false,
     79             marked: std::collections::BTreeSet::new(),
     80             routes: Vec::new(),
     81             peak_hold: std::collections::HashMap::new(),
     82             outputs: Vec::new(),
     83             output_sel: 0,
     84             inputs: Vec::new(),
     85             input_picker: None,
     86             focus: Focus::Apps,
     87             app_sel: 0,
     88             route_sel: 0,
     89             status: String::new(),
     90             prompt: None,
     91             presets: None,
     92             theme: Theme::load(),
     93             theme_picker: None,
     94             should_quit: false,
     95         };
     96         app.refresh();
     97         app
     98     }
     99 
    100     // ── Theme picker (live-swappable) ──────────────────────────────────────────────────
    101 
    102     /// Open the theme picker, listing the built-in default + every `~/.config/hydra/themes/
    103     /// *.toml`. Selection starts on the active theme.
    104     pub fn open_theme_picker(&mut self) {
    105         let names = Theme::available();
    106         let sel = names.iter().position(|n| *n == self.theme.name).unwrap_or(0);
    107         self.theme_picker = Some((names, sel));
    108     }
    109 
    110     pub fn theme_picker_close(&mut self) {
    111         self.theme_picker = None;
    112     }
    113 
    114     pub fn theme_picker_move(&mut self, down: bool) {
    115         if let Some((names, sel)) = self.theme_picker.as_mut() {
    116             if names.is_empty() {
    117                 return;
    118             }
    119             *sel = if down { (*sel + 1).min(names.len() - 1) } else { sel.saturating_sub(1) };
    120         }
    121     }
    122 
    123     /// Apply the highlighted theme live and persist the choice.
    124     pub fn theme_picker_apply(&mut self) {
    125         let Some((names, sel)) = self.theme_picker.as_ref() else { return };
    126         let Some(name) = names.get(*sel).cloned() else { return };
    127         self.theme = Theme::by_name(&name);
    128         // Persist name + the theme's own transparency, so a transparent theme reloads
    129         // transparent on next launch (not clobbered by a stale toggle).
    130         crate::theme::save_active(&name, self.theme.transparent);
    131         self.theme_picker = None;
    132         self.status = format!("theme → {name}");
    133     }
    134 
    135     /// Toggle background transparency on the live theme (and persist).
    136     pub fn toggle_transparency(&mut self) {
    137         self.theme.transparent = !self.theme.transparent;
    138         crate::theme::save_transparency(self.theme.transparent);
    139         self.status =
    140             if self.theme.transparent { "transparency on" } else { "transparency off" }.into();
    141     }
    142 
    143     pub fn is_theme_picker_open(&self) -> bool {
    144         self.theme_picker.is_some()
    145     }
    146 
    147     // ── Modal text prompt (rename device / save preset) ──────────────────────────────
    148 
    149     pub fn begin_rename(&mut self) {
    150         self.prompt = Some((PromptKind::RenameDevice, String::new()));
    151     }
    152 
    153     pub fn begin_save_preset(&mut self) {
    154         self.prompt = Some((PromptKind::SavePreset, String::new()));
    155     }
    156 
    157     pub fn prompt_push(&mut self, c: char) {
    158         if let Some((_, buf)) = self.prompt.as_mut() {
    159             buf.push(c);
    160         }
    161     }
    162 
    163     pub fn prompt_backspace(&mut self) {
    164         if let Some((_, buf)) = self.prompt.as_mut() {
    165             buf.pop();
    166         }
    167     }
    168 
    169     pub fn prompt_cancel(&mut self) {
    170         self.prompt = None;
    171         self.status = "cancelled".into();
    172     }
    173 
    174     /// Confirm the open prompt, dispatching by kind.
    175     pub fn prompt_commit(&mut self) {
    176         let Some((kind, text)) = self.prompt.take() else { return };
    177         let text = text.trim().to_string();
    178         if text.is_empty() {
    179             self.status = "cancelled (empty)".into();
    180             return;
    181         }
    182         match kind {
    183             PromptKind::RenameDevice => match client::request(Command::SetDeviceName { name: text.clone() }) {
    184                 Ok(Response::Ok) => {
    185                     self.status = format!("device → \"{text}\" (apply: sudo killall coreaudiod)")
    186                 }
    187                 Ok(Response::Error(e)) => self.status = e,
    188                 Ok(other) => self.status = format!("unexpected: {other:?}"),
    189                 Err(e) => self.status = format!("rename failed: {e}"),
    190             },
    191             PromptKind::SavePreset => match client::request(Command::SavePreset { name: text.clone() }) {
    192                 Ok(Response::Ok) => self.status = format!("saved preset \"{text}\""),
    193                 Ok(Response::Error(e)) => self.status = e,
    194                 Ok(other) => self.status = format!("unexpected: {other:?}"),
    195                 Err(e) => self.status = format!("save failed: {e}"),
    196             },
    197         }
    198     }
    199 
    200     /// Title shown above the open prompt, if any.
    201     pub fn prompt_title(&self) -> Option<&'static str> {
    202         self.prompt.as_ref().map(|(k, _)| match k {
    203             PromptKind::RenameDevice => "rename device",
    204             PromptKind::SavePreset => "save preset as",
    205         })
    206     }
    207 
    208     pub fn prompt_text(&self) -> Option<&str> {
    209         self.prompt.as_ref().map(|(_, b)| b.as_str())
    210     }
    211 
    212     pub fn is_prompting(&self) -> bool {
    213         self.prompt.is_some()
    214     }
    215 
    216     // ── Presets overlay (list → apply / delete) ───────────────────────────────────────
    217 
    218     /// Open the presets overlay, fetching the current list from the daemon.
    219     pub fn open_presets(&mut self) {
    220         match client::request(Command::ListPresets) {
    221             Ok(Response::Presets(names)) if !names.is_empty() => self.presets = Some((names, 0)),
    222             Ok(Response::Presets(_)) => self.status = "no saved presets — press P to save the current routing".into(),
    223             Ok(other) => self.status = format!("unexpected: {other:?}"),
    224             Err(e) => self.status = format!("list presets failed: {e}"),
    225         }
    226     }
    227 
    228     pub fn presets_close(&mut self) {
    229         self.presets = None;
    230     }
    231 
    232     pub fn presets_move(&mut self, down: bool) {
    233         if let Some((names, sel)) = self.presets.as_mut() {
    234             if names.is_empty() {
    235                 return;
    236             }
    237             *sel = if down { (*sel + 1).min(names.len() - 1) } else { sel.saturating_sub(1) };
    238         }
    239     }
    240 
    241     /// Apply the highlighted preset (replaces all live routes).
    242     pub fn presets_apply(&mut self) {
    243         let Some((names, sel)) = self.presets.as_ref() else { return };
    244         let Some(name) = names.get(*sel).cloned() else { return };
    245         self.presets = None;
    246         match client::request(Command::ApplyPreset { name: name.clone() }) {
    247             Ok(Response::PresetApplied { restored, total }) => {
    248                 self.status = format!("applied \"{name}\": {restored}/{total} route(s) live");
    249             }
    250             Ok(Response::Error(e)) => self.status = e,
    251             Ok(other) => self.status = format!("unexpected: {other:?}"),
    252             Err(e) => self.status = format!("apply failed: {e}"),
    253         }
    254         self.refresh();
    255     }
    256 
    257     /// Delete the highlighted preset.
    258     pub fn presets_delete(&mut self) {
    259         let Some((names, sel)) = self.presets.as_ref() else { return };
    260         let Some(name) = names.get(*sel).cloned() else { return };
    261         match client::request(Command::DeletePreset { name: name.clone() }) {
    262             Ok(Response::Ok) => {
    263                 self.status = format!("deleted preset \"{name}\"");
    264                 self.open_presets(); // refresh the list (closes if now empty)
    265             }
    266             Ok(Response::Error(e)) => self.status = e,
    267             Ok(other) => self.status = format!("unexpected: {other:?}"),
    268             Err(e) => self.status = format!("delete failed: {e}"),
    269         }
    270     }
    271 
    272     pub fn is_presets_open(&self) -> bool {
    273         self.presets.is_some()
    274     }
    275 
    276     /// Pull connection status, app list, output devices, and routes from the daemon.
    277     pub fn refresh(&mut self) {
    278         match client::request(Command::Ping) {
    279             Ok(Response::Pong { version }) => self.connection = Connection::Connected { protocol: version },
    280             Ok(other) => {
    281                 self.connection = Connection::Disconnected { reason: format!("unexpected: {other:?}") };
    282                 return;
    283             }
    284             Err(e) => {
    285                 self.connection = Connection::Disconnected { reason: e.to_string() };
    286                 self.apps.clear();
    287                 self.routes.clear();
    288                 return;
    289             }
    290         }
    291 
    292         if let Ok(Response::Apps(apps)) = client::request(Command::ListApps) {
    293             // Daemon already sorts by kind then name; keep that order.
    294             self.apps = apps;
    295         }
    296         if let Ok(Response::Devices(devices)) = client::request(Command::ListDevices) {
    297             self.set_devices(devices);
    298         }
    299         if let Ok(Response::State(snap)) = client::request(Command::GetState) {
    300             self.routes = snap.routes;
    301             self.update_peak_hold();
    302         }
    303 
    304         self.clamp_selection();
    305     }
    306 
    307     /// Update the per-route peak-hold: snap up to any new peak, otherwise decay toward the
    308     /// current level so the held tick drifts down. Drop holds for routes that no longer exist.
    309     fn update_peak_hold(&mut self) {
    310         const DECAY: f32 = 0.88; // ~per-refresh; gentle fall like a hardware peak meter
    311         let live: std::collections::HashSet<&str> = self.routes.iter().map(|r| r.id.as_str()).collect();
    312         self.peak_hold.retain(|id, _| live.contains(id.as_str()));
    313         for r in &self.routes {
    314             let held = self.peak_hold.entry(r.id.clone()).or_insert(0.0);
    315             *held = if r.peak >= *held { r.peak } else { (*held * DECAY).max(r.peak) };
    316         }
    317     }
    318 
    319     /// The held peak for a route (0.0 if unknown).
    320     pub fn peak_hold_for(&self, id: &str) -> f32 {
    321         self.peak_hold.get(id).copied().unwrap_or(0.0)
    322     }
    323 
    324     /// Split the device list into output targets (for the `o` picker) and input sources (for
    325     /// the `i` input-routing picker); preserve the current output selection across refreshes.
    326     fn set_devices(&mut self, devices: Vec<AudioDevice>) {
    327         let prev_uid = self.outputs.get(self.output_sel).map(|d| d.uid.clone());
    328         // Exclude our own Hydra device from inputs — routing Hydra→Hydra is a feedback loop.
    329         self.inputs =
    330             devices.iter().filter(|d| d.input_channels > 0 && d.uid != "Hydra_UID").cloned().collect();
    331         self.outputs = devices.into_iter().filter(|d| d.output_channels > 0).collect();
    332         self.output_sel = prev_uid
    333             .and_then(|uid| self.outputs.iter().position(|d| d.uid == uid))
    334             .or_else(|| self.outputs.iter().position(|d| d.is_default_output))
    335             .unwrap_or(0);
    336     }
    337 
    338     // ── Input-source picker (route a mic / line-in to the current output) ──────────────
    339 
    340     /// Open the input picker, listing input-capable devices.
    341     pub fn open_input_picker(&mut self) {
    342         if self.inputs.is_empty() {
    343             self.status = "no input devices found".into();
    344             return;
    345         }
    346         let names: Vec<String> = self.inputs.iter().map(|d| d.name.clone()).collect();
    347         self.input_picker = Some((names, 0));
    348     }
    349 
    350     pub fn input_picker_close(&mut self) {
    351         self.input_picker = None;
    352     }
    353 
    354     pub fn input_picker_move(&mut self, down: bool) {
    355         if let Some((names, sel)) = self.input_picker.as_mut() {
    356             if names.is_empty() {
    357                 return;
    358             }
    359             *sel = if down { (*sel + 1).min(names.len() - 1) } else { sel.saturating_sub(1) };
    360         }
    361     }
    362 
    363     /// Route the highlighted input device to the current output (StartInput).
    364     pub fn input_picker_apply(&mut self) {
    365         let Some((_, sel)) = self.input_picker.as_ref() else { return };
    366         let Some(dev) = self.inputs.get(*sel) else { return };
    367         let input_uid = dev.uid.clone();
    368         let src = dev.name.clone();
    369         let output_uid = self.selected_output().map(|d| d.uid.clone());
    370         let dest = self.selected_output().map(|d| d.name.as_str()).unwrap_or("default output").to_string();
    371         self.input_picker = None;
    372         match client::request(Command::StartInput { input_uid, output_uid, gain: DEFAULT_GAIN }) {
    373             Ok(Response::RouteStarted { id }) => self.status = format!("{id}: {src} → {dest}"),
    374             Ok(Response::Error(e)) => self.status = format!("input route: {e}"),
    375             Ok(other) => self.status = format!("unexpected: {other:?}"),
    376             Err(e) => self.status = format!("input route failed: {e}"),
    377         }
    378         self.refresh();
    379     }
    380 
    381     pub fn is_input_picker_open(&self) -> bool {
    382         self.input_picker.is_some()
    383     }
    384 
    385     /// The app list as shown: real apps only (kind < 2) unless `show_all_apps` is on.
    386     pub fn visible_apps(&self) -> Vec<&AudioApp> {
    387         self.apps.iter().filter(|a| self.show_all_apps || a.kind < 2).collect()
    388     }
    389 
    390     /// Toggle whether background daemons/helpers appear in the app list.
    391     pub fn toggle_show_all(&mut self) {
    392         self.show_all_apps = !self.show_all_apps;
    393         self.clamp_selection();
    394     }
    395 
    396     fn clamp_selection(&mut self) {
    397         self.app_sel = self.app_sel.min(self.visible_apps().len().saturating_sub(1));
    398         self.route_sel = self.route_sel.min(self.routes.len().saturating_sub(1));
    399         self.output_sel = self.output_sel.min(self.outputs.len().saturating_sub(1));
    400     }
    401 
    402     /// The output device a new route will target, if any.
    403     pub fn selected_output(&self) -> Option<&AudioDevice> {
    404         self.outputs.get(self.output_sel)
    405     }
    406 
    407     /// Cycle the target output device (the route destination — pick "Hydra" to feed the
    408     /// virtual device, or speakers to monitor).
    409     pub fn cycle_output(&mut self) {
    410         if !self.outputs.is_empty() {
    411             self.output_sel = (self.output_sel + 1) % self.outputs.len();
    412         }
    413     }
    414 
    415     pub fn toggle_focus(&mut self) {
    416         self.focus = match self.focus {
    417             Focus::Apps => Focus::Routes,
    418             Focus::Routes => Focus::Apps,
    419         };
    420     }
    421 
    422     pub fn move_down(&mut self) {
    423         match self.focus {
    424             Focus::Apps => {
    425                 let n = self.visible_apps().len();
    426                 if n > 0 {
    427                     self.app_sel = (self.app_sel + 1).min(n - 1);
    428                 }
    429             }
    430             Focus::Routes if !self.routes.is_empty() => {
    431                 self.route_sel = (self.route_sel + 1).min(self.routes.len() - 1)
    432             }
    433             _ => {}
    434         }
    435     }
    436 
    437     pub fn move_up(&mut self) {
    438         match self.focus {
    439             Focus::Apps => self.app_sel = self.app_sel.saturating_sub(1),
    440             Focus::Routes => self.route_sel = self.route_sel.saturating_sub(1),
    441         }
    442     }
    443 
    444     /// Start monitoring the selected app to the selected output device.
    445     pub fn start_selected(&mut self) {
    446         let visible = self.visible_apps();
    447         let Some(app) = visible.get(self.app_sel) else { return };
    448         let (pid, name) = (app.pid, app.name.clone());
    449         let output_uid = self.selected_output().map(|d| d.uid.clone());
    450         let dest = self.selected_output().map(|d| d.name.as_str()).unwrap_or("default output").to_string();
    451         match client::request(Command::StartMonitor { pid, output_uid, gain: DEFAULT_GAIN }) {
    452             Ok(Response::RouteStarted { id }) => self.status = format!("{id}: {name} → {dest}"),
    453             Ok(Response::Error(e)) => self.status = format!("start failed: {e}"),
    454             Ok(other) => self.status = format!("unexpected: {other:?}"),
    455             Err(e) => self.status = format!("start failed: {e}"),
    456         }
    457         self.refresh();
    458     }
    459 
    460     /// Toggle whether the highlighted app is marked for combining.
    461     pub fn toggle_mark(&mut self) {
    462         let visible = self.visible_apps();
    463         if let Some(app) = visible.get(self.app_sel) {
    464             let pid = app.pid;
    465             if !self.marked.insert(pid) {
    466                 self.marked.remove(&pid);
    467             }
    468         }
    469     }
    470 
    471     /// Combine all marked apps into one route to the selected output (Loopback "combine").
    472     /// Falls back to the highlighted app if nothing is marked.
    473     pub fn combine_marked(&mut self) {
    474         let pids: Vec<i32> = if self.marked.is_empty() {
    475             self.visible_apps().get(self.app_sel).map(|a| a.pid).into_iter().collect()
    476         } else {
    477             self.marked.iter().copied().collect()
    478         };
    479         if pids.is_empty() {
    480             return;
    481         }
    482         let output_uid = self.selected_output().map(|d| d.uid.clone());
    483         let dest = self.selected_output().map(|d| d.name.as_str()).unwrap_or("default output").to_string();
    484         match client::request(Command::StartCombined { pids: pids.clone(), output_uid, gain: DEFAULT_GAIN }) {
    485             Ok(Response::RouteStarted { id }) => {
    486                 self.status = format!("{id}: {} sources → {dest}", pids.len());
    487                 self.marked.clear();
    488             }
    489             Ok(Response::Error(e)) => self.status = format!("combine failed: {e}"),
    490             Ok(other) => self.status = format!("unexpected: {other:?}"),
    491             Err(e) => self.status = format!("combine failed: {e}"),
    492         }
    493         self.refresh();
    494     }
    495 
    496     /// Start each marked app as its OWN route to the selected output (vs `c`, which makes
    497     /// one mixed route with a shared gain). Separate routes give independent per-source
    498     /// volume/mute/record/metering — the real mixer. Verified: N routes to one device sum
    499     /// at the output, and muting one isolates only its source.
    500     pub fn route_each_marked(&mut self) {
    501         let pids: Vec<i32> = if self.marked.is_empty() {
    502             self.visible_apps().get(self.app_sel).map(|a| a.pid).into_iter().collect()
    503         } else {
    504             self.marked.iter().copied().collect()
    505         };
    506         if pids.is_empty() {
    507             return;
    508         }
    509         let output_uid = self.selected_output().map(|d| d.uid.clone());
    510         let dest = self.selected_output().map(|d| d.name.as_str()).unwrap_or("default output").to_string();
    511         let mut started = 0;
    512         for pid in &pids {
    513             if let Ok(Response::RouteStarted { .. }) =
    514                 client::request(Command::StartMonitor { pid: *pid, output_uid: output_uid.clone(), gain: DEFAULT_GAIN })
    515             {
    516                 started += 1;
    517             }
    518         }
    519         self.status = format!("{started} separate route(s) → {dest} (independent volume)");
    520         self.marked.clear();
    521         self.refresh();
    522     }
    523 
    524     pub fn stop_selected(&mut self) {
    525         let Some(route) = self.routes.get(self.route_sel) else { return };
    526         let _ = client::request(Command::StopRoute { id: route.id.clone() });
    527         self.status = "route stopped".into();
    528         self.refresh();
    529     }
    530 
    531     pub fn toggle_mute_selected(&mut self) {
    532         let Some(route) = self.routes.get(self.route_sel) else { return };
    533         let _ = client::request(Command::SetMute { id: route.id.clone(), muted: !route.muted });
    534         self.refresh();
    535     }
    536 
    537     /// Start or stop recording the selected route to a WAV file (daemon picks the path in
    538     /// ~/Music/Hydra). Toggles based on the route's current recording state.
    539     pub fn toggle_record_selected(&mut self) {
    540         let Some(route) = self.routes.get(self.route_sel) else { return };
    541         let id = route.id.clone();
    542         let cmd = if route.recording {
    543             Command::StopRecording { id }
    544         } else {
    545             Command::StartRecording { id, path: None }
    546         };
    547         match client::request(cmd) {
    548             Ok(Response::RecordingStarted { path }) => self.status = format!("● recording → {path}"),
    549             Ok(Response::RecordingStopped { path, frames }) => {
    550                 let secs = frames as f64 / 44100.0;
    551                 self.status = format!("saved {path} ({secs:.1}s)");
    552             }
    553             Ok(Response::Error(e)) => self.status = format!("record: {e}"),
    554             Ok(other) => self.status = format!("unexpected: {other:?}"),
    555             Err(e) => self.status = format!("record failed: {e}"),
    556         }
    557         self.refresh();
    558     }
    559 
    560     /// Scale the selected route's gain by `factor` (multiplicative, so a few presses span
    561     /// the whole range). `up=true` multiplies, `up=false` divides. Clamped to 0..=16.
    562     /// Multiplicative because Core Audio process taps attenuate ~-20 dB, so useful makeup
    563     /// gain is ~10x — unreachable with additive 0.05 steps.
    564     pub fn adjust_gain(&mut self, up: bool) {
    565         let Some(route) = self.routes.get(self.route_sel) else { return };
    566         const STEP: f32 = 1.4; // ~+3 dB per press
    567         let gain = if up {
    568             (route.gain.max(0.05) * STEP).clamp(0.0, 16.0)
    569         } else {
    570             (route.gain / STEP).clamp(0.0, 16.0)
    571         };
    572         let _ = client::request(Command::SetGain { id: route.id.clone(), gain });
    573         self.refresh();
    574     }
    575 
    576     pub fn quit(&mut self) {
    577         self.should_quit = true;
    578     }
    579 }