//! Audio capture: open the default input device, record N seconds of //! 16-bit mono PCM, write to a WAV file. use std::path::Path; use std::sync::{Arc, Mutex}; use std::time::Duration; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{Sample, SampleFormat}; use hound::{SampleFormat as HoundSampleFormat, WavSpec, WavWriter}; use thiserror::Error; use tracing::{info, warn}; #[derive(Debug, Error)] pub enum AudioError { #[error("no input device available")] NoInputDevice, #[error("no output device available")] NoOutputDevice, #[error("device default config error: {0}")] DefaultConfig(#[from] cpal::DefaultStreamConfigError), #[error("device supported config error: {0}")] SupportedConfig(#[from] cpal::SupportedStreamConfigsError), #[error("stream build error: {0}")] BuildStream(#[from] cpal::BuildStreamError), #[error("stream play error: {0}")] PlayStream(#[from] cpal::PlayStreamError), #[error("wav io: {0}")] Wav(#[from] hound::Error), #[error("io: {0}")] Io(#[from] std::io::Error), #[error("unsupported sample format: {0:?}")] UnsupportedFormat(SampleFormat), } pub struct AudioCapture; impl AudioCapture { /// Capture `duration` from the default input device into `path` as /// 16-bit mono PCM WAV. Returns the number of frames written. pub fn record_to_wav(path: &Path, duration: Duration) -> Result { let host = cpal::default_host(); let device = host.default_input_device().ok_or(AudioError::NoInputDevice)?; let device_name = device.name().unwrap_or_else(|_| "".to_string()); let config = device.default_input_config()?; let sample_rate = config.sample_rate().0; let channels = config.channels(); let sample_format = config.sample_format(); info!( target: "spike", device = %device_name, sample_rate, channels, ?sample_format, "opening capture stream" ); let spec = WavSpec { channels: 1, // we down-mix to mono sample_rate, bits_per_sample: 16, sample_format: HoundSampleFormat::Int, }; let writer = Arc::new(Mutex::new(Some(WavWriter::create(path, spec)?))); let frames = Arc::new(Mutex::new(0u64)); let err_fn = |e| warn!(target: "spike", error = %e, "capture stream error"); let stream = match sample_format { SampleFormat::F32 => build_input_stream::( &device, &config.into(), writer.clone(), frames.clone(), channels, err_fn, )?, SampleFormat::I16 => build_input_stream::( &device, &config.into(), writer.clone(), frames.clone(), channels, err_fn, )?, SampleFormat::U16 => build_input_stream::( &device, &config.into(), writer.clone(), frames.clone(), channels, err_fn, )?, other => return Err(AudioError::UnsupportedFormat(other)), }; stream.play()?; std::thread::sleep(duration); drop(stream); // stops capture // Finalize the WAV. if let Some(w) = writer.lock().unwrap().take() { w.finalize()?; } let n = *frames.lock().unwrap(); info!(target: "spike", frames = n, "capture finished"); Ok(n) } } fn build_input_stream( device: &cpal::Device, config: &cpal::StreamConfig, writer: Arc>>>>, frames: Arc>, channels: u16, err_fn: fn(cpal::StreamError), ) -> Result where T: SizedSample + ToI16, { let stream = device.build_input_stream( config, move |data: &[T], _| { let mut w_guard = writer.lock().unwrap(); if let Some(w) = w_guard.as_mut() { let mut n = 0u64; for frame in data.chunks(channels as usize) { // Down-mix to mono by averaging channels. let mut acc: i32 = 0; for s in frame { acc += s.to_i16() as i32; } let mono = (acc / frame.len() as i32) as i16; let _ = w.write_sample(mono); n += 1; } *frames.lock().unwrap() += n; } }, err_fn, None, )?; Ok(stream) } /// `cpal::SizedSample` is the public marker. We expose it so the /// generic stream builder can constrain `T` without leaking cpal types /// out of this module. pub trait SizedSample: cpal::SizedSample + Send + 'static {} impl SizedSample for T {} /// Adapter so we can convert any supported sample format down to i16 /// for WAV writing. pub trait ToI16 { fn to_i16(&self) -> i16; } impl ToI16 for i16 { fn to_i16(&self) -> i16 { *self } } impl ToI16 for u16 { fn to_i16(&self) -> i16 { (i32::from(*self) - i32::from(i16::MAX) - 1) as i16 } } impl ToI16 for f32 { fn to_i16(&self) -> i16 { let v = (*self * f32::from(i16::MAX)).clamp(f32::from(i16::MIN), f32::from(i16::MAX)); v as i16 } } // `Sample` trait import is required for cpal's older method shapes; we // keep an explicit no-op use so this stays consistent across versions. #[allow(dead_code)] fn _assert_sample() {}