Skip to main content

murmur_core/audio/
system_capture.rs

1use anyhow::{Context, Result};
2use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
3use log::{info, warn};
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, Mutex};
6
7use super::capture::{mix_to_mono, resample};
8use super::TARGET_RATE;
9
10/// Metadata for an available audio input device.
11#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
12pub struct AudioDevice {
13    pub name: String,
14    /// True when the device name suggests it carries system/loopback audio
15    /// (e.g. BlackHole, Loopback, Soundflower).
16    pub is_loopback_hint: bool,
17}
18
19/// Well-known virtual audio device names that typically carry system output.
20const LOOPBACK_HINTS: &[&str] = &[
21    "blackhole",
22    "loopback",
23    "soundflower",
24    "virtual",
25    "aggregate",
26    "stereo mix",
27    "what u hear",
28];
29
30fn looks_like_loopback(name: &str) -> bool {
31    let lower = name.to_lowercase();
32    LOOPBACK_HINTS.iter().any(|hint| lower.contains(hint))
33}
34
35/// Lists available audio input devices on the system.
36///
37/// On macOS, virtual audio devices such as BlackHole or Loopback appear as
38/// regular input devices and can capture system audio output when configured
39/// as the system's audio output (or via an aggregate device).
40pub fn list_audio_devices() -> Vec<AudioDevice> {
41    let host = cpal::default_host();
42    let mut devices = Vec::new();
43
44    match host.input_devices() {
45        Ok(iter) => {
46            for device in iter {
47                if let Ok(desc) = device.description() {
48                    let name = desc.name().to_string();
49                    devices.push(AudioDevice {
50                        is_loopback_hint: looks_like_loopback(&name),
51                        name,
52                    });
53                }
54            }
55        }
56        Err(e) => {
57            warn!("failed to enumerate input devices: {e}");
58        }
59    }
60
61    devices
62}
63
64/// Captures audio from a named input device, resampling to 16 kHz mono f32.
65///
66/// Designed for system audio capture: the caller selects a virtual audio
67/// device (e.g. BlackHole 2ch) that mirrors the system output, and this
68/// capturer records from it just like a microphone.
69pub struct SystemAudioCapturer {
70    device_name: String,
71    stream: Option<cpal::Stream>,
72    samples: Arc<Mutex<Vec<f32>>>,
73    running: Arc<AtomicBool>,
74}
75
76impl SystemAudioCapturer {
77    /// Create a new capturer targeting the input device with the given name.
78    ///
79    /// The device is not opened until [`start`] is called.
80    pub fn new(device_name: &str) -> Result<Self> {
81        // Validate that the device exists.
82        let host = cpal::default_host();
83        let _device = find_device_by_name(&host, device_name)
84            .with_context(|| format!("audio device not found: {device_name}"))?;
85
86        Ok(Self {
87            device_name: device_name.to_string(),
88            stream: None,
89            samples: Arc::new(Mutex::new(Vec::new())),
90            running: Arc::new(AtomicBool::new(false)),
91        })
92    }
93
94    /// Start capturing audio. Returns a handle to the shared sample buffer
95    /// that accumulates 16 kHz mono f32 samples.
96    pub fn start(&mut self) -> Result<Arc<Mutex<Vec<f32>>>> {
97        if self.running.load(Ordering::SeqCst) {
98            anyhow::bail!("system audio capture already running");
99        }
100
101        let host = cpal::default_host();
102        let device =
103            find_device_by_name(&host, &self.device_name).context("audio device disappeared")?;
104
105        let supported_config = device
106            .default_input_config()
107            .context("no supported input config for device")?;
108
109        let source_rate = supported_config.sample_rate();
110        let source_channels = supported_config.channels() as u32;
111        let config: cpal::StreamConfig = supported_config.into();
112
113        let samples = Arc::clone(&self.samples);
114        let running = Arc::clone(&self.running);
115
116        // Clear any samples from a previous session.
117        samples.lock().unwrap().clear();
118
119        let stream = device
120            .build_input_stream(
121                &config,
122                move |data: &[f32], _: &cpal::InputCallbackInfo| {
123                    if !running.load(Ordering::Relaxed) {
124                        return;
125                    }
126                    let mono = mix_to_mono(data, source_channels);
127                    let resampled = if source_rate != TARGET_RATE {
128                        resample(&mono, source_rate, TARGET_RATE)
129                    } else {
130                        mono
131                    };
132                    if let Ok(mut buf) = samples.try_lock() {
133                        buf.extend_from_slice(&resampled);
134                    }
135                },
136                |err| {
137                    warn!("system audio capture error: {err}");
138                },
139                None,
140            )
141            .context("failed to build input stream for system audio")?;
142
143        stream
144            .play()
145            .context("failed to start system audio stream")?;
146        self.running.store(true, Ordering::SeqCst);
147        self.stream = Some(stream);
148
149        info!("system audio capture started on '{}'", self.device_name);
150        Ok(Arc::clone(&self.samples))
151    }
152
153    /// Stop capturing.
154    pub fn stop(&mut self) {
155        self.running.store(false, Ordering::SeqCst);
156        if let Some(stream) = self.stream.take() {
157            drop(stream);
158        }
159        info!("system audio capture stopped");
160    }
161
162    /// Whether the capturer is actively recording.
163    pub fn is_running(&self) -> bool {
164        self.running.load(Ordering::SeqCst)
165    }
166
167    /// The name of the target device.
168    pub fn device_name(&self) -> &str {
169        &self.device_name
170    }
171}
172
173impl Drop for SystemAudioCapturer {
174    fn drop(&mut self) {
175        self.stop();
176    }
177}
178
179fn find_device_by_name(host: &cpal::Host, name: &str) -> Result<cpal::Device> {
180    let devices = host
181        .input_devices()
182        .context("failed to enumerate input devices")?;
183
184    for device in devices {
185        if let Ok(desc) = device.description() {
186            if desc.name() == name {
187                return Ok(device);
188            }
189        }
190    }
191
192    anyhow::bail!("input device '{}' not found", name)
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn test_loopback_hint_detection() {
201        assert!(looks_like_loopback("BlackHole 2ch"));
202        assert!(looks_like_loopback("Loopback Audio"));
203        assert!(looks_like_loopback("Soundflower (2ch)"));
204        assert!(looks_like_loopback("Virtual Audio Device"));
205        assert!(!looks_like_loopback("Built-in Microphone"));
206        assert!(!looks_like_loopback("USB Audio Headset"));
207    }
208
209    #[test]
210    #[ignore] // Requires audio hardware; segfaults on headless CI (Windows)
211    fn test_list_audio_devices_returns_vec() {
212        let devices = list_audio_devices();
213        let _ = devices;
214    }
215
216    #[test]
217    #[ignore] // Requires audio hardware; segfaults on headless CI (Windows)
218    fn test_capturer_rejects_nonexistent_device() {
219        let result = SystemAudioCapturer::new("__nonexistent_device_12345__");
220        assert!(result.is_err());
221    }
222}