use std::collections::{HashMap, HashSet};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{Device, Host, SupportedStreamConfig, SupportedStreamConfigRange};
use crate::ui;
use crate::validate::ValidatedConfig;
pub fn print_devices(show_rates: bool) -> anyhow::Result<()> {
let host = cpal::default_host();
let default_input_name = host.default_input_device().map(|d| d.to_string());
let default_output_name = host.default_output_device().map(|d| d.to_string());
let input_devices = collect_devices(&host, true)?;
let output_devices = collect_devices(&host, false)?;
let mut seen = HashSet::new();
let mut all_names: Vec<String> = Vec::new();
for d in input_devices.iter().chain(output_devices.iter()) {
let name = d.to_string();
if seen.insert(name.clone()) {
all_names.push(name);
}
}
ui::header("Audio devices");
for name in &all_names {
let input_dev = input_devices.iter().find(|d| d.to_string() == *name);
let output_dev = output_devices.iter().find(|d| d.to_string() == *name);
let is_default_input = default_input_name.as_deref() == Some(name.as_str());
let is_default_output = default_output_name.as_deref() == Some(name.as_str());
let marker = match (is_default_input, is_default_output) {
(true, true) => Some("default"),
(true, false) => Some("default in"),
(false, true) => Some("default out"),
(false, false) => None,
};
let in_cfgs = input_dev.map(|d| supported_configs(d, true));
let out_cfgs = output_dev.map(|d| supported_configs(d, false));
let in_ch = in_cfgs
.as_ref()
.and_then(|r| r.as_ref().ok())
.and_then(|cfgs| cfgs.iter().map(|c| c.channels()).max());
let out_ch = out_cfgs
.as_ref()
.and_then(|r| r.as_ref().ok())
.and_then(|cfgs| cfgs.iter().map(|c| c.channels()).max());
let rates: Vec<String> = if let Some(Ok(cfgs)) = &in_cfgs {
configs_to_rate_strings(cfgs)
} else if let Some(Ok(cfgs)) = &out_cfgs {
configs_to_rate_strings(cfgs)
} else {
vec![]
};
if in_ch.is_none() && out_ch.is_none() {
let err = in_cfgs
.as_ref()
.and_then(|r| r.as_ref().err())
.map(|e| e.to_string())
.or_else(|| {
out_cfgs
.as_ref()
.and_then(|r| r.as_ref().err())
.map(|e| e.to_string())
})
.unwrap_or_else(|| "no supported configs".to_string());
ui::device_entry_unavailable(name, marker, &err);
} else {
ui::device_entry(name, in_ch, out_ch, show_rates.then_some(rates.as_slice()), marker);
}
}
Ok(())
}
fn configs_to_rate_strings(configs: &[SupportedStreamConfigRange]) -> Vec<String> {
configs
.iter()
.map(|c| {
let min = c.min_sample_rate();
let max = c.max_sample_rate();
if min == max {
format_hz(min)
} else {
format!("{}–{}", format_hz(min), format_hz(max))
}
})
.collect()
}
fn format_hz(rate: u32) -> String {
if rate >= 1_000_000 {
let v = rate as f64 / 1_000_000.0;
if v.fract() == 0.0 {
format!("{} MHz", v as u32)
} else {
format!("{:.1} MHz", v)
}
} else if rate >= 1_000 {
let v = rate as f64 / 1_000.0;
if v.fract() == 0.0 {
format!("{} kHz", v as u32)
} else {
format!("{:.1} kHz", v)
}
} else {
format!("{} Hz", rate)
}
}
#[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,
}
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
)
}
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)
}
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()
}
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| ui::error(format!("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(())
}