From 249adc77b3d5544e5d2de075b99be9a3564d4b4b Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:07:31 -0600 Subject: [PATCH] fix(audio): identify Windows output devices by endpoint id, not friendly name Windows endpoint friendly names are not unique. Measured on this box, FOUR active render endpoints resolved to three display strings, and two were both called "Altavoces": the onboard Realtek and a Cambridge Audio USB DAC. device_filter dedups on the display, so the DAC was folded away and could not be selected at all - and a DAC you cannot select cannot be sent a bit-perfect stream, so this also blocked phase B. device_filter.rs:20-23 states the opposite in a comment - "macOS/Windows device names carry unique displays, so they pass through untouched". That is the third comment in this tree asserting a mechanism that does not hold. Nothing new is queried: cpal 0.17.3 already fills the fields this backend ignored. name() is the ENDPOINT and repeats; driver() is the ADAPTER and is what tells them apart; id() is the stable WASAPI endpoint GUID. On Windows the id becomes the GUID, the display becomes "endpoint (adapter)", and the adapter lands in the description field that was hard-coded None. The default device is matched by id there too, since three endpoints shared a name. Additive cfg arms only, per the protected-core rule: every other platform takes the same (name, name, None, name == default_name) tuple it took before, byte for byte. The open path keeps the name comparison as a fallback so a device_id saved before this change still resolves. Verified against the real hardware, before and after, with a standalone cpal probe: the DAC goes from DROPPED to listed, labelled and flagged default. cc --- crates/qbz-audio/src/backend.rs | 60 ++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/crates/qbz-audio/src/backend.rs b/crates/qbz-audio/src/backend.rs index ab0ef7121..bc861196c 100644 --- a/crates/qbz-audio/src/backend.rs +++ b/crates/qbz-audio/src/backend.rs @@ -456,6 +456,14 @@ impl AudioBackend for CpalDefaultBackend { .map(|desc| desc.name().to_string()) .unwrap_or_else(|_| "Default Output".to_string()); + // WINDOWS ONLY, additive. Endpoint FRIENDLY NAMES are not unique + // there: measured on a real box, three active render endpoints were + // all called "Altavoces" - the motherboard Realtek, a Bluetooth + // handsfree and a Cambridge Audio USB DAC. Matching the default by + // name would mark all three. + #[cfg(windows)] + let default_endpoint_id = default_device.id().ok().map(|i| i.1); + let mut devices = Vec::new(); for device in self .host @@ -466,7 +474,6 @@ impl AudioBackend for CpalDefaultBackend { .description() .map(|desc| desc.name().to_string()) .unwrap_or_else(|_| "Unknown Device".to_string()); - let is_default = name == default_name; // On macOS, probe device capabilities via CoreAudio #[cfg(target_os = "macos")] @@ -479,10 +486,47 @@ impl AudioBackend for CpalDefaultBackend { bool, ) = (None, None, None, false); + // WINDOWS ONLY, additive: id, display and description all come + // from fields cpal already fills but this backend ignored. + // + // `name` is the endpoint ("Altavoces") and repeats across + // devices; `driver()` is the ADAPTER ("Cambridge Audio USB Audio + // 2.0") and is what tells them apart; `id()` is the stable WASAPI + // endpoint GUID. Using the name as the id meant device_filter + // folded every same-named endpoint into one entry, so a USB DAC + // sharing the name "Altavoces" with the onboard card could not be + // selected at all - and an unselectable DAC blocks bit-perfect. + #[cfg(windows)] + let (id, display, description, is_default) = { + let driver = device + .description() + .ok() + .and_then(|d| d.driver().map(str::to_string)) + .filter(|d| !d.is_empty() && d.as_str() != name.as_str()); + let endpoint_id = device.id().ok().map(|i| i.1); + let display = match &driver { + Some(d) => format!("{name} ({d})"), + None => name.clone(), + }; + let is_default = match (&endpoint_id, &default_endpoint_id) { + (Some(a), Some(b)) => a == b, + _ => name == default_name, + }; + ( + endpoint_id.unwrap_or_else(|| name.clone()), + display, + driver, + is_default, + ) + }; + #[cfg(not(windows))] + let (id, display, description, is_default) = + (name.clone(), name.clone(), None, name == default_name); + devices.push(AudioDevice { - id: name.clone(), - name, - description: None, + id, + name: display, + description, is_default, max_sample_rate: max_rate, supported_sample_rates: supported_rates, @@ -563,6 +607,14 @@ impl AudioBackend for CpalDefaultBackend { .output_devices() .map_err(|e| format!("Failed to enumerate devices: {}", e))? .find(|d| { + // WINDOWS ONLY, additive: enumerate_devices publishes the + // stable endpoint GUID as the id there, because friendly + // names repeat. The name comparison stays as the fallback + // so a setting saved before this change still resolves. + #[cfg(windows)] + if d.id().map(|i| i.1 == *device_id).unwrap_or(false) { + return true; + } d.description() .map(|desc| desc.name() == device_id.as_str()) .unwrap_or(false)