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:
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\""