diff --git a/crates/qbz-app/src/settings/bundle.rs b/crates/qbz-app/src/settings/bundle.rs index 2fb968ff7..2c8238cce 100644 --- a/crates/qbz-app/src/settings/bundle.rs +++ b/crates/qbz-app/src/settings/bundle.rs @@ -978,6 +978,7 @@ fn backend_name(b: AudioBackendType) -> &'static str { AudioBackendType::Alsa => "Alsa", AudioBackendType::Pulse => "Pulse", AudioBackendType::Jack => "Jack", + AudioBackendType::WasapiExclusive => "WasapiExclusive", AudioBackendType::SystemDefault => "SystemDefault", } } diff --git a/crates/qbz-audio/src/backend.rs b/crates/qbz-audio/src/backend.rs index 64150f9dd..465008334 100644 --- a/crates/qbz-audio/src/backend.rs +++ b/crates/qbz-audio/src/backend.rs @@ -42,6 +42,16 @@ pub enum AudioBackendType { /// bit-perfect ALSA-exclusive / DAC-passthrough paths. Jack, + /// WASAPI EXCLUSIVE (Windows) - the bit-perfect path. + /// - Takes the endpoint exclusively; other apps go silent + /// - No resampling: the DAC is commanded to the track's own rate + /// - Selecting a device is required, by ENDPOINT ID (Windows gives several + /// endpoints the same friendly name) + /// The trait's `create_output_stream` for this type is the SHARED CPAL one: + /// the exclusive stream bypasses the trait entirely, exactly as ALSA Direct + /// and JACK do. + WasapiExclusive, + /// System default backend (non-Linux platforms) /// - Uses CPAL default host (CoreAudio on macOS, WASAPI on Windows) /// - Automatic device selection via OS audio system @@ -308,6 +318,14 @@ impl BackendManager { backends.push(AudioBackendType::SystemDefault); } + #[cfg(windows)] + { + // Offered unconditionally: whether a given DEVICE accepts exclusive + // mode is answered per device when the stream opens, not here, and + // hiding the backend would hide the answer too. + backends.push(AudioBackendType::WasapiExclusive); + } + backends } @@ -333,6 +351,14 @@ impl BackendManager { Ok(Box::new(CpalDefaultBackend::new()?)) } } + AudioBackendType::WasapiExclusive => { + // The SHARED stream, deliberately. `create_stream_for_backend` + // tries the exclusive stream first and only reaches here when + // that is impossible - no device chosen, or the endpoint + // refusing every format - so this is the graceful fallback + // rather than the intended path. + Ok(Box::new(CpalDefaultBackend::new()?)) + } AudioBackendType::SystemDefault => { // "System": play through the OS default output via CPAL's default // host — CoreAudio/WASAPI off-Linux, the ALSA "default" PCM on Linux diff --git a/crates/qbz-player/src/player/mod.rs b/crates/qbz-player/src/player/mod.rs index 4eb5d0ed0..e01814a72 100644 --- a/crates/qbz-player/src/player/mod.rs +++ b/crates/qbz-player/src/player/mod.rs @@ -887,6 +887,57 @@ fn try_init_stream_with_backend( skip_sink_switch: audio_settings.skip_sink_switch, }; + // WASAPI EXCLUSIVE (Windows) - the bit-perfect path, tried before the + // shared CPAL stream exactly as ALSA Direct is on Linux. + // + // The device id is the WASAPI ENDPOINT ID, which is what the enumeration + // already publishes: Windows hands several endpoints the same friendly + // name, so the name cannot identify one. Without a chosen device there is + // nothing to open exclusively - falling through to shared is the honest + // answer, not picking the default DAC behind the user's back. + #[cfg(windows)] + if backend_type == AudioBackendType::WasapiExclusive { + match config.device_id.as_ref() { + None => { + log::info!( + "[WASAPI] Exclusive mode selected with no device chosen; using the shared stream. Pick an output device to get bit-perfect playback." + ); + } + Some(endpoint_id) => { + use qbz_audio::wasapi_direct::{WasapiDirectStream, WasapiTiming}; + match WasapiDirectStream::new( + endpoint_id, + sample_rate, + config.channels, + WasapiTiming::Events, + ) { + Ok(stream) => { + let mode = stream.bit_perfect_mode(); + let info = stream.open_info(); + log::info!( + "[WASAPI] Exclusive stream open: {} @ {} Hz, {:?}, mode {:?}", + info.endpoint_name, + info.rate, + info.rung, + mode + ); + state.set_bit_perfect_mode(Some(mode)); + return Some(Ok(StreamType::Direct(Arc::new(stream)))); + } + Err(e) => { + // NOT fatal: a device that refuses this rate in + // exclusive mode still plays through the shared path, + // and silence would be a worse answer than a + // resampled track. + log::warn!( + "[WASAPI] Exclusive open failed ({e}); falling back to the shared stream" + ); + } + } + } + } + } + // For ALSA backend with hw: devices, try direct ALSA first (Linux only) #[cfg(target_os = "linux")] if backend_type == AudioBackendType::Alsa { diff --git a/crates/qbz-qt/src/output_labels.rs b/crates/qbz-qt/src/output_labels.rs index cf4032e2c..468bf1807 100644 --- a/crates/qbz-qt/src/output_labels.rs +++ b/crates/qbz-qt/src/output_labels.rs @@ -57,6 +57,7 @@ pub fn output_labels(audio: &AudioSettings) -> OutputLabels { Some(AudioBackendType::Alsa) => ("ALSA", true), Some(AudioBackendType::Jack) => ("JACK", true), Some(AudioBackendType::Pulse) => ("PULS", true), + Some(AudioBackendType::WasapiExclusive) => ("WASAPI", true), Some(AudioBackendType::SystemDefault) => ("SYST", false), None => ("AUTO", false), }; @@ -88,6 +89,16 @@ pub fn output_labels(audio: &AudioSettings) -> OutputLabels { } } Some(AudioBackendType::Pulse) => ("SHARED", false), + // Exclusive needs a CHOSEN device: with none the player falls through + // to the shared stream, and lighting EXCL there would be the LED + // lying, which is the one thing these two must never do. + Some(AudioBackendType::WasapiExclusive) => { + if audio.output_device.is_some() { + ("EXCL", true) + } else { + ("SHARED", false) + } + } Some(AudioBackendType::SystemDefault) | None => ("DEFAULT", false), }; OutputLabels { diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 73859d36f..35c257061 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -1591,6 +1591,11 @@ pub struct SettingsDoc { pub backend_index: i32, #[serde(rename = "backendIsAlsa")] pub backend_is_alsa: bool, + /// True when the WASAPI exclusive backend is the active one. AudioSettings + /// QML gates the Exclusive-mode and DSD rows on it - see the comment there + /// for why they are hidden rather than disabled. + #[serde(rename = "backendIsWasapi")] + pub backend_is_wasapi: bool, #[serde(rename = "backendIsPipewire")] pub backend_is_pipewire: bool, #[serde(rename = "backendIsJack")] @@ -2015,6 +2020,8 @@ fn backend_label(t: AudioBackendType) -> String { AudioBackendType::Pulse => "PulseAudio".to_string(), AudioBackendType::SystemDefault => qbz_i18n::t("System default"), AudioBackendType::Jack => "JACK".to_string(), + // Not translated, like the other backend proper nouns above it. + AudioBackendType::WasapiExclusive => "WASAPI Exclusive".to_string(), } } @@ -2098,6 +2105,7 @@ pub async fn publish_snapshot() { .collect(), backend_index: backend_index as i32 + 1, backend_is_alsa: active_backend == AudioBackendType::Alsa, + backend_is_wasapi: active_backend == AudioBackendType::WasapiExclusive, backend_is_pipewire: active_backend == AudioBackendType::PipeWire, backend_is_jack: active_backend == AudioBackendType::Jack, devices,