tauri-plugin-audio 0.1.1

Desktop audio capture plugin for Tauri
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,
        }
    }
}