Files
qbz/crates/qbz-audio/src/coreaudio_direct.rs
T
Afonso Jorge Ramos 88e072a4ac fix(macos): harden CoreAudio exclusive stream handling
Address review feedback from the macOS Exclusive Mode PR.

Pin the resolved CoreAudio output device before acquiring Hog Mode so
System Default cannot drift between Hog Mode acquisition, sample-rate
switching, and stream creation.

Fall back to software volume when CoreAudio hardware volume is unavailable,
which keeps the volume slider usable on knob-only DACs.

Snapshot and restore CoreAudio hardware volume around Exclusive Mode so
leaving Exclusive Mode does not leave the output device at QBZ's last
hardware volume.

Refuse shared-mode fallback when macOS Exclusive Mode stream creation
fails, so the app does not silently play in non-exclusive mode while the UI
still reports Exclusive Mode.

Clean up Linux unused-variable warnings from the macOS-only guard plumbing.
2026-05-07 09:36:20 -05:00

472 lines
16 KiB
Rust

//! CoreAudio direct access for macOS
//!
//! Provides device capability probing and sample rate switching on macOS
//! using the coreaudio-rs safe wrappers.
//!
//! Phase 1: Device probing + nominal sample rate switching (shared mode)
//! Phase 2 (future): Hog mode + integer mode + IO proc for bit-perfect playback
#![cfg_attr(target_os = "macos", allow(deprecated))]
#[cfg(target_os = "macos")]
use coreaudio::audio_unit::{macos_helpers, Scope};
#[cfg(target_os = "macos")]
use coreaudio::Error;
#[cfg(target_os = "macos")]
use objc2_core_audio::{
kAudioDevicePropertyNominalSampleRate, kAudioDevicePropertyVolumeScalar,
kAudioObjectPropertyElementMaster, kAudioObjectPropertyScopeGlobal,
kAudioObjectPropertyScopeOutput, AudioObjectGetPropertyData, AudioObjectHasProperty,
AudioObjectPropertyAddress, AudioObjectSetPropertyData,
};
#[cfg(target_os = "macos")]
use std::{
mem,
ptr::{null, NonNull},
};
/// CoreAudio device ID (re-exported so callers don't need objc2_core_audio)
#[cfg(target_os = "macos")]
pub type AudioDeviceID = u32;
// CoreAudio transport type constants (FourCC values from AudioHardware.h)
#[cfg(target_os = "macos")]
mod transport_types {
pub const BUILT_IN: u32 = 0x626c746e; // 'bltn'
pub const USB: u32 = 0x75736220; // 'usb '
pub const BLUETOOTH: u32 = 0x626c7565; // 'blue'
pub const BLUETOOTH_LE: u32 = 0x626c6561; // 'blea'
pub const HDMI: u32 = 0x68646d69; // 'hdmi'
pub const DISPLAY_PORT: u32 = 0x64707274; // 'dprt'
pub const THUNDERBOLT: u32 = 0x7468756e; // 'thun'
pub const FIREWIRE: u32 = 0x31333934; // '1394'
pub const VIRTUAL: u32 = 0x76697274; // 'virt'
pub const AGGREGATE: u32 = 0x67727570; // 'grup'
}
/// Common audio sample rates to check against device capabilities
#[cfg(target_os = "macos")]
const COMMON_SAMPLE_RATES: &[u32] = &[
44100, 48000, 88200, 96000, 176400, 192000, 352800, 384000, 705600, 768000,
];
/// Query supported sample rates for a CoreAudio device.
/// Returns discrete rates from the device's available nominal sample rate ranges.
#[cfg(target_os = "macos")]
pub fn query_supported_sample_rates(device_id: AudioDeviceID) -> Result<Vec<u32>, String> {
let ranges = macos_helpers::get_available_sample_rates(device_id)
.map_err(|e| format!("Failed to get sample rate ranges: {:?}", e))?;
let mut rates = Vec::new();
for range in &ranges {
if (range.mMinimum - range.mMaximum).abs() < 0.5 {
// Point value (min == max)
rates.push(range.mMinimum as u32);
} else {
// Continuous range — check which common rates fall within it
for &rate in COMMON_SAMPLE_RATES {
let rate_f = rate as f64;
if rate_f >= range.mMinimum && rate_f <= range.mMaximum {
rates.push(rate);
}
}
}
}
rates.sort_unstable();
rates.dedup();
Ok(rates)
}
/// Set the nominal sample rate of a device.
/// Delegates to coreaudio-rs which handles async confirmation with a 2-second timeout.
#[cfg(target_os = "macos")]
pub fn set_nominal_sample_rate(device_id: AudioDeviceID, target_rate: u32) -> Result<(), String> {
log::info!(
"[CoreAudio] Switching sample rate to {}Hz on device {}",
target_rate,
device_id
);
macos_helpers::set_device_sample_rate(device_id, target_rate as f64)
.map_err(|e| format!("Failed to set sample rate to {}Hz: {:?}", target_rate, e))?;
log::info!("[CoreAudio] Sample rate switched to {}Hz", target_rate);
Ok(())
}
/// Get the current nominal sample rate of a CoreAudio device.
#[cfg(target_os = "macos")]
pub fn get_nominal_sample_rate(device_id: AudioDeviceID) -> Result<u32, String> {
unsafe {
let property_address = AudioObjectPropertyAddress {
mSelector: kAudioDevicePropertyNominalSampleRate,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMaster,
};
let mut rate = 0.0_f64;
let data_size = mem::size_of::<f64>() as u32;
let status = AudioObjectGetPropertyData(
device_id,
NonNull::from(&property_address),
0,
null(),
NonNull::from(&data_size),
NonNull::from(&mut rate).cast(),
);
Error::from_os_status(status)
.map_err(|e| format!("Failed to query CoreAudio nominal sample rate: {:?}", e))?;
if !rate.is_finite() || rate <= 0.0 {
return Err(format!("Invalid CoreAudio nominal sample rate: {}", rate));
}
Ok(rate.round() as u32)
}
}
/// Get the default output device ID.
#[cfg(target_os = "macos")]
pub fn get_default_output_device() -> Result<AudioDeviceID, String> {
macos_helpers::get_default_device_id(false)
.ok_or_else(|| "No default output device found".to_string())
}
/// Get all output device IDs.
#[cfg(target_os = "macos")]
pub fn get_output_device_ids() -> Result<Vec<AudioDeviceID>, String> {
macos_helpers::get_audio_device_ids_for_scope(Scope::Output)
.map_err(|e| format!("Failed to enumerate output devices: {:?}", e))
}
/// Get the name of a CoreAudio device.
#[cfg(target_os = "macos")]
pub fn get_device_name(device_id: AudioDeviceID) -> Result<String, String> {
macos_helpers::get_device_name(device_id)
.map_err(|e| format!("Failed to get device name: {:?}", e))
}
/// Find a CoreAudio output device ID by its name.
#[cfg(target_os = "macos")]
pub fn find_device_by_name(name: &str) -> Result<Option<AudioDeviceID>, String> {
// get_device_id_from_name: input=false means output device
Ok(macos_helpers::get_device_id_from_name(name, false))
}
/// Resolve an optional QBZ output device name to a CoreAudio output device ID.
/// `None` means the current system default output device.
#[cfg(target_os = "macos")]
pub fn resolve_output_device_id(device_name: Option<&str>) -> Result<AudioDeviceID, String> {
match device_name {
Some(name) => find_device_by_name(name)?
.ok_or_else(|| format!("CoreAudio output device '{}' not found", name)),
None => get_default_output_device(),
}
}
/// Resolve an optional QBZ output device name to the exact CoreAudio device name.
/// `None` means the current system default output device.
#[cfg(target_os = "macos")]
pub fn resolve_output_device_name(device_name: Option<&str>) -> Result<String, String> {
let device_id = resolve_output_device_id(device_name)?;
get_device_name(device_id)
}
/// Return the PID currently owning CoreAudio Hog Mode for this device.
/// CoreAudio uses -1 when no process owns the device.
#[cfg(target_os = "macos")]
pub fn get_hogging_pid(device_id: AudioDeviceID) -> Result<i32, String> {
macos_helpers::get_hogging_pid(device_id)
.map(|pid| pid as i32)
.map_err(|e| format!("Failed to query CoreAudio Hog Mode owner: {:?}", e))
}
/// Enable or disable CoreAudio Hog Mode for a device.
#[cfg(target_os = "macos")]
pub fn set_hog_mode(device_id: AudioDeviceID, enabled: bool) -> Result<(), String> {
let current_pid = get_hogging_pid(device_id)?;
let our_pid = std::process::id() as i32;
if enabled {
if current_pid == our_pid {
log::info!(
"[CoreAudio] Hog Mode already owned by QBZ for device {}",
device_id
);
return Ok(());
}
if current_pid != -1 && current_pid != 0 {
return Err(format!(
"CoreAudio device {} is already hogged by pid {}",
device_id, current_pid
));
}
let new_pid = macos_helpers::toggle_hog_mode(device_id)
.map(|pid| pid as i32)
.map_err(|e| format!("Failed to enable CoreAudio Hog Mode: {:?}", e))?;
if new_pid != our_pid {
return Err(format!(
"CoreAudio Hog Mode was not acquired for device {} (owner pid: {})",
device_id, new_pid
));
}
log::info!("[CoreAudio] Hog Mode acquired for device {}", device_id);
return Ok(());
}
if current_pid == our_pid {
let new_pid = macos_helpers::toggle_hog_mode(device_id)
.map(|pid| pid as i32)
.map_err(|e| format!("Failed to release CoreAudio Hog Mode: {:?}", e))?;
log::info!(
"[CoreAudio] Hog Mode released for device {} (owner pid now {})",
device_id,
new_pid
);
} else {
log::debug!(
"[CoreAudio] Hog Mode release skipped for device {} (owner pid: {})",
device_id,
current_pid
);
}
Ok(())
}
/// Get a CoreAudio device's current hardware output volume as scalar 0.0..1.0.
/// Tries master output first, then common stereo channel elements.
#[cfg(target_os = "macos")]
pub fn get_hardware_volume(device_id: AudioDeviceID) -> Result<f32, String> {
for element in [kAudioObjectPropertyElementMaster, 1, 2] {
let property_address = AudioObjectPropertyAddress {
mSelector: kAudioDevicePropertyVolumeScalar,
mScope: kAudioObjectPropertyScopeOutput,
mElement: element,
};
let has_property =
unsafe { AudioObjectHasProperty(device_id, NonNull::from(&property_address)) };
if !has_property {
continue;
}
let mut value = 0.0_f32;
let data_size = mem::size_of::<f32>() as u32;
let status = unsafe {
AudioObjectGetPropertyData(
device_id,
NonNull::from(&property_address),
0,
null(),
NonNull::from(&data_size),
NonNull::from(&mut value).cast(),
)
};
if status == 0 {
return Ok(value.clamp(0.0, 1.0));
}
}
Err(format!(
"CoreAudio device {} does not expose readable output hardware volume",
device_id
))
}
/// Set a CoreAudio device's hardware output volume using scalar 0.0..1.0.
/// Tries master output first, then common stereo channel elements.
#[cfg(target_os = "macos")]
pub fn set_hardware_volume(device_id: AudioDeviceID, volume: f32) -> Result<(), String> {
let clamped = volume.clamp(0.0, 1.0);
let mut last_error = None;
let mut channel_success = false;
for element in [kAudioObjectPropertyElementMaster, 1, 2] {
let property_address = AudioObjectPropertyAddress {
mSelector: kAudioDevicePropertyVolumeScalar,
mScope: kAudioObjectPropertyScopeOutput,
mElement: element,
};
let has_property =
unsafe { AudioObjectHasProperty(device_id, NonNull::from(&property_address)) };
if !has_property {
continue;
}
let mut value = clamped;
let status = unsafe {
AudioObjectSetPropertyData(
device_id,
NonNull::from(&property_address),
0,
null(),
mem::size_of::<f32>() as u32,
NonNull::new((&mut value as *mut f32).cast()).expect("volume pointer"),
)
};
if status == 0 {
log::debug!(
"[CoreAudio] Set hardware volume for device {} element {} to {:.0}%",
device_id,
element,
clamped * 100.0
);
if element == kAudioObjectPropertyElementMaster {
return Ok(());
}
channel_success = true;
continue;
}
last_error = Some(status);
}
if channel_success {
return Ok(());
}
Err(format!(
"CoreAudio device {} does not expose settable output hardware volume{}",
device_id,
last_error
.map(|status| format!(" (last OSStatus {})", status))
.unwrap_or_default()
))
}
/// RAII owner for CoreAudio Hog Mode.
///
/// Captures the device's hardware volume on acquire and restores it
/// when released, so leaving Exclusive Mode returns the device to the
/// volume the user had set before QBZ took over.
#[cfg(target_os = "macos")]
#[derive(Debug)]
pub struct CoreAudioExclusiveGuard {
device_id: AudioDeviceID,
active: bool,
original_hardware_volume: Option<f32>,
}
#[cfg(target_os = "macos")]
impl CoreAudioExclusiveGuard {
/// Acquire CoreAudio Hog Mode for the given device.
///
/// The guard is constructed *before* the FFI call so that any
/// partial-acquire failure (e.g. CoreAudio transfers ownership to
/// us but the readback fails) still triggers `Drop`, which calls
/// `set_hog_mode(false)`. That release is a no-op when we don't
/// actually own the device, so it's safe in either outcome and
/// avoids leaving the device hogged on error.
pub fn acquire(device_id: AudioDeviceID) -> Result<Self, String> {
// Snapshot the current hardware volume before we touch anything,
// so the user's pre-Exclusive level can be restored on release.
// Devices without a readable volume property (knob-only DACs)
// simply don't get a snapshot — restoration is best-effort.
let original_hardware_volume = get_hardware_volume(device_id).ok();
let guard = Self {
device_id,
active: true,
original_hardware_volume,
};
set_hog_mode(device_id, true)?;
Ok(guard)
}
pub fn release(&mut self) -> Result<(), String> {
if !self.active {
return Ok(());
}
// Restore the hardware volume *before* releasing Hog Mode, while
// we still own the device. After release any other process can
// change the volume, so doing it before keeps our restoration
// authoritative.
if let Some(original) = self.original_hardware_volume.take() {
if let Err(e) = set_hardware_volume(self.device_id, original) {
log::warn!(
"[CoreAudio] Failed to restore hardware volume on release: {}",
e
);
}
}
set_hog_mode(self.device_id, false)?;
self.active = false;
Ok(())
}
pub fn set_hardware_volume(&self, volume: f32) -> Result<(), String> {
set_hardware_volume(self.device_id, volume)
}
}
#[cfg(target_os = "macos")]
impl Drop for CoreAudioExclusiveGuard {
fn drop(&mut self) {
if let Err(e) = self.release() {
log::warn!("[CoreAudio] Failed to release Hog Mode on drop: {}", e);
}
}
}
/// Get the transport type of a device (USB, built-in, Bluetooth, etc.)
#[cfg(target_os = "macos")]
pub fn get_device_transport_type(device_id: AudioDeviceID) -> Option<String> {
let transport = macos_helpers::get_device_transport_type(device_id).ok()?;
let transport_str = if transport == transport_types::BUILT_IN {
"built-in"
} else if transport == transport_types::USB {
"usb"
} else if transport == transport_types::BLUETOOTH || transport == transport_types::BLUETOOTH_LE
{
"bluetooth"
} else if transport == transport_types::HDMI || transport == transport_types::DISPLAY_PORT {
"hdmi"
} else if transport == transport_types::THUNDERBOLT {
"thunderbolt"
} else if transport == transport_types::FIREWIRE {
"firewire"
} else if transport == transport_types::VIRTUAL {
"virtual"
} else if transport == transport_types::AGGREGATE {
"aggregate"
} else {
"unknown"
};
Some(transport_str.to_string())
}
// ---- Non-macOS stubs ----
/// Query supported sample rates (stub for non-macOS)
#[cfg(not(target_os = "macos"))]
pub fn query_supported_sample_rates(_device_name: &str) -> Result<Vec<u32>, String> {
Ok(Vec::new())
}
/// Get the current nominal sample rate (stub for non-macOS)
#[cfg(not(target_os = "macos"))]
pub fn get_nominal_sample_rate_by_name(_device_name: &str) -> Result<u32, String> {
Err("CoreAudio is only available on macOS".to_string())
}
/// Set the nominal sample rate (stub for non-macOS)
#[cfg(not(target_os = "macos"))]
pub fn set_nominal_sample_rate_by_name(
_device_name: &str,
_target_rate: u32,
) -> Result<(), String> {
Err("CoreAudio is only available on macOS".to_string())
}
/// Non-macOS placeholder so shared backend/player signatures can mention the type.
#[cfg(not(target_os = "macos"))]
#[derive(Debug)]
pub struct CoreAudioExclusiveGuard;