From f8f98e83481e7376a235fb305a095552433f79ab Mon Sep 17 00:00:00 2001 From: elvis Date: Sun, 6 Sep 2026 19:21:16 -0300 Subject: Local voice assistant on top of Canary, llama.cpp and qwentts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- crates/asist-audio/src/capture.rs | 153 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 crates/asist-audio/src/capture.rs (limited to 'crates/asist-audio/src/capture.rs') 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, + 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}"))), + } +} -- cgit v1.2.3