Merge PR #391 (Vudgekek): macOS CoreAudio Exclusive Mode
Adds CoreAudio Hog Mode integration on macOS so Exclusive Mode actually
takes the device exclusively (previously the macOS path used the
Linux-shaped logic without any kernel-level lock). Pins the CoreAudio
device id through Hog Mode acquisition, sample-rate switching, and
stream creation so System Default doesn't drift mid-session. Linux
behaviour is unchanged.
Review asks (all four resolved by Vudgekek + afonsojramos):
- set_coreaudio_hardware_volume falls back to software volume on Err
rather than reporting success, so knob-only DACs keep a working
volume slider via engine.set_volume().
- CoreAudioExclusiveGuard snapshots the device's hardware volume on
acquire and restores it before releasing Hog Mode, so leaving
Exclusive Mode returns the DAC to the user's pre-Exclusive level.
Guard is constructed before the FFI call so partial-acquire failures
still trigger Drop and release.
- Linux unused-variable warnings on stream_opt and exclusive_guard
cleaned up via cfg_attr(allow(unused_variables)) on the macOS-only
guard plumbing. cargo check is clean.
- Silent shared-mode regression on macOS when backend init fails:
init_device now sets_current_device(None) and returns None on
exclusive_mode==true so the user sees an error instead of
unknowingly playing in shared mode.
Bonus: 5f10b4ba adds real CoreAudio device-rate detection for macOS
Shared Mode, +90 lines in backend.rs.
Conflict resolution: single textual collision in
crates/qbz-audio/src/lib.rs (both branches added a pub use line).
Resolved by keeping both, alphabetical order — coreaudio_direct
re-export from pr-391, device_reservation re-export from pre-release.
All other auto-merges verified line-by-line: AlsaDirectStream's
DeviceReservation::acquire flow (alsa_direct.rs:60-97), the macOS
exclusive_mode guard (player/mod.rs:1077-1085 in qbz-player and the
mirror in src-tauri:1024), and CoreAudioExclusiveGuard's volume
snapshot/restore (coreaudio_direct.rs:351,369,373,388) all coexist
correctly because each branch touched different functions.
cargo check clean across all four crates. 9 device_reservation +
3 reserve_dac + 214 src-tauri unit tests pass.
cc
This commit is contained in:
14 files changed
+983
-165
No files matched your search
+220
-23
@@ -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,18 +443,52 @@ impl AudioBackend for CpalDefaultBackend {
|
||||
}
|
||||
|
||||
fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink> {
|
||||
// On macOS, switch device sample rate to match the requested rate
|
||||
#[cfg(target_os = "macos")]
|
||||
let macos_exclusive_device_name = if config.exclusive_mode && config.device_id.is_none() {
|
||||
match crate::coreaudio_direct::resolve_output_device_name(None) {
|
||||
Ok(name) => {
|
||||
log::info!(
|
||||
"[CoreAudio] Resolved System Default to '{}' for exclusive stream",
|
||||
name
|
||||
);
|
||||
Some(name)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[CoreAudio] Could not resolve System Default device name: {}",
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 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 if let Some(ref device_name) = macos_exclusive_device_name {
|
||||
Self::switch_sample_rate_if_needed(device_name, config.sample_rate);
|
||||
} else {
|
||||
Self::switch_default_device_rate_if_needed(config.sample_rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let device = if let Some(ref device_id) = config.device_id {
|
||||
#[cfg(target_os = "macos")]
|
||||
let effective_device_id = config
|
||||
.device_id
|
||||
.as_ref()
|
||||
.or(macos_exclusive_device_name.as_ref());
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let effective_device_id = config.device_id.as_ref();
|
||||
|
||||
let device = if let Some(device_id) = effective_device_id {
|
||||
self.host
|
||||
.output_devices()
|
||||
.map_err(|e| format!("Failed to enumerate devices: {}", e))?
|
||||
@@ -457,14 +504,76 @@ impl AudioBackend for CpalDefaultBackend {
|
||||
.ok_or_else(|| "No default output device found".to_string())?
|
||||
};
|
||||
|
||||
let mixer_sink = DeviceSinkBuilder::from_device(device)
|
||||
.map_err(|e| format!("Failed to create device sink builder: {}", e))?
|
||||
#[cfg(target_os = "macos")]
|
||||
let shared_mode_override = if !config.exclusive_mode {
|
||||
Self::shared_mode_nominal_stream_config(
|
||||
&device,
|
||||
effective_device_id.map(|name| name.as_str()),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let builder = DeviceSinkBuilder::from_device(device)
|
||||
.map_err(|e| format!("Failed to create device sink builder: {}", e))?;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let mixer_sink = if let Some(override_config) = shared_mode_override {
|
||||
let buffer_size =
|
||||
rodio::cpal::BufferSize::Fixed((override_config.sample_rate() / 20).max(64));
|
||||
builder
|
||||
.with_supported_config(&override_config)
|
||||
.with_buffer_size(buffer_size)
|
||||
.open_stream()
|
||||
.map_err(|e| format!("Failed to create output stream: {}", e))?
|
||||
} else {
|
||||
builder
|
||||
.open_stream()
|
||||
.map_err(|e| format!("Failed to create output stream: {}", e))?
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let mixer_sink = builder
|
||||
.open_stream()
|
||||
.map_err(|e| format!("Failed to create output stream: {}", e))?;
|
||||
|
||||
Ok(mixer_sink)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn create_output_stream_with_exclusive_guard(
|
||||
&self,
|
||||
config: &BackendConfig,
|
||||
) -> BackendResult<(
|
||||
MixerDeviceSink,
|
||||
Option<crate::coreaudio_direct::CoreAudioExclusiveGuard>,
|
||||
)> {
|
||||
if !config.exclusive_mode {
|
||||
return self.create_output_stream(config).map(|sink| (sink, None));
|
||||
}
|
||||
|
||||
// Resolve the target device ONCE before acquiring Hog Mode and pin its
|
||||
// name into the config we hand to `create_output_stream`. Without this,
|
||||
// the rate-switch and CPAL-stream paths inside `create_output_stream`
|
||||
// re-resolve the System Default by name — and macOS reassigns the
|
||||
// System Default the moment we hog the previous one (so other apps
|
||||
// still get audio). The result is Hog Mode held on device A while the
|
||||
// stream is created on device B, which on a multi-device machine
|
||||
// (e.g. an external DAC alongside built-in speakers) leaves the DAC
|
||||
// hogged-but-unused and audio routed to a device that may not even
|
||||
// support the requested sample rate.
|
||||
let device_id =
|
||||
crate::coreaudio_direct::resolve_output_device_id(config.device_id.as_deref())?;
|
||||
let device_name = crate::coreaudio_direct::get_device_name(device_id)?;
|
||||
let guard = crate::coreaudio_direct::CoreAudioExclusiveGuard::acquire(device_id)?;
|
||||
|
||||
let mut effective_config = config.clone();
|
||||
effective_config.device_id = Some(device_name);
|
||||
|
||||
self.create_output_stream(&effective_config)
|
||||
.map(|sink| (sink, Some(guard)))
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
self.host.default_output_device().is_some()
|
||||
}
|
||||
@@ -480,6 +589,66 @@ impl AudioBackend for CpalDefaultBackend {
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
impl CpalDefaultBackend {
|
||||
/// In macOS shared mode, CPAL's default config can briefly report a stale
|
||||
/// sample rate after CoreAudio changes the device's nominal rate. If we
|
||||
/// trust the stale rate, playback can run at the wrong speed until the
|
||||
/// stream is recreated. Prefer opening the CPAL stream at CoreAudio's
|
||||
/// current nominal rate when the two disagree.
|
||||
fn shared_mode_nominal_stream_config(
|
||||
device: &rodio::cpal::Device,
|
||||
effective_device_name: Option<&str>,
|
||||
) -> Option<rodio::cpal::SupportedStreamConfig> {
|
||||
use crate::coreaudio_direct;
|
||||
|
||||
let device_id = match effective_device_name {
|
||||
Some(name) => coreaudio_direct::find_device_by_name(name).ok().flatten(),
|
||||
None => coreaudio_direct::get_default_output_device().ok(),
|
||||
}?;
|
||||
let nominal_rate = coreaudio_direct::get_nominal_sample_rate(device_id).ok()?;
|
||||
let default_config = device.default_output_config().ok()?;
|
||||
let default_rate = default_config.sample_rate();
|
||||
if nominal_rate == default_rate {
|
||||
return None;
|
||||
}
|
||||
|
||||
let supported_configs: Vec<_> = device.supported_output_configs().ok()?.collect();
|
||||
let matching_config = supported_configs
|
||||
.iter()
|
||||
.find_map(|range| {
|
||||
if range.channels() == default_config.channels()
|
||||
&& range.sample_format() == default_config.sample_format()
|
||||
{
|
||||
(*range).try_with_sample_rate(nominal_rate)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.or_else(|| {
|
||||
supported_configs
|
||||
.iter()
|
||||
.find_map(|range| (*range).try_with_sample_rate(nominal_rate))
|
||||
});
|
||||
|
||||
let device_label = effective_device_name.unwrap_or("System Default");
|
||||
if matching_config.is_some() {
|
||||
log::warn!(
|
||||
"[CoreAudio] Shared-mode rate mismatch on '{}': CPAL default {}Hz vs CoreAudio nominal {}Hz. Opening stream at the nominal rate to avoid wrong-speed playback.",
|
||||
device_label,
|
||||
default_rate,
|
||||
nominal_rate
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
"[CoreAudio] Shared-mode rate mismatch on '{}': CPAL default {}Hz vs CoreAudio nominal {}Hz, but no supported CPAL config matched the nominal rate.",
|
||||
device_label,
|
||||
default_rate,
|
||||
nominal_rate
|
||||
);
|
||||
}
|
||||
|
||||
matching_config
|
||||
}
|
||||
|
||||
/// Probe a macOS audio device for capabilities via CoreAudio APIs.
|
||||
/// Returns (supported_rates, max_rate, bus_type, is_hardware).
|
||||
fn probe_macos_device(
|
||||
@@ -493,7 +662,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 +675,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 +706,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 +751,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 +770,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,24 @@
|
||||
//! 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, 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")]
|
||||
@@ -18,16 +32,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 +95,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 +151,268 @@ 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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -118,13 +422,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 +464,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;
|
||||
@@ -62,6 +62,7 @@ pub use backend::{
|
||||
AlsaDirectError, AlsaPlugin, AudioBackend, AudioBackendType, AudioDevice, BackendConfig,
|
||||
BackendManager, BackendResult, BitPerfectMode,
|
||||
};
|
||||
pub use coreaudio_direct::CoreAudioExclusiveGuard;
|
||||
pub use device_reservation::{DeviceReservation, ReservationError};
|
||||
pub use diagnostic::{AudioDiagnostic, BitDepthResult, DiagnosticSource};
|
||||
pub use dynamic_amplify::DynamicAmplify;
|
||||
|
||||
Reference in new issue
Block a user