#[cfg(any(feature = "capture", feature = "playback"))]
use cpal::traits::{DeviceTrait, HostTrait};
use crate::error::DecibriError;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct MicrophoneInfo {
pub index: usize,
pub name: String,
pub id: String,
pub max_input_channels: u16,
pub default_sample_rate: u32,
pub is_default: bool,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SpeakerInfo {
pub index: usize,
pub name: String,
pub id: String,
pub max_output_channels: u16,
pub default_sample_rate: u32,
pub is_default: bool,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum DeviceSelector {
Default,
Index(usize),
Name(String),
Id(String),
}
#[cfg(any(feature = "capture", feature = "playback"))]
struct ComputedRow {
index: usize,
name: String,
id: String,
channels: u16,
sample_rate: u32,
is_default: bool,
}
#[cfg(any(feature = "capture", feature = "playback"))]
fn compute_is_default<Id: PartialEq + ToString>(
rows: Vec<(Option<Id>, String, u16, u32)>,
default_id: Option<Id>,
) -> Vec<ComputedRow> {
rows.into_iter()
.enumerate()
.map(|(index, (id, name, channels, sample_rate))| {
let is_default = match (id.as_ref(), default_id.as_ref()) {
(Some(row_id), Some(default)) => row_id == default,
_ => false,
};
let id = id.map(|x| x.to_string()).unwrap_or_default();
ComputedRow {
index,
name,
id,
channels,
sample_rate,
is_default,
}
})
.collect()
}
#[cfg(any(feature = "capture", feature = "playback"))]
trait DeviceDirection {
type Info;
fn default_device(host: &cpal::Host) -> Option<cpal::Device>;
fn list_devices(host: &cpal::Host) -> Result<Vec<cpal::Device>, DecibriError>;
fn default_config(device: &cpal::Device) -> (u16, u32);
fn build_info(row: ComputedRow) -> Self::Info;
fn no_device_error() -> DecibriError;
fn not_found_error(query: String) -> DecibriError;
}
#[cfg(any(feature = "capture", feature = "playback"))]
struct Input;
#[cfg(any(feature = "capture", feature = "playback"))]
struct Output;
#[cfg(any(feature = "capture", feature = "playback"))]
impl DeviceDirection for Input {
type Info = MicrophoneInfo;
fn default_device(host: &cpal::Host) -> Option<cpal::Device> {
host.default_input_device()
}
fn list_devices(host: &cpal::Host) -> Result<Vec<cpal::Device>, DecibriError> {
host.input_devices()
.map(|it| it.collect())
.map_err(|e| DecibriError::DeviceEnumerationFailed(e.to_string()))
}
fn default_config(device: &cpal::Device) -> (u16, u32) {
match device.default_input_config() {
Ok(config) => (config.channels(), config.sample_rate()),
Err(_) => (0, 0),
}
}
fn build_info(row: ComputedRow) -> Self::Info {
MicrophoneInfo {
index: row.index,
name: row.name,
id: row.id,
max_input_channels: row.channels,
default_sample_rate: row.sample_rate,
is_default: row.is_default,
}
}
fn no_device_error() -> DecibriError {
DecibriError::NoMicrophoneFound
}
fn not_found_error(query: String) -> DecibriError {
DecibriError::MicrophoneNotFound(query)
}
}
#[cfg(any(feature = "capture", feature = "playback"))]
impl DeviceDirection for Output {
type Info = SpeakerInfo;
fn default_device(host: &cpal::Host) -> Option<cpal::Device> {
host.default_output_device()
}
fn list_devices(host: &cpal::Host) -> Result<Vec<cpal::Device>, DecibriError> {
host.output_devices()
.map(|it| it.collect())
.map_err(|e| DecibriError::DeviceEnumerationFailed(e.to_string()))
}
fn default_config(device: &cpal::Device) -> (u16, u32) {
match device.default_output_config() {
Ok(config) => (config.channels(), config.sample_rate()),
Err(_) => (0, 0),
}
}
fn build_info(row: ComputedRow) -> Self::Info {
SpeakerInfo {
index: row.index,
name: row.name,
id: row.id,
max_output_channels: row.channels,
default_sample_rate: row.sample_rate,
is_default: row.is_default,
}
}
fn no_device_error() -> DecibriError {
DecibriError::NoSpeakerFound
}
fn not_found_error(query: String) -> DecibriError {
DecibriError::SpeakerNotFound(query)
}
}
#[cfg(any(feature = "capture", feature = "playback"))]
fn enumerate_devices<D: DeviceDirection>() -> Result<Vec<D::Info>, DecibriError> {
let host = cpal::default_host();
let default_id = D::default_device(&host).and_then(|d| d.id().ok());
let devices = D::list_devices(&host)?;
let rows: Vec<(Option<cpal::DeviceId>, String, u16, u32)> = devices
.into_iter()
.enumerate()
.map(|(index, device)| {
let id = device.id().ok();
let name = device
.description()
.map(|d| d.name().to_string())
.unwrap_or_else(|_| format!("Unknown Device {index}"));
let (channels, sample_rate) = D::default_config(&device);
(id, name, channels, sample_rate)
})
.collect();
Ok(compute_is_default(rows, default_id)
.into_iter()
.map(D::build_info)
.collect())
}
#[cfg(any(feature = "capture", feature = "playback"))]
fn resolve_device_generic<D: DeviceDirection>(
selector: &DeviceSelector,
) -> Result<cpal::Device, DecibriError> {
let host = cpal::default_host();
match selector {
DeviceSelector::Default => D::default_device(&host).ok_or_else(D::no_device_error),
DeviceSelector::Index(idx) => D::list_devices(&host)?
.into_iter()
.nth(*idx)
.ok_or(DecibriError::DeviceIndexOutOfRange),
DeviceSelector::Name(query) => {
let query_lower = query.to_lowercase();
let devices = D::list_devices(&host)?;
let mut matches: Vec<(usize, String, cpal::Device)> = Vec::new();
for (index, device) in devices.into_iter().enumerate() {
let name = device
.description()
.map(|d| d.name().to_string())
.unwrap_or_default();
if name.to_lowercase().contains(&query_lower) {
matches.push((index, name, device));
}
}
match matches.len() {
0 => Err(D::not_found_error(query.clone())),
1 => Ok(matches.into_iter().next().unwrap().2),
_ => {
let match_list = matches
.iter()
.map(|(idx, name, _)| format!(" [{idx}] {name}"))
.collect::<Vec<_>>()
.join("\n");
Err(DecibriError::MultipleDevicesMatch {
name: query.clone(),
matches: format!(
"{match_list}\nUse a more specific name or pass the device index directly."
),
})
}
}
}
DeviceSelector::Id(query) => {
let devices = D::list_devices(&host)?;
for device in devices {
if let Ok(id) = device.id() {
if id.to_string() == *query {
return Ok(device);
}
}
}
Err(D::not_found_error(query.clone()))
}
}
}
#[cfg(any(feature = "capture", feature = "playback"))]
pub fn input_devices() -> Result<Vec<MicrophoneInfo>, DecibriError> {
enumerate_devices::<Input>()
}
#[cfg(any(feature = "capture", feature = "playback"))]
pub fn output_devices() -> Result<Vec<SpeakerInfo>, DecibriError> {
enumerate_devices::<Output>()
}
#[cfg(feature = "capture")]
pub(crate) fn resolve_device(selector: &DeviceSelector) -> Result<cpal::Device, DecibriError> {
resolve_device_generic::<Input>(selector)
}
#[cfg(feature = "playback")]
pub(crate) fn resolve_output_device(
selector: &DeviceSelector,
) -> Result<cpal::Device, DecibriError> {
resolve_device_generic::<Output>(selector)
}
#[cfg(all(test, any(feature = "capture", feature = "playback")))]
mod tests {
use super::*;
fn row(id: Option<&str>, name: &str) -> (Option<String>, String, u16, u32) {
(id.map(String::from), name.to_string(), 2, 48_000)
}
#[test]
fn test_is_default_two_devices_same_name_different_ids() {
let rows = vec![
row(Some("usb-mic-A"), "Microphone"),
row(Some("usb-mic-B"), "Microphone"),
];
let result = compute_is_default(rows, Some("usb-mic-B".to_string()));
assert_eq!(result.len(), 2);
assert!(
!result[0].is_default,
"first duplicate-named mic must NOT be flagged when default is the second"
);
assert!(
result[1].is_default,
"second duplicate-named mic (matching default id) must be flagged"
);
assert_eq!(result[0].name, "Microphone");
assert_eq!(result[1].name, "Microphone");
}
#[test]
fn test_is_default_no_default_reported() {
let rows = vec![row(Some("mic-A"), "Mic A"), row(Some("mic-B"), "Mic B")];
let result = compute_is_default::<String>(rows, None);
assert_eq!(result.len(), 2);
assert!(
result.iter().all(|r| !r.is_default),
"no row may be flagged when host reports no default"
);
}
#[test]
fn test_is_default_row_with_failed_id() {
let rows = vec![
row(None, "Mystery device with unavailable id"),
row(Some("mic-B"), "Mic B"),
];
let result = compute_is_default(rows, Some("mic-B".to_string()));
assert_eq!(result.len(), 2);
assert!(
!result[0].is_default,
"row with id() == None must never be flagged default"
);
assert!(
result[1].is_default,
"row whose id matches the default must be flagged"
);
}
#[test]
fn test_is_default_empty_device_list() {
let rows: Vec<(Option<String>, String, u16, u32)> = vec![];
let result_no_default = compute_is_default::<String>(rows.clone(), None);
let result_with_default = compute_is_default(rows, Some("phantom-mic".to_string()));
assert!(result_no_default.is_empty());
assert!(result_with_default.is_empty());
}
#[test]
fn test_id_is_propagated_to_computed_row() {
let rows = vec![
row(Some("mic-with-id"), "Microphone A"),
row(None, "Microphone without id"),
];
let result = compute_is_default(rows, Some("mic-with-id".to_string()));
assert_eq!(result.len(), 2);
assert_eq!(
result[0].id, "mic-with-id",
"Some(id) must be preserved as a non-empty string"
);
assert_eq!(
result[1].id, "",
"None id must map to an empty string so the device is unreachable via DeviceSelector::Id"
);
}
#[test]
fn test_not_found_error_produces_direction_correct_variant() {
let input_err = Input::not_found_error("nonexistent-input".to_string());
let output_err = Output::not_found_error("nonexistent-output".to_string());
assert!(
matches!(input_err, DecibriError::MicrophoneNotFound(ref s) if s == "nonexistent-input"),
"Input direction must return DecibriError::MicrophoneNotFound"
);
assert!(
matches!(output_err, DecibriError::SpeakerNotFound(ref s) if s == "nonexistent-output"),
"Output direction must return DecibriError::SpeakerNotFound"
);
assert_eq!(
input_err.to_string(),
"No microphone found matching \"nonexistent-input\"",
"Input variant's Display must name the microphone"
);
assert_eq!(
output_err.to_string(),
"No speaker found matching \"nonexistent-output\"",
"Output variant's Display must name the speaker"
);
}
}