//! Captura desde el micrófono. use std::time::Instant; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{Sample, SampleFormat, SupportedStreamConfig}; use crossbeam_channel::Sender; use asist_core::config::AudioConfig; use asist_core::error::{Error, Result}; use crate::{describe, ASR_SAMPLE_RATE}; /// Formato con el que se abrió realmente el dispositivo. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct InputFormat { pub sample_rate: usize, pub channels: usize, } /// Un bloque tal y como sale de la retrollamada, con la marca de tiempo que /// permite luego medir cuánto se ha retrasado el pipeline respecto de la voz. #[derive(Debug)] pub struct CaptureBlock { pub samples: Vec, pub at: Instant, } pub struct Capture { stream: cpal::Stream, pub format: InputFormat, pub device_name: String, } impl Capture { /// Abre la entrada y empieza a empujar bloques por `tx`. /// /// Prefiere 16 kHz mono porque es justo lo que quiere el modelo: si el /// dispositivo lo acepta, no hay remuestreo en ningún punto del camino. pub fn open(config: &AudioConfig, tx: Sender) -> Result { let host = cpal::default_host(); let device = select_device(&host, &config.input_device)?; let device_name = describe(&device); let supported = preferred_config(&device)?; let format = InputFormat { sample_rate: supported.sample_rate() as usize, channels: supported.channels() as usize, }; let stream_config: cpal::StreamConfig = supported.clone().into(); let on_error = |err| tracing::error!(target: "audio", %err, "flujo de entrada"); // Dentro de la retrollamada: convertir a f32, enviar y salir. `send` // sobre un canal sin límite no bloquea, que es la única propiedad que // aquí importa. macro_rules! build { ($sample:ty) => { device .build_input_stream( &stream_config, move |data: &[$sample], _: &_| { let samples = data.iter().map(|s| f32::from_sample(*s)).collect(); let _ = tx.send(CaptureBlock { samples, at: Instant::now(), }); }, on_error, None, ) .map_err(|e| Error::Audio(format!("no se pudo abrir la entrada: {e}")))? }; } let stream = match supported.sample_format() { SampleFormat::F32 => build!(f32), SampleFormat::I16 => build!(i16), SampleFormat::U16 => build!(u16), other => { return Err(Error::Audio(format!( "formato de muestra no soportado: {other:?}" ))) } }; stream .play() .map_err(|e| Error::Audio(format!("no se pudo arrancar la entrada: {e}")))?; tracing::info!( target: "audio", dispositivo = %device_name, hz = format.sample_rate, canales = format.channels, remuestreo = format.sample_rate != ASR_SAMPLE_RATE as usize || format.channels != 1, "entrada abierta" ); Ok(Self { stream, format, device_name, }) } /// Cierra el dispositivo. Al soltar el emisor, la cadena de hilos se /// desmonta sola de arriba abajo. pub fn stop(self) { drop(self.stream); } } fn select_device(host: &cpal::Host, wanted: &str) -> Result { if wanted.is_empty() { return host .default_input_device() .ok_or_else(|| Error::Audio("no hay dispositivo de entrada".into())); } let wanted_lower = wanted.to_lowercase(); let devices = host .input_devices() .map_err(|e| Error::Audio(format!("no se pudieron listar las entradas: {e}")))?; let mut seen = Vec::new(); for device in devices { let name = describe(&device); if name.to_lowercase().contains(&wanted_lower) { return Ok(device); } seen.push(name); } Err(Error::Audio(format!( "ninguna entrada coincide con «{wanted}». Disponibles: {}", seen.join(", ") ))) } fn preferred_config(device: &cpal::Device) -> Result { let native = device .supported_input_configs() .map_err(|e| Error::Audio(format!("no se pudo consultar la entrada: {e}")))? .filter(|range| range.channels() == 1) .filter(|range| { range.min_sample_rate() <= ASR_SAMPLE_RATE && ASR_SAMPLE_RATE <= range.max_sample_rate() }) .find(|range| range.sample_format() == SampleFormat::F32) .map(|range| range.with_sample_rate(ASR_SAMPLE_RATE)); match native { Some(config) => Ok(config), None => device .default_input_config() .map_err(|e| Error::Audio(format!("sin configuración de entrada: {e}"))), } }