aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-audio/src/capture.rs
diff options
context:
space:
mode:
authorelvis <elvis@claros.ar>2026-09-06 19:21:16 -0300
committerelvis <elvis@claros.ar>2026-09-06 19:23:37 -0300
commitf8f98e83481e7376a235fb305a095552433f79ab (patch)
tree6d0ecddb26bef8430a086431de40f8ce9b1039e6 /crates/asist-audio/src/capture.rs
downloadasist-p-f8f98e83481e7376a235fb305a095552433f79ab.tar.gz
asist-p-f8f98e83481e7376a235fb305a095552433f79ab.zip
Local voice assistant on top of Canary, llama.cpp and qwentts
Pipeline en Rust de hilos y canales que une los tres motores: el micrófono alimenta un segmentador con VAD, las intervenciones cerradas van al reconocedor, la transcripción al modelo y cada frase que este cierra sale hacia el sintetizador sin esperar al resto de la respuesta. Dos reglas sostienen el diseño: ninguna etapa bloquea a la anterior —quien va sobrado descarta trabajo en lugar de acumular retraso— y todo lo que viaja por los canales lleva el turno al que pertenece, así que interrumpir es subir el contador y levantar dos banderas de cancelación. Seis crates: core (configuración, eventos, HTTP, telemetría, herramientas), audio (cpal, VAD, anillo de reproducción), asr, llm, tts y app (supervisor de procesos y orquestador). Los motores van como submódulos fijados a un commit, con los cambios locales en vendor/patches. Midiendo el pipeline aparecieron tres cuellos de botella de configuración que valieron más que cualquier cambio de código, todos documentados en docs/RENDIMIENTO.md: - tts-server decodificaba el audio en bloques de 24 s, de modo que el modo «streaming» llegaba de una pieza: 4948 ms -> 585 ms hasta el primer audio. - La plantilla de chat del modelo abre <think> y no lo cierra nunca, sin variable que lo apague: 8630 ms -> 413 ms hasta el primer token, con una copia de la plantilla que deja el bloque cerrado de entrada. - Cualquier indicación de estilo junto a la guía de herramientas hace que este modelo de 2B deje de llamarlas y se invente el dato (8/8 aciertos con la guía sola, 0/8 con la persona de asistente de voz). El turno alterna ahora entre dos instrucciones de sistema. La ejecución de órdenes del sistema queda implementada y apagada, tras cuatro barreras: lista blanca sobre el ejecutable, rutas rechazadas, sin shell que interprete metacaracteres y plazo máximo. 68 pruebas unitarias sin modelos, más seis de integración que se saltan solas si no hay servidores y se turnan la GPU: en paralelo, los dos servidores no caben en 4 GB y miden contención en vez de latencia. Claude-Session: https://claude.ai/code/session_01FNxz5cSdQSscJH9H7b8uGU
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}"))),
+ }
+}