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/main.rs | 474 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 474 insertions(+) create mode 100644 crates/asist-app/src/main.rs (limited to 'crates/asist-app/src/main.rs') diff --git a/crates/asist-app/src/main.rs b/crates/asist-app/src/main.rs new file mode 100644 index 0000000..2788d97 --- /dev/null +++ b/crates/asist-app/src/main.rs @@ -0,0 +1,474 @@ +//! Asistente de voz local: escucha, piensa y responde hablando. +//! +//! Une tres motores que ya existen —Canary para oír, llama.cpp para pensar y +//! qwentts para hablar— en un pipeline de hilos y canales donde ninguna etapa +//! espera a la siguiente. + +mod pipeline; +mod render; +mod session; +mod supervisor; + +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{bail, Context, Result}; +use crossbeam_channel::unbounded; + +use asist_asr::Recognizer; +use asist_audio::{Capture, CaptureBlock, Playback}; +use asist_core::config::Config; +use asist_core::event::Event; +use asist_core::tools::ToolRegistry; +use asist_llm::LlmClient; +use asist_tts::TtsClient; + +use pipeline::Pipeline; +use render::Renderer; +use session::Session; +use supervisor::Supervisor; + +fn main() -> Result<()> { + let args = Args::parse()?; + if args.help { + println!("{USAGE}"); + return Ok(()); + } + init_logging(&args); + + let mut config = Config::load(&args.config) + .with_context(|| format!("no se pudo cargar {}", args.config.display()))?; + args.apply(&mut config); + + match args.command { + Command::Run => run(config, &args), + Command::Devices => devices(), + Command::Check => check(&config), + } +} + +fn run(config: Config, args: &Args) -> Result<()> { + let session = Session::new(); + + // Los clientes se crean antes de arrancar nada: así se detecta lo que ya + // esté escuchando y no se levanta un servidor por duplicado. + let llm = LlmClient::new(config.llm_authority(), &config.llm); + let tts = TtsClient::new(config.tts_authority(), &config.tts); + let (llm_up, tts_up) = (llm.healthy(), tts.healthy()); + + let mut supervisor = Supervisor::new(&args.log_dir)?; + supervisor.start(&config, llm_up, tts_up)?; + + let timeout = Duration::from_secs(config.supervisor.startup_timeout_secs); + eprintln!("Esperando a los motores (hasta {} s)…", timeout.as_secs()); + llm.wait_ready(timeout).map_err(|e| { + anyhow::anyhow!( + "{e}\nRevisa {}", + supervisor.log_path("llama-server").display() + ) + })?; + tts.wait_ready(timeout).map_err(|e| { + anyhow::anyhow!( + "{e}\nRevisa {}", + supervisor.log_path("tts-server").display() + ) + })?; + llm.warn_if_thinking_template(); + + if let Some(reference) = &config.tts.reference { + tts.register_voice(reference) + .context("no se pudo registrar la voz clonada")?; + } + // Se paga aquí la construcción de los grafos del sintetizador, unos 3,5 s + // que si no se los comería el primer turno de verdad. + if let Err(err) = tts.warmup() { + tracing::warn!(target: "tts", %err, "falló el precalentado; el primer turno irá lento"); + } + + eprintln!("Cargando el modelo de voz a texto…"); + let recognizer = Recognizer::load(&config.asr)?; + + let playback = Playback::open(&config.audio)?; + let (capture_tx, capture_rx) = unbounded::(); + let capture = Capture::open(&config.audio, capture_tx.clone())?; + + let tools = ToolRegistry::from_config(&config.tools); + if tools.is_empty() { + eprintln!("Herramientas: ninguna"); + } else { + eprintln!("Herramientas: {}", tools.names().join(", ")); + } + if config.tools.shell { + eprintln!( + "Ejecución de órdenes ACTIVA — permitidas: {}{}", + config.tools.shell_allowlist.join(", "), + if config.tools.shell_dry_run { + " (simulación)" + } else { + "" + } + ); + } + + let pipeline = Pipeline::spawn( + &config, + session.clone(), + recognizer, + llm, + tts, + tools, + playback.handle(), + capture_rx, + capture_tx, + capture.format, + )?; + + eprintln!( + "\nListo. Micrófono: {} · Altavoz: {} ({} Hz)\nHabla cuando quieras. Enter para salir.\n", + capture.device_name, playback.device_name, playback.sample_rate + ); + + // Enter cierra, pero sólo si hay alguien delante para pulsarlo. Con la + // entrada redirigida —bajo systemd, en un contenedor, con `< /dev/null`— + // `read_line` devuelve EOF al instante y el asistente se cerraría nada + // más arrancar. En ese caso se espera a una señal. + if stdin_is_tty() { + let session = session.clone(); + std::thread::spawn(move || { + let mut line = String::new(); + let _ = std::io::stdin().read_line(&mut line); + session.request_stop(); + }); + } else { + eprintln!("(entrada no interactiva: para cerrar, manda SIGINT o SIGTERM)"); + install_signal_handler(session.clone()); + } + + let mut renderer = Renderer::new(config.asr.partials); + let metrics = std::sync::Arc::clone(&pipeline.metrics); + let mut next_health_check = std::time::Instant::now() + Duration::from_secs(5); + loop { + match pipeline.events.recv_timeout(Duration::from_millis(150)) { + Ok(event) => { + let shutdown = matches!(event, Event::Shutdown); + renderer.apply(&event); + if shutdown { + break; + } + } + Err(crossbeam_channel::RecvTimeoutError::Timeout) => {} + Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break, + } + if session.is_stopping() { + break; + } + // Si un servidor se muere a mitad de sesión, todos los turnos + // siguientes fallarían con un error de transporte sin explicar por + // qué. Mejor decirlo una vez, con el registro a mano, y cerrar. + if std::time::Instant::now() >= next_health_check { + next_health_check = std::time::Instant::now() + Duration::from_secs(5); + if let Some((name, code)) = supervisor.crashed() { + renderer.finish(); + eprintln!( + "\n{name} ha terminado inesperadamente (código {}). Revisa {}", + code.map(|c| c.to_string()) + .unwrap_or_else(|| "desconocido".into()), + supervisor.log_path(name).display() + ); + break; + } + } + } + + renderer.finish(); + capture.stop(); + pipeline.shutdown(); + playback.stop(); + supervisor.shutdown(); + + if metrics.turns() > 0 { + eprintln!("\n{}", metrics.summary()); + } + Ok(()) +} + +/// Lista los dispositivos de audio, para poder nombrarlos en la configuración. +fn devices() -> Result<()> { + use asist_audio::describe; + use cpal_reexport::traits::HostTrait; + let host = cpal_reexport::default_host(); + + let default_in = host.default_input_device().map(|d| describe(&d)); + println!("Entradas:"); + for device in host.input_devices()? { + let name = describe(&device); + let mark = if Some(&name) == default_in.as_ref() { + " (por defecto)" + } else { + "" + }; + println!(" {name}{mark}"); + } + + let default_out = host.default_output_device().map(|d| describe(&d)); + println!("\nSalidas:"); + for device in host.output_devices()? { + let name = describe(&device); + let mark = if Some(&name) == default_out.as_ref() { + " (por defecto)" + } else { + "" + }; + println!(" {name}{mark}"); + } + Ok(()) +} + +/// Comprueba que está todo en su sitio, sin abrir el micrófono. +fn check(config: &Config) -> Result<()> { + let mut problems = Vec::new(); + let ok = |label: &str, detail: String| println!(" ok {label:<22} {detail}"); + + let paths: [(&str, &std::path::Path); 6] = [ + ("modelo asr", &config.asr.model_dir), + ("binario llama", &config.supervisor.llama.binary), + ("modelo llm", &config.supervisor.llama.model), + ("plantilla chat", &config.supervisor.llama.chat_template), + ("binario tts", &config.supervisor.tts.binary), + ("modelo tts", &config.supervisor.tts.model), + ]; + println!("Ficheros:"); + for (label, path) in paths { + if path.exists() { + ok(label, path.display().to_string()); + } else { + println!(" FALTA {label:<22} {}", path.display()); + problems.push(format!("falta {label}: {}", path.display())); + } + } + + if let Some(reference) = &config.tts.reference { + println!("Voz de referencia «{}»:", reference.name); + for (label, path) in [ + ("hablante (.spk)", &reference.speaker), + ("códigos (.rvq)", &reference.codes), + ("transcripción", &reference.transcript), + ] { + if path.exists() { + ok(label, path.display().to_string()); + } else { + println!(" FALTA {label:<22} {}", path.display()); + problems.push(format!("falta {label}")); + } + } + } + + println!("Servidores:"); + let llm = LlmClient::new(config.llm_authority(), &config.llm); + let tts = TtsClient::new(config.tts_authority(), &config.tts); + for (label, up, authority) in [ + ("llama-server", llm.healthy(), config.llm_authority()), + ("tts-server", tts.healthy(), config.tts_authority()), + ] { + println!( + " {:<5} {label:<22} {authority}", + if up { "ok" } else { "-" } + ); + } + if llm.healthy() { + llm.warn_if_thinking_template(); + } + if tts.healthy() { + match tts.voices() { + Ok(voices) if voices.contains(&config.tts.voice) => { + println!(" ok voz «{}» registrada", config.tts.voice); + } + Ok(voices) => println!( + " - voz «{}» aún no registrada (hay: {})", + config.tts.voice, + voices.join(", ") + ), + Err(err) => println!(" - no se pudieron listar las voces: {err}"), + } + } + + if problems.is_empty() { + println!("\nTodo listo."); + Ok(()) + } else { + bail!( + "{} problema(s):\n - {}", + problems.len(), + problems.join("\n - ") + ) + } +} + +// cpal se usa aquí sólo para listar dispositivos; llega a través de asist-audio. +use asist_audio::cpal as cpal_reexport; + +const USAGE: &str = "\ +asistente — asistente de voz local + +USO: + asistente [ORDEN] [OPCIONES] + +ÓRDENES: + run Escucha y responde (por defecto) + check Comprueba ficheros, modelos y servidores, y sale + devices Lista los dispositivos de audio + +OPCIONES: + -c, --config Fichero de configuración [config/asistente.toml] + --log-dir Carpeta de los registros de los servidores [logs/] + --voice Voz del sintetizador + --no-manage No lanzar los servidores; suponerlos ya arriba + --no-partials Sin transcripción provisional (ahorra CPU) + --barge-in Permitir cortar al asistente hablando encima + --shell Activar la ejecución de órdenes del sistema + -v, --verbose Registro de depuración (equivale a RUST_LOG=debug) + -h, --help Esta ayuda + +VARIABLES: + RUST_LOG Filtro de registro, p. ej. «info,tts=debug,latencia=info» +"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Command { + Run, + Check, + Devices, +} + +struct Args { + command: Command, + config: PathBuf, + log_dir: PathBuf, + voice: Option, + no_manage: bool, + no_partials: bool, + barge_in: bool, + shell: bool, + verbose: bool, + help: bool, +} + +impl Args { + fn parse() -> Result { + let mut args = Self { + command: Command::Run, + config: PathBuf::from("config/asistente.toml"), + log_dir: PathBuf::from("logs"), + voice: None, + no_manage: false, + no_partials: false, + barge_in: false, + shell: false, + verbose: false, + help: false, + }; + let mut raw = std::env::args().skip(1); + while let Some(arg) = raw.next() { + match arg.as_str() { + "run" => args.command = Command::Run, + "check" => args.command = Command::Check, + "devices" => args.command = Command::Devices, + "-c" | "--config" => { + args.config = raw.next().context("--config necesita una ruta")?.into() + } + "--log-dir" => { + args.log_dir = raw.next().context("--log-dir necesita una ruta")?.into() + } + "--voice" => args.voice = Some(raw.next().context("--voice necesita un nombre")?), + "--no-manage" => args.no_manage = true, + "--no-partials" => args.no_partials = true, + "--barge-in" => args.barge_in = true, + "--shell" => args.shell = true, + "-v" | "--verbose" => args.verbose = true, + "-h" | "--help" => args.help = true, + other => bail!("opción desconocida: {other}\n\n{USAGE}"), + } + } + Ok(args) + } + + /// La línea de órdenes manda sobre el fichero. + fn apply(&self, config: &mut Config) { + if let Some(voice) = &self.voice { + config.tts.voice = voice.clone(); + } + if self.no_manage { + config.supervisor.manage = false; + } + if self.no_partials { + config.asr.partials = false; + } + if self.barge_in { + config.vad.barge_in = true; + } + if self.shell { + config.tools.shell = true; + } + } +} + +/// ¿Hay una persona al otro lado de la entrada estándar? +fn stdin_is_tty() -> bool { + #[cfg(unix)] + { + unsafe extern "C" { + fn isatty(fd: i32) -> i32; + } + unsafe { isatty(0) == 1 } + } + #[cfg(not(unix))] + true +} + +/// Cierre ordenado ante SIGINT/SIGTERM. +/// +/// Importa más de lo que parece: matar el proceso en seco deja a los +/// servidores hijos con la memoria de la GPU tomada. +#[cfg(unix)] +fn install_signal_handler(session: Session) { + use std::sync::OnceLock; + static SESSION: OnceLock = OnceLock::new(); + let _ = SESSION.set(session); + + extern "C" fn on_signal(_: i32) { + if let Some(session) = SESSION.get() { + session.request_stop(); + } + } + unsafe extern "C" { + fn signal(sig: i32, handler: extern "C" fn(i32)) -> usize; + } + unsafe { + signal(2, on_signal); // SIGINT + signal(15, on_signal); // SIGTERM + } +} + +#[cfg(not(unix))] +fn install_signal_handler(_session: Session) {} + +fn init_logging(args: &Args) { + use tracing_subscriber::{fmt, EnvFilter}; + // ONNX Runtime informa a nivel INFO de cada transformación del grafo: + // varios cientos de líneas que tapan la transcripción. Se silencia salvo + // que se pida expresamente por RUST_LOG. + let default = if args.verbose { + "debug,ort=warn" + } else { + "info,ort=warn" + }; + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default)); + fmt() + .with_env_filter(filter) + // Al terminal de texto van la transcripción y la respuesta; el registro + // va a stderr para que se puedan separar con una redirección. + .with_writer(std::io::stderr) + .with_target(true) + .without_time() + .init(); +} -- cgit v1.2.3