crates/veilvoice-audio/src/devices.rs

what this file is for · veilvoice-audio · 257 lines · the same file on GitHub

The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.


// SPDX-License-Identifier: GPL-3.0-or-later
//! Enumerating audio devices, and guessing which of them are virtual cables.
//!
//! # What this is for
//!
//! Live scrambling is only useful if the veiled voice can be routed *into*
//! something else -- a call, a stream, a recorder. The way that is done on every
//! desktop platform is a **virtual audio cable**: a driver that presents a
//! playback device on one side and a microphone on the other, so anything that
//! can select a microphone can receive VeilVoice's output.
//!
//! So the list this module produces is not merely a list. Picking the wrong
//! output device is the single most common way for live mode to appear broken
//! while working perfectly, and the whole reason [`DeviceInfo::is_virtual_cable`]
//! exists is to put the right entry in front of the user.
//!
//! # The detection is name matching, and that is a limitation, not an oversight
//!
//! There is **no portable way to ask an audio device whether it is virtual**.
//! CPAL does not expose it because the underlying APIs largely do not either.
//! So [`VIRTUAL_CABLE_HINTS`] matches on name fragments, which means:
//!
//! * a cable this list has never heard of is reported as an ordinary device;
//! * a real device whose name happens to contain "loopback" or "virtual" is
//!   flagged when it should not be.
//!
//! Both are wrong in the harmless direction: the flag reorders and annotates a
//! list, it never restricts what the user may choose. A heuristic that hides
//! options would be a different and worse thing than one that highlights them,
//! and this is deliberately the second.
//!
//! The alternative -- showing an unsorted list of identically named endpoints
//! and letting the user find the right one -- was tried and is worse.
//!
//! # Enumeration can fail, and does
//!
//! Device lists come from the OS and are not stable: a device can disappear
//! between being listed and being opened, a host may have no devices at all,
//! and on Linux a machine with no sound server is entirely normal. Every
//! function here returns a [`crate::Error`] rather than panicking or quietly
//! returning an empty list, because an empty list and a failed query mean very
//! different things to somebody trying to work out why they cannot be heard.
//!
//! # In plain words
//!
//! This asks your computer which microphones and speakers it has, and works out
//! which of them are **virtual cables**.
//!
//! A virtual cable is a small piece of software that pretends to be a speaker on
//! one side and a microphone on the other. It is how a veiled voice gets into a
//! call: VeilVoice plays into the cable, and the calling program picks the cable
//! as its microphone and never knows the difference.
//!
//! Working out which device is a cable is done by recognising the names the common
//! ones use, so it is a good guess rather than a certainty. Nothing depends on the
//! guess being right: it decides which device is *suggested*, never which ones you
//! are allowed to choose.

use crate::Error;
use cpal::traits::{DeviceTrait, HostTrait};


/// Which direction a device carries audio.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Direction {
    /// A capture device (microphone, loopback).
    Input,
    /// A playback device (speakers, virtual cable).
    Output,
}



/// A device the user can choose.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DeviceInfo {
    /// Human-readable device name, as the OS reports it.
    pub name: String,
    /// Whether this is the host's default device for its direction.
    pub is_default: bool,
    /// Whether the name matches a known virtual audio cable.
    pub is_virtual_cable: bool,
}



/// Name fragments used by the common virtual audio cables.
///
/// Routing the veiled voice into one of these is what lets any other
/// application, whether a call, a stream or a recorder, receive it as if it were a
/// microphone. Matching on the name is crude, but there is no portable way to
/// ask an audio device whether it is virtual, and the alternative is making the
/// user hunt through a list of identically-named endpoints.
const VIRTUAL_CABLE_HINTS: &[&str] = &[
    "cable input",  // VB-CABLE (Windows), the one the installer offers
    "cable output", //
    "vb-audio",     // VB-Audio's other products
    "voicemeeter",  //
    "blackhole",    // macOS
    "soundflower",  // macOS, older
    "loopback",     // Rogue Amoeba, and some ALSA setups
    "pulse",        // PulseAudio null sink, commonly named this way
    "virtual",      // generic catch-all, last resort
];



/// Whether a device's name suggests it is a virtual cable rather than
/// real hardware.
///
/// A guess from a name, and treated as one everywhere it is used: it decides
/// what to suggest, never what to refuse.
fn looks_virtual(name: &str) -> bool {
    let lower = name.to_lowercase();
    VIRTUAL_CABLE_HINTS.iter().any(|h| lower.contains(h))
}



/// A device's name as the platform reports it, or `None` when it will not say.
///
/// `cpal` 0.18 replaced `Device::name` with a whole `DeviceDescription`, of
/// which the name is the only field anything here wants. Unwrapping it once
/// keeps that detail in one place rather than at all five call sites.
fn name_of_opt(device: &cpal::Device) -> Option<String> {
    device.description().ok().map(|d| d.name().to_string())
}



/// List the devices available in one direction.
pub fn list(direction: Direction) -> Result<Vec<DeviceInfo>, Error> {
    let host = cpal::default_host();
    let default_name = match direction {
        Direction::Input => host.default_input_device().and_then(|d| name_of_opt(&d)),
        Direction::Output => host.default_output_device().and_then(|d| name_of_opt(&d)),
    };

    let devices: Vec<cpal::Device> = match direction {
        Direction::Input => host
            .input_devices()
            .map_err(|e| Error::Device(e.to_string()))?
            .collect(),
        Direction::Output => host
            .output_devices()
            .map_err(|e| Error::Device(e.to_string()))?
            .collect(),
    };

    Ok(devices
        .into_iter()
        .filter_map(|d| name_of_opt(&d))
        .map(|name| DeviceInfo {
            is_default: Some(&name) == default_name.as_ref(),
            is_virtual_cable: looks_virtual(&name),
            name,
        })
        .collect())
}



/// Find the first output device that looks like a virtual audio cable.
///
/// Returns `None` rather than an error when none is installed: that is a normal
/// state, and the caller should offer to install one rather than fail.
pub fn find_virtual_cable() -> Option<DeviceInfo> {
    list(Direction::Output)
        .ok()?
        .into_iter()
        .find(|d| d.is_virtual_cable)
}



/// The name of an opened device, or a placeholder when the OS will not say.
///
/// Saves every caller from depending on `cpal` just to print a device name.
pub fn name_of(device: &cpal::Device) -> String {
    name_of_opt(device).unwrap_or_else(|| "<unnamed device>".into())
}



/// Look up a device by exact name, or the host default when `name` is `None`.
pub fn open(direction: Direction, name: Option<&str>) -> Result<cpal::Device, Error> {
    let host = cpal::default_host();
    match name {
        None => match direction {
            Direction::Input => host.default_input_device(),
            Direction::Output => host.default_output_device(),
        }
        .ok_or_else(|| Error::Device("no default device".into())),
        Some(wanted) => {
            let mut devices: Box<dyn Iterator<Item = cpal::Device>> = match direction {
                Direction::Input => Box::new(
                    host.input_devices()
                        .map_err(|e| Error::Device(e.to_string()))?,
                ),
                Direction::Output => Box::new(
                    host.output_devices()
                        .map_err(|e| Error::Device(e.to_string()))?,
                ),
            };
            devices
                .find(|d| name_of_opt(d).map(|n| n == wanted).unwrap_or(false))
                .ok_or_else(|| Error::Device(format!("no device named {wanted:?}")))
        }
    }
}


#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn virtual_cable_names_are_recognised() {
        for name in [
            "CABLE Input (VB-Audio Virtual Cable)",
            "VoiceMeeter Aux Input",
            "BlackHole 2ch",
            "Loopback Audio",
        ] {
            assert!(looks_virtual(name), "{name} should be recognised");
        }
    }

    #[test]
    fn ordinary_devices_are_not_mistaken_for_cables() {
        for name in [
            "Speakers (Realtek High Definition Audio)",
            "Headset Earphone",
            "HDMI Output",
        ] {
            assert!(!looks_virtual(name), "{name} should not be flagged");
        }
    }

    #[test]
    fn matching_ignores_case() {
        assert!(looks_virtual("cable input"));
        assert!(looks_virtual("CABLE INPUT"));
        assert!(looks_virtual("Cable Input"));
    }

    /// Enumeration must not panic or hang on a machine with no sound hardware,
    /// which is exactly what CI runners look like.
    #[test]
    fn enumeration_is_safe_without_audio_hardware() {
        for direction in [Direction::Input, Direction::Output] {
            match list(direction) {
                Ok(devices) => {
                    assert!(devices.iter().filter(|d| d.is_default).count() <= 1);
                }
                Err(Error::Device(_)) => {} // headless runner: acceptable
                Err(e) => panic!("unexpected error: {e}"),
            }
        }
    }

    #[test]
    fn missing_virtual_cable_is_not_an_error() {
        let _ = find_virtual_cable();
    }

    #[test]
    fn opening_an_unknown_device_reports_its_name() {
        match open(Direction::Output, Some("definitely-not-a-real-device")) {
            Err(Error::Device(msg)) => assert!(msg.contains("definitely-not-a-real-device")),
            Err(e) => panic!("unexpected error: {e}"),
            Ok(_) => panic!("a nonexistent device should not open"),
        }
    }
}