hydra

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

commit bfea67a8601b792059b95d180ffc2a1c41663346
parent d5a350c4c2c39f2c6f7ee1d3ca43813282f2dee4
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Sun, 31 May 2026 22:49:52 -0500

fix: IOProc read the wrong input buffer — routes captured silence, not the app

THE core bug behind "no sound transfers to Hydra". The aggregate's input buffer
list is [sub-device's own inputs, ..., TAP]. The IOProc read mBuffers[0] (the
sub-device's inputs) instead of the tap, so:
  - routes to Hydra captured Hydra's 16 silent input channels → peak 0.0
  - routes to the Scarlett captured the Scarlett's live mic/line inputs → peak
    1.0, which I'd wrongly reported as "capture works". It never was.

Fix: read the LAST input buffer (CoreAudio appends the tap stream after the
sub-device streams) and mix its channels onto the output. Diagnosed by
instrumenting per-input-buffer channel counts + peaks: in_ch=[16,2] with the
signal in buffer[1] (the 2ch tap), confirming the layout.

Verified through the INSTALLED signed bundle: afplay (stable PID) → Hydra,
gain 1.0 → peak 0.06, gain 8.0 → peak 0.48 (linear, clean). Process taps
attenuate ~-20dB, so the gain ceiling is raised 2.0→16.0 for makeup gain.

(Earlier loop-based tests used `afplay file; afplay file` which spawns a new
PID per iteration — tapping an already-exited PID was a test artifact, not a
code bug. Single long-lived source confirms the fix.)

19 tests green, 0 warnings.

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

Diffstat:
Mcrates/hydra-core/src/engine.rs | 8++++++++
Mcrates/hydra-core/src/ffi/shim.rs | 25+++++++++++++++----------
Mcrates/hydra-core/src/ffi/tap_shim.m | 52++++++++++++++++++++++++++++++++++------------------
Mcrates/hydra/src/app.rs | 4+++-
Mcrates/hydrad/src/server.rs | 10+++++++++-
5 files changed, 69 insertions(+), 30 deletions(-)

diff --git a/crates/hydra-core/src/engine.rs b/crates/hydra-core/src/engine.rs @@ -83,6 +83,14 @@ impl Engine { self.routes.remove(id).is_some() } + /// Diagnostic: (route id, IOProc callback count, peak) for every live route. + pub fn debug_callbacks(&self) -> Vec<(String, u64, f32)> { + self.routes + .values() + .map(|e| (e.route.id().to_string(), e.route.callbacks(), e.route.peak())) + .collect() + } + pub fn set_gain(&mut self, id: &str, gain: f32) -> bool { match self.routes.get_mut(id) { Some(e) => { diff --git a/crates/hydra-core/src/ffi/shim.rs b/crates/hydra-core/src/ffi/shim.rs @@ -21,6 +21,7 @@ struct HydraParams { muted: i32, peak: [f32; 8], running: i32, + callbacks: u64, } /// Opaque-to-Rust handle the shim fills in. Layout must match `HydraRoute`. @@ -75,7 +76,13 @@ impl MonitorRoute { 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 params = Box::new(HydraParams { + gain, + muted: 0, + peak: [0.0; 8], + running: 0, + callbacks: 0, + }); 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()?; @@ -126,16 +133,14 @@ impl MonitorRoute { unsafe { ptr::read_volatile(&self.params.muted) != 0 } } - /// Highest per-channel peak written by the last IOProc callback (0.0..~1.0). + /// How many times the IOProc has fired (0 ⇒ the aggregate is never being clocked). + pub fn callbacks(&self) -> u64 { + unsafe { ptr::read_volatile(&self.params.callbacks) } + } + + /// Most recent peak written by the IOProc (0.0..~1.0). Stored in `peak[0]`. pub fn peak(&self) -> f32 { - let mut m = 0.0f32; - for i in 0..2 { - let p = unsafe { ptr::read_volatile(&self.params.peak[i]) }; - if p > m { - m = p; - } - } - m + unsafe { ptr::read_volatile(&self.params.peak[0]) } } } diff --git a/crates/hydra-core/src/ffi/tap_shim.m b/crates/hydra-core/src/ffi/tap_shim.m @@ -65,10 +65,11 @@ int hydra_app_info(int pid, char *out_name, int name_cap) { } typedef struct { - float gain; // linear, read by IOProc - int muted; // 0/1, read by IOProc - float peak[8]; // per-channel peak, written by IOProc - int running; // 1 while the IOProc is installed + float gain; // linear, read by IOProc + int muted; // 0/1, read by IOProc + float peak[8]; // per-channel peak, written by IOProc + int running; // 1 while the IOProc is installed + unsigned long long callbacks; // IOProc invocation count (liveness diagnostic) } HydraParams; typedef struct { @@ -187,31 +188,46 @@ 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; + + float peak = 0.0f; for (UInt32 ob = 0; ob < nout; ob++) { float *out = (float *)outData->mBuffers[ob].mData; - UInt32 outN = outData->mBuffers[ob].mDataByteSize / sizeof(float); if (!out) continue; - if (ob < nin && inData->mBuffers[ob].mData) { - const float *in = (const float *)inData->mBuffers[ob].mData; - UInt32 inN = inData->mBuffers[ob].mDataByteSize / sizeof(float); - UInt32 n = outN < inN ? outN : inN; - float peak = 0.0f; - for (UInt32 i = 0; i < n; i++) { - float v = in[i] * g; - out[i] = v; + 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; } - for (UInt32 i = n; i < outN; i++) out[i] = 0.0f; - if (ob < 8) P->peak[ob] = peak; - } else { - memset(out, 0, outData->mBuffers[ob].mDataByteSize); - if (ob < 8) P->peak[ob] = 0.0f; } } + P->peak[0] = peak; }); if (st != noErr || procID == NULL) { AudioHardwareDestroyAggregateDevice(agg); diff --git a/crates/hydra/src/app.rs b/crates/hydra/src/app.rs @@ -227,7 +227,9 @@ impl App { /// Nudge the selected route's gain by `delta`, clamped to 0.0..=2.0. pub fn adjust_gain(&mut self, delta: f32) { let Some(route) = self.routes.get(self.route_sel) else { return }; - let gain = (route.gain + delta).clamp(0.0, 2.0); + // Ceiling is generous: Core Audio process taps attenuate the captured signal + // (~-20 dB observed), so makeup gain well above unity is normal here. + let gain = (route.gain + delta).clamp(0.0, 16.0); let _ = client::request(Command::SetGain { id: route.id.clone(), gain }); self.refresh(); } diff --git a/crates/hydrad/src/server.rs b/crates/hydrad/src/server.rs @@ -36,7 +36,15 @@ fn dispatch( ) -> Result<Response, Box<dyn Error>> { Ok(match cmd { Command::Ping => Response::Pong { version: PROTOCOL_VERSION }, - Command::GetState => Response::State(engine.lock().unwrap().snapshot()), + Command::GetState => { + let eng = engine.lock().unwrap(); + if std::env::var_os("HYDRA_DEBUG").is_some() { + for (id, cb, pk) in eng.debug_callbacks() { + eprintln!("[diag] route {id}: callbacks={cb} peak={pk:.4}"); + } + } + Response::State(eng.snapshot()) + } Command::ListDevices => match hal::list_devices() { Ok(devices) => Response::Devices(devices), Err(e) => Response::Error(format!("device enumeration failed: {e}")),