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
This commit is contained in:
vicrodh committed 2026-08-27 11:19:19 -06:00
1 parent 2aa8d2d5f1
commit 09fcdeaa06
5 files changed
+97

No files matched your search

+1
View File
@@ -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",
}
}
+26
View File
@@ -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
+51
View File
@@ -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 {
+11
View File
@@ -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 {
+8
View File
@@ -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,