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:
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}")),