voice_bird_cli/audio/
capture.rs1use anyhow::{anyhow, Context};
9use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
10use tokio::sync::mpsc;
11
12#[derive(Debug, Clone, Copy)]
15pub struct CaptureInfo {
16 pub sample_rate: u32,
17 pub channels: u16,
18}
19
20pub enum CaptureKeepAlive {
29 Cpal(cpal::Stream),
30 #[cfg(target_os = "macos")]
31 Sck(crate::audio::loopback::loopback_macos::LoopbackKeepAlive),
32 #[cfg(target_os = "windows")]
33 Wasapi(crate::audio::loopback::loopback_windows::WasapiKeepAlive),
34}
35
36pub struct CaptureHandle {
42 pub frames_rx: mpsc::Receiver<Vec<f32>>,
43 pub info: CaptureInfo,
44 pub stream: CaptureKeepAlive,
45}
46
47impl CaptureHandle {
48 pub fn split(self) -> (mpsc::Receiver<Vec<f32>>, CaptureInfo, CaptureKeepAlive) {
52 (self.frames_rx, self.info, self.stream)
53 }
54}
55
56pub fn capture_default_input() -> anyhow::Result<CaptureHandle> {
60 capture_input(None)
61}
62
63pub fn capture_input(name: Option<&str>) -> anyhow::Result<CaptureHandle> {
70 let host = cpal::default_host();
71 let device = match name {
72 None => host
73 .default_input_device()
74 .ok_or_else(|| anyhow!("no default input device"))?,
75 Some(want) => {
76 let mut found = None;
77 if let Ok(devices) = host.input_devices() {
78 for d in devices {
79 if matches!(d.name(), Ok(n) if n == want) {
80 found = Some(d);
81 break;
82 }
83 }
84 }
85 found.ok_or_else(|| anyhow!("input device not found: {want}"))?
86 }
87 };
88 let config = device
89 .default_input_config()
90 .context("default input config")?;
91 let sample_rate = config.sample_rate().0;
92 let channels = config.channels();
93 let format = config.sample_format();
94
95 let (tx, rx) = mpsc::channel::<Vec<f32>>(64);
96
97 let err_fn = |e| log::error!("cpal stream error: {e}");
98
99 let stream = match format {
100 cpal::SampleFormat::F32 => {
101 let tx = tx.clone();
102 device.build_input_stream(
103 &config.into(),
104 move |data: &[f32], _: &cpal::InputCallbackInfo| {
105 let _ = tx.blocking_send(data.to_vec());
106 },
107 err_fn,
108 None,
109 )
110 }
111 cpal::SampleFormat::I16 => {
112 let tx = tx.clone();
113 device.build_input_stream(
114 &config.into(),
115 move |data: &[i16], _: &cpal::InputCallbackInfo| {
116 let v: Vec<f32> = data.iter().map(|s| *s as f32 / i16::MAX as f32).collect();
117 let _ = tx.blocking_send(v);
118 },
119 err_fn,
120 None,
121 )
122 }
123 cpal::SampleFormat::U16 => {
124 let tx = tx.clone();
125 device.build_input_stream(
126 &config.into(),
127 move |data: &[u16], _: &cpal::InputCallbackInfo| {
128 let v: Vec<f32> = data
129 .iter()
130 .map(|s| (*s as f32 - 32768.0) / 32768.0)
131 .collect();
132 let _ = tx.blocking_send(v);
133 },
134 err_fn,
135 None,
136 )
137 }
138 f => return Err(anyhow!("unsupported sample format: {f:?}")),
139 }
140 .context("build input stream")?;
141
142 stream.play().context("stream.play()")?;
143
144 Ok(CaptureHandle {
145 frames_rx: rx,
146 info: CaptureInfo {
147 sample_rate,
148 channels,
149 },
150 stream: CaptureKeepAlive::Cpal(stream),
151 })
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn capture_input_with_unknown_name_returns_err() {
160 match capture_input(Some("___definitely_not_a_real_device___")) {
161 Ok(_) => panic!("expected Err for bogus device name"),
162 Err(e) => {
163 let msg = format!("{e}");
164 assert!(
165 msg.contains("not found"),
166 "error message should mention 'not found', got: {msg}"
167 );
168 }
169 }
170 }
171}