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

+3
View File
@@ -3922,7 +3922,9 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
dependencies = [
"block2",
"dispatch2",
"libc",
"objc2",
"objc2-core-audio-types",
"objc2-core-foundation",
@@ -4879,6 +4881,7 @@ dependencies = [
"dirs",
"ebur128",
"log",
"objc2-core-audio",
"qbz-models",
"rodio",
"rusqlite",
+169 -33
View File
@@ -462,13 +462,79 @@ fn create_output_stream_with_config(
}
}
/// Output stream type - either rodio or ALSA Direct
/// Output stream type - either rodio or ALSA Direct.
///
/// On macOS, the Rodio variant carries an optional `exclusive_guard`
/// whose `Drop` releases CoreAudio Hog Mode and is otherwise inert —
/// so exclusive and shared modes share a single code path.
enum StreamType {
Rodio(MixerDeviceSink),
Rodio {
sink: MixerDeviceSink,
/// Holds CoreAudio Hog Mode for the lifetime of the stream.
/// Load-bearing via `Drop`; reads happen through pattern matches
/// (e.g., `set_coreaudio_hardware_volume`).
#[cfg(target_os = "macos")]
exclusive_guard: Option<crate::audio::CoreAudioExclusiveGuard>,
},
#[cfg(target_os = "linux")]
AlsaDirect(Arc<crate::audio::AlsaDirectStream>),
}
impl StreamType {
/// Construct a shared-mode Rodio stream (no exclusive guard).
fn rodio(sink: MixerDeviceSink) -> Self {
StreamType::Rodio {
sink,
#[cfg(target_os = "macos")]
exclusive_guard: None,
}
}
/// Apply the volume to CoreAudio hardware if the device supports it.
///
/// Returns `true` only when the hardware accepted the change so the
/// caller can pin the software stream to unity gain. Returns `false`
/// for shared mode and for knob-only DACs (no settable volume
/// property), letting the caller fall back to software volume.
#[cfg(target_os = "macos")]
fn set_coreaudio_hardware_volume(&self, volume: f32) -> bool {
match self {
StreamType::Rodio {
exclusive_guard: Some(guard),
..
} => match guard.set_hardware_volume(volume) {
Ok(()) => true,
Err(e) => {
log::warn!(
"[CoreAudio] Hardware volume failed; falling back to software: {}",
e
);
false
}
},
_ => false,
}
}
}
fn apply_engine_volume(
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))] stream_opt: &Option<StreamType>,
engine: &PlaybackEngine,
volume: f32,
) {
#[cfg(target_os = "macos")]
if stream_opt
.as_ref()
.map(|stream| stream.set_coreaudio_hardware_volume(volume))
.unwrap_or(false)
{
engine.set_volume(1.0);
return;
}
engine.set_volume(volume);
}
/// Try to create output stream using the backend system (if configured)
/// Returns None if backend system is not configured (backend_type = None)
///
@@ -478,8 +544,16 @@ fn try_init_stream_with_backend(
sample_rate: u32,
channels: u16,
) -> Option<Result<StreamType, String>> {
// Check if backend system is configured
let backend_type = audio_settings.backend_type?;
// Check if backend system is configured. On macOS, default Auto to
// SystemDefault so CoreAudio device probing, rate switching, and Hog Mode
// are available to existing installs with no saved backend.
let backend_type = audio_settings.backend_type.or_else(|| {
if cfg!(target_os = "macos") {
Some(AudioBackendType::SystemDefault)
} else {
None
}
})?;
log::info!(
"Using backend system: {:?} (device: {:?}, plugin: {:?})",
@@ -541,14 +615,25 @@ fn try_init_stream_with_backend(
}
// Fallback to regular rodio stream (PipeWire, Pulse, ALSA via CPAL)
match backend.create_output_stream(&config) {
Ok(mixer_sink) => {
match backend.create_output_stream_with_exclusive_guard(&config) {
Ok((mixer_sink, _exclusive_guard)) => {
log::info!(
"Stream created via {:?} backend at {}Hz",
backend_type,
sample_rate
);
Some(Ok(StreamType::Rodio(mixer_sink)))
#[cfg(target_os = "macos")]
let stream = if backend_type == AudioBackendType::SystemDefault {
StreamType::Rodio {
sink: mixer_sink,
exclusive_guard: _exclusive_guard,
}
} else {
StreamType::rodio(mixer_sink)
};
#[cfg(not(target_os = "macos"))]
let stream = StreamType::rodio(mixer_sink);
Some(Ok(stream))
}
Err(e) => {
log::error!("Backend stream creation failed: {}", e);
@@ -907,7 +992,7 @@ impl Player {
-> Option<StreamType> {
// Try backend system if configured
if let Ok(settings) = thread_settings.lock() {
if settings.backend_type.is_some() {
if settings.backend_type.is_some() || cfg!(target_os = "macos") {
// Use provided sample rate/channels to maintain DAC passthrough
log::info!(
"Initializing backend system with {}Hz/{}ch",
@@ -926,6 +1011,22 @@ impl Player {
return Some(stream_type);
}
Some(Err(e)) => {
// On macOS, the backend path is the only one that
// honors Exclusive Mode (CoreAudio Hog Mode). Falling
// through to legacy CPAL here would silently create a
// shared-mode stream while the UI still shows
// Exclusive — surface the failure instead so the
// user sees that audio is unavailable rather than
// unknowingly playing in shared mode.
#[cfg(target_os = "macos")]
if settings.exclusive_mode {
log::error!(
"macOS Exclusive Mode init failed: {} — refusing to fall back to shared CPAL",
e
);
state.set_current_device(None);
return None;
}
log::warn!(
"Backend system init failed: {}, falling back to legacy",
e
@@ -986,7 +1087,7 @@ impl Player {
{
Ok(mixer_sink) => {
log::info!("Audio output initialized successfully");
Some(StreamType::Rodio(mixer_sink))
Some(StreamType::rodio(mixer_sink))
}
Err(e) => {
log::error!(
@@ -996,7 +1097,7 @@ impl Player {
match DeviceSinkBuilder::open_default_sink() {
Ok(mixer_sink) => {
log::info!("Fallback to default audio output succeeded");
Some(StreamType::Rodio(mixer_sink))
Some(StreamType::rodio(mixer_sink))
}
Err(e2) => {
log::error!("Failed to create default audio output: {}", e2);
@@ -1078,7 +1179,7 @@ impl Player {
let dac_passthrough = thread_settings
.lock()
.ok()
.map(|s| s.dac_passthrough)
.map(|s| cfg!(target_os = "linux") && s.dac_passthrough)
.unwrap_or(false);
// Check if we need to recreate the stream
@@ -1093,15 +1194,32 @@ impl Player {
.and_then(|s| s.backend_type)
.map(|b| b == AudioBackendType::Alsa)
.unwrap_or(false);
let using_coreaudio_exclusive = thread_settings
.lock()
.ok()
.map(|s| {
cfg!(target_os = "macos")
&& s.backend_type.unwrap_or(AudioBackendType::SystemDefault)
== AudioBackendType::SystemDefault
&& s.exclusive_mode
})
.unwrap_or(false);
let needs_new_stream = stream_opt.is_none()
|| (dac_passthrough && format_changed)
|| (using_alsa_direct && format_changed);
|| (using_alsa_direct && format_changed)
|| (using_coreaudio_exclusive && format_changed);
if needs_new_stream {
if stream_opt.is_some() {
if (dac_passthrough || using_alsa_direct) && format_changed {
let mode = if using_alsa_direct {
if (dac_passthrough
|| using_alsa_direct
|| using_coreaudio_exclusive)
&& format_changed
{
let mode = if using_coreaudio_exclusive {
"CoreAudio exclusive"
} else if using_alsa_direct {
"ALSA Direct"
} else {
"DAC passthrough"
@@ -1122,9 +1240,10 @@ impl Player {
}
log::info!(
"DAC passthrough: {}, ALSA Direct: {}",
"DAC passthrough: {}, ALSA Direct: {}, CoreAudio exclusive: {}",
dac_passthrough,
using_alsa_direct
using_alsa_direct,
using_coreaudio_exclusive
);
// Try backend system first (if configured), then fall back to legacy CPAL
@@ -1213,7 +1332,7 @@ impl Player {
channels,
dac_passthrough,
)
.map(StreamType::Rodio)
.map(StreamType::rodio)
}
}
} else {
@@ -1254,7 +1373,7 @@ impl Player {
channels,
dac_passthrough,
)
.map(StreamType::Rodio)
.map(StreamType::rodio)
};
// Handle stream creation result
@@ -1334,7 +1453,7 @@ impl Player {
// Create PlaybackEngine from StreamType
let mut engine = match stream {
StreamType::Rodio(ref mixer_sink) => {
StreamType::Rodio { sink: mixer_sink, .. } => {
match PlaybackEngine::new_rodio(&mixer_sink.mixer()) {
Ok(e) => {
*consecutive_sink_failures = 0;
@@ -1401,7 +1520,7 @@ impl Player {
};
let volume = thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
engine.set_volume(volume);
apply_engine_volume(&stream_opt, &engine, volume);
let source = match decode_with_fallback(&data) {
Ok(s) => s,
@@ -1519,7 +1638,7 @@ impl Player {
let dac_passthrough = thread_settings
.lock()
.ok()
.map(|s| s.dac_passthrough)
.map(|s| cfg!(target_os = "linux") && s.dac_passthrough)
.unwrap_or(false);
// Check if we need to recreate the stream
@@ -1532,15 +1651,32 @@ impl Player {
.and_then(|s| s.backend_type)
.map(|b| b == AudioBackendType::Alsa)
.unwrap_or(false);
let using_coreaudio_exclusive = thread_settings
.lock()
.ok()
.map(|s| {
cfg!(target_os = "macos")
&& s.backend_type.unwrap_or(AudioBackendType::SystemDefault)
== AudioBackendType::SystemDefault
&& s.exclusive_mode
})
.unwrap_or(false);
let needs_new_stream = stream_opt.is_none()
|| (dac_passthrough && format_changed)
|| (using_alsa_direct && format_changed);
|| (using_alsa_direct && format_changed)
|| (using_coreaudio_exclusive && format_changed);
if needs_new_stream {
if stream_opt.is_some() {
if (dac_passthrough || using_alsa_direct) && format_changed {
let mode = if using_alsa_direct {
if (dac_passthrough
|| using_alsa_direct
|| using_coreaudio_exclusive)
&& format_changed
{
let mode = if using_coreaudio_exclusive {
"CoreAudio exclusive"
} else if using_alsa_direct {
"ALSA Direct"
} else {
"DAC passthrough"
@@ -1610,7 +1746,7 @@ impl Player {
channels,
dac_passthrough,
)
.map(StreamType::Rodio)
.map(StreamType::rodio)
}
}
} else {
@@ -1628,7 +1764,7 @@ impl Player {
channels,
dac_passthrough,
)
.map(StreamType::Rodio)
.map(StreamType::rodio)
};
match stream_result {
@@ -1670,7 +1806,7 @@ impl Player {
// Create PlaybackEngine
let mut engine = match stream {
StreamType::Rodio(ref mixer_sink) => {
StreamType::Rodio { sink: mixer_sink, .. } => {
match PlaybackEngine::new_rodio(&mixer_sink.mixer()) {
Ok(e) => {
*consecutive_sink_failures = 0;
@@ -1701,7 +1837,7 @@ impl Player {
};
let volume = thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
engine.set_volume(volume);
apply_engine_volume(&stream_opt, &engine, volume);
// Wait for minimum buffer before starting playback
log::info!("Streaming: waiting for initial buffer...");
@@ -1896,7 +2032,7 @@ impl Player {
};
let mut engine = match stream {
StreamType::Rodio(ref mixer_sink) => {
StreamType::Rodio { sink: mixer_sink, .. } => {
match PlaybackEngine::new_rodio(&mixer_sink.mixer()) {
Ok(e) => e,
Err(e) => {
@@ -1924,7 +2060,7 @@ impl Player {
let volume =
thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
engine.set_volume(volume);
apply_engine_volume(&stream_opt, &engine, volume);
let source = match decode_with_fallback(&audio_data) {
Ok(s) => s,
@@ -2002,7 +2138,7 @@ impl Player {
.volume
.store((volume * 100.0) as u64, Ordering::SeqCst);
if let Some(ref engine) = *current_engine {
engine.set_volume(volume);
apply_engine_volume(&stream_opt, &engine, volume);
}
log::info!("Audio thread: volume set to {}", volume);
}
@@ -2031,7 +2167,7 @@ impl Player {
}
let mut engine = match stream {
StreamType::Rodio(ref mixer_sink) => {
StreamType::Rodio { sink: mixer_sink, .. } => {
match PlaybackEngine::new_rodio(&mixer_sink.mixer()) {
Ok(e) => e,
Err(e) => {
@@ -2058,7 +2194,7 @@ impl Player {
};
let volume = thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
engine.set_volume(volume);
apply_engine_volume(&stream_opt, &engine, volume);
let source = match decode_with_fallback(audio_data) {
Ok(s) => s,