hydra

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

commit 6f8b8c69d4f0a6a651b8a755b690151c91582c3b
parent e7bb12a18f1380d43ef74a5a8f26d84d8b1bd85f
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Mon,  1 Jun 2026 14:48:19 -0500

fix: default Hydra to 44100 Hz — the real cause of one-ear-in-Discord

Measured A/B (diagnostics/devdiff.m) of Hydra vs Loopback's working virtual mic,
both 2ch: the ONE difference was sample rate — Hydra 48000, Loopback 44100 — and
the user's whole setup runs at 44.1k. Hydra at 48k against a 44.1k host forces a
resample in the capture path that collapses stereo to one channel; Loopback
matched the host so it stayed clean. (Channel labels were a red herring: Hydra
already had proper [L,R], Loopback had [Unknown,Unknown], yet Loopback worked.)

- BlackHole.c: startup rate via -DkDefault_SampleRate (defaults to 48000 =
  upstream unchanged; device still supports all rates, this only sets the
  come-up rate).
- build-driver.sh: HYDRA_DRIVER_RATE, default 44100. Corrected the stale comment
  that wrongly blamed the 16ch count for the one-ear bug.
- diagnostics/devdiff.m + hydra_mic_probe.js: the measurement tools that found it
  (device property diff; browser capture probe).

Reinstall driver to apply. Verify: devdiff shows Hydra input ASBD 44100 Hz.

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

Diffstat:
Adiagnostics/devdiff.m | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adiagnostics/hydra_mic_probe.js | 23+++++++++++++++++++++++
Mdriver/upstream/BlackHole/BlackHole.c | 7++++++-
Mscripts/build-driver.sh | 13+++++++++----
4 files changed, 154 insertions(+), 5 deletions(-)

diff --git a/diagnostics/devdiff.m b/diagnostics/devdiff.m @@ -0,0 +1,116 @@ +// devdiff.m — dump the CoreAudio properties that matter for stereo capture, for every +// input-capable device. Built to A/B Hydra against Loopback's virtual mic: same channel +// count but different downstream behaviour in WebRTC ⇒ the difference must be in format / +// channel layout / labels, which this prints. +// +// clang -framework CoreAudio -framework CoreFoundation devdiff.m -o devdiff && ./devdiff +#import <CoreAudio/CoreAudio.h> +#import <CoreFoundation/CoreFoundation.h> +#import <stdio.h> + +static CFStringRef cfprop(AudioObjectID dev, AudioObjectPropertySelector sel) { + CFStringRef s = NULL; UInt32 z = sizeof(s); + AudioObjectPropertyAddress a = { sel, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain }; + if (AudioObjectGetPropertyData(dev, &a, 0, NULL, &z, &s) == noErr) return s; + return NULL; +} +static void pcf(const char *label, CFStringRef s) { + if (!s) { printf("%s(nil)", label); return; } + char buf[256]; CFStringGetCString(s, buf, sizeof(buf), kCFStringEncodingUTF8); + printf("%s%s", label, buf); +} + +static const char *chanLabel(AudioChannelLabel l) { + switch (l) { + case kAudioChannelLabel_Left: return "L"; + case kAudioChannelLabel_Right: return "R"; + case kAudioChannelLabel_Center: return "C"; + case kAudioChannelLabel_Mono: return "Mono"; + case kAudioChannelLabel_Unknown: return "Unknown(0xFFFFFFFF)"; + case kAudioChannelLabel_Unused: return "Unused"; + case kAudioChannelLabel_Discrete: return "Discrete"; + default: + if ((l & 0xFFFF0000) == (kAudioChannelLabel_Discrete_0 & 0xFFFF0000)) return "Discrete_N"; + return "other"; + } +} + +static void dumpLayout(AudioObjectID dev) { + AudioObjectPropertyAddress a = { + kAudioDevicePropertyPreferredChannelLayout, + kAudioObjectPropertyScopeInput, kAudioObjectPropertyElementMain + }; + UInt32 z = 0; + if (AudioObjectGetPropertyDataSize(dev, &a, 0, NULL, &z) != noErr || z == 0) { + printf(" input channel layout: (none)\n"); + return; + } + AudioChannelLayout *lay = (AudioChannelLayout *)malloc(z); + if (AudioObjectGetPropertyData(dev, &a, 0, NULL, &z, lay) == noErr) { + printf(" input channel layout: tag=0x%X", lay->mChannelLayoutTag); + if (lay->mChannelLayoutTag == kAudioChannelLayoutTag_UseChannelDescriptions) { + printf(" descriptions=%u [", lay->mNumberChannelDescriptions); + for (UInt32 i = 0; i < lay->mNumberChannelDescriptions; i++) { + printf("%s%s", i ? "," : "", chanLabel(lay->mChannelDescriptions[i].mChannelLabel)); + } + printf("]"); + } else if (lay->mChannelLayoutTag == kAudioChannelLayoutTag_Stereo) { + printf(" (Stereo L/R)"); + } else if (lay->mChannelLayoutTag == kAudioChannelLayoutTag_Mono) { + printf(" (Mono)"); + } + printf("\n"); + } + free(lay); +} + +static void dumpFormat(AudioObjectID dev) { + AudioStreamBasicDescription f; UInt32 z = sizeof(f); + AudioObjectPropertyAddress a = { + kAudioDevicePropertyStreamFormat, + kAudioObjectPropertyScopeInput, kAudioObjectPropertyElementMain + }; + if (AudioObjectGetPropertyData(dev, &a, 0, NULL, &z, &f) == noErr) { + printf(" input ASBD: %.0f Hz, %u ch, %u bits, flags=0x%X\n", + f.mSampleRate, f.mChannelsPerFrame, f.mBitsPerChannel, f.mFormatFlags); + } else { + printf(" input ASBD: (no input stream)\n"); + } +} + +static UInt32 inChannels(AudioObjectID dev) { + AudioObjectPropertyAddress a = { + kAudioDevicePropertyStreamConfiguration, + kAudioObjectPropertyScopeInput, kAudioObjectPropertyElementMain + }; + UInt32 z = 0; if (AudioObjectGetPropertyDataSize(dev, &a, 0, NULL, &z) != noErr || z == 0) return 0; + AudioBufferList *bl = (AudioBufferList *)malloc(z); UInt32 ch = 0; + if (AudioObjectGetPropertyData(dev, &a, 0, NULL, &z, bl) == noErr) + for (UInt32 i = 0; i < bl->mNumberBuffers; i++) ch += bl->mBuffers[i].mNumberChannels; + free(bl); return ch; +} + +int main(void) { + AudioObjectPropertyAddress a = { + kAudioHardwarePropertyDevices, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain + }; + UInt32 z = 0; + AudioObjectGetPropertyDataSize(kAudioObjectSystemObject, &a, 0, NULL, &z); + UInt32 n = z / sizeof(AudioObjectID); + AudioObjectID *devs = (AudioObjectID *)malloc(z); + AudioObjectGetPropertyData(kAudioObjectSystemObject, &a, 0, NULL, &z, devs); + + for (UInt32 i = 0; i < n; i++) { + UInt32 ich = inChannels(devs[i]); + if (ich == 0) continue; // input-capable only + CFStringRef name = cfprop(devs[i], kAudioObjectPropertyName); + char nm[256] = ""; if (name) { CFStringGetCString(name, nm, sizeof(nm), kCFStringEncodingUTF8); CFRelease(name); } + printf("● %s (input ch=%u)\n", nm, ich); + dumpFormat(devs[i]); + dumpLayout(devs[i]); + CFStringRef uid = cfprop(devs[i], kAudioDevicePropertyDeviceUID); + pcf(" uid: ", uid); printf("\n"); if (uid) CFRelease(uid); + } + free(devs); + return 0; +} diff --git a/diagnostics/hydra_mic_probe.js b/diagnostics/hydra_mic_probe.js @@ -0,0 +1,23 @@ +// Paste into Vesktop DevTools console (Cmd+Opt+I) with Hydra selected as input. +// Reports what Chromium's WebRTC capture actually sees from the device — the layer +// shell tools can't reach. Run with Hydra selected, then again with Loopback, compare. +(async () => { + const md = navigator.mediaDevices; + const s = await md.getUserMedia({ audio: { channelCount: 2, echoCancellation:false, noiseSuppression:false, autoGainControl:false } }); + const t = s.getAudioTracks()[0]; + console.log("[probe] track:", t.label); + console.log("[probe] settings:", JSON.stringify(t.getSettings())); // channelCount HERE is the truth + // Tap the actual samples per channel + const ac = new AudioContext(); + const src = ac.createMediaStreamSource(s); + const sp = ac.createScriptProcessor(4096, 2, 2); + let n=0, peakL=0, peakR=0; + sp.onaudioprocess = e => { + const L=e.inputBuffer.getChannelData(0), R=e.inputBuffer.getChannelData(1); + for (let i=0;i<L.length;i++){ peakL=Math.max(peakL,Math.abs(L[i])); peakR=Math.max(peakR,Math.abs(R[i])); } + if (++n>=20){ sp.disconnect(); src.disconnect(); s.getTracks().forEach(x=>x.stop()); ac.close(); + console.log(`[probe] captured peaks L=${peakL.toFixed(4)} R=${peakR.toFixed(4)} ${peakL>0.001&&peakR>0.001?"BOTH ✓":"ONE EAR ✗"}`); } + }; + src.connect(sp); sp.connect(ac.destination); + console.log("[probe] listening 2s — make sure audio is routing into Hydra..."); +})(); diff --git a/driver/upstream/BlackHole/BlackHole.c b/driver/upstream/BlackHole/BlackHole.c @@ -263,7 +263,12 @@ static Boolean gBox_Acquired = kBox_A static pthread_mutex_t gDevice_IOMutex = PTHREAD_MUTEX_INITIALIZER; -static Float64 gDevice_SampleRate = 48000.0; +// Hydra: startup sample rate is overridable at build time (-DkDefault_SampleRate=44100) so +// the virtual device can come up matching the host's rate. Defaults to 48000 = upstream. +#ifndef kDefault_SampleRate +#define kDefault_SampleRate 48000.0 +#endif +static Float64 gDevice_SampleRate = kDefault_SampleRate; static Float64 gDevice_RequestedSampleRate = 0.0; static UInt64 gDevice_IOIsRunning = 0; static UInt64 gDevice2_IOIsRunning = 0; diff --git a/scripts/build-driver.sh b/scripts/build-driver.sh @@ -23,11 +23,15 @@ APP="$OUT/Hydra.driver" DRIVER_NAME="Hydra" BUNDLE_ID="com.ganten.hydra.driver" -# Default 2ch (stereo): matches a normal mic / Loopback's device, so WebRTC apps -# (Discord/Vesktop, Zoom) downmix cleanly to L+R. A 16ch device made Vesktop send only -# one ear — the stereo capture path doesn't map 16→2 cleanly when only ch 0/1 carry signal. -# Override for multi-channel DAW routing with HYDRA_DRIVER_CHANNELS=N. +# Default 2ch stereo (matches a normal mic / Loopback). Override for DAW use with +# HYDRA_DRIVER_CHANNELS=N. CHANNELS="${HYDRA_DRIVER_CHANNELS:-2}" +# Default startup sample rate 44100. THIS was the real "one ear in Discord" cause: Hydra +# defaulted to 48000 while the host setup ran at 44100, and the resample in the capture path +# collapsed stereo to one channel. Loopback worked because it matched the host at 44100. +# The device still *supports* 48000 etc.; this only sets the rate it comes up at. +# Override with HYDRA_DRIVER_RATE=48000 if your setup is 48k. +RATE="${HYDRA_DRIVER_RATE:-44100}" MANUFACTURER="Ganten" SIGN_ID="${HYDRA_SIGN_ID:--}" # '-' = ad-hoc @@ -45,6 +49,7 @@ DEFS=( -DkPlugIn_Icon="\"$DRIVER_NAME.icns\"" -DkManufacturer_Name="\"$MANUFACTURER\"" -DkNumber_Of_Channels=$CHANNELS + -DkDefault_SampleRate=${RATE}.0 -DkHas_Driver_Name_Format=false -DkDevice_Name="\"$DRIVER_NAME\"" -DkDevice2_Name="\"$DRIVER_NAME Mirror\""