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-app/src/render.rs | 258 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 crates/asist-app/src/render.rs (limited to 'crates/asist-app/src/render.rs') diff --git a/crates/asist-app/src/render.rs b/crates/asist-app/src/render.rs new file mode 100644 index 0000000..7fd1e86 --- /dev/null +++ b/crates/asist-app/src/render.rs @@ -0,0 +1,258 @@ +//! Presentación en el terminal. +//! +//! Una sola línea viva que se reescribe con la transcripción provisional, y +//! líneas fijas para lo que ya es definitivo. La distinción importa: ver que +//! el asistente te está oyendo *mientras* hablas es lo que hace que la espera +//! se note menos de lo que dura. + +use std::io::Write; +use std::time::Duration; + +use asist_core::event::{Event, InterruptReason}; + +const RESET: &str = "\x1b[0m"; +const DIM: &str = "\x1b[2m"; +const BOLD: &str = "\x1b[1m"; +const CYAN: &str = "\x1b[36m"; +const GREEN: &str = "\x1b[32m"; +const YELLOW: &str = "\x1b[33m"; +const RED: &str = "\x1b[31m"; + +pub struct Renderer { + width: usize, + line_open: bool, + color: bool, + show_partials: bool, + reply: String, +} + +impl Renderer { + pub fn new(show_partials: bool) -> Self { + Self { + width: terminal_width(), + line_open: false, + // Sin terminal interactivo, los códigos de color sólo ensucian + // el fichero de registro. + color: std::env::var_os("NO_COLOR").is_none() && is_tty(), + show_partials, + reply: String::new(), + } + } + + fn paint(&self, code: &str, text: &str) -> String { + if self.color { + format!("{code}{text}{RESET}") + } else { + text.to_string() + } + } + + /// Borra la línea viva para poder escribir encima algo definitivo. + fn close_line(&mut self) { + if self.line_open { + print!("\r\x1b[K"); + self.line_open = false; + } + } + + fn live(&mut self, text: &str) { + let mut shown: String = text.chars().collect(); + if shown.chars().count() > self.width.saturating_sub(4) { + let keep = self.width.saturating_sub(7); + let skip = shown.chars().count() - keep; + shown = format!("…{}", shown.chars().skip(skip).collect::()); + } + print!("\r\x1b[K{shown}"); + let _ = std::io::stdout().flush(); + self.line_open = true; + } + + pub fn apply(&mut self, event: &Event) { + match event { + Event::SpeechStarted { .. } => { + self.reply.clear(); + if self.show_partials { + self.live(&self.paint(DIM, "escuchando…")); + } + } + Event::Partial { + committed, + volatile, + .. + } => { + if !self.show_partials { + return; + } + let line = format!( + "{} {}", + self.paint(DIM, committed), + self.paint(DIM, volatile) + ); + self.live(&line); + } + Event::Transcript { + text, + audio_secs, + decode, + .. + } => { + self.close_line(); + println!( + "{} {} {}", + self.paint(BOLD, "tú >"), + text, + self.paint( + DIM, + &format!("({audio_secs:.1} s, asr {} ms)", decode.as_millis()) + ) + ); + } + Event::Discarded { .. } => { + self.close_line(); + } + Event::ReplyStarted { ttft, .. } => { + self.close_line(); + print!("{} ", self.paint(CYAN, "asistente >")); + let _ = std::io::stdout().flush(); + tracing::debug!(target: "render", ttft_ms = ttft.as_millis(), "primer token"); + } + Event::ReplyDelta { text, .. } => { + self.reply.push_str(text); + print!("{text}"); + let _ = std::io::stdout().flush(); + } + Event::ReplyDone { .. } => { + println!(); + } + Event::ToolRequested { + name, arguments, .. + } => { + self.close_line(); + println!( + "{} {name} {}", + self.paint(YELLOW, "herramienta >"), + self.paint(DIM, &short(arguments, 120)) + ); + } + Event::ToolFinished { + name, + ok, + output, + took, + .. + } => { + let mark = if *ok { + self.paint(GREEN, "ok") + } else { + self.paint(RED, "error") + }; + println!( + "{} {name} {mark} {}", + self.paint(YELLOW, "herramienta <"), + self.paint( + DIM, + &format!("{} · {} ms", short(output, 120), took.as_millis()) + ) + ); + } + Event::AudioStarted { latency, .. } => { + tracing::info!( + target: "latencia", + ms = latency.as_millis(), + "primer audio (latencia percibida)" + ); + if self.color { + println!( + "{}", + self.paint(DIM, &format!(" ▸ voz en {}", human(*latency))) + ); + } + } + Event::Sentence { index, text, .. } => { + tracing::debug!(target: "render", frase = index, %text, "a sintetizar"); + } + Event::AudioFinished { .. } => {} + Event::Interrupted { reason, .. } => { + self.close_line(); + let why = match reason { + InterruptReason::UserSpoke => "te has adelantado", + InterruptReason::Requested => "cortado", + }; + println!("{}", self.paint(YELLOW, &format!(" ▪ {why}"))); + } + Event::Warning { message, .. } => { + self.close_line(); + println!("{} {message}", self.paint(YELLOW, "aviso >")); + } + Event::Failed { message, .. } => { + self.close_line(); + println!("{} {message}", self.paint(RED, "error >")); + } + Event::Shutdown => { + self.close_line(); + } + } + } + + pub fn finish(&mut self) { + self.close_line(); + let _ = std::io::stdout().flush(); + } +} + +fn short(text: &str, max: usize) -> String { + let text = text.replace('\n', " "); + if text.chars().count() <= max { + return text; + } + text.chars().take(max).collect::() + "…" +} + +fn human(d: Duration) -> String { + if d.as_millis() < 1000 { + format!("{} ms", d.as_millis()) + } else { + format!("{:.1} s", d.as_secs_f32()) + } +} + +fn terminal_width() -> usize { + std::env::var("COLUMNS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(100) +} + +fn is_tty() -> bool { + #[cfg(unix)] + { + unsafe extern "C" { + fn isatty(fd: i32) -> i32; + } + unsafe { isatty(1) == 1 } + } + #[cfg(not(unix))] + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn el_texto_largo_se_recorta_con_puntos_suspensivos() { + assert_eq!(short("abcdefghij", 5), "abcde…"); + assert_eq!(short("abc", 5), "abc"); + } + + #[test] + fn los_saltos_de_linea_no_rompen_la_linea_viva() { + assert_eq!(short("a\nb", 10), "a b"); + } + + #[test] + fn las_duraciones_se_leen_en_la_unidad_adecuada() { + assert_eq!(human(Duration::from_millis(430)), "430 ms"); + assert_eq!(human(Duration::from_millis(2500)), "2.5 s"); + } +} -- cgit v1.2.3