commit 85a303fa45a6b1c42c91a9314ee638c2497e916d
parent e89e691ab77a9835614c4a6a496335701b5bfe32
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date: Mon, 1 Jun 2026 17:59:41 -0500
feat: route hardware inputs (mic / line-in / interface) to any output
New source type alongside app-capture: route a hardware INPUT device to an
output. No process tap, no TCC — a plain [input, output] aggregate with the
shared IOProc reading the input device's own stream.
- tap_shim.m: factored the realtime IOProc into shared hydra_render(useLast) +
hydra_publish_rate (process tap = last input buffer, hardware input = first);
new hydra_input_start builds the input→output aggregate. ~70 lines of realtime
code now shared, not duplicated.
- shim.rs: MonitorRoute::start_input + new_params() helper (keeps the Rust/C
layout assertion in one place). engine::start_input. IPC StartInput command.
daemon dispatch + input_label.
- TUI: 'i' opens an input-source picker (lists input-capable devices, excludes
Hydra to avoid feedback); ⏎ routes the chosen input to the current output.
Reuses the shared overlay; footer hint added.
VERIFIED: input route builds an aggregate whose IOProc clocks (callbacks climb);
picker lists real inputs (Scarlett 20ch, MacBook mic, etc.). NOT yet confirmed:
actual mic audio flowing — needs mic TCC consent + (for built-in mic) lid open;
that's a user manual test. 28 tests, 0 warnings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
8 files changed, 325 insertions(+), 108 deletions(-)
diff --git a/crates/hydra-core/src/engine.rs b/crates/hydra-core/src/engine.rs
@@ -100,6 +100,30 @@ impl Engine {
Ok(id)
}
+ /// Route a hardware input device (by UID, e.g. the MacBook mic) to an output. No tap/TCC.
+ pub fn start_input(
+ &mut self,
+ input_uid: &str,
+ output_uid: Option<&str>,
+ gain: f32,
+ label: String,
+ ) -> Result<String> {
+ let route = MonitorRoute::start_input(input_uid, output_uid, gain)?;
+ let id = route.id().to_string();
+ self.routes.insert(
+ id.clone(),
+ Entry {
+ route,
+ label,
+ bundle_ids: Vec::new(), // hardware input has no bundle id
+ source_count: 1,
+ output_uid: output_uid.map(str::to_string),
+ last_callbacks: u64::MAX,
+ },
+ );
+ Ok(id)
+ }
+
/// Stop and tear down a route. Returns whether it existed.
pub fn stop(&mut self, id: &str) -> bool {
self.routes.remove(id).is_some()
diff --git a/crates/hydra-core/src/ffi/shim.rs b/crates/hydra-core/src/ffi/shim.rs
@@ -60,6 +60,13 @@ extern "C" {
fn hydra_monitor_stop(route: *mut HydraRoute);
/// `sizeof(HydraParams)` as the C compiler sees it — for the layout assertion.
fn hydra_params_size() -> usize;
+ /// Route a hardware INPUT device (e.g. the MacBook mic) to an output. No tap/TCC.
+ fn hydra_input_start(
+ input_uid: *const c_char,
+ output_uid: *const c_char,
+ params: *mut HydraParams,
+ out_route: *mut HydraRoute,
+ ) -> i32;
}
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
@@ -96,6 +103,33 @@ unsafe impl Send for ParamsPtr {}
// which outlives any access. Safe to move between threads (the daemon holds it behind a Mutex).
unsafe impl Send for MonitorRoute {}
+/// Allocate a fresh, heap-stable `HydraParams` for a new route. Asserts the Rust/C layouts
+/// match (the struct is shared with the realtime IOProc; a mismatch would corrupt audio-thread
+/// memory) before any route is created.
+fn new_params(gain: f32) -> Box<HydraParams> {
+ assert_eq!(
+ std::mem::size_of::<HydraParams>(),
+ unsafe { hydra_params_size() },
+ "HydraParams layout mismatch between Rust and tap_shim.m"
+ );
+ Box::new(HydraParams {
+ gain,
+ muted: 0,
+ peak: [0.0; 8],
+ running: 0,
+ callbacks: 0,
+ rec_on: AtomicI32::new(0),
+ rec_buf: ptr::null_mut(),
+ rec_cap: 0,
+ rec_channels: 0,
+ rec_write: AtomicU64::new(0),
+ rec_read: AtomicU64::new(0),
+ rec_overruns: AtomicU64::new(0),
+ fmt_channels: AtomicU32::new(0),
+ fmt_sample_rate: AtomicU32::new(0),
+ })
+}
+
impl MonitorRoute {
/// 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> {
@@ -112,31 +146,7 @@ impl MonitorRoute {
let proc_objs: Vec<AudioObjectID> =
pids.iter().map(|&p| process_object_for_pid(p)).collect::<Result<_>>()?;
- // Guard against the Rust/C struct layouts drifting apart (this struct is shared with
- // the realtime IOProc; a mismatch would corrupt audio-thread memory).
- const _: () = ();
- assert_eq!(
- std::mem::size_of::<HydraParams>(),
- unsafe { hydra_params_size() },
- "HydraParams layout mismatch between Rust and tap_shim.m"
- );
-
- let mut params = Box::new(HydraParams {
- gain,
- muted: 0,
- peak: [0.0; 8],
- running: 0,
- callbacks: 0,
- rec_on: AtomicI32::new(0),
- rec_buf: ptr::null_mut(),
- rec_cap: 0,
- rec_channels: 0,
- rec_write: AtomicU64::new(0),
- rec_read: AtomicU64::new(0),
- rec_overruns: AtomicU64::new(0),
- fmt_channels: AtomicU32::new(0),
- fmt_sample_rate: AtomicU32::new(0),
- });
+ let mut params = new_params(gain);
let mut raw = HydraRoute { tap: 0, aggregate: 0, ioproc: ptr::null_mut(), params: ptr::null_mut() };
let c_uid = output_uid.map(CString::new).transpose()?;
@@ -164,6 +174,30 @@ impl MonitorRoute {
})
}
+ /// Route a hardware input device (by UID — e.g. the MacBook mic) to `output_uid` (or the
+ /// default output). No process tap, no TCC consent: a plain input→output aggregate.
+ pub fn start_input(input_uid: &str, output_uid: Option<&str>, gain: f32) -> Result<Self> {
+ let mut params = new_params(gain);
+ let mut raw = HydraRoute { tap: 0, aggregate: 0, ioproc: ptr::null_mut(), params: ptr::null_mut() };
+
+ let c_in = CString::new(input_uid)?;
+ let c_out = output_uid.map(CString::new).transpose()?;
+ let out_ptr = c_out.as_ref().map_or(ptr::null(), |c| c.as_ptr());
+
+ let st = unsafe { hydra_input_start(c_in.as_ptr(), out_ptr, params.as_mut() as *mut _, &mut raw) };
+ if st != 0 {
+ bail!("starting input route for {input_uid} failed: {}", os_status(st));
+ }
+
+ Ok(Self {
+ id: format!("r{}", NEXT_ID.fetch_add(1, Ordering::Relaxed)),
+ pids: Vec::new(), // hardware input has no PID
+ raw,
+ params,
+ recorder: None,
+ })
+ }
+
pub fn id(&self) -> &str {
&self.id
}
diff --git a/crates/hydra-core/src/ffi/tap_shim.m b/crates/hydra-core/src/ffi/tap_shim.m
@@ -140,6 +140,140 @@ static NSString *hydra_device_uid(AudioObjectID dev) {
return (__bridge_transfer NSString *)uid;
}
+// The realtime IOProc body, shared by process-tap routes and hardware-input routes. They
+// differ only in WHICH input buffer carries the source audio:
+// - process tap: the LAST input buffer (CoreAudio appends the tap after sub-device streams)
+// - hardware input: the FIRST input buffer (the input device's own stream)
+// `useLast` picks. Everything else — gain/mute, peak, the recording ring, fmt publish — is
+// identical, so it lives here once. Must stay allocation/lock free (audio thread).
+static void hydra_render(HydraParams *P, const AudioBufferList *inData, AudioBufferList *outData, int useLast) {
+ P->callbacks++;
+ const float g = P->muted ? 0.0f : P->gain;
+ const UInt32 nin = inData ? inData->mNumberBuffers : 0;
+ const UInt32 nout = outData ? outData->mNumberBuffers : 0;
+
+ const AudioBuffer *src = NULL;
+ if (nin > 0) src = useLast ? &inData->mBuffers[nin - 1] : &inData->mBuffers[0];
+ const float *srcData = (src && src->mData) ? (const float *)src->mData : NULL;
+ const UInt32 srcCh = src ? src->mNumberChannels : 0;
+ const UInt32 srcFrames = (srcData && srcCh) ? (src->mDataByteSize / sizeof(float) / srcCh) : 0;
+
+ if (srcCh && __c11_atomic_load(&P->fmt_channels, __ATOMIC_RELAXED) == 0) {
+ __c11_atomic_store(&P->fmt_channels, srcCh, __ATOMIC_RELEASE);
+ }
+
+ // Recording: raw (pre-gain) source → SPSC ring; drop+count on overrun, never block.
+ if (__c11_atomic_load(&P->rec_on, __ATOMIC_ACQUIRE) && srcData && P->rec_buf && P->rec_cap) {
+ const unsigned int total = srcFrames * srcCh;
+ unsigned long long w = __c11_atomic_load(&P->rec_write, __ATOMIC_RELAXED);
+ unsigned long long r = __c11_atomic_load(&P->rec_read, __ATOMIC_ACQUIRE);
+ const unsigned int cap = P->rec_cap;
+ unsigned int freeSpace = (unsigned int)(cap - (w - r));
+ if (total <= freeSpace) {
+ for (unsigned int i = 0; i < total; i++) P->rec_buf[(w + i) % cap] = srcData[i];
+ __c11_atomic_store(&P->rec_write, w + total, __ATOMIC_RELEASE);
+ } else {
+ __c11_atomic_fetch_add(&P->rec_overruns, total, __ATOMIC_RELAXED);
+ }
+ }
+
+ float peak = 0.0f;
+ for (UInt32 ob = 0; ob < nout; ob++) {
+ float *out = (float *)outData->mBuffers[ob].mData;
+ if (!out) continue;
+ const UInt32 outCh = outData->mBuffers[ob].mNumberChannels;
+ const UInt32 outFrames = outCh ? (outData->mBuffers[ob].mDataByteSize / sizeof(float) / outCh) : 0;
+ memset(out, 0, outData->mBuffers[ob].mDataByteSize);
+ if (!srcData || srcCh == 0) continue;
+ const UInt32 frames = outFrames < srcFrames ? outFrames : srcFrames;
+ const UInt32 ch = outCh < srcCh ? outCh : srcCh;
+ for (UInt32 f = 0; f < frames; f++) {
+ for (UInt32 c = 0; c < ch; c++) {
+ float v = srcData[f * srcCh + c] * g;
+ out[f * outCh + c] = v;
+ float av = fabsf(v);
+ if (av > peak) peak = av;
+ }
+ }
+ }
+ P->peak[0] = peak;
+}
+
+// Read the aggregate's nominal sample rate into params (for the WAV header).
+static void hydra_publish_rate(AudioObjectID agg, HydraParams *P) {
+ Float64 sr = 0;
+ UInt32 srSize = sizeof(sr);
+ AudioObjectPropertyAddress srAddr = {
+ kAudioDevicePropertyNominalSampleRate, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain
+ };
+ if (AudioObjectGetPropertyData(agg, &srAddr, 0, NULL, &srSize, &sr) == noErr && sr > 0) {
+ __c11_atomic_store(&P->fmt_sample_rate, (unsigned int)sr, __ATOMIC_RELEASE);
+ }
+}
+
+// Build a route capturing a hardware INPUT device (e.g. the MacBook mic) to an output.
+// No process tap, no TCC: just an aggregate of [input, output] with the shared IOProc
+// reading the input device's own stream (buffer 0). Returns an OSStatus.
+OSStatus hydra_input_start(const char *inputUID, // required: the source input device
+ const char *outputUID, // nullable -> default output
+ HydraParams *params,
+ HydraRoute *outRoute) {
+ if (!inputUID || !inputUID[0] || !params || !outRoute) return kAudio_ParamError;
+ @autoreleasepool {
+ AudioObjectID inDev = hydra_device_for_uid(inputUID);
+ NSString *inUID = hydra_device_uid(inDev);
+ if (inUID == nil) return kAudioHardwareBadDeviceError;
+
+ AudioObjectID outDev = (outputUID && outputUID[0]) ? hydra_device_for_uid(outputUID)
+ : hydra_default_output();
+ NSString *outUID = hydra_device_uid(outDev);
+ if (outUID == nil) return kAudioHardwareBadDeviceError;
+
+ // Private aggregate: input device first (its stream is input buffer 0), output as the
+ // clock master. No tap list.
+ NSString *aggUID = [[NSUUID UUID] UUIDString];
+ NSDictionary *description = @{
+ @(kAudioAggregateDeviceNameKey): @"Hydra Input",
+ @(kAudioAggregateDeviceUIDKey): aggUID,
+ @(kAudioAggregateDeviceIsPrivateKey): @YES,
+ @(kAudioAggregateDeviceMainSubDeviceKey): outUID,
+ @(kAudioAggregateDeviceSubDeviceListKey): @[
+ @{ @(kAudioSubDeviceUIDKey): inUID },
+ @{ @(kAudioSubDeviceUIDKey): outUID },
+ ],
+ };
+ AudioObjectID agg = 0;
+ OSStatus st = AudioHardwareCreateAggregateDevice((__bridge CFDictionaryRef)description, &agg);
+ if (st != noErr || agg == 0) return st != noErr ? st : kAudioHardwareUnspecifiedError;
+
+ HydraParams *P = params;
+ AudioDeviceIOProcID procID = NULL;
+ st = AudioDeviceCreateIOProcIDWithBlock(&procID, agg, NULL,
+ ^(const AudioTimeStamp *now, const AudioBufferList *inD, const AudioTimeStamp *inT,
+ AudioBufferList *outD, const AudioTimeStamp *outT) {
+ (void)now; (void)inT; (void)outT;
+ hydra_render(P, inD, outD, /*useLast=*/0); // input device = first buffer
+ });
+ if (st != noErr || procID == NULL) {
+ AudioHardwareDestroyAggregateDevice(agg);
+ return st != noErr ? st : kAudioHardwareUnspecifiedError;
+ }
+ hydra_publish_rate(agg, P);
+ st = AudioDeviceStart(agg, procID);
+ if (st != noErr) {
+ AudioDeviceDestroyIOProcID(agg, procID);
+ AudioHardwareDestroyAggregateDevice(agg);
+ return st;
+ }
+ P->running = 1;
+ outRoute->tap = 0; // no tap; teardown handles tap==0
+ outRoute->aggregate = agg;
+ outRoute->ioproc = procID;
+ outRoute->params = P;
+ return noErr;
+ }
+}
+
// Build tap + aggregate + IOProc and start it. Returns an OSStatus (noErr on success).
OSStatus hydra_monitor_start(const AudioObjectID *procObjs,
int nProcs,
@@ -200,7 +334,7 @@ OSStatus hydra_monitor_start(const AudioObjectID *procObjs,
return st != noErr ? st : kAudioHardwareUnspecifiedError;
}
- // 5. IOProc: copy tap input -> device output with live gain/mute, write peaks.
+ // 5. IOProc: the shared render reads the tap (LAST input buffer) → output.
HydraParams *P = params;
AudioDeviceIOProcID procID = NULL;
st = AudioDeviceCreateIOProcIDWithBlock(&procID, agg, NULL,
@@ -208,70 +342,7 @@ OSStatus hydra_monitor_start(const AudioObjectID *procObjs,
const AudioTimeStamp *inTime, AudioBufferList *outData,
const AudioTimeStamp *outTime) {
(void)now; (void)inTime; (void)outTime;
- P->callbacks++;
- const float g = P->muted ? 0.0f : P->gain;
- const UInt32 nin = inData ? inData->mNumberBuffers : 0;
- const UInt32 nout = outData ? outData->mNumberBuffers : 0;
- (void)nout;
- // The tapped audio is the LAST input buffer: CoreAudio appends the tap's
- // stream after the aggregate sub-device's own input streams. buffer[0] is the
- // sub-device's inputs (a hardware device's mics/lines, or the virtual device's
- // own input channels) — NOT what we want. Reading buffer[0] was the bug that
- // made routes capture silence (or the sub-device's live inputs) instead of the app.
- const AudioBuffer *tap = (nin > 0) ? &inData->mBuffers[nin - 1] : NULL;
- const float *tapData = (tap && tap->mData) ? (const float *)tap->mData : NULL;
- const UInt32 tapCh = tap ? tap->mNumberChannels : 0;
- const UInt32 tapFrames = (tapData && tapCh) ? (tap->mDataByteSize / sizeof(float) / tapCh) : 0;
-
- // Publish the tap channel count once known, so the recorder can header the WAV.
- if (tapCh && __c11_atomic_load(&P->fmt_channels, __ATOMIC_RELAXED) == 0) {
- __c11_atomic_store(&P->fmt_channels, tapCh, __ATOMIC_RELEASE);
- }
-
- // Recording: copy the raw (pre-gain) tap into the SPSC ring. We record the
- // source as captured, independent of the monitor gain knob. Lock-free; on a
- // full ring we drop and count rather than block the audio thread.
- if (__c11_atomic_load(&P->rec_on, __ATOMIC_ACQUIRE) && tapData && P->rec_buf && P->rec_cap) {
- const unsigned int total = tapFrames * tapCh;
- unsigned long long w = __c11_atomic_load(&P->rec_write, __ATOMIC_RELAXED);
- unsigned long long r = __c11_atomic_load(&P->rec_read, __ATOMIC_ACQUIRE);
- const unsigned int cap = P->rec_cap;
- unsigned int freeSpace = (unsigned int)(cap - (w - r));
- if (total <= freeSpace) {
- for (unsigned int i = 0; i < total; i++) {
- P->rec_buf[(w + i) % cap] = tapData[i];
- }
- __c11_atomic_store(&P->rec_write, w + total, __ATOMIC_RELEASE);
- } else {
- __c11_atomic_fetch_add(&P->rec_overruns, total, __ATOMIC_RELAXED);
- }
- }
-
- float peak = 0.0f;
- for (UInt32 ob = 0; ob < nout; ob++) {
- float *out = (float *)outData->mBuffers[ob].mData;
- if (!out) continue;
- const UInt32 outCh = outData->mBuffers[ob].mNumberChannels;
- const UInt32 outFrames = outCh ? (outData->mBuffers[ob].mDataByteSize / sizeof(float) / outCh) : 0;
-
- // Silence the whole output buffer first (covers unused channels + the
- // no-tap case), then mix the tap's channels onto the leading output channels.
- memset(out, 0, outData->mBuffers[ob].mDataByteSize);
- if (!tapData || tapCh == 0) {
- continue;
- }
- const UInt32 frames = outFrames < tapFrames ? outFrames : tapFrames;
- const UInt32 ch = outCh < tapCh ? outCh : tapCh;
- for (UInt32 f = 0; f < frames; f++) {
- for (UInt32 c = 0; c < ch; c++) {
- float v = tapData[f * tapCh + c] * g;
- out[f * outCh + c] = v;
- float av = fabsf(v);
- if (av > peak) peak = av;
- }
- }
- }
- P->peak[0] = peak;
+ hydra_render(P, inData, outData, /*useLast=*/1); // tap = last input buffer
});
if (st != noErr || procID == NULL) {
AudioHardwareDestroyAggregateDevice(agg);
@@ -279,19 +350,7 @@ OSStatus hydra_monitor_start(const AudioObjectID *procObjs,
return st != noErr ? st : kAudioHardwareUnspecifiedError;
}
- // Publish the aggregate's nominal sample rate for the WAV header.
- {
- Float64 sr = 0;
- UInt32 srSize = sizeof(sr);
- AudioObjectPropertyAddress srAddr = {
- kAudioDevicePropertyNominalSampleRate,
- kAudioObjectPropertyScopeGlobal,
- kAudioObjectPropertyElementMain
- };
- if (AudioObjectGetPropertyData(agg, &srAddr, 0, NULL, &srSize, &sr) == noErr && sr > 0) {
- __c11_atomic_store(&P->fmt_sample_rate, (unsigned int)sr, __ATOMIC_RELEASE);
- }
- }
+ hydra_publish_rate(agg, P);
st = AudioDeviceStart(agg, procID);
if (st != noErr) {
diff --git a/crates/hydra-ipc/src/lib.rs b/crates/hydra-ipc/src/lib.rs
@@ -46,6 +46,8 @@ pub enum Command {
/// 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 },
+ /// Route a hardware INPUT device (mic / line-in, by UID) to an output. No tap/TCC.
+ StartInput { input_uid: String, 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
@@ -51,6 +51,10 @@ pub struct App {
pub outputs: Vec<AudioDevice>,
/// Index into `outputs` of the currently chosen route target.
pub output_sel: usize,
+ /// Input-capable devices (mics, line-in, interfaces) — sources for hardware routing.
+ pub inputs: Vec<AudioDevice>,
+ /// When `Some`, the input-source picker is open: (input device names, selected index).
+ pub input_picker: Option<(Vec<String>, usize)>,
pub focus: Focus,
pub app_sel: usize,
pub route_sel: usize,
@@ -77,6 +81,8 @@ impl App {
peak_hold: std::collections::HashMap::new(),
outputs: Vec::new(),
output_sel: 0,
+ inputs: Vec::new(),
+ input_picker: None,
focus: Focus::Apps,
app_sel: 0,
route_sel: 0,
@@ -286,7 +292,7 @@ impl App {
self.apps = apps;
}
if let Ok(Response::Devices(devices)) = client::request(Command::ListDevices) {
- self.set_outputs(devices);
+ self.set_devices(devices);
}
if let Ok(Response::State(snap)) = client::request(Command::GetState) {
self.routes = snap.routes;
@@ -313,17 +319,67 @@ impl App {
self.peak_hold.get(id).copied().unwrap_or(0.0)
}
- /// Keep only output-capable devices; default the selection to the system default output.
- fn set_outputs(&mut self, devices: Vec<AudioDevice>) {
+ /// Split the device list into output targets (for the `o` picker) and input sources (for
+ /// the `i` input-routing picker); preserve the current output selection across refreshes.
+ fn set_devices(&mut self, devices: Vec<AudioDevice>) {
let prev_uid = self.outputs.get(self.output_sel).map(|d| d.uid.clone());
+ // Exclude our own Hydra device from inputs — routing Hydra→Hydra is a feedback loop.
+ self.inputs =
+ devices.iter().filter(|d| d.input_channels > 0 && d.uid != "Hydra_UID").cloned().collect();
self.outputs = devices.into_iter().filter(|d| d.output_channels > 0).collect();
- // Preserve the prior choice across refreshes; else pick the default output.
self.output_sel = prev_uid
.and_then(|uid| self.outputs.iter().position(|d| d.uid == uid))
.or_else(|| self.outputs.iter().position(|d| d.is_default_output))
.unwrap_or(0);
}
+ // ── Input-source picker (route a mic / line-in to the current output) ──────────────
+
+ /// Open the input picker, listing input-capable devices.
+ pub fn open_input_picker(&mut self) {
+ if self.inputs.is_empty() {
+ self.status = "no input devices found".into();
+ return;
+ }
+ let names: Vec<String> = self.inputs.iter().map(|d| d.name.clone()).collect();
+ self.input_picker = Some((names, 0));
+ }
+
+ pub fn input_picker_close(&mut self) {
+ self.input_picker = None;
+ }
+
+ pub fn input_picker_move(&mut self, down: bool) {
+ if let Some((names, sel)) = self.input_picker.as_mut() {
+ if names.is_empty() {
+ return;
+ }
+ *sel = if down { (*sel + 1).min(names.len() - 1) } else { sel.saturating_sub(1) };
+ }
+ }
+
+ /// Route the highlighted input device to the current output (StartInput).
+ pub fn input_picker_apply(&mut self) {
+ let Some((_, sel)) = self.input_picker.as_ref() else { return };
+ let Some(dev) = self.inputs.get(*sel) else { return };
+ let input_uid = dev.uid.clone();
+ let src = dev.name.clone();
+ 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();
+ self.input_picker = None;
+ match client::request(Command::StartInput { input_uid, output_uid, gain: DEFAULT_GAIN }) {
+ Ok(Response::RouteStarted { id }) => self.status = format!("{id}: {src} → {dest}"),
+ Ok(Response::Error(e)) => self.status = format!("input route: {e}"),
+ Ok(other) => self.status = format!("unexpected: {other:?}"),
+ Err(e) => self.status = format!("input route failed: {e}"),
+ }
+ self.refresh();
+ }
+
+ pub fn is_input_picker_open(&self) -> bool {
+ self.input_picker.is_some()
+ }
+
/// The app list as shown: real apps only (kind < 2) unless `show_all_apps` is on.
pub fn visible_apps(&self) -> Vec<&AudioApp> {
self.apps.iter().filter(|a| self.show_all_apps || a.kind < 2).collect()
diff --git a/crates/hydra/src/main.rs b/crates/hydra/src/main.rs
@@ -57,7 +57,10 @@ fn run(terminal: &mut Tui) -> Result<(), Box<dyn Error>> {
// Don't poll the daemon while a modal (prompt / presets / theme picker) is open —
// it would overwrite the status line and fight the open overlay.
- let modal_open = app.is_prompting() || app.is_presets_open() || app.is_theme_picker_open();
+ let modal_open = app.is_prompting()
+ || app.is_presets_open()
+ || app.is_theme_picker_open()
+ || app.is_input_picker_open();
if !modal_open && last_refresh.elapsed() >= REFRESH {
app.refresh();
last_refresh = Instant::now();
@@ -101,6 +104,17 @@ fn handle_key(app: &mut App, code: KeyCode) {
}
return;
}
+ // The input-source picker captures keys while open.
+ if app.is_input_picker_open() {
+ match code {
+ KeyCode::Esc | KeyCode::Char('q') => app.input_picker_close(),
+ KeyCode::Down | KeyCode::Char('j') => app.input_picker_move(true),
+ KeyCode::Up | KeyCode::Char('k') => app.input_picker_move(false),
+ KeyCode::Enter => app.input_picker_apply(),
+ _ => {}
+ }
+ return;
+ }
match code {
KeyCode::Char('q') | KeyCode::Esc => app.quit(),
KeyCode::Char('r') => app.refresh(),
@@ -115,6 +129,7 @@ fn handle_key(app: &mut App, code: KeyCode) {
KeyCode::Char('a') => app.toggle_show_all(),
KeyCode::Char('t') => app.open_theme_picker(),
KeyCode::Char('T') => app.toggle_transparency(),
+ KeyCode::Char('i') => app.open_input_picker(),
KeyCode::Char('n') => app.begin_rename(),
KeyCode::Char('p') => app.open_presets(),
KeyCode::Char('P') => app.begin_save_preset(),
diff --git a/crates/hydra/src/ui.rs b/crates/hydra/src/ui.rs
@@ -37,6 +37,9 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
if let Some((names, sel)) = &app.theme_picker {
draw_overlay(f, "theme", names, *sel, "⏎ apply · T transparency · esc close", theme);
}
+ if let Some((names, sel)) = &app.input_picker {
+ draw_overlay(f, "route an input", names, *sel, "⏎ route to current output · esc close", theme);
+ }
}
/// A centered modal list overlay (presets, theme picker). `hint` is shown just below.
@@ -240,6 +243,8 @@ fn draw_footer(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
hint(" separate ", theme),
key("o", theme),
hint(" output ", theme),
+ key("i", theme),
+ hint(" input ", theme),
key("t", theme),
hint(" theme ", theme),
key("⇥", theme),
diff --git a/crates/hydrad/src/server.rs b/crates/hydrad/src/server.rs
@@ -123,6 +123,16 @@ fn dispatch(
notify_route_change();
resp
}
+ Command::StartInput { input_uid, output_uid, gain } => {
+ let label = input_label(&input_uid, output_uid.as_deref());
+ let resp = match engine.lock().unwrap().start_input(&input_uid, output_uid.as_deref(), gain, label) {
+ Ok(id) => Response::RouteStarted { id },
+ Err(e) => Response::Error(e.to_string()),
+ };
+ // Hardware-input routes aren't persisted yet (no bundle id); skip persist().
+ notify_route_change();
+ resp
+ }
Command::StopRoute { id } => {
let r = ok_or_missing(engine.lock().unwrap().stop(&id), &id);
persist(engine);
@@ -247,6 +257,18 @@ fn route_label(pid: i32, output_uid: Option<&str>) -> (String, Option<String>) {
(format!("{name} → {output}"), bundle_id)
}
+/// Display label for a hardware-input route, e.g. "MacBook Pro Microphone → Hydra".
+fn input_label(input_uid: &str, output_uid: Option<&str>) -> String {
+ let devs = hal::list_devices().unwrap_or_default();
+ let name = |uid: &str| devs.iter().find(|d| d.uid == uid).map(|d| d.name.clone());
+ let src = name(input_uid).unwrap_or_else(|| input_uid.to_string());
+ let dest = match output_uid {
+ Some(uid) => name(uid).unwrap_or_else(|| uid.to_string()),
+ None => "Default Output".to_string(),
+ };
+ format!("{src} → {dest}")
+}
+
/// 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>) {