1use std::collections::{HashMap, HashSet};
6
7use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
8use cpal::{Device, Host, SupportedStreamConfig, SupportedStreamConfigRange};
9
10use crate::validate::ValidatedConfig;
11
12pub 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#[derive(Clone)]
45#[allow(dead_code)]
46pub struct ResolvedDevice {
47 pub alias: String,
49 pub name: String,
51 pub device: Device,
53 pub is_input: bool,
55 pub is_output: bool,
57 pub max_input_channels: u16,
59 pub max_output_channels: u16,
61 pub preferred_input_channels: u16,
64 pub preferred_output_channels: u16,
66}
67
68#[derive(Clone)]
70pub struct ResolvedAudioDevices {
71 pub devices: HashMap<String, ResolvedDevice>,
72 pub connect_warnings: Vec<String>,
74 pub disabled_route_indices: HashSet<usize>,
76 pub unavailable_inputs: HashSet<String>,
78 pub unavailable_outputs: HashSet<String>,
80}
81
82impl ResolvedAudioDevices {
83 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 pub fn route_enabled(&self, index: usize) -> bool {
93 !self.disabled_route_indices.contains(&index)
94 }
95
96 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 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 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 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
163pub 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 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 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 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#[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
438pub(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
468fn 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#[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}