From 6ce3ec31f1443430a1b4ef3f99b04cd76aad5abb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 2 Oct 2026 14:26:02 -0700 Subject: [PATCH] fix(macos): avoid Bluetooth headset mics in automatic selection --- docs/features/README.md | 12 ++++ src/audio.rs | 131 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 141 insertions(+), 2 deletions(-) diff --git a/docs/features/README.md b/docs/features/README.md index 5144a98..c8d673e 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -255,6 +255,8 @@ latency measurement. Settings // settings ├── Dictation shortcut -> Capture a replacement binding ├── Microphone -> Automatic or saved device +│ ├── Automatic -> Compiled preferences, then the macOS default +│ │ └── Default is a Bluetooth headset -> Built-in mic when present // avoids the call profile │ └── Open menu covers the controls beneath it; click elsewhere dismisses ├── Microphone mode │ ├── Keep ready (fast) -> Open while idle; pre-roll available @@ -295,6 +297,16 @@ The script check does not prove subprocess avoidance or exercise Music, Spotify, or VLC through Automation. An opt-in native assertion smoke exercises IOKit without starting microphone capture. +**Fixed after 2.1.22:** Automatic selection opened a Bluetooth headset when it was +the macOS default input, which switches AirPods and similar headsets into a +low-quality call profile for all audio, including music +([#106](https://github.com/anomalyco/hex/issues/106), +[#88](https://github.com/anomalyco/hex/issues/88)). It now prefers a built-in +microphone and keeps the headset only when none exists; an explicitly saved +headset is still opened. `automatic_input_prefers_built_in_over_a_bluetooth_default` +in [audio.rs](../../src/audio.rs) covers the choice, not CoreAudio's transport +report or a connected headset. + **Fixed after 2.1.20:** through 2.1.20 the microphone menu was painted without occluding the panel, so hovering or clicking a device also reached the setting under the pointer ([#94](https://github.com/anomalyco/hex/issues/94)), and it diff --git a/src/audio.rs b/src/audio.rs index 444824f..7653a00 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -611,12 +611,126 @@ fn find_device(host: &cpal::Host, queries: &[&str]) -> Result { return Ok(device.clone()); } } - host.default_input_device().ok_or_else(|| { + let default = host.default_input_device().ok_or_else(|| { eyre!( "no preferred or default input device is available (preferred: {})", queries.join(", ") ) - }) + })?; + Ok(avoid_bluetooth_input(default, &devices)) +} + +#[cfg(target_os = "macos")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum InputTransport { + BuiltIn, + Bluetooth, + Other, +} + +/// Opening a Bluetooth headset's microphone switches it into a low-quality +/// call profile for all audio, so automatic selection prefers a built-in mic. +#[cfg(target_os = "macos")] +fn automatic_input_index(default: usize, transports: &[InputTransport]) -> usize { + if transports.get(default) != Some(&InputTransport::Bluetooth) { + return default; + } + transports + .iter() + .position(|transport| *transport == InputTransport::BuiltIn) + .unwrap_or(default) +} + +#[cfg(target_os = "macos")] +fn avoid_bluetooth_input(default: Device, devices: &[Device]) -> Device { + let default_id = default.id().ok(); + let Some(default_index) = devices + .iter() + .position(|device| device.id().ok() == default_id) + else { + return default; + }; + let transports = devices.iter().map(input_transport).collect::>(); + let index = automatic_input_index(default_index, &transports); + if index != default_index { + tracing::info!( + bluetooth = %devices[default_index], + selected = %devices[index], + "automatic microphone selection skipped a Bluetooth headset" + ); + } + devices[index].clone() +} + +#[cfg(target_os = "linux")] +fn avoid_bluetooth_input(default: Device, _devices: &[Device]) -> Device { + default +} + +#[cfg(target_os = "macos")] +fn input_transport(device: &Device) -> InputTransport { + use std::ffi::c_void; + use std::ptr::NonNull; + + use objc2_core_audio::{ + AudioObjectGetPropertyData, AudioObjectID, AudioObjectPropertyAddress, + AudioObjectPropertySelector, kAudioDevicePropertyTransportType, + kAudioDeviceTransportTypeBluetooth, kAudioDeviceTransportTypeBluetoothLE, + kAudioDeviceTransportTypeBuiltIn, kAudioHardwarePropertyTranslateUIDToDevice, + kAudioObjectPropertyElementMain, kAudioObjectPropertyScopeGlobal, kAudioObjectSystemObject, + }; + use objc2_core_foundation::CFString; + + fn read_u32( + object: AudioObjectID, + selector: AudioObjectPropertySelector, + qualifier: Option>, + qualifier_size: u32, + ) -> Option { + let mut address = AudioObjectPropertyAddress { + mSelector: selector, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain, + }; + let mut value = 0_u32; + let mut size = std::mem::size_of::() as u32; + let status = unsafe { + AudioObjectGetPropertyData( + object, + NonNull::from(&mut address), + qualifier_size, + qualifier.map_or(std::ptr::null(), |qualifier| qualifier.as_ptr()), + NonNull::from(&mut size), + NonNull::from(&mut value).cast(), + ) + }; + (status == 0 && size as usize == std::mem::size_of::()).then_some(value) + } + + let Ok(id) = device.id() else { + return InputTransport::Other; + }; + let uid = CFString::from_str(id.id()); + let uid_ref: *const CFString = &*uid; + let Some(device_id) = read_u32( + kAudioObjectSystemObject as AudioObjectID, + kAudioHardwarePropertyTranslateUIDToDevice, + Some(NonNull::from(&uid_ref).cast()), + std::mem::size_of::<*const CFString>() as u32, + ) + .filter(|device_id| *device_id != 0) else { + return InputTransport::Other; + }; + match read_u32(device_id, kAudioDevicePropertyTransportType, None, 0) { + Some(transport) if transport == kAudioDeviceTransportTypeBuiltIn => InputTransport::BuiltIn, + Some(transport) + if transport == kAudioDeviceTransportTypeBluetooth + || transport == kAudioDeviceTransportTypeBluetoothLE => + { + InputTransport::Bluetooth + } + _ => InputTransport::Other, + } } fn mono(samples: &[T], channels: usize, convert: impl Fn(&T) -> f32) -> Vec { @@ -684,6 +798,19 @@ fn captured_through( mod tests { use super::*; + #[cfg(target_os = "macos")] + #[test] + fn automatic_input_prefers_built_in_over_a_bluetooth_default() { + use InputTransport::{Bluetooth, BuiltIn, Other}; + + assert_eq!(automatic_input_index(1, &[BuiltIn, Bluetooth]), 0); + assert_eq!(automatic_input_index(0, &[Bluetooth, Other, BuiltIn]), 2); + // Non-Bluetooth defaults, such as USB microphones, are kept. + assert_eq!(automatic_input_index(1, &[BuiltIn, Other]), 1); + // Without a built-in microphone the Bluetooth default is still used. + assert_eq!(automatic_input_index(0, &[Bluetooth, Other]), 0); + } + #[cfg(target_os = "macos")] #[test] fn mach_event_ticks_are_converted_to_nanoseconds() {