use anyhow::Result;
use cpal::traits::{DeviceTrait, HostTrait};
use super::types::AudioDeviceInfo;
#[cfg(all(target_os = "linux", feature = "pulse-metadata"))]
use super::pulse;
#[cfg(target_os = "linux")]
mod alsa_suppress {
use std::os::raw::{c_char, c_int};
use std::sync::Once;
type SndLibErrorHandlerT =
unsafe extern "C" fn(*const c_char, c_int, *const c_char, c_int, *const c_char);
#[link(name = "asound")]
unsafe extern "C" {
fn snd_lib_error_set_handler(handler: Option<SndLibErrorHandlerT>) -> c_int;
}
unsafe extern "C" fn silent_error_handler(
_file: *const c_char,
_line: c_int,
_function: *const c_char,
_err: c_int,
_fmt: *const c_char,
) {
}
static INIT: Once = Once::new();
pub fn init() {
INIT.call_once(|| {
unsafe {
snd_lib_error_set_handler(Some(silent_error_handler));
}
});
}
}
#[cfg(not(target_os = "linux"))]
mod alsa_suppress {
pub fn init() {}
}
pub fn list_audio_devices() -> Result<Vec<AudioDeviceInfo>> {
#[cfg(all(target_os = "linux", feature = "pulse-metadata"))]
{
match pulse::list_pulse_devices() {
Ok(mut devices) if !devices.is_empty() => {
let cpal_descriptions = get_cpal_descriptions();
crate::verbose!("CPAL descriptions: {:?}", cpal_descriptions);
for device in &mut devices {
crate::verbose!(
"PulseAudio: name={:?}, display={:?}",
device.name,
device.display_name
);
if let Some(display) = &device.display_name {
let normalized = normalize_for_matching(display);
if let Some(cpal_name) = cpal_descriptions
.iter()
.find(|c| normalize_for_matching(c) == normalized)
{
crate::verbose!("Matched: {} -> {}", device.name, cpal_name);
device.name = cpal_name.clone();
}
}
}
return Ok(devices);
}
Ok(_) => {} Err(_e) => {
}
}
}
list_cpal_devices()
}
pub(crate) fn normalize_for_matching(name: &str) -> String {
let base = name.split('(').next().unwrap_or(name);
base.chars()
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
.collect::<String>()
.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
pub(crate) fn fuzzy_device_match(stored_name: &str, cpal_description: &str) -> bool {
let stored_normalized = normalize_for_matching(stored_name);
let cpal_normalized = normalize_for_matching(cpal_description);
if stored_normalized == cpal_normalized {
return true;
}
let cpal_words: Vec<&str> = cpal_normalized.split_whitespace().collect();
let significant_words: Vec<&str> = cpal_words
.iter()
.filter(|w| {
w.len() >= 3
&& !matches!(
**w,
"mono" | "stereo" | "input" | "output" | "analog" | "digital" | "audio" | "usb"
)
})
.copied()
.collect();
if significant_words.is_empty() {
return false;
}
let matches = significant_words
.iter()
.filter(|w| stored_normalized.contains(*w))
.count();
matches == significant_words.len()
}
fn get_cpal_descriptions() -> Vec<String> {
alsa_suppress::init();
let host = cpal::default_host();
host.input_devices()
.ok()
.map(|devices| {
devices
.filter_map(|d| d.description().ok().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
fn list_cpal_devices() -> Result<Vec<AudioDeviceInfo>> {
alsa_suppress::init();
let host = cpal::default_host();
let default_device_name = host
.default_input_device()
.and_then(|d| d.description().ok())
.map(|d| d.to_string());
let mut devices = Vec::new();
for device in host.input_devices()? {
if let Ok(desc) = device.description() {
let raw_name = desc.to_string();
if is_virtual_device(&raw_name) {
continue;
}
let display_name = clean_device_name(&raw_name);
devices.push(AudioDeviceInfo {
name: raw_name.clone(),
display_name: Some(display_name),
is_default: default_device_name.as_ref() == Some(&raw_name),
form_factor: None,
bus: None,
is_monitor: false,
});
}
}
if devices.is_empty() {
anyhow::bail!("No audio input devices found");
}
Ok(devices)
}
fn is_virtual_device(name: &str) -> bool {
let lower = name.to_lowercase();
if lower.contains("discard all samples")
|| lower.contains("generate zero samples")
|| lower.contains("null")
{
return true;
}
if lower.contains("output") && lower.contains("monitor") {
return true;
}
if lower == "pipewire sound server" {
return true;
}
false
}
fn clean_device_name(name: &str) -> String {
let mut cleaned = name.to_string();
let suffixes_to_remove = [
" (currently PipeWire Media Server)",
" (currently PulseAudio)",
" Analog Stereo",
" Digital Stereo",
" Stereo",
" Mono",
];
for suffix in suffixes_to_remove {
if let Some(pos) = cleaned.find(suffix) {
cleaned.truncate(pos);
}
}
cleaned = cleaned.trim_end_matches([',', ' ']).to_string();
if cleaned.is_empty() {
return name.to_string();
}
cleaned
}
pub(super) fn init_platform() {
alsa_suppress::init();
}