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:249adc77balready 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 thatfc0d35e29gated on it. 229 + 71 + 110 tests pass; five QML audits pass. cc
This commit is contained in:
5 files changed
+97
No files matched your search
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user