//! 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(); }