Skip to main content

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}