murmur_core/audio/
system_capture.rs1use 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
12pub struct AudioDevice {
13 pub name: String,
14 pub is_loopback_hint: bool,
17}
18
19const 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
35pub 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
64pub 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 pub fn new(device_name: &str) -> Result<Self> {
81 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 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 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 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 pub fn is_running(&self) -> bool {
164 self.running.load(Ordering::SeqCst)
165 }
166
167 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] fn test_list_audio_devices_returns_vec() {
212 let devices = list_audio_devices();
213 let _ = devices;
214 }
215
216 #[test]
217 #[ignore] fn test_capturer_rejects_nonexistent_device() {
219 let result = SystemAudioCapturer::new("__nonexistent_device_12345__");
220 assert!(result.is_err());
221 }
222}