hydra

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

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:
Mcrates/hydra-core/src/engine.rs | 24++++++++++++++++++++++++
Mcrates/hydra-core/src/ffi/shim.rs | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mcrates/hydra-core/src/ffi/tap_shim.m | 215++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mcrates/hydra-ipc/src/lib.rs | 2++
Mcrates/hydra/src/app.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/hydra/src/main.rs | 17++++++++++++++++-
Mcrates/hydra/src/ui.rs | 5+++++
Mcrates/hydrad/src/server.rs | 22++++++++++++++++++++++
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>) {