soundview 0.3.0

Live analyzer/voiceprint visualization of system audio
Documentation
use anyhow::{anyhow, bail, Context, Result};
use regex::Regex;
use tracing::{debug, error, info, warn};

use sdl2::audio::{AudioCallback, AudioDevice, AudioSpecDesired};
use sdl2::AudioSubsystem;

use std::vec::Vec;

/// Assigns the specified SDL hint value
fn set_hint(name: &str) {
    let before = sdl2::hint::get(name);
    sdl2::hint::set(name, "1");
    debug!("{}: {:?} => {:?}", name, before, sdl2::hint::get(name));
}

/// Sets up the SDL audio subsystem. This can only be called once per process.
/// The result should be passed to a new Recorder instance.
pub fn init_audio(sdl_context: &sdl2::Sdl) -> Result<AudioSubsystem> {
    if cfg!(unix) {
        // Complain if the library doesn't support SDL_AUDIO_INCLUDE_MONITORS
        let sdl_version = sdl2::version::version();
        if sdl_version.minor == 0 && sdl_version.patch < 16 {
            warn!("SDL 2.0.16 (Aug 2021) or greater is required to visualize playing audio with PulseAudio");
        }
    }

    // Enables PulseAudio "monitor" devices in the list.
    // These allow directly visualizing audio that's playing on the local system.
    // Without this we can only visualize mic/line-in, or manually-created loopback devices.
    // This option is implemented as of SDL 2.0.16 (Aug 2021) and newer.
    // On platforms without PulseAudio, this is currently a no-op but might be relevant in the future.
    set_hint("SDL_AUDIO_INCLUDE_MONITORS");

    sdl_context.audio().map_err(|e| anyhow!(e))
}

/// Audio callback that receives device audio and forwards it to a channel for processing.
/// This runs on its own thread and must be Send.
struct Callback {
    first_sample: bool,
    audio_out: crossbeam_channel::Sender<Vec<f32>>,
}

impl AudioCallback for Callback {
    type Channel = f32;

    fn callback(&mut self, out: &mut [f32]) {
        if self.first_sample {
            // Before sending our first sample for this new device,
            // send an empty vector as a signal/hack that the levels may have changed.
            // We do this WITHIN the callback to avoid weird races from sending
            // this signal in the middle of a prior device still recording.
            // However, in practice this may not work anyway if there's a signal spike when switching devices.
            if let Err(e) = self.audio_out.send(Vec::new()) {
                error!("Failed to send empty buffer: {}", e);
            }
            self.first_sample = false;
        }

        if let Err(e) = self.audio_out.send(Vec::from(out)) {
            // This implies that the fourier thread has panicked
            // (A full queue would just result in a block)
            error!("Failed to send audio: {}", e);
        }
    }
}

/// Handles audio sampling from a single device at a time.
/// Includes support for switching between multiple recording devices.
pub struct Recorder {
    audio_subsystem: AudioSubsystem,
    freq: Option<i32>,
    samples: Option<u16>,
    audio_out: Option<crossbeam_channel::Sender<Vec<f32>>>,
    rec_dev: Option<AudioDevice<Callback>>,
    rec_dev_name: Option<String>,
}

struct Devices {
    device_names: Vec<String>,
    cur_device_idx: Option<usize>,
}

impl Recorder {
    /// Sets up a new stopped recorder which will send [0.0, 1.0] scaled audio data to the provided
    /// output channel. Whenever the device changes, an empty vector will be sent to the output.
    pub fn new(
        audio_subsystem: AudioSubsystem,
        freq: Option<i32>,
        samples: Option<u16>,
        audio_out: crossbeam_channel::Sender<Vec<f32>>,
    ) -> Recorder {
        Recorder {
            audio_subsystem,
            freq,
            samples,
            audio_out: Some(audio_out),
            rec_dev: Option::None,
            rec_dev_name: Option::None,
        }
    }

    /// Picks a reasonable initial device and starts recording from it on a separate thread.
    /// The provided user_filter is used for selecting the initial device, but not for
    /// switching devices later. An error is returned if user_filter doesn't match any devices.
    pub fn autoselect_start(self: &mut Recorder, user_filter: Option<Regex>) -> Result<()> {
        let mut capture_devices = self.list_capture_devices()?.device_names;
        if capture_devices.is_empty() {
            bail!("No capture devices were found");
        }

        if let Some(user_filter) = user_filter {
            // Treat user filter as a hard requirement for the initial device (but not for selecting next/prev later)
            let filtered_capture_devices = Vec::from_iter(
                capture_devices
                    .iter()
                    .filter(|device_name| user_filter.is_match(device_name)),
            );
            if filtered_capture_devices.is_empty() {
                bail!(
                    "No capture devices matched filter={}: {:?}",
                    user_filter,
                    capture_devices
                );
            }
            capture_devices =
                Vec::from_iter(filtered_capture_devices.into_iter().map(|v| v.to_string()));
        }

        self.record(select_reasonable_device(capture_devices).as_str())
    }

    /// Stops recording the current device and starts recording from a previous device.
    pub fn prev_device(self: &mut Recorder) -> Result<()> {
        let devices = self.list_capture_devices()?;
        if devices.device_names.is_empty() {
            bail!("No capture devices were found");
        }

        let prev_idx = match &devices.cur_device_idx {
            Some(cur_idx) => {
                if *cur_idx == 0 {
                    // wrap around to last item
                    devices.device_names.len() - 1
                } else {
                    cur_idx - 1
                }
            }
            None => 0,
        };

        // unwrap: prev_idx was bounds-checked against device_names
        self.record(devices.device_names.get(prev_idx).unwrap())
    }

    /// Stops recording the current device and starts recording from a next device.
    pub fn next_device(self: &mut Recorder) -> Result<()> {
        let devices = self.list_capture_devices()?;
        if devices.device_names.is_empty() {
            bail!("No capture devices were found");
        }

        let next_idx = match &devices.cur_device_idx {
            Some(cur_idx) => {
                if *cur_idx + 1 >= devices.device_names.len() {
                    // wrap around to first item
                    0
                } else {
                    cur_idx + 1
                }
            }
            None => 0,
        };

        // unwrap: next_idx was bounds-checked against device_names
        self.record(devices.device_names.get(next_idx).unwrap())
    }

    /// Stops recording.
    pub fn stop(self: &mut Recorder) {
        // Dropping audio_out eventually stops the fourier thread
        self.audio_out = None;
        // Dropping rec_dev stops recording (and drops its separate copy of audio_out)
        self.rec_dev = None;
    }

    /// Returns a list of available capture devices.
    fn list_capture_devices(self: &Recorder) -> Result<Devices> {
        let device_count = match self.audio_subsystem.num_audio_capture_devices() {
            Some(device_count) => device_count,
            None => bail!("Couldn't get device count"),
        };
        let mut device_names = Vec::new();
        for device_idx in 0..device_count {
            // this effectively filters for devices with capture support
            if let Ok(device_name) = self.audio_subsystem.audio_capture_device_name(device_idx) {
                device_names.push(device_name);
            }
        }

        let cur_device_idx = match &self.rec_dev_name {
            Some(cur_device_name) => device_names.iter().position(|dev| dev.eq(cur_device_name)),
            None => None,
        };

        debug!("found capture devices: {:?}", device_names);
        Ok(Devices {
            device_names,
            cur_device_idx,
        })
    }

    /// Starts recording on the specified device. Any existing recording is automatically stopped.
    fn record(self: &mut Recorder, device_name: &str) -> Result<()> {
        let desired_spec = AudioSpecDesired {
            freq: self.freq.clone(),
            channels: Some(1),
            samples: self.samples.clone(),
        };

        if let Some(audio_out_cpy) = self.audio_out.clone() {
            self.rec_dev_name = None;
            self.rec_dev = None;

            let rec_dev = self
                .audio_subsystem
                .open_capture(device_name, &desired_spec, |actual_spec| {
                    info!("Capturing audio from: {}", device_name);
                    debug!("Audio spec: {:?}", actual_spec);
                    // Initialize the audio callback, which will be invoked on a separate thread
                    Callback {
                        first_sample: true,
                        audio_out: audio_out_cpy,
                    }
                })
                .map_err(|e| anyhow!(e))
                .with_context(|| {
                    format!("Failed to open audio capture device: '{}'", device_name)
                })?;

            // Start playback
            rec_dev.resume();

            // Save rec_dev so that it doesn't get closed automatically
            self.rec_dev_name = Some(device_name.to_string());
            self.rec_dev = Some(rec_dev);
            Ok(())
        } else {
            bail!("Audio output is missing, process shutting down?");
        }
    }
}

fn select_reasonable_device(mut capture_devices: Vec<String>) -> String {
    // Try selecting any PulseAudio/Pipewire "monitor" devices if any are available.
    if capture_devices.len() > 1 {
        let monitor_filter = Regex::new("^Monitor of.*").unwrap();
        let monitor_devices = Vec::from_iter(
            capture_devices
                .iter()
                .filter(|device_name| monitor_filter.is_match(device_name))
                .map(|device_name| device_name.clone()),
        );
        if !monitor_devices.is_empty() {
            debug!("monitor devices: {:?}", monitor_devices);
            capture_devices = monitor_devices;
        }
    }

    // Try filtering out any video card "HDMI" devices. Who uses those anyway?
    if capture_devices.len() > 1 {
        let nonhdmi_avoid = Regex::new(".*HDMI.*").unwrap();
        let nonhdmi_devices = Vec::from_iter(
            capture_devices
                .iter()
                .filter(|device_name| !nonhdmi_avoid.is_match(device_name))
                .map(|device_name| device_name.clone()),
        );
        if !nonhdmi_devices.is_empty() {
            debug!("non-hdmi devices: {:?}", nonhdmi_devices);
            capture_devices = nonhdmi_devices;
        }
    }

    // Pick first from whatever passed the above filters
    match capture_devices.first() {
        Some(device) => device.clone(),
        // Should not be empty: checked is_empty earlier
        None => capture_devices.first().unwrap().clone(),
    }
}