aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-audio/src/lib.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/lib.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/lib.rs')
-rw-r--r--crates/asist-audio/src/lib.rs148
1 files changed, 148 insertions, 0 deletions
diff --git a/crates/asist-audio/src/lib.rs b/crates/asist-audio/src/lib.rs
new file mode 100644
index 0000000..f6979ca
--- /dev/null
+++ b/crates/asist-audio/src/lib.rs
@@ -0,0 +1,148 @@
+//! Entrada y salida de audio, y el detector de voz que las separa en turnos.
+//!
+//! La regla que gobierna este crate: **la retrollamada de audio no bloquea
+//! nunca**. cpal la ejecuta en un hilo de tiempo real y cualquier espera ahí
+//! se oye como un chasquido, así que se limita a copiar muestras a un canal
+//! (entrada) o a vaciar un anillo ya rellenado (salida). Todo el trabajo real
+//! —remuestreo, VAD, HTTP— ocurre en hilos normales al otro lado.
+
+pub mod capture;
+pub mod playback;
+pub mod vad;
+
+/// Se reexporta para que el binario pueda listar dispositivos sin volver a
+/// declarar cpal y arriesgarse a resolver otra versión.
+pub use cpal;
+
+pub use capture::{Capture, CaptureBlock, InputFormat};
+pub use playback::{Playback, PlaybackHandle};
+pub use vad::{Gate, Segmenter, Utterance, VoiceEvent};
+
+/// Frecuencia a la que trabaja Canary. La captura se abre directamente aquí
+/// cuando el dispositivo lo permite, lo que quita el remuestreo del camino.
+pub const ASR_SAMPLE_RATE: u32 = 16_000;
+
+/// Frecuencia a la que sintetiza qwentts.
+pub const TTS_SAMPLE_RATE: u32 = 24_000;
+
+/// Nombre legible de un dispositivo.
+///
+/// `DeviceTrait::name` está obsoleto en cpal 0.17 a favor de `description`,
+/// que devuelve una ficha entera; aquí sólo interesa el nombre, y tener un
+/// único sitio donde extraerlo evita repetir el desempaquetado.
+pub fn describe(device: &impl cpal::traits::DeviceTrait) -> String {
+ device
+ .description()
+ .map(|d| d.name().to_string())
+ .unwrap_or_else(|_| "desconocido".into())
+}
+
+/// Nivel RMS de un bloque, la medida con la que el VAD decide.
+pub fn rms(samples: &[f32]) -> f32 {
+ if samples.is_empty() {
+ return 0.0;
+ }
+ let sum: f32 = samples.iter().map(|v| v * v).sum();
+ (sum / samples.len() as f32).sqrt()
+}
+
+/// Mezcla a mono y remuestrea linealmente a `target`.
+///
+/// La interpolación lineal basta: el dispositivo ya entrega la señal limitada
+/// en banda, y un remuestreador decente costaría más que la decodificación a
+/// la que alimenta.
+pub fn to_mono_at(samples: &[f32], format: InputFormat, target: u32) -> Vec<f32> {
+ let mono: Vec<f32> = if format.channels > 1 {
+ samples
+ .chunks(format.channels)
+ .map(|frame| frame.iter().sum::<f32>() / format.channels as f32)
+ .collect()
+ } else {
+ samples.to_vec()
+ };
+
+ if format.sample_rate == target as usize {
+ return mono;
+ }
+ let ratio = target as f64 / format.sample_rate as f64;
+ let out_len = (mono.len() as f64 * ratio) as usize;
+ (0..out_len)
+ .map(|i| {
+ let pos = i as f64 / ratio;
+ let idx = pos as usize;
+ let frac = (pos - idx as f64) as f32;
+ let a = mono.get(idx).copied().unwrap_or(0.0);
+ let b = mono.get(idx + 1).copied().unwrap_or(a);
+ a + (b - a) * frac
+ })
+ .collect()
+}
+
+/// Convierte s16le a f32 en [-1, 1]. Es el formato en el que el TTS entrega.
+pub fn s16le_to_f32(bytes: &[u8], out: &mut Vec<f32>) {
+ for pair in bytes.chunks_exact(2) {
+ let sample = i16::from_le_bytes([pair[0], pair[1]]);
+ out.push(sample as f32 / 32768.0);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn el_rms_de_una_senal_constante_es_su_amplitud() {
+ assert!((rms(&[0.5; 100]) - 0.5).abs() < 1e-6);
+ assert_eq!(rms(&[]), 0.0);
+ }
+
+ #[test]
+ fn el_estereo_se_mezcla_a_mono_promediando() {
+ let format = InputFormat {
+ sample_rate: 16_000,
+ channels: 2,
+ };
+ let out = to_mono_at(&[1.0, 0.0, 0.5, 0.5], format, 16_000);
+ assert_eq!(out, vec![0.5, 0.5]);
+ }
+
+ #[test]
+ fn el_remuestreo_ajusta_la_duracion() {
+ let format = InputFormat {
+ sample_rate: 48_000,
+ channels: 1,
+ };
+ let out = to_mono_at(&vec![0.0; 4800], format, 16_000);
+ assert_eq!(out.len(), 1600, "48 kHz -> 16 kHz debe dividir por tres");
+ }
+
+ #[test]
+ fn a_la_misma_frecuencia_el_remuestreo_no_toca_nada() {
+ let format = InputFormat {
+ sample_rate: 16_000,
+ channels: 1,
+ };
+ let input = vec![0.1, -0.2, 0.3];
+ assert_eq!(to_mono_at(&input, format, 16_000), input);
+ }
+
+ #[test]
+ fn s16le_recorre_el_rango_completo() {
+ let mut out = Vec::new();
+ s16le_to_f32(&[0x00, 0x00, 0xff, 0x7f, 0x00, 0x80], &mut out);
+ assert_eq!(out[0], 0.0);
+ assert!((out[1] - 1.0).abs() < 1e-4);
+ assert!((out[2] + 1.0).abs() < 1e-6);
+ }
+
+ #[test]
+ fn un_byte_suelto_no_produce_una_muestra_a_medias() {
+ let mut out = Vec::new();
+ s16le_to_f32(&[0x00, 0x00, 0x11], &mut out);
+ assert_eq!(
+ out.len(),
+ 1,
+ "el byte impar se ignora en vez de corromper la muestra"
+ );
+ }
+}