diff --git a/crates/qbz-audio/src/backend.rs b/crates/qbz-audio/src/backend.rs index bf8188831..80f610af1 100644 --- a/crates/qbz-audio/src/backend.rs +++ b/crates/qbz-audio/src/backend.rs @@ -443,6 +443,28 @@ impl AudioBackend for CpalDefaultBackend { } fn create_output_stream(&self, config: &BackendConfig) -> BackendResult { + #[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")] @@ -450,13 +472,23 @@ impl AudioBackend for CpalDefaultBackend { 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))? diff --git a/crates/qbz-audio/src/coreaudio_direct.rs b/crates/qbz-audio/src/coreaudio_direct.rs index 1af6dd261..2ac340c5d 100644 --- a/crates/qbz-audio/src/coreaudio_direct.rs +++ b/crates/qbz-audio/src/coreaudio_direct.rs @@ -14,8 +14,10 @@ use coreaudio::audio_unit::{macos_helpers, Scope}; use coreaudio::Error; #[cfg(target_os = "macos")] use objc2_core_audio::{ - kAudioDevicePropertyNominalSampleRate, kAudioObjectPropertyElementMaster, - kAudioObjectPropertyScopeGlobal, AudioObjectGetPropertyData, AudioObjectPropertyAddress, + kAudioDevicePropertyNominalSampleRate, kAudioDevicePropertyVolumeScalar, + kAudioObjectPropertyElementMaster, kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyScopeOutput, AudioObjectGetPropertyData, AudioObjectHasProperty, + AudioObjectPropertyAddress, AudioObjectSetPropertyData, }; #[cfg(target_os = "macos")] use std::{ @@ -160,6 +162,14 @@ pub fn resolve_output_device_id(device_name: Option<&str>) -> Result) -> 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")] @@ -224,6 +234,69 @@ pub fn set_hog_mode(device_id: AudioDeviceID, enabled: bool) -> Result<(), Strin Ok(()) } +/// 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. #[cfg(target_os = "macos")] #[derive(Debug)] @@ -251,6 +324,10 @@ impl CoreAudioExclusiveGuard { 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")] diff --git a/crates/qbz-player/src/player/mod.rs b/crates/qbz-player/src/player/mod.rs index d2fef8cfb..98546ad36 100644 --- a/crates/qbz-player/src/player/mod.rs +++ b/crates/qbz-player/src/player/mod.rs @@ -473,6 +473,36 @@ impl StreamType { StreamType::AlsaDirect(_) => None, } } + + #[cfg(target_os = "macos")] + fn set_coreaudio_hardware_volume(&self, volume: f32) -> bool { + match self { + StreamType::CoreAudio { + _exclusive_guard: Some(guard), + .. + } => { + if let Err(e) = guard.set_hardware_volume(volume) { + log::warn!("[CoreAudio] Hardware volume failed: {}", e); + } + true + } + _ => false, + } + } +} + +fn apply_engine_volume(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) @@ -1538,7 +1568,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, @@ -1876,7 +1906,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..."); @@ -2103,7 +2133,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, @@ -2201,7 +2231,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); } @@ -2312,7 +2342,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 diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index 667599168..5cbe2941a 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -484,6 +484,36 @@ impl StreamType { StreamType::AlsaDirect(_) => None, } } + + #[cfg(target_os = "macos")] + fn set_coreaudio_hardware_volume(&self, volume: f32) -> bool { + match self { + StreamType::CoreAudio { + _exclusive_guard: Some(guard), + .. + } => { + if let Err(e) = guard.set_hardware_volume(volume) { + log::warn!("[CoreAudio] Hardware volume failed: {}", e); + } + true + } + _ => false, + } + } +} + +fn apply_engine_volume(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) @@ -1459,7 +1489,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, @@ -1780,7 +1810,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..."); @@ -2007,7 +2037,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, @@ -2085,7 +2115,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); } @@ -2145,7 +2175,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,