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:
vicrodh committed 2026-05-07 19:09:37 -06:00
commit a67e3c9217
14 files changed
+983 -165

No files matched your search

+220 -23
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,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
);
}
}
}
+321 -15
View File
@@ -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;
+1
View File
@@ -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;