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/tests/integracion.rs | 266 ++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 crates/asist-app/tests/integracion.rs (limited to 'crates/asist-app/tests') diff --git a/crates/asist-app/tests/integracion.rs b/crates/asist-app/tests/integracion.rs new file mode 100644 index 0000000..91a074a --- /dev/null +++ b/crates/asist-app/tests/integracion.rs @@ -0,0 +1,266 @@ +//! Pruebas contra los servidores de verdad. +//! +//! Se saltan solas cuando no hay nada escuchando, para que `cargo test` siga +//! siendo útil sin 5 GB de modelos cargados. Con los servidores arriba +//! comprueban lo que las pruebas unitarias no pueden: que el protocolo que se +//! ha escrito es el que los servidores hablan, y que las latencias siguen +//! donde se midieron. +//! +//! Para ejecutarlas: scripts/servidores.sh arrancar && cargo test -- --ignored + +use std::path::PathBuf; +use std::sync::{Mutex, MutexGuard, OnceLock}; +use std::time::{Duration, Instant}; + +use asist_core::config::Config; +use asist_core::http::Cancel; +use asist_core::tools::ToolRegistry; +use asist_llm::chat::Conversation; +use asist_llm::{Delta, LlmClient, Message}; +use asist_tts::TtsClient; + +/// Las pruebas se turnan la GPU. +/// +/// Los dos servidores comparten una tarjeta de 4 GB, y medido: con la prueba +/// de síntesis corriendo a la vez, el primer token del LLM pasa de ~0,5 s a +/// ~5 s. En paralelo esto no mide latencia, mide quién llegó primero. El +/// pipeline de verdad no tiene el problema porque la síntesis no arranca +/// hasta que el modelo cierra su primera frase. +fn en_exclusiva() -> MutexGuard<'static, ()> { + static GPU: OnceLock> = OnceLock::new(); + GPU.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|e| e.into_inner()) +} + +fn config() -> Config { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../config/asistente.toml"); + Config::load(path).expect("no se pudo cargar la configuración") +} + +/// Devuelve los clientes, o `None` si los servidores no están arriba. +fn clientes() -> Option<(Config, LlmClient, TtsClient)> { + let config = config(); + let llm = LlmClient::new(config.llm_authority(), &config.llm); + let tts = TtsClient::new(config.tts_authority(), &config.tts); + if !llm.healthy() || !tts.healthy() { + eprintln!("servidores no disponibles; prueba omitida"); + return None; + } + Some((config, llm, tts)) +} + +#[test] +fn el_llm_responde_en_streaming_sin_razonar_en_voz_alta() { + let _gpu = en_exclusiva(); + let Some((config, llm, _)) = clientes() else { + return; + }; + + let mut chat = Conversation::new(&config.general.system_prompt, 4); + chat.push(Message::user("Saluda en una frase corta.")); + + let started = Instant::now(); + let mut ttft = None; + let outcome = llm + .stream(&chat, None, &Cancel::new(), |delta| { + if let Delta::Text(_) = delta { + ttft.get_or_insert_with(|| started.elapsed()); + } + true + }) + .expect("la petición al LLM falló"); + + assert!(!outcome.text.trim().is_empty(), "respuesta vacía"); + // La plantilla con sin cerrar deja esto en 7-9 s. Si esta prueba + // se cae, casi seguro que llama-server arrancó sin --chat-template-file. + let ttft = ttft.expect("no llegó ningún fragmento con texto"); + assert!( + ttft < Duration::from_secs(3), + "el primer token tardó {ttft:?}: ¿arrancó llama-server con la plantilla sin razonamiento?" + ); + assert!( + !outcome.text.contains(""), + "el razonamiento se está colando en la respuesta: {}", + outcome.text + ); +} + +#[test] +fn el_llm_puede_pedir_una_herramienta() { + let _gpu = en_exclusiva(); + let Some((config, llm, _)) = clientes() else { + return; + }; + + let tools = ToolRegistry::from_config(&config.tools); + let mut chat = Conversation::new( + "Eres un asistente. Usa las herramientas disponibles cuando hagan falta.", + 4, + ); + chat.push(Message::user( + "¿Qué hora es exactamente? Usa la herramienta.", + )); + + let outcome = llm + .stream(&chat, Some(&tools), &Cancel::new(), |_| true) + .expect("la petición al LLM falló"); + + if outcome.tool_calls.is_empty() { + // Un modelo de 2B no siempre acierta a llamar; lo que se comprueba es + // que el ida y vuelta funciona, no que el modelo sea listo. + eprintln!("el modelo no pidió herramienta: {}", outcome.text); + return; + } + let call = &outcome.tool_calls[0]; + assert_eq!(call.name, "hora_actual"); + let result = tools.dispatch(call); + assert!(result.ok, "la herramienta falló: {}", result.output); + assert!(!result.output.trim().is_empty()); +} + +#[test] +fn la_sintesis_empieza_a_sonar_antes_de_terminar() { + let _gpu = en_exclusiva(); + let Some((config, _, tts)) = clientes() else { + return; + }; + + if let Some(reference) = &config.tts.reference { + tts.register_voice(reference) + .expect("no se registró la voz"); + } + // Sin precalentar, la primera síntesis carga los grafos y mide el arranque + // del servidor en vez del régimen normal. + tts.warmup().expect("falló el precalentado"); + + let mut bloques = 0usize; + let outcome = tts + .speak( + "Hola, esto es una prueba de latencia del sintetizador de voz.", + &Cancel::new(), + |samples| { + if !samples.is_empty() { + bloques += 1; + } + true + }, + ) + .expect("la síntesis falló"); + + assert!(outcome.samples > 0, "no se recibió audio"); + assert!( + bloques > 1, + "el audio llegó de una sola vez ({bloques} bloque): revisa --codec-chunk-dur, \ + que de fábrica son 24 s y bloquean el streaming" + ); + let ttfb = outcome.ttfb.expect("sin marca de primer bloque"); + assert!( + ttfb < Duration::from_secs(2), + "el primer audio tardó {ttfb:?}; con --codec-chunk-dur 1.0 se midió 585 ms" + ); + eprintln!( + "ttfb={:?} audio={:.2}s rtf={:.2}", + ttfb, + outcome.audio_secs(), + outcome.rtf() + ); +} + +#[test] +fn una_sintesis_se_puede_cortar_a_media_frase() { + let _gpu = en_exclusiva(); + let Some((config, _, tts)) = clientes() else { + return; + }; + if let Some(reference) = &config.tts.reference { + let _ = tts.register_voice(reference); + } + + // Es la operación que sostiene el barge-in: si no se pudiera cortar, el + // asistente seguiría hablando encima del usuario hasta acabar la frase. + let cancel = Cancel::new(); + let mut recibidos = 0usize; + let outcome = tts + .speak( + "Esta es una frase larga que no debería llegar a escucharse entera \ + porque se va a cortar en cuanto empiece a sonar el primer bloque.", + &cancel, + |samples| { + recibidos += samples.len(); + // Cortar en el primer bloque. + false + }, + ) + .expect("la síntesis falló"); + + assert!(outcome.cancelled, "debió quedar marcada como cancelada"); + assert!( + outcome.audio_secs() < 3.0, + "se recibieron {:.1} s de audio: el corte no surtió efecto", + outcome.audio_secs() + ); +} + +#[test] +fn la_voz_clonada_queda_registrada() { + let _gpu = en_exclusiva(); + let Some((config, _, tts)) = clientes() else { + return; + }; + let Some(reference) = &config.tts.reference else { + eprintln!("sin voz de referencia configurada; prueba omitida"); + return; + }; + tts.register_voice(reference) + .expect("no se registró la voz"); + let voces = tts.voices().expect("no se pudieron listar las voces"); + assert!( + voces.contains(&reference.name), + "«{}» no aparece entre {voces:?}", + reference.name + ); +} + +#[test] +#[ignore = "carga el modelo de ASR (~200 MB) y decodifica; lento"] +fn el_asr_transcribe_lo_que_sintetiza_el_tts() { + let _gpu = en_exclusiva(); + // El bucle completo sin micrófono: se sintetiza una frase conocida y se + // comprueba que el reconocedor la recupera. Es la única prueba que ejerce + // ASR y TTS contra el mismo audio. + let Some((config, _, tts)) = clientes() else { + return; + }; + if let Some(reference) = &config.tts.reference { + let _ = tts.register_voice(reference); + } + + let frase = "hola qué tal estás hoy"; + let mut audio: Vec = Vec::new(); + tts.speak(frase, &Cancel::new(), |samples| { + audio.extend_from_slice(samples); + true + }) + .expect("la síntesis falló"); + assert!(!audio.is_empty(), "no se generó audio"); + + let recognizer = + asist_asr::Recognizer::load(&config.asr).expect("no se pudo cargar el modelo de ASR"); + let texto = recognizer + .transcribe(&audio, asist_tts::SAMPLE_RATE) + .expect("la transcripción falló") + .to_lowercase(); + + eprintln!("dicho: «{frase}» / oído: «{texto}»"); + // No se exige una coincidencia exacta —24 kHz remuestreados a 16 kHz y un + // modelo de 180M dan para lo que dan—, sino que reconozca algo. + let acertadas = frase + .split_whitespace() + .filter(|palabra| texto.contains(palabra)) + .count(); + assert!( + acertadas >= 2, + "sólo se reconocieron {acertadas} palabras de «{frase}» en «{texto}»" + ); +} -- cgit v1.2.3