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
This commit is contained in:
vicrodh committed 2026-08-27 04:07:31 -06:00
1 parent fc0d35e295
commit 249adc77b3
1 file changed
+56 -4
+56 -4
View File
@@ -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)