//! PCM16 mono capture via cpal for streaming STT. //! //! Prefers a device input config that natively supports the target rate (16 kHz) //! so no resampling is needed; otherwise it uses the device default (typically //! 48 kHz stereo F32 on macOS) and downmixes + resamples to 16 kHz mono for the //! STT API. cpal streams are not `Send` on all platforms; capture runs on a //! dedicated std thread and forwards PCM chunks through a sync channel. use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::mpsc::TrySendError; use std::thread::{self, JoinHandle}; use std::time::Duration; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{FromSample, Sample, SampleFormat, SizedSample}; use tokio::sync::mpsc as async_mpsc; use crate::error::VoiceError; /// Stop handle for the cpal input stream (owned by a background thread). pub struct CaptureHandle { stop: Arc, thread: Option>, bridge: tokio::task::JoinHandle<()>, } impl CaptureHandle { /// Stop capture and wait for the thread to exit. /// /// Dropping a `CaptureHandle` also stops capture (see the `Drop` impl), but /// without joining; call `stop()` when you need to be sure the device is /// released before continuing. pub fn stop(mut self) { self.stop.store(true, Ordering::Release); if let Some(thread) = self.thread.take() { let _ = thread.join(); } // `Drop` runs next and aborts the bridge task. } } impl Drop for CaptureHandle { fn drop(&mut self) { // Always signal the capture thread to exit so the mic is released even // when `stop()` was never called — e.g. the STT session ended on its // own (server close / error) or the pipeline shut down mid-utterance. // The thread observes the flag within one poll interval and exits, // dropping the cpal stream. We deliberately do not join here so `Drop` // never blocks (it may run on an async executor). self.stop.store(true, Ordering::Release); self.bridge.abort(); } } /// Spawn cpal capture; PCM16 LE chunks are forwarded to `pcm_tx`. pub fn spawn_pcm_capture( sample_rate: u32, pcm_tx: async_mpsc::Sender>, ) -> Result { let (sync_tx, sync_rx) = std::sync::mpsc::sync_channel::>(64); // Bridge the cpal callback's std sync channel to the async STT sender. The // `recv()` blocks between audio chunks for the whole session, so it runs on // the blocking pool (via `spawn_blocking` + `blocking_send`) instead of a // core runtime worker — parking a worker here would shrink executor capacity // under pager load. The loop exits on its own when capture stops (sync_tx is // dropped) or the STT consumer goes away (`blocking_send` errors), so the // `abort()` in `CaptureHandle`'s teardown is only a backstop. let bridge = tokio::task::spawn_blocking(move || { while let Ok(bytes) = sync_rx.recv() { if pcm_tx.blocking_send(bytes).is_err() { break; } } }); let stop = Arc::new(AtomicBool::new(false)); let stop_flag = Arc::clone(&stop); let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::>(1); let thread = thread::spawn(move || { run_capture_loop(sample_rate, sync_tx, stop_flag, ready_tx); }); // Wait briefly for the device to actually open (mirrors the STT // `wait_ready` handshake) so device/permission failures propagate to the // caller — and on to a `VoiceEvent::Error` toast — instead of leaving the // session "listening" with no audio. match ready_rx.recv_timeout(Duration::from_secs(5)) { Ok(Ok(())) => {} Ok(Err(e)) => { let _ = thread.join(); bridge.abort(); return Err(e); } Err(_) => { stop.store(true, Ordering::Release); let _ = thread.join(); bridge.abort(); return Err(VoiceError::Config( "voice capture did not start within 5s".into(), )); } } Ok(CaptureHandle { stop, thread: Some(thread), bridge, }) } /// Record mono PCM16 LE for a fixed duration (probe / diagnostics). pub fn capture_pcm_for_duration( sample_rate: u32, seconds: u32, ) -> Result<(Vec, u32), VoiceError> { let (sync_tx, sync_rx) = std::sync::mpsc::sync_channel::>(256); let stop = Arc::new(AtomicBool::new(false)); let stop_flag = Arc::clone(&stop); let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::>(1); let thread = thread::spawn(move || { run_capture_loop(sample_rate, sync_tx, stop_flag, ready_tx); }); // Surface device-open failures before recording instead of returning empty. match ready_rx.recv_timeout(Duration::from_secs(2)) { Ok(Ok(())) => {} Ok(Err(e)) => { let _ = thread.join(); return Err(e); } Err(_) => { stop.store(true, Ordering::Release); let _ = thread.join(); return Err(VoiceError::Config( "voice capture did not start within 2s".into(), )); } } thread::sleep(Duration::from_secs(seconds.max(1) as u64)); stop.store(true, Ordering::Release); let _ = thread.join(); let mut pcm = Vec::new(); let mut chunks = 0u32; while let Ok(chunk) = sync_rx.try_recv() { chunks += 1; pcm.extend_from_slice(&chunk); } Ok((pcm, chunks)) } struct CaptureStreamParams<'a> { device: &'a cpal::Device, stream_config: cpal::StreamConfig, in_channels: u16, stream_rate: u32, target_rate: u32, sync_tx: std::sync::mpsc::SyncSender>, stop: Arc, /// Count of PCM chunks dropped because the channel was full. Logged off the /// audio thread by `run_capture_loop`. dropped: Arc, } fn run_capture_loop( sample_rate: u32, sync_tx: std::sync::mpsc::SyncSender>, stop: Arc, ready_tx: std::sync::mpsc::SyncSender>, ) { let dropped = Arc::new(AtomicUsize::new(0)); // Open the device first and report success/failure to the caller (the // capture-side equivalent of the STT `wait_ready` handshake) so that // device/permission errors surface as a `VoiceError` instead of being // logged silently here. let (stream, device_name) = match open_capture_stream( sample_rate, sync_tx, Arc::clone(&stop), Arc::clone(&dropped), ) { Ok(v) => { let _ = ready_tx.send(Ok(())); v } Err(e) => { tracing::warn!(error = %e, "voice capture failed to start"); let _ = ready_tx.send(Err(e)); return; } }; run_capture_poll_loop(stream, stop, dropped, device_name); } /// Open the input device, build, and start the cpal capture stream. All /// device/config/permission failures surface here as a `VoiceError` so the /// caller can report them before entering the steady-state loop. fn open_capture_stream( sample_rate: u32, sync_tx: std::sync::mpsc::SyncSender>, stop: Arc, dropped: Arc, ) -> Result<(cpal::Stream, String), VoiceError> { let host = cpal::default_host(); let device = host .default_input_device() .ok_or_else(|| VoiceError::Config("no default input audio device".into()))?; let device_name = device.name().unwrap_or_else(|_| "".to_string()); let default_config = device.default_input_config().map_err(|e| { VoiceError::Config(format!( "default input config for {device_name}: {e} (grant mic permission in System Settings)" )) })?; // Prefer a device-native `sample_rate` (e.g. a mic that supports 16 kHz // directly) so we can skip resampling entirely; fall back to the device // default and the linear resampler when no native config matches. let supported = native_rate_config(&device, sample_rate).unwrap_or(default_config); let stream_rate = supported.sample_rate().0; let in_channels = supported.channels(); let sample_format = supported.sample_format(); let stream_config: cpal::StreamConfig = supported.into(); tracing::info!( device = %device_name, stream_rate, channels = in_channels, ?sample_format, target_rate = sample_rate, "voice capture stream" ); let params = CaptureStreamParams { device: &device, stream_config, in_channels, stream_rate, target_rate: sample_rate, sync_tx, stop, dropped, }; let stream = match sample_format { SampleFormat::F32 => build_capture_stream::(params)?, SampleFormat::F64 => build_capture_stream::(params)?, SampleFormat::I8 => build_capture_stream::(params)?, SampleFormat::I16 => build_capture_stream::(params)?, SampleFormat::I32 => build_capture_stream::(params)?, SampleFormat::I64 => build_capture_stream::(params)?, SampleFormat::U8 => build_capture_stream::(params)?, SampleFormat::U16 => build_capture_stream::(params)?, SampleFormat::U32 => build_capture_stream::(params)?, SampleFormat::U64 => build_capture_stream::(params)?, other => { return Err(VoiceError::Config(format!( "unsupported input sample format {other:?} on {device_name} \ (supported: f32/f64/i8/i16/i32/i64/u8/u16/u32/u64)" ))); } }; stream .play() .map_err(|e| VoiceError::Config(format!("play input stream: {e}")))?; Ok((stream, device_name)) } /// Steady-state loop: wait for shutdown and report dropped frames off the /// real-time audio thread (logging here keeps the callback allocation/lock-free). fn run_capture_poll_loop( stream: cpal::Stream, stop: Arc, dropped: Arc, device_name: String, ) { let mut last_reported = 0usize; let mut ticks: u32 = 0; while !stop.load(Ordering::Acquire) { thread::sleep(Duration::from_millis(50)); ticks += 1; // ~once per second if ticks.is_multiple_of(20) { let total = dropped.load(Ordering::Relaxed); if total > last_reported { tracing::warn!( device = %device_name, dropped_total = total, dropped_since_last = total - last_reported, "voice capture dropping PCM chunks (consumer not keeping up)" ); last_reported = total; } } } drop(stream); let total = dropped.load(Ordering::Relaxed); if total > 0 { tracing::warn!( device = %device_name, dropped_total = total, "voice capture finished with dropped PCM chunks" ); } } /// Find a supported input config whose range includes `target_rate`, so capture /// runs at the STT rate with no resampling. Prefers a config matching the /// device's default sample format; returns `None` when nothing matches. fn native_rate_config( device: &cpal::Device, target_rate: u32, ) -> Option { let target = cpal::SampleRate(target_rate); let preferred_format = device .default_input_config() .ok() .map(|c| c.sample_format()); let configs: Vec<_> = device.supported_input_configs().ok()?.collect(); let contains_target = |c: &cpal::SupportedStreamConfigRange| { c.min_sample_rate() <= target && target <= c.max_sample_rate() }; configs .iter() .find(|c| contains_target(c) && Some(c.sample_format()) == preferred_format) .or_else(|| configs.iter().find(|c| contains_target(c))) .map(|c| c.with_sample_rate(target)) } fn build_capture_stream(params: CaptureStreamParams<'_>) -> Result where T: Sample + SizedSample, i16: FromSample, { let CaptureStreamParams { device, stream_config, in_channels, stream_rate, target_rate, sync_tx, stop, dropped, } = params; let channels = in_channels as usize; let stop_cb = Arc::clone(&stop); let stream = device .build_input_stream( &stream_config, move |data: &[T], _: &cpal::InputCallbackInfo| { if stop_cb.load(Ordering::Acquire) { return; } let mono = frames_to_mono_i16(data, channels); let pcm = if stream_rate == target_rate { mono } else { resample_mono_i16(&mono, stream_rate, target_rate) }; if pcm.is_empty() { return; } let bytes: Vec = pcm.iter().flat_map(|s| s.to_le_bytes()).collect(); // Never block the real-time audio thread: shed load if the // consumer is behind. Dropped chunks are counted and logged by // `run_capture_loop`. match sync_tx.try_send(bytes) { Ok(()) => {} Err(TrySendError::Full(_)) => { dropped.fetch_add(1, Ordering::Relaxed); } Err(TrySendError::Disconnected(_)) => {} } }, |err| { tracing::warn!(error = %err, "voice capture stream error"); }, None, ) .map_err(|e| VoiceError::Config(format!("build input stream: {e}")))?; Ok(stream) } fn frames_to_mono_i16(data: &[T], channels: usize) -> Vec where T: Sample, i16: FromSample, { if channels == 0 { return Vec::new(); } if channels == 1 { return data.iter().map(|s| i16::from_sample(*s)).collect(); } let mut mono = Vec::with_capacity(data.len() / channels); for frame in data.chunks_exact(channels) { let mut sum: i32 = 0; for sample in frame { sum += i16::from_sample(*sample) as i32; } let avg = (sum / channels as i32).clamp(i16::MIN as i32, i16::MAX as i32); mono.push(avg as i16); } mono } fn resample_mono_i16(samples: &[i16], input_rate: u32, output_rate: u32) -> Vec { if samples.is_empty() || input_rate == 0 || output_rate == 0 { return Vec::new(); } if input_rate == output_rate { return samples.to_vec(); } let output_len = ((samples.len() as u64 * output_rate as u64) / input_rate as u64).max(1) as usize; let step = input_rate as f64 / output_rate as f64; let mut output = Vec::with_capacity(output_len); for i in 0..output_len { let src_pos = i as f64 * step; let idx = src_pos.floor() as usize; let frac = src_pos - idx as f64; let s0 = samples[idx] as f64; let s1 = *samples.get(idx + 1).unwrap_or(&samples[idx]) as f64; let sample = s0 + (s1 - s0) * frac; let clamped = sample.round().max(i16::MIN as f64).min(i16::MAX as f64); output.push(clamped as i16); } output } #[cfg(test)] mod tests { use super::*; #[test] fn resample_halves_rate() { let input: Vec = (0..48).map(|i| (i * 100) as i16).collect(); let out = resample_mono_i16(&input, 48_000, 16_000); assert_eq!(out.len(), 16); } #[test] fn downmix_stereo_to_mono() { let stereo = [i16::MAX, i16::MIN]; let mono = frames_to_mono_i16(&stereo, 2); assert_eq!(mono.len(), 1); assert_eq!(mono[0], 0); } }