use cpal::{
traits::{DeviceTrait, HostTrait},
Device,
};
#[derive(Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AudioDevice {
pub id: Option<String>,
pub name: String,
pub supports_input: bool,
pub supports_output: bool,
pub can_capture_input: bool,
pub can_capture_output: bool,
pub capture_input_unavailable_reason: Option<String>,
pub capture_output_unavailable_reason: Option<String>,
pub is_default_input: bool,
pub is_default_output: bool,
}
#[tauri::command]
#[specta::specta]
pub fn get_devices() -> Result<Vec<AudioDevice>, String> {
let host = cpal::default_host();
let defaults = DeviceDefaults {
input_id: host
.default_input_device()
.and_then(|device| device.id().ok())
.map(|id| id.to_string()),
output_id: host
.default_output_device()
.and_then(|device| device.id().ok())
.map(|id| id.to_string()),
};
let devices = host.devices().map_err(|error| error.to_string())?;
let devices = devices
.filter_map(|device| audio_device(device, &defaults))
.collect();
let mut devices = reduce_devices(devices);
#[cfg(target_os = "linux")]
devices.extend(crate::pipewire::output_devices());
Ok(devices)
}
struct DeviceDefaults {
input_id: Option<String>,
output_id: Option<String>,
}
fn audio_device(device: Device, defaults: &DeviceDefaults) -> Option<AudioDevice> {
let id = device.id().ok().map(|id| id.to_string());
is_user_facing_device(id.as_deref()).then(|| {
let description = device.description().ok();
let name = description
.as_ref()
.map(|description| description.name().to_owned())
.or_else(|| id.clone())
.unwrap_or_else(|| "Unknown device".to_string());
let is_default_input = id.as_ref() == defaults.input_id.as_ref();
let is_default_output = id.as_ref() == defaults.output_id.as_ref();
let supports_input = device.supports_input();
let supports_output = device.supports_output();
let capture_input_unavailable_reason =
(!supports_input).then(|| "Device does not expose an input stream".to_string());
let capture_output_unavailable_reason = output_capture_unavailable_reason(supports_output);
AudioDevice {
id,
name,
supports_input,
supports_output,
can_capture_input: supports_input,
can_capture_output: supports_output && capture_output_unavailable_reason.is_none(),
capture_input_unavailable_reason,
capture_output_unavailable_reason,
is_default_input,
is_default_output,
}
})
}
fn output_capture_unavailable_reason(supports_output: bool) -> Option<String> {
if !supports_output {
Some("Device does not expose an output stream".to_string())
} else if cfg!(target_os = "windows") {
None
} else {
Some("Output capture is only supported on Windows in the CPAL-first build".to_string())
}
}
fn is_user_facing_device(id: Option<&str>) -> bool {
id.and_then(|id| id.strip_prefix("alsa:"))
.is_none_or(is_user_facing_alsa_device)
}
fn is_user_facing_alsa_device(alsa_id: &str) -> bool {
matches!(
alsa_id
.split_once(':')
.map_or(alsa_id, |(plugin, _)| plugin),
"default" | "pipewire" | "bluealsa" | "sysdefault" | "hdmi"
)
}
fn reduce_devices(devices: Vec<AudioDevice>) -> Vec<AudioDevice> {
let mut reduced: Vec<AudioDevice> = Vec::with_capacity(devices.len());
for device in devices {
if let Some(index) = duplicate_wasapi_index(&reduced, &device) {
let existing = &reduced[index];
if (device.is_default_input && !existing.is_default_input)
|| (device.is_default_output && !existing.is_default_output)
{
reduced[index] = device;
}
} else {
reduced.push(device);
}
}
reduced
}
fn duplicate_wasapi_index(devices: &[AudioDevice], device: &AudioDevice) -> Option<usize> {
let key = wasapi_dedupe_key(device)?;
devices
.iter()
.position(|existing| wasapi_dedupe_key(existing).as_deref() == Some(key.as_str()))
}
fn wasapi_dedupe_key(device: &AudioDevice) -> Option<String> {
device.id.as_deref()?.strip_prefix("wasapi:")?;
Some(format!(
"{}|{}|{}",
normalize_device_label(&device.name),
device.supports_input,
device.supports_output
))
}
fn normalize_device_label(label: &str) -> String {
label
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
#[cfg(test)]
mod tests {
use super::{
is_user_facing_device, output_capture_unavailable_reason, reduce_devices, AudioDevice,
};
#[test]
fn keeps_non_reduced_hosts_untouched() {
assert!(is_user_facing_device(Some("wasapi:{0.0.0.00000000}")));
assert!(is_user_facing_device(Some(
"coreaudio:BuiltInSpeakerDevice"
)));
assert!(is_user_facing_device(Some("jack:system")));
assert!(is_user_facing_device(None));
}
#[test]
fn filters_noisy_alsa_aliases() {
assert!(is_user_facing_device(Some("alsa:default")));
assert!(is_user_facing_device(Some("alsa:pipewire")));
assert!(is_user_facing_device(Some("alsa:sysdefault:CARD=PCH")));
assert!(is_user_facing_device(Some("alsa:hdmi:CARD=NVidia,DEV=0")));
assert!(!is_user_facing_device(Some("alsa:null")));
assert!(!is_user_facing_device(Some("alsa:hw:CARD=PCH,DEV=0")));
assert!(!is_user_facing_device(Some("alsa:plughw:CARD=PCH,DEV=0")));
assert!(!is_user_facing_device(Some("alsa:dmix:CARD=PCH,DEV=0")));
assert!(!is_user_facing_device(Some(
"alsa:surround51:CARD=PCH,DEV=0"
)));
}
#[test]
fn dedupes_wasapi_devices_by_user_facing_identity() {
let devices = vec![
test_device(
"wasapi:{0.0.0.00000000}.{first}",
"Speakers",
false,
true,
false,
),
test_device(
"wasapi:{0.0.0.00000000}.{second}",
" Speakers ",
false,
true,
true,
),
test_device(
"wasapi:{0.0.1.00000000}.{third}",
"Speakers",
true,
false,
false,
),
test_device("coreaudio:Speakers", "Speakers", false, true, false),
];
let reduced = reduce_devices(devices);
assert_eq!(reduced.len(), 3);
assert_eq!(
reduced[0].id.as_deref(),
Some("wasapi:{0.0.0.00000000}.{second}")
);
assert_eq!(
reduced[1].id.as_deref(),
Some("wasapi:{0.0.1.00000000}.{third}")
);
assert_eq!(reduced[2].id.as_deref(), Some("coreaudio:Speakers"));
}
fn test_device(
id: &str,
name: &str,
supports_input: bool,
supports_output: bool,
is_default_output: bool,
) -> AudioDevice {
AudioDevice {
id: Some(id.to_string()),
name: name.to_string(),
supports_input,
supports_output,
can_capture_input: supports_input,
can_capture_output: supports_output && cfg!(target_os = "windows"),
capture_input_unavailable_reason: (!supports_input)
.then(|| "Device does not expose an input stream".to_string()),
capture_output_unavailable_reason: output_capture_unavailable_reason(supports_output),
is_default_input: false,
is_default_output,
}
}
}