use std::collections::{HashMap, HashSet};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{Device, Host, SupportedStreamConfig, SupportedStreamConfigRange};
use crate::validate::ValidatedConfig;
pub fn print_devices(show_rates: bool) -> anyhow::Result<()> {
let inventory = crate::device_inventory::list_audio_devices()?;
println!("Audio devices");
for device in &inventory.all {
let marker = match (device.is_default_input, device.is_default_output) {
(true, true) => " · default",
(true, false) => " · default in",
(false, true) => " · default out",
(false, false) => "",
};
let rates = if show_rates {
" · rates: use audiorouter check for configured sample-rate validation"
} else {
""
};
println!(
" {} — {}ch in, {}ch out{}{}",
device.name, device.max_input_channels, device.max_output_channels, marker, rates
);
}
Ok(())
}
#[derive(Clone)]
#[allow(dead_code)]
pub struct ResolvedDevice {
pub alias: String,
pub name: String,
pub device: Device,
pub is_input: bool,
pub is_output: bool,
pub max_input_channels: u16,
pub max_output_channels: u16,
pub preferred_input_channels: u16,
pub preferred_output_channels: u16,
}
#[derive(Clone)]
pub struct ResolvedAudioDevices {
pub devices: HashMap<String, ResolvedDevice>,
pub connect_warnings: Vec<String>,
pub disabled_route_indices: HashSet<usize>,
pub unavailable_inputs: HashSet<String>,
pub unavailable_outputs: HashSet<String>,
}
impl ResolvedAudioDevices {
pub fn missing_device_aliases(&self) -> HashSet<String> {
let mut missing = self.unavailable_inputs.clone();
missing.extend(self.unavailable_outputs.iter().cloned());
missing
}
pub fn route_enabled(&self, index: usize) -> bool {
!self.disabled_route_indices.contains(&index)
}
pub fn active_route_count(&self, plan: &ValidatedConfig) -> usize {
plan.routes
.iter()
.enumerate()
.filter(|(i, _)| self.route_enabled(*i))
.count()
}
pub fn input_device_names(&self) -> Vec<&str> {
self.devices
.values()
.filter(|d| d.is_input)
.map(|d| d.alias.as_str())
.collect()
}
pub fn output_device_names(&self) -> Vec<&str> {
self.devices
.values()
.filter(|d| d.is_output)
.map(|d| d.alias.as_str())
.collect()
}
pub fn connectivity_events(
&self,
next: &ResolvedAudioDevices,
plan: &ValidatedConfig,
) -> Vec<String> {
let mut events = Vec::new();
for alias in self.unavailable_inputs.difference(&next.unavailable_inputs) {
events.push(format_device_event(plan, alias, "input", "connected"));
}
for alias in next.unavailable_inputs.difference(&self.unavailable_inputs) {
events.push(format_device_event(plan, alias, "input", "disconnected"));
}
for alias in self
.unavailable_outputs
.difference(&next.unavailable_outputs)
{
events.push(format_device_event(plan, alias, "output", "connected"));
}
for alias in next
.unavailable_outputs
.difference(&self.unavailable_outputs)
{
events.push(format_device_event(plan, alias, "output", "disconnected"));
}
events.sort();
events
}
}
fn format_device_event(plan: &ValidatedConfig, alias: &str, side: &str, state: &str) -> String {
let device = plan
.device_by_name(alias)
.map(|role| role.device.as_str())
.unwrap_or(alias);
format!("device \"{alias}\" (\"{device}\") {state} as {side}")
}
pub fn resolve_devices(
plan: &ValidatedConfig,
) -> Result<ResolvedAudioDevices, crate::error::AppError> {
let host = cpal::default_host();
let input_devices = collect_devices(&host, true).map_err(|e| {
crate::error::AppError::runtime(format!("failed to enumerate input devices: {e}"))
})?;
let output_devices = collect_devices(&host, false).map_err(|e| {
crate::error::AppError::runtime(format!("failed to enumerate output devices: {e}"))
})?;
let sample_rate = plan.config.engine.sample_rate;
let mut resolved: HashMap<String, ResolvedDevice> = HashMap::new();
let mut connect_warnings: Vec<String> = Vec::new();
let mut unavailable_inputs: HashSet<String> = HashSet::new();
let mut unavailable_outputs: HashSet<String> = HashSet::new();
for role in &plan.devices {
let dev_name = &role.device;
if role.needs_input && !input_devices.iter().any(|d| &d.to_string() == dev_name) {
unavailable_inputs.insert(role.name.clone());
connect_warnings.push(format!(
"device \"{}\" (\"{}\") is not currently connected as input; related routes disabled",
role.name, dev_name
));
}
if role.needs_output && !output_devices.iter().any(|d| &d.to_string() == dev_name) {
unavailable_outputs.insert(role.name.clone());
connect_warnings.push(format!(
"device \"{}\" (\"{}\") is not currently connected as output; related routes disabled",
role.name, dev_name
));
}
}
let disabled_route_indices: HashSet<usize> = plan
.routes
.iter()
.enumerate()
.filter_map(|(i, route)| {
if unavailable_inputs.contains(&route.from) || unavailable_outputs.contains(&route.to) {
Some(i)
} else {
None
}
})
.collect();
if !disabled_route_indices.is_empty() {
connect_warnings.push(format!(
"{} route(s) disabled because required audio devices are not connected",
disabled_route_indices.len()
));
}
for role in &plan.devices {
let dev_name = &role.device;
let mut cpal_input_device: Option<Device> = None;
let mut max_in_ch: u16 = 0;
let mut pref_in_ch: u16 = 0;
let mut cpal_output_device: Option<Device> = None;
let mut max_out_ch: u16 = 0;
let mut pref_out_ch: u16 = 0;
let active_input_routes: Vec<_> = plan
.routes
.iter()
.enumerate()
.filter(|(i, r)| !disabled_route_indices.contains(i) && r.from == role.name)
.map(|(_, r)| r)
.collect();
let active_output_routes: Vec<_> = plan
.routes
.iter()
.enumerate()
.filter(|(i, r)| !disabled_route_indices.contains(i) && r.to == role.name)
.map(|(_, r)| r)
.collect();
let needs_input = !active_input_routes.is_empty();
let needs_output = !active_output_routes.is_empty();
let required_input_channels = active_input_routes
.iter()
.flat_map(|r| r.from_channels.iter())
.copied()
.max()
.unwrap_or(0);
let required_output_channels = active_output_routes
.iter()
.flat_map(|r| r.to_channels.iter())
.copied()
.max()
.unwrap_or(0);
if needs_input {
let found = input_devices.iter().find(|d| &d.to_string() == dev_name);
match found {
Some(d) => {
let max_ch = max_channels(d, true).unwrap_or(0);
if max_ch < required_input_channels as u16 {
return Err(crate::error::AppError::config(format!(
"device alias \"{}\" uses audio device \"{}\" as input requiring {} channel(s), \
but only {} input channel(s) are available",
role.name, dev_name, required_input_channels, max_ch
)));
}
if !supports_sample_rate(d, true, sample_rate) {
return Err(crate::error::AppError::config(format!(
"device \"{}\" does not support the configured sample rate {} Hz",
dev_name, sample_rate
)));
}
max_in_ch = max_ch;
pref_in_ch = preferred_channels(d, true);
cpal_input_device = Some(d.clone());
}
None => continue,
}
}
if needs_output {
let found = output_devices.iter().find(|d| &d.to_string() == dev_name);
match found {
Some(d) => {
let max_ch = max_channels(d, false).unwrap_or(0);
if max_ch < required_output_channels as u16 {
return Err(crate::error::AppError::config(format!(
"output device \"{}\" resolved to \"{}\", \
but route requires output channel {}",
role.name, dev_name, required_output_channels
)));
}
if !supports_sample_rate(d, false, sample_rate) {
return Err(crate::error::AppError::config(format!(
"device \"{}\" does not support the configured sample rate {} Hz",
dev_name, sample_rate
)));
}
max_out_ch = max_ch;
pref_out_ch = preferred_channels(d, false);
cpal_output_device = Some(d.clone());
}
None => continue,
}
}
if !needs_input && !needs_output {
if unavailable_inputs.contains(&role.name) || unavailable_outputs.contains(&role.name) {
continue;
}
let found_as_input = input_devices.iter().any(|d| &d.to_string() == dev_name);
let found_as_output = output_devices.iter().any(|d| &d.to_string() == dev_name);
if !found_as_input && !found_as_output {
connect_warnings.push(format!(
"device \"{}\" (\"{}\") is not currently connected",
role.name, dev_name
));
}
continue;
}
if max_in_ch == 0
&& let Some(d) = input_devices.iter().find(|d| &d.to_string() == dev_name)
{
max_in_ch = max_channels(d, true).unwrap_or(0);
pref_in_ch = preferred_channels(d, true);
}
if max_out_ch == 0
&& let Some(d) = output_devices.iter().find(|d| &d.to_string() == dev_name)
{
max_out_ch = max_channels(d, false).unwrap_or(0);
pref_out_ch = preferred_channels(d, false);
}
let device = cpal_input_device
.or(cpal_output_device)
.expect("at least one role must be active");
resolved.insert(
role.name.clone(),
ResolvedDevice {
alias: role.name.clone(),
name: role.device.clone(),
device,
is_input: needs_input,
is_output: needs_output,
max_input_channels: max_in_ch,
max_output_channels: max_out_ch,
preferred_input_channels: pref_in_ch,
preferred_output_channels: pref_out_ch,
},
);
}
Ok(ResolvedAudioDevices {
devices: resolved,
connect_warnings,
disabled_route_indices,
unavailable_inputs,
unavailable_outputs,
})
}
#[allow(dead_code)]
pub fn find_stream_config(
device: &Device,
is_input: bool,
sample_rate: u32,
_desired_buffer_size: u32,
) -> anyhow::Result<SupportedStreamConfig> {
let supported_configs = supported_configs(device, is_input)?;
for config_range in supported_configs {
let min = config_range.min_sample_rate();
let max = config_range.max_sample_rate();
if sample_rate >= min && sample_rate <= max {
return Ok(config_range.with_sample_rate(sample_rate));
}
}
anyhow::bail!(
"no supported config found for device \"{}\" at {} Hz",
device,
sample_rate
)
}
pub(crate) fn collect_devices(host: &Host, is_input: bool) -> anyhow::Result<Vec<Device>> {
let mut result = Vec::new();
if is_input {
for device in host.input_devices()? {
result.push(device);
}
} else {
for device in host.output_devices()? {
result.push(device);
}
}
Ok(result)
}
pub(crate) fn max_channels(device: &Device, is_input: bool) -> Option<u16> {
let configs = supported_configs(device, is_input).ok()?;
configs.iter().map(|c| c.channels()).max()
}
pub(crate) fn preferred_channels(device: &Device, is_input: bool) -> u16 {
let result = if is_input {
device.default_input_config()
} else {
device.default_output_config()
};
match result {
Ok(config) => config.channels(),
Err(_) => 0,
}
}
fn supports_sample_rate(device: &Device, is_input: bool, rate: u32) -> bool {
let Ok(configs) = supported_configs(device, is_input) else {
return true;
};
for c in configs {
if rate >= c.min_sample_rate() && rate <= c.max_sample_rate() {
return true;
}
}
false
}
fn supported_configs(
device: &Device,
is_input: bool,
) -> anyhow::Result<Vec<SupportedStreamConfigRange>> {
if is_input {
Ok(device.supported_input_configs()?.collect())
} else {
Ok(device.supported_output_configs()?.collect())
}
}
#[allow(dead_code)]
pub fn verify_device_openable(
device: &Device,
is_input: bool,
sample_rate: u32,
) -> anyhow::Result<()> {
let config = find_stream_config(device, is_input, sample_rate, 256)?;
let stream_config = cpal::StreamConfig {
channels: config.channels(),
sample_rate,
buffer_size: cpal::BufferSize::Default,
};
let err_fn = |err| tracing::error!("stream error: {err}");
if is_input {
let stream = match config.sample_format() {
cpal::SampleFormat::F32 => device.build_input_stream::<f32, _, _>(
stream_config,
|_d: &[f32], _i: &cpal::InputCallbackInfo| {},
err_fn,
None,
)?,
cpal::SampleFormat::I16 => device.build_input_stream::<i16, _, _>(
stream_config,
|_d: &[i16], _i: &cpal::InputCallbackInfo| {},
err_fn,
None,
)?,
cpal::SampleFormat::U16 => device.build_input_stream::<u16, _, _>(
stream_config,
|_d: &[u16], _i: &cpal::InputCallbackInfo| {},
err_fn,
None,
)?,
_ => anyhow::bail!("unsupported sample format"),
};
stream.play()?;
drop(stream);
} else {
let stream = match config.sample_format() {
cpal::SampleFormat::F32 => device.build_output_stream::<f32, _, _>(
stream_config,
|_d: &mut [f32], _i: &cpal::OutputCallbackInfo| {},
err_fn,
None,
)?,
cpal::SampleFormat::I16 => device.build_output_stream::<i16, _, _>(
stream_config,
|_d: &mut [i16], _i: &cpal::OutputCallbackInfo| {},
err_fn,
None,
)?,
cpal::SampleFormat::U16 => device.build_output_stream::<u16, _, _>(
stream_config,
|_d: &mut [u16], _i: &cpal::OutputCallbackInfo| {},
err_fn,
None,
)?,
_ => anyhow::bail!("unsupported sample format"),
};
stream.play()?;
drop(stream);
}
Ok(())
}