feat(audio): add CoreAudio exclusive mode

This commit is contained in:
Vudgekek committed 2026-05-06 23:26:15 -05:00
1 parent ecb8f96fa1
commit e0151af6d9
6 files changed
+265 -35

No files matched your search

+87 -20
View File
@@ -236,6 +236,19 @@ pub trait AudioBackend: Send + Sync {
/// Create an output stream for the given configuration
fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink>;
/// Create an output stream and optionally return a platform exclusive-mode guard.
/// Most backends do not need a guard; macOS CoreAudio uses this to keep Hog Mode
/// owned for the lifetime of the stream.
fn create_output_stream_with_exclusive_guard(
&self,
config: &BackendConfig,
) -> BackendResult<(
MixerDeviceSink,
Option<crate::coreaudio_direct::CoreAudioExclusiveGuard>,
)> {
self.create_output_stream(config).map(|sink| (sink, None))
}
/// Check if this backend is available on the current system
fn is_available(&self) -> bool;
@@ -294,7 +307,9 @@ impl BackendManager {
}
#[cfg(not(target_os = "linux"))]
{
log::info!("PipeWire not available on this platform, using system default audio");
log::info!(
"PipeWire not available on this platform, using system default audio"
);
Ok(Box::new(CpalDefaultBackend::new()?))
}
}
@@ -403,9 +418,7 @@ impl AudioBackend for CpalDefaultBackend {
// On macOS, probe device capabilities via CoreAudio
#[cfg(target_os = "macos")]
let (supported_rates, max_rate, bus_type, is_hw) = {
Self::probe_macos_device(&name)
};
let (supported_rates, max_rate, bus_type, is_hw) = { Self::probe_macos_device(&name) };
#[cfg(not(target_os = "macos"))]
let (supported_rates, max_rate, bus_type, is_hw): (
Option<Vec<u32>>,
@@ -430,14 +443,16 @@ impl AudioBackend for CpalDefaultBackend {
}
fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink> {
// On macOS, switch device sample rate to match the requested rate
// On macOS, only exclusive mode takes ownership of the device rate.
// Shared mode must leave the user's current CoreAudio device rate alone.
#[cfg(target_os = "macos")]
{
if let Some(ref device_id) = config.device_id {
Self::switch_sample_rate_if_needed(device_id, config.sample_rate);
} else {
// Switch default device rate
Self::switch_default_device_rate_if_needed(config.sample_rate);
if config.exclusive_mode {
if let Some(ref device_id) = config.device_id {
Self::switch_sample_rate_if_needed(device_id, config.sample_rate);
} else {
Self::switch_default_device_rate_if_needed(config.sample_rate);
}
}
}
@@ -465,6 +480,30 @@ impl AudioBackend for CpalDefaultBackend {
Ok(mixer_sink)
}
#[cfg(target_os = "macos")]
fn create_output_stream_with_exclusive_guard(
&self,
config: &BackendConfig,
) -> BackendResult<(
MixerDeviceSink,
Option<crate::coreaudio_direct::CoreAudioExclusiveGuard>,
)> {
let guard = if config.exclusive_mode {
let device_id =
crate::coreaudio_direct::resolve_output_device_id(config.device_id.as_deref())?;
Some(crate::coreaudio_direct::CoreAudioExclusiveGuard::acquire(
device_id,
)?)
} else {
None
};
match self.create_output_stream(config) {
Ok(sink) => Ok((sink, guard)),
Err(e) => Err(e),
}
}
fn is_available(&self) -> bool {
self.host.default_output_device().is_some()
}
@@ -493,7 +532,10 @@ impl CpalDefaultBackend {
id
}
Ok(None) => {
log::debug!("[CoreAudio] Device '{}' not found via CoreAudio", device_name);
log::debug!(
"[CoreAudio] Device '{}' not found via CoreAudio",
device_name
);
return (None, None, None, false);
}
Err(e) => {
@@ -503,17 +545,29 @@ impl CpalDefaultBackend {
};
let supported_rates = coreaudio_direct::query_supported_sample_rates(device_id)
.inspect(|rates| log::info!("[CoreAudio] Device '{}' supported rates: {:?}", device_name, rates))
.inspect_err(|e| log::warn!("[CoreAudio] Failed to query rates for '{}': {}", device_name, e))
.inspect(|rates| {
log::info!(
"[CoreAudio] Device '{}' supported rates: {:?}",
device_name,
rates
)
})
.inspect_err(|e| {
log::warn!(
"[CoreAudio] Failed to query rates for '{}': {}",
device_name,
e
)
})
.ok()
.filter(|r| !r.is_empty());
let max_rate = supported_rates
.as_ref()
.and_then(|rates| rates.iter().max().copied());
let bus_type = coreaudio_direct::get_device_transport_type(device_id);
let is_hardware = bus_type
.as_deref()
.is_some_and(|t| t == "usb" || t == "built-in" || t == "thunderbolt" || t == "firewire");
let is_hardware = bus_type.as_deref().is_some_and(|t| {
t == "usb" || t == "built-in" || t == "thunderbolt" || t == "firewire"
});
(supported_rates, max_rate, bus_type, is_hardware)
}
@@ -522,12 +576,19 @@ impl CpalDefaultBackend {
fn switch_sample_rate_if_needed(device_name: &str, target_rate: u32) {
use crate::coreaudio_direct;
log::info!("[CoreAudio] Rate switch requested: device='{}' target={}Hz", device_name, target_rate);
log::info!(
"[CoreAudio] Rate switch requested: device='{}' target={}Hz",
device_name,
target_rate
);
let device_id = match coreaudio_direct::find_device_by_name(device_name) {
Ok(Some(id)) => id,
Ok(None) => {
log::warn!("[CoreAudio] Cannot switch rate: device '{}' not found", device_name);
log::warn!(
"[CoreAudio] Cannot switch rate: device '{}' not found",
device_name
);
return;
}
Err(e) => {
@@ -560,7 +621,10 @@ impl CpalDefaultBackend {
let device_id = match coreaudio_direct::get_default_output_device() {
Ok(id) => id,
Err(e) => {
log::debug!("[CoreAudio] Could not get default device for rate switch: {}", e);
log::debug!(
"[CoreAudio] Could not get default device for rate switch: {}",
e
);
return;
}
};
@@ -576,7 +640,10 @@ impl CpalDefaultBackend {
}
if let Err(e) = coreaudio_direct::set_nominal_sample_rate(device_id, target_rate) {
log::warn!("[CoreAudio] Failed to switch default device sample rate: {}", e);
log::warn!(
"[CoreAudio] Failed to switch default device sample rate: {}",
e
);
}
}
}
+170 -15
View File
@@ -6,10 +6,22 @@
//! 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;
use coreaudio::audio_unit::{macos_helpers, Scope};
#[cfg(target_os = "macos")]
use coreaudio::audio_unit::Scope;
use coreaudio::Error;
#[cfg(target_os = "macos")]
use objc2_core_audio::{
kAudioDevicePropertyNominalSampleRate, kAudioObjectPropertyElementMaster,
kAudioObjectPropertyScopeGlobal, AudioObjectGetPropertyData, AudioObjectPropertyAddress,
};
#[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")]
@@ -18,16 +30,16 @@ 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 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 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'
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
@@ -81,6 +93,34 @@ pub fn set_nominal_sample_rate(device_id: AudioDeviceID, target_rate: u32) -> Re
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> {
@@ -109,6 +149,119 @@ pub fn find_device_by_name(name: &str) -> Result<Option<AudioDeviceID>, String>
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(),
}
}
/// 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(())
}
/// RAII owner for CoreAudio Hog Mode.
#[cfg(target_os = "macos")]
#[derive(Debug)]
pub struct CoreAudioExclusiveGuard {
device_id: AudioDeviceID,
active: bool,
}
#[cfg(target_os = "macos")]
impl CoreAudioExclusiveGuard {
pub fn acquire(device_id: AudioDeviceID) -> Result<Self, String> {
set_hog_mode(device_id, true)?;
Ok(Self {
device_id,
active: true,
})
}
pub fn release(&mut self) -> Result<(), String> {
if !self.active {
return Ok(());
}
set_hog_mode(self.device_id, false)?;
self.active = false;
Ok(())
}
}
#[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> {
@@ -118,13 +271,10 @@ pub fn get_device_transport_type(device_id: AudioDeviceID) -> Option<String> {
"built-in"
} else if transport == transport_types::USB {
"usb"
} else if transport == transport_types::BLUETOOTH
|| transport == transport_types::BLUETOOTH_LE
} else if transport == transport_types::BLUETOOTH || transport == transport_types::BLUETOOTH_LE
{
"bluetooth"
} else if transport == transport_types::HDMI
|| transport == transport_types::DISPLAY_PORT
{
} else if transport == transport_types::HDMI || transport == transport_types::DISPLAY_PORT {
"hdmi"
} else if transport == transport_types::THUNDERBOLT {
"thunderbolt"
@@ -163,3 +313,8 @@ pub fn set_nominal_sample_rate_by_name(
) -> 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;
+1
View File
@@ -61,6 +61,7 @@ pub use backend::{
AlsaDirectError, AlsaPlugin, AudioBackend, AudioBackendType, AudioDevice, BackendConfig,
BackendManager, BackendResult, BitPerfectMode,
};
pub use coreaudio_direct::CoreAudioExclusiveGuard;
pub use diagnostic::{AudioDiagnostic, BitDepthResult, DiagnosticSource};
pub use dynamic_amplify::DynamicAmplify;
pub use loudness::{calculate_gain_factor, db_to_linear, extract_replaygain, ReplayGainData};