feat(audio): add CoreAudio exclusive mode
This commit is contained in:
6 files changed
+265
-35
No files matched your search
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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};
|
||||
|
||||
Reference in new issue
Block a user