hydra

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

commit b8282cab4a1d246263fbcb6b4475ae7f82e01515
parent c68822d831f1cf8cba9dd9474c5a0bfc25fd86a0
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Mon,  1 Jun 2026 15:31:02 -0500

UX2: per-source volume via separate routes ('C') — no realtime rewrite

The naive approach (one multi-tap aggregate mixing N sources at independent
gains) meant rewriting the realtime IOProc + shared C struct — pure risk. A
measurement found the better design: N independent single-source routes to the
SAME output device sum cleanly at the device, each keeping its own
gain/mute/record/meter. Verified end-to-end:
- two distinct tones (300/900Hz) → two routes → Hydra: BOTH present at the
  device input (Goertzel 7e12 / 7e13)
- muting one route dropped its tone ~10000x while the other stayed full
  → genuine per-source isolation, using only the already-proven route path.

So UX2 ships as a TUI action, not a C rewrite: 'C' starts each marked app as its
own route (vs 'c' = one mixed route, shared gain). Footer: "c mix / C separate".
Keeps the unsafe realtime path untouched.

27 tests green, 0 warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Diffstat:
Mcrates/hydra/src/app.rs | 28++++++++++++++++++++++++++++
Mcrates/hydra/src/main.rs | 1+
Mcrates/hydra/src/ui.rs | 4+++-
3 files changed, 32 insertions(+), 1 deletion(-)

diff --git a/crates/hydra/src/app.rs b/crates/hydra/src/app.rs @@ -383,6 +383,34 @@ impl App { self.refresh(); } + /// Start each marked app as its OWN route to the selected output (vs `c`, which makes + /// one mixed route with a shared gain). Separate routes give independent per-source + /// volume/mute/record/metering — the real mixer. Verified: N routes to one device sum + /// at the output, and muting one isolates only its source. + pub fn route_each_marked(&mut self) { + let pids: Vec<i32> = if self.marked.is_empty() { + self.visible_apps().get(self.app_sel).map(|a| a.pid).into_iter().collect() + } else { + self.marked.iter().copied().collect() + }; + if pids.is_empty() { + return; + } + let output_uid = self.selected_output().map(|d| d.uid.clone()); + let dest = self.selected_output().map(|d| d.name.as_str()).unwrap_or("default output").to_string(); + let mut started = 0; + for pid in &pids { + if let Ok(Response::RouteStarted { .. }) = + client::request(Command::StartMonitor { pid: *pid, output_uid: output_uid.clone(), gain: DEFAULT_GAIN }) + { + started += 1; + } + } + self.status = format!("{started} separate route(s) → {dest} (independent volume)"); + self.marked.clear(); + self.refresh(); + } + pub fn stop_selected(&mut self) { let Some(route) = self.routes.get(self.route_sel) else { return }; let _ = client::request(Command::StopRoute { id: route.id.clone() }); diff --git a/crates/hydra/src/main.rs b/crates/hydra/src/main.rs @@ -99,6 +99,7 @@ fn handle_key(app: &mut App, code: KeyCode) { KeyCode::Enter => app.start_selected(), KeyCode::Char(' ') => app.toggle_mark(), KeyCode::Char('c') => app.combine_marked(), + KeyCode::Char('C') => app.route_each_marked(), KeyCode::Char('o') => app.cycle_output(), KeyCode::Char('a') => app.toggle_show_all(), KeyCode::Char('n') => app.begin_rename(), diff --git a/crates/hydra/src/ui.rs b/crates/hydra/src/ui.rs @@ -237,7 +237,9 @@ fn draw_footer(f: &mut Frame, area: Rect, app: &App, theme: &Theme) { key("␣", theme), hint(" mark ", theme), key("c", theme), - hint(" combine ", theme), + hint(" mix ", theme), + key("C", theme), + hint(" separate ", theme), key("o", theme), hint(" output ", theme), key("a", theme),