aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-app/src/render.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-app/src/render.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-app/src/render.rs')
-rw-r--r--crates/asist-app/src/render.rs258
1 files changed, 258 insertions, 0 deletions
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::<String>());
+ }
+ 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::<String>() + "…"
+}
+
+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");
+ }
+}