Skip to main content

audiorouter_core/
devices.rs

1//! CPAL device enumeration and resolution.
2//!
3//! This module bridges the validated config plan to CPAL audio devices.
4
5use std::collections::{HashMap, HashSet};
6
7use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
8use cpal::{Device, Host, SupportedStreamConfig, SupportedStreamConfigRange};
9
10use crate::validate::ValidatedConfig;
11
12/// Print all available audio devices with their input and output channel counts.
13/// Does not read config.
14///
15/// # Errors
16///
17/// Returns an error if the CPAL host or device enumeration fails.
18pub fn print_devices(show_rates: bool) -> anyhow::Result<()> {
19    let inventory = crate::device_inventory::list_audio_devices()?;
20
21    println!("Audio devices");
22    for device in &inventory.all {
23        let marker = match (device.is_default_input, device.is_default_output) {
24            (true, true) => " · default",
25            (true, false) => " · default in",
26            (false, true) => " · default out",
27            (false, false) => "",
28        };
29        let rates = if show_rates {
30            " · rates: use audiorouter check for configured sample-rate validation"
31        } else {
32            ""
33        };
34        println!(
35            "  {} — {}ch in, {}ch out{}{}",
36            device.name, device.max_input_channels, device.max_output_channels, marker, rates
37        );
38    }
39
40    Ok(())
41}
42
43/// Information about a resolved audio device.
44#[derive(Clone)]
45#[allow(dead_code)]
46pub struct ResolvedDevice {
47    /// Config-local alias.
48    pub alias: String,
49    /// Actual CPAL device name.
50    pub name: String,
51    /// The CPAL device handle.
52    pub device: Device,
53    /// Whether this device is used as an input.
54    pub is_input: bool,
55    /// Whether this device is used as an output.
56    pub is_output: bool,
57    /// Max input channels available.
58    pub max_input_channels: u16,
59    /// Max output channels available.
60    pub max_output_channels: u16,
61    /// Preferred (default) input channel count reported by the OS
62    /// (CoreAudio `kAudioDevicePropertyStreamFormat`).
63    pub preferred_input_channels: u16,
64    /// Preferred (default) output channel count reported by the OS.
65    pub preferred_output_channels: u16,
66}
67
68/// A fully resolved set of devices, ready for stream opening.
69#[derive(Clone)]
70pub struct ResolvedAudioDevices {
71    pub devices: HashMap<String, ResolvedDevice>,
72    /// Warnings about config-defined devices that are not currently connected.
73    pub connect_warnings: Vec<String>,
74    /// Route indices disabled because at least one endpoint device is not connected.
75    pub disabled_route_indices: HashSet<usize>,
76    /// Route-referenced input aliases that are currently unavailable.
77    pub unavailable_inputs: HashSet<String>,
78    /// Route-referenced output aliases that are currently unavailable.
79    pub unavailable_outputs: HashSet<String>,
80}
81
82impl ResolvedAudioDevices {
83    /// All device aliases that are currently missing (not connected).
84    /// Combines unavailable inputs and outputs.
85    pub fn missing_device_aliases(&self) -> HashSet<String> {
86        let mut missing = self.unavailable_inputs.clone();
87        missing.extend(self.unavailable_outputs.iter().cloned());
88        missing
89    }
90
91    /// Returns true when the route at `index` is active for stream construction.
92    pub fn route_enabled(&self, index: usize) -> bool {
93        !self.disabled_route_indices.contains(&index)
94    }
95
96    /// Number of active routes after connectivity pruning.
97    pub fn active_route_count(&self, plan: &ValidatedConfig) -> usize {
98        plan.routes
99            .iter()
100            .enumerate()
101            .filter(|(i, _)| self.route_enabled(*i))
102            .count()
103    }
104
105    /// All resolved device aliases that need an input stream.
106    pub fn input_device_names(&self) -> Vec<&str> {
107        self.devices
108            .values()
109            .filter(|d| d.is_input)
110            .map(|d| d.alias.as_str())
111            .collect()
112    }
113
114    /// All resolved device aliases that need an output stream.
115    pub fn output_device_names(&self) -> Vec<&str> {
116        self.devices
117            .values()
118            .filter(|d| d.is_output)
119            .map(|d| d.alias.as_str())
120            .collect()
121    }
122
123    /// Human-readable device connectivity changes between two resolutions.
124    pub fn connectivity_events(
125        &self,
126        next: &ResolvedAudioDevices,
127        plan: &ValidatedConfig,
128    ) -> Vec<String> {
129        let mut events = Vec::new();
130
131        for alias in self.unavailable_inputs.difference(&next.unavailable_inputs) {
132            events.push(format_device_event(plan, alias, "input", "connected"));
133        }
134        for alias in next.unavailable_inputs.difference(&self.unavailable_inputs) {
135            events.push(format_device_event(plan, alias, "input", "disconnected"));
136        }
137        for alias in self
138            .unavailable_outputs
139            .difference(&next.unavailable_outputs)
140        {
141            events.push(format_device_event(plan, alias, "output", "connected"));
142        }
143        for alias in next
144            .unavailable_outputs
145            .difference(&self.unavailable_outputs)
146        {
147            events.push(format_device_event(plan, alias, "output", "disconnected"));
148        }
149
150        events.sort();
151        events
152    }
153}
154
155fn format_device_event(plan: &ValidatedConfig, alias: &str, side: &str, state: &str) -> String {
156    let device = plan
157        .device_by_name(alias)
158        .map(|role| role.device.as_str())
159        .unwrap_or(alias);
160    format!("device \"{alias}\" (\"{device}\") {state} as {side}")
161}
162
163/// Resolve all devices in the validated config against actual CPAL devices.
164///
165/// For each device that `needs_input`, search input devices by exact name.
166/// For each device that `needs_output`, search output devices by exact name.
167/// Then validate channel counts and sample rate.
168///
169/// Devices **not used by any route** (neither input nor output) are checked
170/// for connectivity only — if they are not currently found among system
171/// devices, a warning is added to [`ResolvedAudioDevices::connect_warnings`]
172/// instead of returning an error. This lets users keep a config with optional
173/// devices that may be plugged in later.
174///
175/// # Errors
176///
177/// Missing route-referenced devices are warnings, not errors: every route that
178/// uses the missing input/output side is disabled. Still returns a `Config`
179/// error when a connected device has insufficient channels or an unsupported
180/// sample rate. Returns a `Runtime` error for CPAL enumeration failures.
181pub fn resolve_devices(
182    plan: &ValidatedConfig,
183) -> Result<ResolvedAudioDevices, crate::error::AppError> {
184    let host = cpal::default_host();
185
186    let input_devices = collect_devices(&host, true).map_err(|e| {
187        crate::error::AppError::runtime(format!("failed to enumerate input devices: {e}"))
188    })?;
189    let output_devices = collect_devices(&host, false).map_err(|e| {
190        crate::error::AppError::runtime(format!("failed to enumerate output devices: {e}"))
191    })?;
192
193    let sample_rate = plan.config.engine.sample_rate;
194
195    let mut resolved: HashMap<String, ResolvedDevice> = HashMap::new();
196    let mut connect_warnings: Vec<String> = Vec::new();
197    let mut unavailable_inputs: HashSet<String> = HashSet::new();
198    let mut unavailable_outputs: HashSet<String> = HashSet::new();
199
200    // First pass: identify route endpoint sides that are currently missing.
201    // Missing input disables routes that read from that alias; missing output
202    // disables routes that write to that alias. If the same device is still
203    // available in the opposite direction, routes using that side may continue.
204    for role in &plan.devices {
205        let dev_name = &role.device;
206
207        if role.needs_input && !input_devices.iter().any(|d| &d.to_string() == dev_name) {
208            unavailable_inputs.insert(role.name.clone());
209            connect_warnings.push(format!(
210                "device \"{}\" (\"{}\") is not currently connected as input; related routes disabled",
211                role.name, dev_name
212            ));
213        }
214
215        if role.needs_output && !output_devices.iter().any(|d| &d.to_string() == dev_name) {
216            unavailable_outputs.insert(role.name.clone());
217            connect_warnings.push(format!(
218                "device \"{}\" (\"{}\") is not currently connected as output; related routes disabled",
219                role.name, dev_name
220            ));
221        }
222    }
223
224    let disabled_route_indices: HashSet<usize> = plan
225        .routes
226        .iter()
227        .enumerate()
228        .filter_map(|(i, route)| {
229            if unavailable_inputs.contains(&route.from) || unavailable_outputs.contains(&route.to) {
230                Some(i)
231            } else {
232                None
233            }
234        })
235        .collect();
236
237    if !disabled_route_indices.is_empty() {
238        connect_warnings.push(format!(
239            "{} route(s) disabled because required audio devices are not connected",
240            disabled_route_indices.len()
241        ));
242    }
243
244    for role in &plan.devices {
245        let dev_name = &role.device;
246
247        let mut cpal_input_device: Option<Device> = None;
248        let mut max_in_ch: u16 = 0;
249        let mut pref_in_ch: u16 = 0;
250        let mut cpal_output_device: Option<Device> = None;
251        let mut max_out_ch: u16 = 0;
252        let mut pref_out_ch: u16 = 0;
253
254        let active_input_routes: Vec<_> = plan
255            .routes
256            .iter()
257            .enumerate()
258            .filter(|(i, r)| !disabled_route_indices.contains(i) && r.from == role.name)
259            .map(|(_, r)| r)
260            .collect();
261        let active_output_routes: Vec<_> = plan
262            .routes
263            .iter()
264            .enumerate()
265            .filter(|(i, r)| !disabled_route_indices.contains(i) && r.to == role.name)
266            .map(|(_, r)| r)
267            .collect();
268        let needs_input = !active_input_routes.is_empty();
269        let needs_output = !active_output_routes.is_empty();
270        let required_input_channels = active_input_routes
271            .iter()
272            .flat_map(|r| r.from_channels.iter())
273            .copied()
274            .max()
275            .unwrap_or(0);
276        let required_output_channels = active_output_routes
277            .iter()
278            .flat_map(|r| r.to_channels.iter())
279            .copied()
280            .max()
281            .unwrap_or(0);
282
283        if needs_input {
284            let found = input_devices.iter().find(|d| &d.to_string() == dev_name);
285            match found {
286                Some(d) => {
287                    let max_ch = max_channels(d, true).unwrap_or(0);
288                    if max_ch < required_input_channels as u16 {
289                        return Err(crate::error::AppError::config(format!(
290                            "device alias \"{}\" uses audio device \"{}\" as input requiring {} channel(s), \
291                             but only {} input channel(s) are available",
292                            role.name, dev_name, required_input_channels, max_ch
293                        )));
294                    }
295                    if !supports_sample_rate(d, true, sample_rate) {
296                        return Err(crate::error::AppError::config(format!(
297                            "device \"{}\" does not support the configured sample rate {} Hz",
298                            dev_name, sample_rate
299                        )));
300                    }
301                    max_in_ch = max_ch;
302                    pref_in_ch = preferred_channels(d, true);
303                    cpal_input_device = Some(d.clone());
304                }
305                None => continue,
306            }
307        }
308
309        if needs_output {
310            let found = output_devices.iter().find(|d| &d.to_string() == dev_name);
311            match found {
312                Some(d) => {
313                    let max_ch = max_channels(d, false).unwrap_or(0);
314                    if max_ch < required_output_channels as u16 {
315                        return Err(crate::error::AppError::config(format!(
316                            "output device \"{}\" resolved to \"{}\", \
317                             but route requires output channel {}",
318                            role.name, dev_name, required_output_channels
319                        )));
320                    }
321                    if !supports_sample_rate(d, false, sample_rate) {
322                        return Err(crate::error::AppError::config(format!(
323                            "device \"{}\" does not support the configured sample rate {} Hz",
324                            dev_name, sample_rate
325                        )));
326                    }
327                    max_out_ch = max_ch;
328                    pref_out_ch = preferred_channels(d, false);
329                    cpal_output_device = Some(d.clone());
330                }
331                None => continue,
332            }
333        }
334
335        // Devices not used by any route: check connectivity, warn if absent.
336        if !needs_input && !needs_output {
337            if unavailable_inputs.contains(&role.name) || unavailable_outputs.contains(&role.name) {
338                continue;
339            }
340            let found_as_input = input_devices.iter().any(|d| &d.to_string() == dev_name);
341            let found_as_output = output_devices.iter().any(|d| &d.to_string() == dev_name);
342            if !found_as_input && !found_as_output {
343                connect_warnings.push(format!(
344                    "device \"{}\" (\"{}\") is not currently connected",
345                    role.name, dev_name
346                ));
347            }
348            continue;
349        }
350
351        // Probe physical channel counts for the unused direction (display only, never fails).
352        if max_in_ch == 0
353            && let Some(d) = input_devices.iter().find(|d| &d.to_string() == dev_name)
354        {
355            max_in_ch = max_channels(d, true).unwrap_or(0);
356            pref_in_ch = preferred_channels(d, true);
357        }
358        if max_out_ch == 0
359            && let Some(d) = output_devices.iter().find(|d| &d.to_string() == dev_name)
360        {
361            max_out_ch = max_channels(d, false).unwrap_or(0);
362            pref_out_ch = preferred_channels(d, false);
363        }
364
365        let device = cpal_input_device
366            .or(cpal_output_device)
367            .expect("at least one role must be active");
368
369        resolved.insert(
370            role.name.clone(),
371            ResolvedDevice {
372                alias: role.name.clone(),
373                name: role.device.clone(),
374                device,
375                is_input: needs_input,
376                is_output: needs_output,
377                max_input_channels: max_in_ch,
378                max_output_channels: max_out_ch,
379                preferred_input_channels: pref_in_ch,
380                preferred_output_channels: pref_out_ch,
381            },
382        );
383    }
384
385    Ok(ResolvedAudioDevices {
386        devices: resolved,
387        connect_warnings,
388        disabled_route_indices,
389        unavailable_inputs,
390        unavailable_outputs,
391    })
392}
393
394/// Find the best supported stream config for a device at the given sample rate.
395#[allow(dead_code)]
396pub fn find_stream_config(
397    device: &Device,
398    is_input: bool,
399    sample_rate: u32,
400    _desired_buffer_size: u32,
401) -> anyhow::Result<SupportedStreamConfig> {
402    let supported_configs = supported_configs(device, is_input)?;
403
404    for config_range in supported_configs {
405        let min = config_range.min_sample_rate();
406        let max = config_range.max_sample_rate();
407        if sample_rate >= min && sample_rate <= max {
408            return Ok(config_range.with_sample_rate(sample_rate));
409        }
410    }
411
412    anyhow::bail!(
413        "no supported config found for device \"{}\" at {} Hz",
414        device,
415        sample_rate
416    )
417}
418
419pub(crate) fn collect_devices(host: &Host, is_input: bool) -> anyhow::Result<Vec<Device>> {
420    let mut result = Vec::new();
421    if is_input {
422        for device in host.input_devices()? {
423            result.push(device);
424        }
425    } else {
426        for device in host.output_devices()? {
427            result.push(device);
428        }
429    }
430    Ok(result)
431}
432
433pub(crate) fn max_channels(device: &Device, is_input: bool) -> Option<u16> {
434    let configs = supported_configs(device, is_input).ok()?;
435    configs.iter().map(|c| c.channels()).max()
436}
437
438/// Query the OS-reported preferred (default) channel count for a device.
439///
440/// On macOS this reads the device's default stream format
441/// (`kAudioDevicePropertyStreamFormat`), which reflects the channel layout
442/// the device advertises when an app simply opens it without explicit
443/// channel configuration — the "preferred channels".
444pub(crate) fn preferred_channels(device: &Device, is_input: bool) -> u16 {
445    let result = if is_input {
446        device.default_input_config()
447    } else {
448        device.default_output_config()
449    };
450    match result {
451        Ok(config) => config.channels(),
452        Err(_) => 0,
453    }
454}
455
456fn supports_sample_rate(device: &Device, is_input: bool, rate: u32) -> bool {
457    let Ok(configs) = supported_configs(device, is_input) else {
458        return true;
459    };
460    for c in configs {
461        if rate >= c.min_sample_rate() && rate <= c.max_sample_rate() {
462            return true;
463        }
464    }
465    false
466}
467
468/// Collect supported stream config ranges into a Vec, handling the
469/// input/output type mismatch by collecting eagerly.
470fn supported_configs(
471    device: &Device,
472    is_input: bool,
473) -> anyhow::Result<Vec<SupportedStreamConfigRange>> {
474    if is_input {
475        Ok(device.supported_input_configs()?.collect())
476    } else {
477        Ok(device.supported_output_configs()?.collect())
478    }
479}
480
481/// Play silence to a device briefly to verify it can be opened.
482///
483/// Used by `--check` mode to confirm stream viability without keeping a
484/// long-running stream alive.
485#[allow(dead_code)]
486pub fn verify_device_openable(
487    device: &Device,
488    is_input: bool,
489    sample_rate: u32,
490) -> anyhow::Result<()> {
491    let config = find_stream_config(device, is_input, sample_rate, 256)?;
492    let stream_config = cpal::StreamConfig {
493        channels: config.channels(),
494        sample_rate,
495        buffer_size: cpal::BufferSize::Default,
496    };
497
498    let err_fn = |err| tracing::error!("stream error: {err}");
499
500    if is_input {
501        let stream = match config.sample_format() {
502            cpal::SampleFormat::F32 => device.build_input_stream::<f32, _, _>(
503                stream_config,
504                |_d: &[f32], _i: &cpal::InputCallbackInfo| {},
505                err_fn,
506                None,
507            )?,
508            cpal::SampleFormat::I16 => device.build_input_stream::<i16, _, _>(
509                stream_config,
510                |_d: &[i16], _i: &cpal::InputCallbackInfo| {},
511                err_fn,
512                None,
513            )?,
514            cpal::SampleFormat::U16 => device.build_input_stream::<u16, _, _>(
515                stream_config,
516                |_d: &[u16], _i: &cpal::InputCallbackInfo| {},
517                err_fn,
518                None,
519            )?,
520            _ => anyhow::bail!("unsupported sample format"),
521        };
522        stream.play()?;
523        drop(stream);
524    } else {
525        let stream = match config.sample_format() {
526            cpal::SampleFormat::F32 => device.build_output_stream::<f32, _, _>(
527                stream_config,
528                |_d: &mut [f32], _i: &cpal::OutputCallbackInfo| {},
529                err_fn,
530                None,
531            )?,
532            cpal::SampleFormat::I16 => device.build_output_stream::<i16, _, _>(
533                stream_config,
534                |_d: &mut [i16], _i: &cpal::OutputCallbackInfo| {},
535                err_fn,
536                None,
537            )?,
538            cpal::SampleFormat::U16 => device.build_output_stream::<u16, _, _>(
539                stream_config,
540                |_d: &mut [u16], _i: &cpal::OutputCallbackInfo| {},
541                err_fn,
542                None,
543            )?,
544            _ => anyhow::bail!("unsupported sample format"),
545        };
546        stream.play()?;
547        drop(stream);
548    }
549
550    Ok(())
551}