decibri/device.rs
1//! Audio device discovery and selection.
2//!
3//! [`input_devices`] and [`output_devices`] list the available microphones and
4//! speakers as [`MicrophoneInfo`] / [`SpeakerInfo`]. A [`DeviceSelector`] picks
5//! one by system default, index, case-insensitive name substring, or stable
6//! per-host id, and is the value [`crate::MicrophoneConfig`] and
7//! [`crate::SpeakerConfig`] carry in their `device` field.
8//!
9//! The platform enumeration and resolution themselves run behind
10//! [`crate::backend::AudioBackend`]; this module owns the public device types
11//! and the pure `is_default` matching, and routes listing through the seam.
12
13use crate::error::DecibriError;
14
15/// Information about a microphone (audio input) device.
16///
17/// Returned by [`input_devices`] and
18/// [`Microphone::devices`](crate::Microphone::devices). For speakers, see
19/// [`SpeakerInfo`].
20#[derive(Debug, Clone)]
21#[non_exhaustive]
22pub struct MicrophoneInfo {
23 pub index: usize,
24 pub name: String,
25 /// Stable per-host device ID, suitable for passing to
26 /// [`DeviceSelector::Id`] for selection that survives across
27 /// enumerations.
28 ///
29 /// The string is the platform device identifier:
30 /// - Windows (WASAPI): endpoint ID (e.g. `{0.0.1.00000000}.{...}`)
31 /// - macOS (CoreAudio): device UID
32 /// - Linux (ALSA): PCM identifier
33 ///
34 /// Empty string if the platform could not produce a stable ID for this
35 /// device (rare; some host backends cannot assign IDs to every
36 /// enumerated device). A device with an empty `id` cannot be
37 /// selected by [`DeviceSelector::Id`] and must be selected via
38 /// [`DeviceSelector::Index`] or [`DeviceSelector::Name`] instead.
39 ///
40 /// [`Display`]: std::fmt::Display
41 pub id: String,
42 pub max_input_channels: u16,
43 pub default_sample_rate: u32,
44 pub is_default: bool,
45}
46
47/// Information about a speaker (audio output) device.
48///
49/// Returned by [`output_devices`] and
50/// [`Speaker::devices`](crate::Speaker::devices). For microphones, see
51/// [`MicrophoneInfo`].
52#[derive(Debug, Clone)]
53#[non_exhaustive]
54pub struct SpeakerInfo {
55 pub index: usize,
56 pub name: String,
57 /// Stable per-host device ID. See [`MicrophoneInfo::id`] for format and
58 /// fallback semantics; the rules are identical for output devices.
59 pub id: String,
60 pub max_output_channels: u16,
61 pub default_sample_rate: u32,
62 pub is_default: bool,
63}
64
65/// How to select an audio device.
66#[derive(Debug, Clone)]
67#[non_exhaustive]
68pub enum DeviceSelector {
69 /// Use the system default device.
70 Default,
71 /// Select by device index.
72 Index(usize),
73 /// Select by case-insensitive name substring match.
74 Name(String),
75 /// Select by stable per-host device ID.
76 ///
77 /// The ID is the string produced by `cpal::DeviceId`'s [`Display`] impl
78 /// (WASAPI endpoint ID on Windows, CoreAudio UID on macOS, ALSA pcm_id
79 /// on Linux). Matching is exact string equality, not substring.
80 ///
81 /// Obtain an ID from [`MicrophoneInfo::id`] / [`SpeakerInfo::id`]
82 /// returned by [`input_devices`] / [`output_devices`].
83 ///
84 /// Prefer `Id` over [`Self::Name`] when you need stable device selection
85 /// across enumerations: display names can shift across OS versions or
86 /// when other devices are plugged in, but per-host IDs don't.
87 ///
88 /// [`Display`]: std::fmt::Display
89 Id(String),
90}
91
92/// Internal: enumerated device row with `is_default` already resolved.
93/// Used as the output of the pure [`compute_is_default`] helper so both input
94/// and output enumeration can share the same device-identity matching logic
95/// (and so the logic is testable without mocking the host).
96#[cfg(any(feature = "capture", feature = "playback"))]
97pub(crate) struct ComputedRow {
98 pub(crate) index: usize,
99 pub(crate) name: String,
100 /// Stable per-host device ID as a string, or empty if the device has no
101 /// host-assignable ID. Forwarded to `MicrophoneInfo.id` / `SpeakerInfo.id`.
102 pub(crate) id: String,
103 pub(crate) channels: u16,
104 pub(crate) sample_rate: u32,
105 pub(crate) is_default: bool,
106}
107
108/// Pure helper for Issue #14 fix.
109///
110/// Given a list of `(id, name, channels, sample_rate)` tuples representing
111/// enumerated devices and an optional default-device id, produce one
112/// `ComputedRow` per input with `is_default` resolved by identity comparison
113/// (NOT by name-equality. See [#14](https://github.com/decibri/decibri/issues/14) for context.)
114///
115/// Semantics:
116/// - A row's `id` of `None` (caller couldn't fetch a host `DeviceId` for that
117/// device) is treated as "not default".
118/// - A `default_id` of `None` (no default device reported by the host) means
119/// no row is flagged default.
120/// - Otherwise, a row is default iff its id equals `default_id`. With a stable
121/// per-host id (WASAPI endpoint ID, CoreAudio UID, ALSA pcm_id) exactly one
122/// row matches even when multiple devices share a display name.
123///
124/// Generic over `Id: PartialEq` so unit tests can use `String` ids and verify
125/// the matching logic without depending on a live host.
126#[cfg(any(feature = "capture", feature = "playback"))]
127pub(crate) fn compute_is_default<Id: PartialEq + ToString>(
128 rows: Vec<(Option<Id>, String, u16, u32)>,
129 default_id: Option<Id>,
130) -> Vec<ComputedRow> {
131 rows.into_iter()
132 .enumerate()
133 .map(|(index, (id, name, channels, sample_rate))| {
134 let is_default = match (id.as_ref(), default_id.as_ref()) {
135 (Some(row_id), Some(default)) => row_id == default,
136 _ => false,
137 };
138 // `cpal::DeviceId` implements `Display`, so `.to_string()` gives
139 // a stable per-host identifier. `None` (device has no assignable
140 // id on this host) becomes an empty string: the enumeration still
141 // includes the device but it cannot be selected by `DeviceSelector::Id`.
142 let id = id.map(|x| x.to_string()).unwrap_or_default();
143 ComputedRow {
144 index,
145 name,
146 id,
147 channels,
148 sample_rate,
149 is_default,
150 }
151 })
152 .collect()
153}
154
155/// List the available microphone (input) devices.
156///
157/// Also available as [`Microphone::devices`](crate::Microphone::devices).
158#[cfg(any(feature = "capture", feature = "playback"))]
159pub fn input_devices() -> Result<Vec<MicrophoneInfo>, DecibriError> {
160 use crate::backend::AudioBackend;
161 crate::backend::CpalBackend.input_devices()
162}
163
164/// List the available speaker (output) devices.
165///
166/// Also available as [`Speaker::devices`](crate::Speaker::devices).
167#[cfg(any(feature = "capture", feature = "playback"))]
168pub fn output_devices() -> Result<Vec<SpeakerInfo>, DecibriError> {
169 use crate::backend::AudioBackend;
170 crate::backend::CpalBackend.output_devices()
171}
172
173#[cfg(all(test, any(feature = "capture", feature = "playback")))]
174mod tests {
175 use super::*;
176
177 // Synthetic id type for testing. Any `PartialEq` works because
178 // `compute_is_default` is generic over the id type. Using String keeps the
179 // tests readable and avoids any dependency on a live host.
180 fn row(id: Option<&str>, name: &str) -> (Option<String>, String, u16, u32) {
181 (id.map(String::from), name.to_string(), 2, 48_000)
182 }
183
184 /// Issue #14: two devices with the same display name but different per-host
185 /// IDs; only the device whose id matches the default is flagged.
186 #[test]
187 fn test_is_default_two_devices_same_name_different_ids() {
188 let rows = vec![
189 row(Some("usb-mic-A"), "Microphone"),
190 row(Some("usb-mic-B"), "Microphone"),
191 ];
192 let result = compute_is_default(rows, Some("usb-mic-B".to_string()));
193
194 assert_eq!(result.len(), 2);
195 assert!(
196 !result[0].is_default,
197 "first duplicate-named mic must NOT be flagged when default is the second"
198 );
199 assert!(
200 result[1].is_default,
201 "second duplicate-named mic (matching default id) must be flagged"
202 );
203 // Both rows keep their shared display name. The bug was never about
204 // renaming devices, only about misattribution of is_default.
205 assert_eq!(result[0].name, "Microphone");
206 assert_eq!(result[1].name, "Microphone");
207 }
208
209 /// Host reports no default device (rare but possible on headless Linux /
210 /// CI runners with no audio devices): no row is flagged.
211 #[test]
212 fn test_is_default_no_default_reported() {
213 let rows = vec![row(Some("mic-A"), "Mic A"), row(Some("mic-B"), "Mic B")];
214 let result = compute_is_default::<String>(rows, None);
215
216 assert_eq!(result.len(), 2);
217 assert!(
218 result.iter().all(|r| !r.is_default),
219 "no row may be flagged when host reports no default"
220 );
221 }
222
223 /// A device whose `id()` call failed (represented as `None` in the helper
224 /// input) is never flagged default, even if the host reports some default.
225 #[test]
226 fn test_is_default_row_with_failed_id() {
227 let rows = vec![
228 row(None, "Mystery device with unavailable id"),
229 row(Some("mic-B"), "Mic B"),
230 ];
231 let result = compute_is_default(rows, Some("mic-B".to_string()));
232
233 assert_eq!(result.len(), 2);
234 assert!(
235 !result[0].is_default,
236 "row with id() == None must never be flagged default"
237 );
238 assert!(
239 result[1].is_default,
240 "row whose id matches the default must be flagged"
241 );
242 }
243
244 /// Empty device list produces an empty result, regardless of what the
245 /// host reports as the default.
246 #[test]
247 fn test_is_default_empty_device_list() {
248 let rows: Vec<(Option<String>, String, u16, u32)> = vec![];
249 let result_no_default = compute_is_default::<String>(rows.clone(), None);
250 let result_with_default = compute_is_default(rows, Some("phantom-mic".to_string()));
251
252 assert!(result_no_default.is_empty());
253 assert!(result_with_default.is_empty());
254 }
255
256 /// The `id` field on `ComputedRow` is populated from the input `Option<Id>`
257 /// via `ToString`, with `None` becoming an empty string. This field flows
258 /// through to `MicrophoneInfo.id` / `SpeakerInfo.id` and backs
259 /// `DeviceSelector::Id` selection.
260 #[test]
261 fn test_id_is_propagated_to_computed_row() {
262 let rows = vec![
263 row(Some("mic-with-id"), "Microphone A"),
264 row(None, "Microphone without id"),
265 ];
266 let result = compute_is_default(rows, Some("mic-with-id".to_string()));
267
268 assert_eq!(result.len(), 2);
269 assert_eq!(
270 result[0].id, "mic-with-id",
271 "Some(id) must be preserved as a non-empty string"
272 );
273 assert_eq!(
274 result[1].id, "",
275 "None id must map to an empty string so the device is unreachable via DeviceSelector::Id"
276 );
277 }
278}