Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
131 changes: 129 additions & 2 deletions src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -611,12 +611,126 @@ fn find_device(host: &cpal::Host, queries: &[&str]) -> Result<Device> {
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::<Vec<_>>();
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<NonNull<c_void>>,
qualifier_size: u32,
) -> Option<u32> {
let mut address = AudioObjectPropertyAddress {
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain,
};
let mut value = 0_u32;
let mut size = std::mem::size_of::<u32>() 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::<u32>()).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<T>(samples: &[T], channels: usize, convert: impl Fn(&T) -> f32) -> Vec<f32> {
Expand Down Expand Up @@ -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() {
Expand Down
Loading