From 09fcdeaa069793a439514062dbb4e9dcb13d4050 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:19:19 -0600 Subject: [PATCH] feat(audio): WASAPI Exclusive as a selectable backend AudioBackendType::WasapiExclusive, offered on Windows, plus the selection arm that opens the exclusive stream before the shared one - the same shape ALSA Direct has on Linux. No separate enumeration was needed: 249adc77b already made AudioDevice.id the WASAPI ENDPOINT ID, which is exactly what WasapiDirectStream::new wants. That fix turns out to have been the load-bearing one; by friendly name this could not have addressed a device at all. Two deliberate fallbacks, both of which prefer a resampled track to silence: - No device chosen: there is nothing to open exclusively, so it says so and uses the shared stream. Grabbing the default DAC would be deciding for the user. - The endpoint refuses the rate: warn and fall through. A DAC that will not take 192/24 in exclusive still plays it shared. The mode LED lights EXCL only when a device IS chosen, because with none the player falls through to shared - a lit EXCL there would be the LED lying, which is the one thing output_labels must never do. backendIsWasapi joins the settings document, which is what unhides the Exclusive-mode and DSD rows that fc0d35e29 gated on it. 229 + 71 + 110 tests pass; five QML audits pass. cc --- crates/qbz-app/src/settings/bundle.rs | 1 + crates/qbz-audio/src/backend.rs | 26 ++++++++++++++ crates/qbz-player/src/player/mod.rs | 51 +++++++++++++++++++++++++++ crates/qbz-qt/src/output_labels.rs | 11 ++++++ crates/qbz-qt/src/settings_qt.rs | 8 +++++ 5 files changed, 97 insertions(+) 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,