aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-audio/src/capture.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/asist-audio/src/capture.rs')
-rw-r--r--crates/asist-audio/src/capture.rs153
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}"))),
+ }
+}