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:
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),