gled-core 2.32.1

The engine of gled without a user interface: inputs, timing, rendering, Art-Net/DMX output and storage
Documentation
//! Which audio devices can capture sound: real inputs, not monitors of an
//! output or ALSA's virtual pseudo-devices.

use cpal::{
    DeviceDescription, DeviceId,
    traits::{DeviceTrait, HostTrait},
};

/// The audio devices sound can be captured from, as cpal sees them now.
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;
    }

    // On Linux the driver field can identify pseudo-devices.
    // Filter common ALSA virtual/plugin sources while keeping PipeWire,
    // which can represent real microphones on modern Linux desktops.
    #[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(" | ")
        );
    }
}