Skip to main content

audiorouter_core/
device_inventory.rs

1//! System audio device inventory for dashboard/device-list APIs.
2
3use std::collections::{HashMap, HashSet};
4
5use cpal::traits::HostTrait;
6use serde::Serialize;
7
8use crate::devices::{collect_devices, max_channels, preferred_channels};
9
10#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
11#[serde(rename_all = "camelCase")]
12pub struct AudioDeviceInfo {
13    pub name: String,
14    pub max_input_channels: u16,
15    pub max_output_channels: u16,
16    pub preferred_input_channels: u16,
17    pub preferred_output_channels: u16,
18    pub is_default_input: bool,
19    pub is_default_output: bool,
20}
21
22#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
23pub struct DevicesResponse {
24    pub inputs: Vec<AudioDeviceInfo>,
25    pub outputs: Vec<AudioDeviceInfo>,
26    pub all: Vec<AudioDeviceInfo>,
27}
28
29/// Enumerate currently visible system audio devices without reading audiorouter config.
30pub fn list_audio_devices() -> anyhow::Result<DevicesResponse> {
31    let host = cpal::default_host();
32    let default_input_name = host.default_input_device().map(|d| d.to_string());
33    let default_output_name = host.default_output_device().map(|d| d.to_string());
34
35    let input_devices = collect_devices(&host, true)?;
36    let output_devices = collect_devices(&host, false)?;
37
38    let mut names = Vec::new();
39    let mut seen = HashSet::new();
40    for d in input_devices.iter().chain(output_devices.iter()) {
41        let name = d.to_string();
42        if seen.insert(name.clone()) {
43            names.push(name);
44        }
45    }
46
47    let input_by_name: HashMap<String, _> = input_devices
48        .iter()
49        .map(|d| (d.to_string(), d.clone()))
50        .collect();
51    let output_by_name: HashMap<String, _> = output_devices
52        .iter()
53        .map(|d| (d.to_string(), d.clone()))
54        .collect();
55
56    let mut all = Vec::new();
57    for name in names {
58        let input = input_by_name.get(&name);
59        let output = output_by_name.get(&name);
60        all.push(AudioDeviceInfo {
61            max_input_channels: input.and_then(|d| max_channels(d, true)).unwrap_or(0),
62            max_output_channels: output.and_then(|d| max_channels(d, false)).unwrap_or(0),
63            preferred_input_channels: input.map(|d| preferred_channels(d, true)).unwrap_or(0),
64            preferred_output_channels: output.map(|d| preferred_channels(d, false)).unwrap_or(0),
65            is_default_input: default_input_name.as_deref() == Some(name.as_str()),
66            is_default_output: default_output_name.as_deref() == Some(name.as_str()),
67            name,
68        });
69    }
70
71    all.sort_by(|a, b| a.name.cmp(&b.name));
72    let inputs = all
73        .iter()
74        .filter(|d| d.max_input_channels > 0)
75        .cloned()
76        .collect();
77    let outputs = all
78        .iter()
79        .filter(|d| d.max_output_channels > 0)
80        .cloned()
81        .collect();
82
83    Ok(DevicesResponse {
84        inputs,
85        outputs,
86        all,
87    })
88}
89
90/// Compact fingerprint of a device inventory snapshot for change detection.
91/// Two equal fingerprints guarantee identical device names, channel counts,
92/// and default-device flags.
93fn device_fingerprint(response: &DevicesResponse) -> String {
94    let mut entries: Vec<(&str, u16, u16, bool, bool)> = response
95        .all
96        .iter()
97        .map(|d| {
98            (
99                d.name.as_str(),
100                d.max_input_channels,
101                d.max_output_channels,
102                d.is_default_input,
103                d.is_default_output,
104            )
105        })
106        .collect();
107    entries.sort_by(|a, b| a.0.cmp(b.0));
108    entries
109        .into_iter()
110        .map(|(n, i, o, di, do_)| format!("{n}:{i}/{o}/{di}/{do_}"))
111        .collect::<Vec<_>>()
112        .join(";")
113}
114
115/// A set of human-readable descriptions of what changed between two snapshots.
116/// Returns an empty vec when nothing changed.
117pub fn device_diff(prev: &DevicesResponse, curr: &DevicesResponse) -> Vec<String> {
118    if device_fingerprint(prev) == device_fingerprint(curr) {
119        return Vec::new();
120    }
121
122    let prev_map: HashMap<&str, &AudioDeviceInfo> =
123        prev.all.iter().map(|d| (d.name.as_str(), d)).collect();
124    let curr_map: HashMap<&str, &AudioDeviceInfo> =
125        curr.all.iter().map(|d| (d.name.as_str(), d)).collect();
126
127    let prev_names: HashSet<&str> = prev_map.keys().copied().collect();
128    let curr_names: HashSet<&str> = curr_map.keys().copied().collect();
129
130    let mut events = Vec::new();
131
132    // Added devices
133    let mut added: Vec<&str> = curr_names.difference(&prev_names).copied().collect();
134    added.sort_unstable();
135    for name in added {
136        let d = &curr_map[name];
137        events.push(format!(
138            "{name} connected (in:{}, out:{})",
139            d.max_input_channels, d.max_output_channels
140        ));
141    }
142
143    // Removed devices
144    let mut removed: Vec<&str> = prev_names.difference(&curr_names).copied().collect();
145    removed.sort_unstable();
146    for name in removed {
147        events.push(format!("{name} disconnected"));
148    }
149
150    // Changed devices (channel counts or defaults)
151    let mut changed: Vec<&str> = curr_names.intersection(&prev_names).copied().collect();
152    changed.sort_unstable();
153    for name in changed {
154        let p = &prev_map[name];
155        let c = &curr_map[name];
156        if p.max_input_channels != c.max_input_channels
157            || p.max_output_channels != c.max_output_channels
158        {
159            events.push(format!(
160                "{name} channels changed (in:{}→{}, out:{}→{})",
161                p.max_input_channels,
162                c.max_input_channels,
163                p.max_output_channels,
164                c.max_output_channels
165            ));
166        }
167        if p.is_default_input != c.is_default_input && c.is_default_input {
168            events.push(format!("{name} became default input"));
169        }
170        if p.is_default_output != c.is_default_output && c.is_default_output {
171            events.push(format!("{name} became default output"));
172        }
173    }
174
175    events
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn dev(name: &str, i: u16, o: u16) -> AudioDeviceInfo {
183        AudioDeviceInfo {
184            name: name.to_string(),
185            max_input_channels: i,
186            max_output_channels: o,
187            preferred_input_channels: i.min(2),
188            preferred_output_channels: o.min(2),
189            is_default_input: false,
190            is_default_output: false,
191        }
192    }
193
194    fn resp(devs: &[AudioDeviceInfo]) -> DevicesResponse {
195        let all = devs.to_vec();
196        let inputs = all
197            .iter()
198            .filter(|d| d.max_input_channels > 0)
199            .cloned()
200            .collect();
201        let outputs = all
202            .iter()
203            .filter(|d| d.max_output_channels > 0)
204            .cloned()
205            .collect();
206        DevicesResponse {
207            inputs,
208            outputs,
209            all,
210        }
211    }
212
213    #[test]
214    fn no_change_returns_empty() {
215        let r = resp(&[dev("A", 2, 0), dev("B", 0, 2)]);
216        assert!(device_diff(&r, &r).is_empty());
217    }
218
219    #[test]
220    fn device_added() {
221        let prev = resp(&[dev("A", 2, 0)]);
222        let curr = resp(&[dev("A", 2, 0), dev("B", 0, 2)]);
223        let diff = device_diff(&prev, &curr);
224        assert_eq!(diff.len(), 1);
225        assert!(diff[0].contains("B connected"));
226    }
227
228    #[test]
229    fn device_removed() {
230        let prev = resp(&[dev("A", 2, 0), dev("B", 0, 2)]);
231        let curr = resp(&[dev("A", 2, 0)]);
232        let diff = device_diff(&prev, &curr);
233        assert_eq!(diff.len(), 1);
234        assert!(diff[0].contains("B disconnected"));
235    }
236
237    #[test]
238    fn channel_count_changed() {
239        let prev = resp(&[dev("A", 2, 2)]);
240        let curr = resp(&[dev("A", 4, 2)]);
241        let diff = device_diff(&prev, &curr);
242        assert_eq!(diff.len(), 1);
243        assert!(diff[0].contains("channels changed"));
244    }
245
246    #[test]
247    fn fingerprint_order_independent() {
248        let r1 = resp(&[dev("A", 2, 0), dev("B", 0, 2)]);
249        let r2 = resp(&[dev("B", 0, 2), dev("A", 2, 0)]);
250        assert_eq!(device_fingerprint(&r1), device_fingerprint(&r2));
251    }
252}