Skip to main content

voice_bird_cli/audio/
capture.rs

1//! Thin cpal wrapper that streams interleaved f32 frames from the default
2//! input device over an mpsc channel.
3//!
4//! `cpal::Stream` is `!Send`, so it cannot be moved into a `tokio::spawn`
5//! task. Callers keep the `Stream` on the owning thread (typically the `App`
6//! struct) and only move the `frames_rx` receiver into the async producer
7//! task. [`CaptureHandle::split`] makes this ergonomic.
8use anyhow::{anyhow, Context};
9use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
10use tokio::sync::mpsc;
11
12/// Metadata about the cpal stream: its device-native sample rate and
13/// channel count. Used by the resampler to normalize to 16 kHz mono.
14#[derive(Debug, Clone, Copy)]
15pub struct CaptureInfo {
16    pub sample_rate: u32,
17    pub channels: u16,
18}
19
20/// Keep-alive handle for whatever backend is producing frames. The `App`
21/// pins this to its owning thread (same as a bare `cpal::Stream` used to be)
22/// and dropping it cleanly stops capture.
23///
24/// NOTE: intentionally NOT required to be `Send`. `cpal::Stream` is `!Send`,
25/// so the whole enum is `!Send` by auto-trait inference, which matches what
26/// the `App` expects. The macOS SCK variant's underlying `SCStream` happens
27/// to be `Send` by itself but we don't rely on that.
28pub 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
36/// Returned by [`capture_default_input`] / [`capture_input`] /
37/// [`crate::audio::loopback::capture_loopback`]. Holds the receiver end of
38/// the frames channel plus the live backend keep-alive. Call
39/// [`CaptureHandle::split`] to hand the receiver to an async task while
40/// keeping the keep-alive on the calling (Send-free) thread.
41pub struct CaptureHandle {
42    pub frames_rx: mpsc::Receiver<Vec<f32>>,
43    pub info: CaptureInfo,
44    pub stream: CaptureKeepAlive,
45}
46
47impl CaptureHandle {
48    /// Consume the handle and split it into the `Send` parts (the receiver
49    /// and metadata) and the `!Send` keep-alive, which the caller must keep
50    /// alive on the current thread for capture to continue.
51    pub fn split(self) -> (mpsc::Receiver<Vec<f32>>, CaptureInfo, CaptureKeepAlive) {
52        (self.frames_rx, self.info, self.stream)
53    }
54}
55
56/// Open the default input device, start capturing, and return a handle
57/// whose `frames_rx` yields interleaved f32 frames at the device's native
58/// sample rate and channel count.
59pub fn capture_default_input() -> anyhow::Result<CaptureHandle> {
60    capture_input(None)
61}
62
63/// Open a specific input device by cpal name (or the default if `None`),
64/// start capturing, and return a handle whose `frames_rx` yields interleaved
65/// f32 frames at the device's native sample rate and channel count.
66///
67/// If `name` is `Some` but no device matches, returns an error — callers
68/// can choose to fall back to `capture_input(None)` and surface a banner.
69pub 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}