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:
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)
+}