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
Generated
+3
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user