diff options
Diffstat (limited to 'crates/asist-audio/src/capture.rs')
| -rw-r--r-- | crates/asist-audio/src/capture.rs | 153 |
1 files changed, 153 insertions, 0 deletions
diff --git a/crates/asist-audio/src/capture.rs b/crates/asist-audio/src/capture.rs new file mode 100644 index 0000000..05da992 --- /dev/null +++ b/crates/asist-audio/src/capture.rs @@ -0,0 +1,153 @@ +//! 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<f32>, + 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<CaptureBlock>) -> Result<Self> { + 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<cpal::Device> { + 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<SupportedStreamConfig> { + 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}"))), + } +} |