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.
This commit is contained in:
Afonso Jorge Ramos authored and GitHub committed 2026-05-07 09:36:20 -05:00
1 parent 96fd15cb33
commit 88e072a4ac
4 files changed
+238 -116

No files matched your search

+23 -13
View File
@@ -520,20 +520,30 @@ impl AudioBackend for CpalDefaultBackend {
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),
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 {
+77 -3
View File
@@ -234,6 +234,47 @@ pub fn set_hog_mode(device_id: AudioDeviceID, enabled: bool) -> Result<(), Strin
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")]
@@ -298,21 +339,41 @@ pub fn set_hardware_volume(device_id: AudioDeviceID, volume: f32) -> Result<(),
}
/// 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> {
set_hog_mode(device_id, true)?;
Ok(Self {
// 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> {
@@ -320,6 +381,19 @@ impl CoreAudioExclusiveGuard {
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(())