hydra

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

commit 52b4f527012273d89595572605c099c000e4052e
parent acbe771cab891ab484e45422e7042e32e914eaf6
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Sun, 31 May 2026 17:39:57 -0500

feat: combine multiple sources into one route (Loopback "combine")

The Obj-C shim already took an array of process objects, so multi-source was a
plumbing job: MonitorRoute::start_combined taps N PIDs via one CATapDescription
(initStereoMixdownOfProcesses), Engine::start_combined tracks source_count +
bundle_ids, new StartCombined IPC command, daemon combined_label ("Safari +
Spotify +1 → Hydra"). TUI: space marks apps (✓), c combines them (or the
highlighted app if none marked); marks shown in amber.

VERIFIED live: combined two afplay PIDs → one route, source_count=2,
peak=0.247 (real mixed audio flowing). Single-source persistence unchanged
(combined routes aren't persisted yet — would need atomic multi-bundle restore).

19 tests green, 0 warnings.

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

Diffstat:
Mcrates/hydra-core/src/engine.rs | 41++++++++++++++++++++++++++++++++++++-----
Mcrates/hydra-core/src/ffi/shim.rs | 43+++++++++++++++++++++++++++++++++----------
Mcrates/hydra-ipc/src/lib.rs | 3+++
Mcrates/hydra/src/app.rs | 37+++++++++++++++++++++++++++++++++++++
Mcrates/hydra/src/main.rs | 2++
Mcrates/hydra/src/ui.rs | 9++++++++-
Mcrates/hydrad/src/server.rs | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 197 insertions(+), 16 deletions(-)

diff --git a/crates/hydra-core/src/engine.rs b/crates/hydra-core/src/engine.rs @@ -14,8 +14,11 @@ struct Entry { route: MonitorRoute, /// Human-readable "app → output" label for display. label: String, - /// Bundle id of the captured app — the stable key we persist (PIDs don't survive restarts). - bundle_id: Option<String>, + /// Bundle ids of the captured app(s) — the stable key(s) we persist (PIDs don't survive + /// restarts). Single-source routes have one; combined routes have several. + bundle_ids: Vec<String>, + /// Number of sources mixed into this route. + source_count: usize, /// Target output device UID, or `None` for the system default. output_uid: Option<String>, } @@ -45,7 +48,32 @@ impl Engine { let id = route.id().to_string(); self.routes.insert( id.clone(), - Entry { route, label, bundle_id, output_uid: output_uid.map(str::to_string) }, + Entry { + route, + label, + bundle_ids: bundle_id.into_iter().collect(), + source_count: 1, + output_uid: output_uid.map(str::to_string), + }, + ); + Ok(id) + } + + /// Combine several PIDs into one mixed route. `bundle_ids` are the persistable keys for + /// the sources (any that lack a bundle id are simply omitted from persistence). + pub fn start_combined( + &mut self, + pids: &[i32], + output_uid: Option<&str>, + gain: f32, + label: String, + bundle_ids: Vec<String>, + ) -> Result<String> { + let route = MonitorRoute::start_combined(pids, output_uid, gain)?; + let id = route.id().to_string(); + self.routes.insert( + id.clone(), + Entry { route, label, bundle_ids, source_count: pids.len(), output_uid: output_uid.map(str::to_string) }, ); Ok(id) } @@ -83,7 +111,7 @@ impl Engine { .map(|e| RouteSummary { id: e.route.id().to_string(), target: e.label.clone(), - source_count: 1, + source_count: e.source_count, active: true, gain: e.route.gain(), muted: e.route.muted(), @@ -99,11 +127,14 @@ impl Engine { /// bundle id (anonymous helper processes) are skipped — there's no stable way to restore /// them after a restart. pub fn to_config(&self) -> Config { + // Persist only single-source routes for now: a combined route would need its full + // bundle-id list re-resolved atomically at restore, which we don't model yet. let mut routes: Vec<SavedRoute> = self .routes .values() + .filter(|e| e.source_count == 1) .filter_map(|e| { - e.bundle_id.as_ref().map(|b| SavedRoute { + e.bundle_ids.first().map(|b| SavedRoute { bundle_id: b.clone(), output_uid: e.output_uid.clone(), gain: e.route.gain(), diff --git a/crates/hydra-core/src/ffi/shim.rs b/crates/hydra-core/src/ffi/shim.rs @@ -48,7 +48,8 @@ static NEXT_ID: AtomicU64 = AtomicU64::new(1); /// A running monitor route. Tearing down on `Drop` is what keeps coreaudiod from wedging. pub struct MonitorRoute { id: String, - pid: i32, + /// The PIDs whose audio this route taps and mixes together (the "combine" feature). + pids: Vec<i32>, raw: HydraRoute, /// Heap-stable params the C IOProc reads; we keep it alive for the route's lifetime. params: Box<HydraParams>, @@ -59,32 +60,54 @@ pub struct MonitorRoute { unsafe impl Send for MonitorRoute {} impl MonitorRoute { - /// Tap `pid` and monitor it to `output_uid` (or the default output if `None`). + /// Tap a single `pid` and monitor it to `output_uid` (or the default output if `None`). pub fn start(pid: i32, output_uid: Option<&str>, gain: f32) -> Result<Self> { - let proc_obj = process_object_for_pid(pid)?; + Self::start_combined(&[pid], output_uid, gain) + } + + /// Tap several PIDs at once, mixing their audio into one route. The CATapDescription is + /// built over every process's tap object (`initStereoMixdownOfProcesses:`), so the + /// aggregate's input is the sum of all of them. + pub fn start_combined(pids: &[i32], output_uid: Option<&str>, gain: f32) -> Result<Self> { + if pids.is_empty() { + bail!("a route needs at least one source"); + } + let proc_objs: Vec<AudioObjectID> = + pids.iter().map(|&p| process_object_for_pid(p)).collect::<Result<_>>()?; + let mut params = Box::new(HydraParams { gain, muted: 0, peak: [0.0; 8], running: 0 }); let mut raw = HydraRoute { tap: 0, aggregate: 0, ioproc: ptr::null_mut(), params: ptr::null_mut() }; - let c_uid = output_uid.map(|s| CString::new(s)).transpose()?; + let c_uid = output_uid.map(CString::new).transpose()?; let uid_ptr = c_uid.as_ref().map_or(ptr::null(), |c| c.as_ptr()); - let procs = [proc_obj]; let st = unsafe { - hydra_monitor_start(procs.as_ptr(), 1, uid_ptr, params.as_mut() as *mut _, &mut raw) + hydra_monitor_start( + proc_objs.as_ptr(), + proc_objs.len() as i32, + uid_ptr, + params.as_mut() as *mut _, + &mut raw, + ) }; if st != 0 { - bail!("starting monitor for pid {pid} failed: {}", os_status(st)); + bail!("starting monitor for pids {pids:?} failed: {}", os_status(st)); } - Ok(Self { id: format!("r{}", NEXT_ID.fetch_add(1, Ordering::Relaxed)), pid, raw, params }) + Ok(Self { + id: format!("r{}", NEXT_ID.fetch_add(1, Ordering::Relaxed)), + pids: pids.to_vec(), + raw, + params, + }) } pub fn id(&self) -> &str { &self.id } - pub fn pid(&self) -> i32 { - self.pid + pub fn pids(&self) -> &[i32] { + &self.pids } pub fn set_gain(&mut self, gain: f32) { diff --git a/crates/hydra-ipc/src/lib.rs b/crates/hydra-ipc/src/lib.rs @@ -43,6 +43,9 @@ pub enum Command { /// Start monitoring one process's audio to an output device (P1). /// `output_uid = None` means the system default output. StartMonitor { pid: i32, output_uid: Option<String>, gain: f32 }, + /// Combine several processes' audio into a single route to one output (the Loopback + /// "combine sources" feature). `output_uid = None` means the system default output. + StartCombined { pids: Vec<i32>, output_uid: Option<String>, gain: f32 }, /// Tear down a route by id. StopRoute { id: String }, /// Live-adjust a route's gain (linear, 0.0..~2.0). diff --git a/crates/hydra/src/app.rs b/crates/hydra/src/app.rs @@ -43,6 +43,7 @@ impl App { connection: Connection::Disconnected { reason: "connecting…".into() }, apps: Vec::new(), show_all_apps: false, + marked: std::collections::BTreeSet::new(), routes: Vec::new(), outputs: Vec::new(), output_sel: 0, @@ -172,6 +173,42 @@ impl App { self.refresh(); } + /// Toggle whether the highlighted app is marked for combining. + pub fn toggle_mark(&mut self) { + let visible = self.visible_apps(); + if let Some(app) = visible.get(self.app_sel) { + let pid = app.pid; + if !self.marked.insert(pid) { + self.marked.remove(&pid); + } + } + } + + /// Combine all marked apps into one route to the selected output (Loopback "combine"). + /// Falls back to the highlighted app if nothing is marked. + pub fn combine_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(); + match client::request(Command::StartCombined { pids: pids.clone(), output_uid, gain: 1.0 }) { + Ok(Response::RouteStarted { id }) => { + self.status = format!("{id}: {} sources → {dest}", pids.len()); + self.marked.clear(); + } + Ok(Response::Error(e)) => self.status = format!("combine failed: {e}"), + Ok(other) => self.status = format!("unexpected: {other:?}"), + Err(e) => self.status = format!("combine failed: {e}"), + } + 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 @@ -72,6 +72,8 @@ fn handle_key(app: &mut App, code: KeyCode) { KeyCode::Down | KeyCode::Char('j') => app.move_down(), KeyCode::Up | KeyCode::Char('k') => app.move_up(), KeyCode::Enter => app.start_selected(), + KeyCode::Char(' ') => app.toggle_mark(), + KeyCode::Char('c') => app.combine_marked(), KeyCode::Char('o') => app.cycle_output(), KeyCode::Char('a') => app.toggle_show_all(), KeyCode::Char('m') => app.toggle_mute_selected(), diff --git a/crates/hydra/src/ui.rs b/crates/hydra/src/ui.rs @@ -77,7 +77,10 @@ fn draw_apps(f: &mut Frame, area: Rect, app: &App, theme: &Theme) { .map(|a| { // Foreground apps in full fg; background helpers dimmed. let name_color = if a.kind == 0 { theme.fg } else { theme.fg_dim }; + let marked = app.marked.contains(&a.pid); + let mark = if marked { "✓ " } else { " " }; ListItem::new(Line::from(vec![ + Span::styled(mark, Style::default().fg(theme.accent)), Span::styled(format!("{:>6} ", a.pid), Style::default().fg(theme.fg_dim)), Span::styled(a.name.clone(), Style::default().fg(name_color)), ])) @@ -146,10 +149,14 @@ fn draw_footer(f: &mut Frame, area: Rect, app: &App, theme: &Theme) { hint(" select ", theme), key("⏎", theme), hint(" monitor ", theme), + key("␣", theme), + hint(" mark ", theme), + key("c", theme), + hint(" combine ", theme), key("o", theme), hint(" output ", theme), key("a", theme), - hint(" all apps ", theme), + hint(" all ", theme), key("⇥", theme), hint(" routes ", theme), ], diff --git a/crates/hydrad/src/server.rs b/crates/hydrad/src/server.rs @@ -53,6 +53,20 @@ fn dispatch( notify_route_change(); resp } + Command::StartCombined { pids, output_uid, gain } => { + let (label, bundle_ids) = combined_label(&pids, output_uid.as_deref()); + let resp = match engine + .lock() + .unwrap() + .start_combined(&pids, output_uid.as_deref(), gain, label, bundle_ids) + { + Ok(id) => Response::RouteStarted { id }, + Err(e) => Response::Error(e.to_string()), + }; + persist(engine); + notify_route_change(); + resp + } Command::StopRoute { id } => { let r = ok_or_missing(engine.lock().unwrap().stop(&id), &id); persist(engine); @@ -114,3 +128,67 @@ fn route_label(pid: i32, output_uid: Option<&str>) -> (String, Option<String>) { (format!("{name} → {output}"), bundle_id) } + +/// Build the label + bundle-id list for a combined (multi-source) route. +/// Label reads like "Safari + Spotify +1 → Hydra". +fn combined_label(pids: &[i32], output_uid: Option<&str>) -> (String, Vec<String>) { + let apps = process::list_audio_processes(); + let names: Vec<String> = pids + .iter() + .map(|&pid| { + apps.iter().find(|a| a.pid == pid).map(|a| a.name.clone()).unwrap_or_else(|| format!("pid {pid}")) + }) + .collect(); + let bundle_ids: Vec<String> = pids + .iter() + .filter_map(|&pid| apps.iter().find(|a| a.pid == pid).and_then(|a| a.bundle_id.clone())) + .collect(); + + let sources = match names.len() { + 0 => "nothing".to_string(), + 1 => names[0].clone(), + 2 => format!("{} + {}", names[0], names[1]), + n => format!("{} + {} +{}", names[0], names[1], n - 2), + }; + + let output = match output_uid { + Some(uid) => hal::list_devices() + .ok() + .and_then(|ds| ds.into_iter().find(|d| d.uid == uid).map(|d| d.name)) + .unwrap_or_else(|| uid.to_string()), + None => "Default Output".to_string(), + }; + + (format!("{sources} → {output}"), bundle_ids) +} + +/// Build the label + bundle-id list for a combined (multi-source) route. +/// Label reads like "Safari + Spotify +1 → Hydra". +fn combined_label(pids: &[i32], output_uid: Option<&str>) -> (String, Vec<String>) { + let apps = process::list_audio_processes(); + let names: Vec<String> = pids + .iter() + .map(|&pid| { + apps.iter().find(|a| a.pid == pid).map(|a| a.name.clone()).unwrap_or_else(|| format!("pid {pid}")) + }) + .collect(); + let bundle_ids: Vec<String> = + pids.iter().filter_map(|&pid| apps.iter().find(|a| a.pid == pid).and_then(|a| a.bundle_id.clone())).collect(); + + let sources = match names.len() { + 0 => "nothing".to_string(), + 1 => names[0].clone(), + 2 => format!("{} + {}", names[0], names[1]), + n => format!("{} + {} +{}", names[0], names[1], n - 2), + }; + + let output = match output_uid { + Some(uid) => hal::list_devices() + .ok() + .and_then(|ds| ds.into_iter().find(|d| d.uid == uid).map(|d| d.name)) + .unwrap_or_else(|| uid.to_string()), + None => "Default Output".to_string(), + }; + + (format!("{sources} → {output}"), bundle_ids) +}