diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 020f88a14..a8f1a826c 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -2483,7 +2483,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", @@ -2912,6 +2914,7 @@ dependencies = [ "dirs", "ebur128", "log", + "objc2-core-audio", "qbz-models", "rodio", "rusqlite", diff --git a/crates/qbz-audio/Cargo.toml b/crates/qbz-audio/Cargo.toml index 04b8530ca..4aa633678 100644 --- a/crates/qbz-audio/Cargo.toml +++ b/crates/qbz-audio/Cargo.toml @@ -49,3 +49,4 @@ zbus = { version = "4", default-features = false, features = ["tokio"] } [target.'cfg(target_os = "macos")'.dependencies] # CoreAudio for device probing, sample rate switching, and (future) hog mode / exclusive mode coreaudio-rs = "0.14.1" +objc2-core-audio = "0.3.2" diff --git a/crates/qbz-audio/src/backend.rs b/crates/qbz-audio/src/backend.rs index 96724aab7..93adc951e 100644 --- a/crates/qbz-audio/src/backend.rs +++ b/crates/qbz-audio/src/backend.rs @@ -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; + /// 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, + )> { + 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>, @@ -430,18 +443,52 @@ impl AudioBackend for CpalDefaultBackend { } fn create_output_stream(&self, config: &BackendConfig) -> BackendResult { - // 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, + )> { + 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 { + 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 + ); } } } diff --git a/crates/qbz-audio/src/coreaudio_direct.rs b/crates/qbz-audio/src/coreaudio_direct.rs index 99c9d3423..f2f5fac80 100644 --- a/crates/qbz-audio/src/coreaudio_direct.rs +++ b/crates/qbz-audio/src/coreaudio_direct.rs @@ -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 { + unsafe { + let property_address = AudioObjectPropertyAddress { + mSelector: kAudioDevicePropertyNominalSampleRate, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster, + }; + let mut rate = 0.0_f64; + let data_size = mem::size_of::() 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 { @@ -109,6 +151,268 @@ pub fn find_device_by_name(name: &str) -> Result, 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 { + 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 { + 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 { + 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 { + 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::() 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::() 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, +} + +#[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 { + // 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 { @@ -118,13 +422,10 @@ pub fn get_device_transport_type(device_id: AudioDeviceID) -> Option { "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; diff --git a/crates/qbz-audio/src/lib.rs b/crates/qbz-audio/src/lib.rs index ddd18baee..91ca71ec6 100644 --- a/crates/qbz-audio/src/lib.rs +++ b/crates/qbz-audio/src/lib.rs @@ -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; diff --git a/crates/qbz-player/src/player/mod.rs b/crates/qbz-player/src/player/mod.rs index 44cb6cfa0..44da8191a 100644 --- a/crates/qbz-player/src/player/mod.rs +++ b/crates/qbz-player/src/player/mod.rs @@ -451,13 +451,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, + }, #[cfg(target_os = "linux")] AlsaDirect(Arc), } +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, + 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) /// @@ -469,10 +535,10 @@ fn try_init_stream_with_backend( state: &SharedState, ) -> Option> { // Check if backend system is configured. - // On non-Linux, default to SystemDefault when not explicitly set, - // so macOS gets CoreAudio device probing and sample rate switching. + // On macOS, default to SystemDefault when not explicitly set, + // so CoreAudio device probing and sample rate switching are active. let backend_type = audio_settings.backend_type.or_else(|| { - if cfg!(not(target_os = "linux")) { + if cfg!(target_os = "macos") { Some(qbz_audio::AudioBackendType::SystemDefault) } else { None @@ -540,15 +606,26 @@ 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 ); state.set_bit_perfect_mode(Some(BitPerfectMode::Disabled)); - 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); @@ -970,7 +1047,7 @@ impl Player { -> Option { // 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", @@ -990,6 +1067,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 @@ -1050,7 +1143,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!( @@ -1060,7 +1153,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); @@ -1147,7 +1240,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 @@ -1162,15 +1255,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" @@ -1201,9 +1311,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 @@ -1294,7 +1405,7 @@ impl Player { channels, dac_passthrough, ) - .map(StreamType::Rodio) + .map(StreamType::rodio) } } } else { @@ -1337,7 +1448,7 @@ impl Player { channels, dac_passthrough, ) - .map(StreamType::Rodio) + .map(StreamType::rodio) }; // Handle stream creation result @@ -1421,7 +1532,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; @@ -1488,7 +1599,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, @@ -1607,7 +1718,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 @@ -1620,15 +1731,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" @@ -1711,7 +1839,7 @@ impl Player { channels, dac_passthrough, ) - .map(StreamType::Rodio) + .map(StreamType::rodio) } } } else { @@ -1729,7 +1857,7 @@ impl Player { channels, dac_passthrough, ) - .map(StreamType::Rodio) + .map(StreamType::rodio) }; match stream_result { @@ -1774,7 +1902,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; @@ -1805,7 +1933,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..."); @@ -2000,7 +2128,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) => { @@ -2028,7 +2156,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, @@ -2126,7 +2254,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); } @@ -2152,12 +2280,8 @@ impl Player { // playback reach this handler with // current_audio_data Some and skip the // streaming branch entirely (issue #335). - if current_audio_data.is_none() - && current_streaming_source.is_none() - { - log::warn!( - "Audio thread: cannot seek - no audio data available" - ); + if current_audio_data.is_none() && current_streaming_source.is_none() { + log::warn!("Audio thread: cannot seek - no audio data available"); return; } if let Some(ref stream_src) = *current_streaming_source { @@ -2210,7 +2334,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) => { @@ -2237,7 +2361,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); // Build the decoded source for the seek. Both // streaming and cached paths use Symphonia's native @@ -2251,56 +2375,42 @@ impl Player { // probe the format (e.g., rodio-only MP4/AAC), // preserving existing behavior for those cases. let skip_duration = Duration::from_secs(position_secs); - let skipped_source: Box + Send> = - if let Some(ref stream_src) = *current_streaming_source { - match IncrementalStreamingSource::new(stream_src.clone()) { - Ok(mut s) => { - if let Err(e) = s.seek_to(skip_duration) { - log::error!( - "Failed to native-seek streaming source: {}", - e - ); - return; - } - Box::new(s) - } - Err(e) => { + let skipped_source: Box + Send> = if let Some( + ref stream_src, + ) = + *current_streaming_source + { + match IncrementalStreamingSource::new(stream_src.clone()) { + Ok(mut s) => { + if let Err(e) = s.seek_to(skip_duration) { log::error!( - "Failed to create streaming source for seek: {}", + "Failed to native-seek streaming source: {}", e ); return; } + Box::new(s) } - } else { - let audio_data = current_audio_data - .as_ref() - .expect("current_audio_data was checked Some above"); - match InMemorySource::new(audio_data.clone()) { - Ok(mut s) => match s.seek_to(skip_duration) { - Ok(()) => Box::new(s), - Err(e) => { - log::warn!( + Err(e) => { + log::error!( + "Failed to create streaming source for seek: {}", + e + ); + return; + } + } + } else { + let audio_data = current_audio_data + .as_ref() + .expect("current_audio_data was checked Some above"); + match InMemorySource::new(audio_data.clone()) { + Ok(mut s) => match s.seek_to(skip_duration) { + Ok(()) => Box::new(s), + Err(e) => { + log::warn!( "Native seek on cached source failed ({}); falling back to skip_duration", e ); - match decode_with_fallback(audio_data) { - Ok(fb) => Box::new(fb.skip_duration(skip_duration)), - Err(e) => { - log::error!( - "Failed to decode audio for seek: {}", - e - ); - return; - } - } - } - }, - Err(e) => { - log::warn!( - "InMemorySource probe failed ({}); falling back to skip_duration", - e - ); match decode_with_fallback(audio_data) { Ok(fb) => Box::new(fb.skip_duration(skip_duration)), Err(e) => { @@ -2312,8 +2422,25 @@ impl Player { } } } + }, + Err(e) => { + log::warn!( + "InMemorySource probe failed ({}); falling back to skip_duration", + e + ); + match decode_with_fallback(audio_data) { + Ok(fb) => Box::new(fb.skip_duration(skip_duration)), + Err(e) => { + log::error!( + "Failed to decode audio for seek: {}", + e + ); + return; + } + } } - }; + } + }; // Send Reset to analyzer (seek invalidates accumulated samples) let _ = analyzer_tx.try_send(AnalyzerMessage::Reset); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0429e4212..2b0ab5b25 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index bc1b24954..7179c96dd 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -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, + }, #[cfg(target_os = "linux")] AlsaDirect(Arc), } +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, + 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> { - // 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 { // 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, diff --git a/src/lib/components/views/SettingsView.svelte b/src/lib/components/views/SettingsView.svelte index 62c31a707..266f877db 100644 --- a/src/lib/components/views/SettingsView.svelte +++ b/src/lib/components/views/SettingsView.svelte @@ -995,10 +995,26 @@ ); // Smart toggle states - auto-disable incompatible features - let exclusiveModeDisabled = $derived(selectedBackend === 'PipeWire' || selectedBackend === 'Auto' || selectedBackend === 'PulseAudio'); - let exclusiveModeTooltipOverride = $derived( - exclusiveModeDisabled - ? 'Exclusive mode is only available with ALSA Direct backend. PipeWire and PulseAudio are multiplexed audio servers and cannot provide true exclusive access.' + let selectedBackendInfo = $derived(availableBackends.find(b => b.name === selectedBackend) ?? null); + let selectedBackendSupportsExclusive = $derived( + selectedBackend === 'ALSA Direct' + || (platform === 'macos' && selectedBackendInfo?.backend_type === 'SystemDefault') + ); + let exclusiveModeDisabled = $derived(!selectedBackendSupportsExclusive); + let exclusiveModeTooltipOverrideKey = $derived( + !exclusiveModeDisabled + ? (platform === 'macos' + ? 'settings.audio.exclusiveModeMacosDesc' + : null) + : platform === 'macos' + ? 'settings.audio.exclusiveModeMacosAvailable' + : (selectedBackend === 'PipeWire' || selectedBackend === 'PulseAudio') + ? 'settings.audio.exclusiveModeUnavailableMultiplexed' + : 'settings.audio.exclusiveModeUnavailableGeneric' + ); + let exclusiveModeHelpOverrideKey = $derived( + platform === 'macos' + ? 'settings.audio.exclusiveModeMacosHelp' : null ); let dacPassthroughDisabled = $derived(selectedBackend !== 'PipeWire'); @@ -2810,7 +2826,7 @@ exclusive_mode: boolean; dac_passthrough: boolean; preferred_sample_rate: number | null; - backend_type: 'PipeWire' | 'Alsa' | 'Pulse' | null; + backend_type: 'PipeWire' | 'Alsa' | 'Pulse' | 'SystemDefault' | null; alsa_plugin: 'Hw' | 'PlugHw' | 'Pcm' | null; alsa_hardware_volume: boolean; stream_first_track: boolean; @@ -2827,7 +2843,7 @@ } interface BackendInfo { - backend_type: 'PipeWire' | 'Alsa' | 'Pulse'; + backend_type: 'PipeWire' | 'Alsa' | 'Pulse' | 'SystemDefault'; name: string; description: string; is_available: boolean; @@ -2911,7 +2927,7 @@ } } - async function loadBackendDevices(backendType: 'PipeWire' | 'Alsa' | 'Pulse') { + async function loadBackendDevices(backendType: BackendInfo['backend_type']) { isLoadingDevices = true; try { const devices = await invoke('v2_get_devices_for_backend', { backendType }); @@ -3236,12 +3252,15 @@ } } - // Exclusive mode only works with ALSA Direct - if (backendName !== 'ALSA Direct') { + const backendSupportsExclusive = backendName === 'ALSA Direct' + || (platform === 'macos' && backend?.backend_type === 'SystemDefault'); + + // Exclusive mode works with ALSA Direct and macOS System Audio. + if (!backendSupportsExclusive) { if (exclusiveMode) { exclusiveMode = false; await invoke('v2_set_audio_exclusive_mode', { enabled: false }); - console.log('[Audio] Disabled exclusive mode (only compatible with ALSA Direct)'); + console.log('[Audio] Disabled exclusive mode (only compatible with ALSA Direct or macOS System Audio)'); } } @@ -4378,8 +4397,8 @@ {/if}
- {$t('settings.audio.exclusiveMode')} (?) - {exclusiveModeTooltipOverride ?? $t('settings.audio.exclusiveModeDesc')} + {$t('settings.audio.exclusiveMode')} (?) + {exclusiveModeTooltipOverrideKey ? $t(exclusiveModeTooltipOverrideKey) : $t('settings.audio.exclusiveModeDesc')}
diff --git a/src/lib/i18n/locales/de.json b/src/lib/i18n/locales/de.json index cf523c6f0..bee015a0d 100644 --- a/src/lib/i18n/locales/de.json +++ b/src/lib/i18n/locales/de.json @@ -2038,6 +2038,11 @@ "exclusiveModeDesc": "Audiogerät für exklusiven Gebrauch sperren", "exclusiveModeNotAvailable": "Der Exklusivmodus ist nur mit dem ALSA-Direct-Backend verfügbar. PipeWire und PulseAudio sind gemultiplexte Audio-Server und können keinen echten exklusiven Zugriff bieten.", "exclusiveModeHelp": "Nur ALSA Direct. Sperrt das Audiogerät, sodass keine andere Anwendung es verwenden kann. Nicht verfügbar mit PipeWire/PulseAudio.", + "exclusiveModeMacosDesc": "Audiogerät für exklusiven Gebrauch sperren und die Abtastrate automatisch für bitperfekte Wiedergabe anpassen", + "exclusiveModeMacosHelp": "Verhindert, dass Systemtöne oder andere Apps die Wiedergabe stören, und stellt die Abtastrate des Ausgabegeräts über CoreAudio passend zur Wiedergabe ein.", + "exclusiveModeMacosAvailable": "Der Exklusivmodus ist mit Systemaudio unter macOS verfügbar.", + "exclusiveModeUnavailableMultiplexed": "Der Exklusivmodus ist nur mit ALSA Direct oder macOS-Systemaudio verfügbar. PipeWire und PulseAudio sind gemultiplexte Audio-Server und können keinen echten exklusiven Zugriff bieten.", + "exclusiveModeUnavailableGeneric": "Der Exklusivmodus ist nur mit ALSA Direct oder macOS-Systemaudio verfügbar.", "dacPassthrough": "DAC-Passthrough", "dacPassthroughDesc": "Systemmixer umgehen für bitperfekte Ausgabe", "dacPassthroughNote": "Bezieht sich auf den dekodierten Audio-Inhalt. DAC-Anzeigen zeigen möglicherweise das Transportformat, nicht die Inhaltsauflösung.", diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index 997ba3f1b..699f32240 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -2038,6 +2038,11 @@ "exclusiveModeDesc": "Lock audio device for exclusive use", "exclusiveModeNotAvailable": "Exclusive mode is only available with ALSA Direct backend. PipeWire and PulseAudio are multiplexed audio servers and cannot provide true exclusive access.", "exclusiveModeHelp": "ALSA Direct only. Locks the audio device so no other application can use it. Prevents system sounds from interfering with playback. Not available with PipeWire/PulseAudio since they are multiplexed audio servers.", + "exclusiveModeMacosDesc": "Lock audio device for exclusive use and automatically adjust sample rate for bit-perfect playback", + "exclusiveModeMacosHelp": "Prevents system sounds/other apps from interfering with playback and switches the sample rate of the output device to match playback using CoreAudio.", + "exclusiveModeMacosAvailable": "Exclusive mode is available with System Audio on macOS.", + "exclusiveModeUnavailableMultiplexed": "Exclusive mode is only available with ALSA Direct or macOS System Audio. PipeWire and PulseAudio are multiplexed audio servers and cannot provide true exclusive access.", + "exclusiveModeUnavailableGeneric": "Exclusive mode is only available with ALSA Direct or macOS System Audio.", "dacPassthrough": "DAC Passthrough", "dacPassthroughDesc": "Bypass system mixer for bit-perfect output", "dacPassthroughNote": "Refers to decoded audio content. DAC displays may show transport format, not content resolution.", diff --git a/src/lib/i18n/locales/es.json b/src/lib/i18n/locales/es.json index efa66060f..2c85bf15a 100644 --- a/src/lib/i18n/locales/es.json +++ b/src/lib/i18n/locales/es.json @@ -2038,6 +2038,11 @@ "exclusiveModeDesc": "Bloquear dispositivo de audio para uso exclusivo", "exclusiveModeNotAvailable": "El modo exclusivo solo está disponible con ALSA Direct. PipeWire y PulseAudio son servidores de audio multiplexados y no pueden proporcionar acceso exclusivo real.", "exclusiveModeHelp": "Solo ALSA Direct. Bloquea el dispositivo de audio para que ninguna otra aplicación pueda usarlo. No disponible con PipeWire/PulseAudio ya que son servidores multiplexados.", + "exclusiveModeMacosDesc": "Bloquea el dispositivo de audio para uso exclusivo y ajusta automáticamente la tasa de muestreo para reproducción bit-perfect", + "exclusiveModeMacosHelp": "Evita que los sonidos del sistema u otras apps interfieran con la reproducción y cambia la tasa de muestreo del dispositivo de salida para coincidir con la reproducción usando CoreAudio.", + "exclusiveModeMacosAvailable": "El modo exclusivo está disponible con Audio del Sistema en macOS.", + "exclusiveModeUnavailableMultiplexed": "El modo exclusivo solo está disponible con ALSA Direct o Audio del Sistema en macOS. PipeWire y PulseAudio son servidores de audio multiplexados y no pueden proporcionar acceso exclusivo real.", + "exclusiveModeUnavailableGeneric": "El modo exclusivo solo está disponible con ALSA Direct o Audio del Sistema en macOS.", "dacPassthrough": "Passthrough DAC", "dacPassthroughDesc": "Bypass del mixer del sistema para salida bit-perfect", "dacPassthroughNote": "Se refiere al contenido de audio decodificado. Las pantallas del DAC pueden mostrar el formato de transporte, no la resolución del contenido.", diff --git a/src/lib/i18n/locales/fr.json b/src/lib/i18n/locales/fr.json index 91e735107..4e2df9bc9 100644 --- a/src/lib/i18n/locales/fr.json +++ b/src/lib/i18n/locales/fr.json @@ -2038,6 +2038,11 @@ "exclusiveModeDesc": "Verrouiller le périphérique audio pour un usage exclusif", "exclusiveModeNotAvailable": "Le mode exclusif n'est disponible qu'avec le backend ALSA Direct. PipeWire et PulseAudio sont des serveurs audio multiplexés et ne peuvent pas fournir un accès exclusif véritable.", "exclusiveModeHelp": "ALSA Direct uniquement. Verrouille le périphérique audio pour qu'aucune autre application ne puisse l'utiliser. Non disponible avec PipeWire/PulseAudio.", + "exclusiveModeMacosDesc": "Verrouiller le périphérique audio pour un usage exclusif et ajuster automatiquement le taux d'échantillonnage pour une lecture bit-perfect", + "exclusiveModeMacosHelp": "Empêche les sons système ou d'autres apps d'interférer avec la lecture et change le taux d'échantillonnage du périphérique de sortie pour correspondre à la lecture via CoreAudio.", + "exclusiveModeMacosAvailable": "Le mode exclusif est disponible avec l'audio système sur macOS.", + "exclusiveModeUnavailableMultiplexed": "Le mode exclusif est uniquement disponible avec ALSA Direct ou l'audio système macOS. PipeWire et PulseAudio sont des serveurs audio multiplexés et ne peuvent pas fournir un accès exclusif véritable.", + "exclusiveModeUnavailableGeneric": "Le mode exclusif est uniquement disponible avec ALSA Direct ou l'audio système macOS.", "dacPassthrough": "Passthrough DAC", "dacPassthroughDesc": "Contourner le mixeur système pour une sortie bit-perfect", "dacPassthroughNote": "Concerne le contenu audio décodé. Les affichages du DAC peuvent montrer le format de transport, pas la résolution du contenu.", diff --git a/src/lib/i18n/locales/pt.json b/src/lib/i18n/locales/pt.json index 4eea259c8..9a1526322 100644 --- a/src/lib/i18n/locales/pt.json +++ b/src/lib/i18n/locales/pt.json @@ -2038,6 +2038,11 @@ "exclusiveModeDesc": "Bloquear dispositivo de áudio para uso exclusivo", "exclusiveModeNotAvailable": "O modo exclusivo só está disponível com o backend ALSA Direct. PipeWire e PulseAudio são servidores de áudio multiplexados e não podem fornecer acesso verdadeiramente exclusivo.", "exclusiveModeHelp": "Apenas ALSA Direct. Bloqueia o dispositivo de áudio para que nenhum outro aplicativo possa usá-lo. Não disponível com PipeWire/PulseAudio.", + "exclusiveModeMacosDesc": "Bloquear dispositivo de áudio para uso exclusivo e ajustar automaticamente a taxa de amostragem para reprodução bit-perfect", + "exclusiveModeMacosHelp": "Impede que sons do sistema ou outros apps interfiram na reprodução e altera a taxa de amostragem do dispositivo de saída para corresponder à reprodução usando CoreAudio.", + "exclusiveModeMacosAvailable": "O modo exclusivo está disponível com Áudio do Sistema no macOS.", + "exclusiveModeUnavailableMultiplexed": "O modo exclusivo só está disponível com ALSA Direct ou Áudio do Sistema no macOS. PipeWire e PulseAudio são servidores de áudio multiplexados e não podem fornecer acesso verdadeiramente exclusivo.", + "exclusiveModeUnavailableGeneric": "O modo exclusivo só está disponível com ALSA Direct ou Áudio do Sistema no macOS.", "dacPassthrough": "DAC Passthrough", "dacPassthroughDesc": "Ignorar mixer do sistema para saída bit-perfect", "dacPassthroughNote": "Refere-se ao conteúdo de áudio decodificado. Os displays do DAC podem mostrar o formato de transporte, não a resolução do conteúdo.",