use cpal::{
DeviceDescription, DeviceId,
traits::{DeviceTrait, HostTrait},
};
pub(crate) fn scan_audio_inputs() -> Vec<(DeviceId, DeviceDescription)> {
let host = cpal::default_host();
let Ok(devices) = host.input_devices() else {
tracing::warn!("Could not list the audio input devices");
return Vec::new();
};
Vec::from_iter(devices.filter_map(|device| {
let id = device.id().ok()?;
let description = device.description().ok()?;
if !is_capture_input_device(&description) {
return None;
}
Some((id, description))
}))
}
fn is_capture_input_device(description: &DeviceDescription) -> bool {
if !description.supports_input() {
return false;
}
let name = description.name().to_lowercase();
if name.contains("monitor of") {
return false;
}
if description
.extended()
.any(|line| line.to_lowercase().contains("monitor of"))
{
return false;
}
#[cfg(target_os = "linux")]
if let Some(driver) = description.driver() {
let driver = driver.to_lowercase();
let is_pipewire = driver == "pipewire" || driver.starts_with("pipewire:");
let is_virtual_driver = driver == "default"
|| driver == "pulse"
|| driver.starts_with("sysdefault:")
|| driver.starts_with("dsnoop:")
|| driver.starts_with("iec958:")
|| driver.starts_with("dmix:")
|| driver.starts_with("usbstream:");
if is_virtual_driver && !is_pipewire {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audio::audio_device_label;
use cpal::DeviceDescriptionBuilder;
use cpal::DeviceDirection;
use cpal::traits::StreamTrait;
use std::sync::atomic::Ordering::Relaxed;
use std::sync::{Arc, atomic::AtomicBool};
use std::time::{Duration, Instant};
#[test]
fn is_capture_input_device_accepts_real_input_device() {
let description = DeviceDescriptionBuilder::new("Built-in Microphone")
.direction(DeviceDirection::Input)
.build();
assert!(is_capture_input_device(&description));
}
#[test]
fn is_capture_input_device_accepts_duplex_device() {
let description = DeviceDescriptionBuilder::new("USB Audio Codec")
.direction(DeviceDirection::Duplex)
.build();
assert!(is_capture_input_device(&description));
}
#[test]
fn is_capture_input_device_rejects_monitor_sources() {
let description = DeviceDescriptionBuilder::new("Monitor of Built-in Audio")
.direction(DeviceDirection::Input)
.extended(vec!["Monitor of sink output".to_string()])
.build();
assert!(!is_capture_input_device(&description));
}
#[test]
#[cfg(target_os = "linux")]
fn is_capture_input_device_rejects_alsa_virtual_pseudo_devices() {
for pseudo_driver in &[
"default",
"pulse",
"sysdefault:CARD=PCH",
"dsnoop:CARD=PCH,DEV=0",
"iec958:CARD=PCH",
] {
let description = DeviceDescriptionBuilder::new("Some Device")
.direction(DeviceDirection::Input)
.driver(*pseudo_driver)
.build();
assert!(
!is_capture_input_device(&description),
"should reject ALSA pseudo-device with driver={pseudo_driver}"
);
}
}
#[test]
#[cfg(target_os = "linux")]
fn is_capture_input_device_accepts_pipewire_mic_device() {
let description = DeviceDescriptionBuilder::new("Built-in Microphone")
.direction(DeviceDirection::Input)
.driver("pipewire")
.build();
assert!(
is_capture_input_device(&description),
"should accept real mic exposed via PipeWire"
);
}
#[test]
#[cfg(target_os = "linux")]
fn is_capture_input_device_accepts_hw_addressed_devices() {
for driver in &["hw:0,0", "hw:1,0", "plughw:0,0"] {
let description = DeviceDescriptionBuilder::new("Built-in Mic")
.direction(DeviceDirection::Input)
.driver(*driver)
.build();
assert!(
is_capture_input_device(&description),
"should accept real device with driver={driver}"
);
}
}
#[test]
#[ignore = "Requires a live microphone/audio input device"]
fn live_audio_input_stream_receives_samples() {
let host = cpal::default_host();
let default_input_id = host
.default_input_device()
.and_then(|device| device.id().ok());
let mut candidates = host
.input_devices()
.expect("Failed to enumerate input devices")
.filter_map(|device| {
let description = device.description().ok()?;
if !is_capture_input_device(&description) {
return None;
}
if device.default_input_config().is_err() {
return None;
}
Some(device)
})
.collect::<Vec<_>>();
if let Some(default_id) = default_input_id {
candidates.sort_by_key(|device| {
if device.id().ok().as_ref() == Some(&default_id) {
0
} else {
1
}
});
}
assert!(
!candidates.is_empty(),
"No usable input device found. Connect/select a microphone and retry."
);
let mut attempts = Vec::new();
for device in candidates {
let label = device
.description()
.map(|description| {
audio_device_label(&device.id().expect("Missing device id"), &description)
})
.unwrap_or_else(|_| "Unknown input device".to_string());
let config = device
.default_input_config()
.expect("Input device has no default input config");
let stream_config = cpal::StreamConfig::from(config);
let got_samples = Arc::new(AtomicBool::new(false));
let got_non_silent_signal = Arc::new(AtomicBool::new(false));
let stream_error = Arc::new(AtomicBool::new(false));
let stream = match config.sample_format() {
cpal::SampleFormat::F32 => {
let got_samples = got_samples.clone();
let got_non_silent_signal = got_non_silent_signal.clone();
let stream_error = stream_error.clone();
device.build_input_stream(
stream_config,
move |data: &[f32], _| {
if !data.is_empty() {
got_samples.store(true, Relaxed);
}
if data.iter().any(|sample| sample.abs() > 0.0) {
got_non_silent_signal.store(true, Relaxed);
}
},
move |_| {
stream_error.store(true, Relaxed);
},
None,
)
}
cpal::SampleFormat::I16 => {
let got_samples = got_samples.clone();
let got_non_silent_signal = got_non_silent_signal.clone();
let stream_error = stream_error.clone();
device.build_input_stream(
stream_config,
move |data: &[i16], _| {
if !data.is_empty() {
got_samples.store(true, Relaxed);
}
if data.iter().any(|sample| *sample != 0) {
got_non_silent_signal.store(true, Relaxed);
}
},
move |_| {
stream_error.store(true, Relaxed);
},
None,
)
}
cpal::SampleFormat::I32 => {
let got_samples = got_samples.clone();
let got_non_silent_signal = got_non_silent_signal.clone();
let stream_error = stream_error.clone();
device.build_input_stream(
stream_config,
move |data: &[i32], _| {
if !data.is_empty() {
got_samples.store(true, Relaxed);
}
if data.iter().any(|sample| *sample != 0) {
got_non_silent_signal.store(true, Relaxed);
}
},
move |_| {
stream_error.store(true, Relaxed);
},
None,
)
}
cpal::SampleFormat::U16 => {
let got_samples = got_samples.clone();
let got_non_silent_signal = got_non_silent_signal.clone();
let stream_error = stream_error.clone();
device.build_input_stream(
stream_config,
move |data: &[u16], _| {
if !data.is_empty() {
got_samples.store(true, Relaxed);
}
if data.iter().any(|sample| *sample != u16::MAX / 2) {
got_non_silent_signal.store(true, Relaxed);
}
},
move |_| {
stream_error.store(true, Relaxed);
},
None,
)
}
other => panic!("Unsupported sample format for test: {other:?}"),
}
.expect("Failed to build audio input stream");
stream.play().expect("Failed to start audio input stream");
let start = Instant::now();
while start.elapsed() < Duration::from_secs(2) {
if stream_error.load(Relaxed) {
attempts.push(format!("{label}: stream error"));
break;
}
if got_samples.load(Relaxed) && got_non_silent_signal.load(Relaxed) {
return;
}
std::thread::sleep(Duration::from_millis(20));
}
if !stream_error.load(Relaxed) {
let state = if !got_samples.load(Relaxed) {
"no samples"
} else {
"samples but silent"
};
attempts.push(format!("{label}: {state}"));
}
}
panic!(
"No input device produced non-silent signal. Device attempts: {}. Ensure microphone permissions are granted, the mic is not muted, and speak/tap near the mic during the test.",
attempts.join(" | ")
);
}
}