aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-app
diff options
context:
space:
mode:
Diffstat (limited to 'crates/asist-app')
-rw-r--r--crates/asist-app/src/main.rs275
-rw-r--r--crates/asist-app/src/pipeline.rs176
-rw-r--r--crates/asist-app/src/registry.rs34
-rw-r--r--crates/asist-app/src/render.rs47
-rw-r--r--crates/asist-app/src/session.rs50
-rw-r--r--crates/asist-app/src/supervisor.rs78
-rw-r--r--crates/asist-app/tests/integration.rs (renamed from crates/asist-app/tests/integracion.rs)318
7 files changed, 478 insertions, 500 deletions
diff --git a/crates/asist-app/src/main.rs b/crates/asist-app/src/main.rs
index cc08e62..d70cd2a 100644
--- a/crates/asist-app/src/main.rs
+++ b/crates/asist-app/src/main.rs
@@ -1,8 +1,8 @@
-//! Asistente de voz local: escucha, piensa y responde hablando.
+//! Local voice assistant: it listens, thinks and answers out loud.
//!
-//! 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.
+//! It joins three existing engines (Canary to hear, llama.cpp to think and
+//! qwentts to speak) in a pipeline of threads and channels where no stage
+//! waits for the next.
mod pipeline;
mod registry;
@@ -38,7 +38,7 @@ fn main() -> Result<()> {
init_logging(&args);
let mut config = Config::load(&args.config)
- .with_context(|| format!("no se pudo cargar {}", args.config.display()))?;
+ .with_context(|| format!("could not load {}", args.config.display()))?;
args.apply(&mut config);
match args.command {
@@ -51,10 +51,10 @@ fn main() -> Result<()> {
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.
- // Compartido: el orquestador conversa con él y la herramienta de cámara
- // lo usa para describir lo que capta.
+ // Clients are created before starting anything: that way whatever is
+ // already listening is detected and no server is started twice.
+ // Shared: the orchestrator converses with it and the camera tool uses it
+ // to describe what it captures.
let llm = Arc::new(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());
@@ -63,32 +63,25 @@ fn run(config: Config, args: &Args) -> Result<()> {
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());
+ eprintln!("Waiting for the engines (up to {} 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()
- )
+ anyhow::anyhow!("{e}\nSee {}", supervisor.log_path("llama-server").display())
})?;
+ tts.wait_ready(timeout)
+ .map_err(|e| anyhow::anyhow!("{e}\nSee {}", 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")?;
+ .context("could not register the cloned voice")?;
}
- // 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.
+ // Building the synthesizer graphs is paid here, about 3.5 s that the
+ // first real turn would otherwise eat.
if let Err(err) = tts.warmup() {
- tracing::warn!(target: "tts", %err, "falló el precalentado; el primer turno irá lento");
+ tracing::warn!(target: "tts", %err, "warmup failed; the first turn will be slow");
}
- eprintln!("Cargando el modelo de voz a texto…");
+ eprintln!("Loading the speech-to-text model…");
let recognizer = Recognizer::load(&config.asr)?;
let playback = Playback::open(&config.audio)?;
@@ -97,48 +90,48 @@ fn run(config: Config, args: &Args) -> Result<()> {
let (tools, skipped) = registry::build(&config, &llm);
if tools.is_empty() {
- eprintln!("Herramientas: ninguna");
+ eprintln!("Tools: none");
} else {
- eprintln!("Herramientas: {}", tools.names().join(", "));
+ eprintln!("Tools: {}", tools.names().join(", "));
}
- // Lo que no se pudo activar se dice en voz alta, en vez de dejar al
- // usuario preguntándose por qué el asistente no busca ni ve.
+ // Whatever could not be enabled is reported, instead of leaving the user
+ // wondering why the assistant does not search or see.
for skip in &skipped {
- eprintln!(" · {} no disponible: {}", skip.tool, skip.reason);
+ eprintln!(" · {} unavailable: {}", skip.tool, skip.reason);
}
- // Las dos capacidades que tocan algo fuera del proceso se anuncian: una
- // enciende la cámara y la otra ejecuta órdenes. Quien lo arranca debería
- // saberlo sin tener que leerse la configuración.
+ // The two capabilities that touch something outside the process are
+ // announced: one turns the camera on and the other runs commands. Whoever
+ // starts it should know without having to read the configuration.
if tools.get("mirar_la_pantalla").is_some() {
eprintln!(
- "Captura de pantalla ACTIVA — a {} px{}",
+ "Screen capture ON — at {} px{}",
config.screen.width,
if config.screen.save_dir.is_empty() {
String::new()
} else {
- format!(", guardando en {}", config.screen.save_dir)
+ format!(", saving to {}", config.screen.save_dir)
}
);
}
if tools.get("mirar_por_la_camara").is_some() {
eprintln!(
- "Cámara ACTIVA — {} a {}x{}{}",
+ "Camera ON — {} at {}x{}{}",
config.camera.device.display(),
config.camera.width,
config.camera.height,
if config.camera.save_dir.is_empty() {
String::new()
} else {
- format!(", guardando fotogramas en {}", config.camera.save_dir)
+ format!(", saving frames to {}", config.camera.save_dir)
}
);
}
if config.tools.shell {
eprintln!(
- "Ejecución de órdenes ACTIVA — permitidas: {}{}",
+ "Command execution ON — allowed: {}{}",
config.tools.shell_allowlist.join(", "),
if config.tools.shell_dry_run {
- " (simulación)"
+ " (dry run)"
} else {
""
}
@@ -159,14 +152,14 @@ fn run(config: Config, args: &Args) -> Result<()> {
)?;
eprintln!(
- "\nListo. Micrófono: {} · Altavoz: {} ({} Hz)\nHabla cuando quieras. Enter para salir.\n",
+ "\nReady. Microphone: {} · Speaker: {} ({} Hz)\nTalk whenever you like. Enter to quit.\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.
+ // Enter quits, but only if someone is there to press it. With redirected
+ // input (under systemd, in a container, with `< /dev/null`) `read_line`
+ // returns EOF immediately and the assistant would quit right after
+ // starting. In that case it waits for a signal.
if stdin_is_tty() {
let session = session.clone();
std::thread::spawn(move || {
@@ -175,7 +168,7 @@ fn run(config: Config, args: &Args) -> Result<()> {
session.request_stop();
});
} else {
- eprintln!("(entrada no interactiva: para cerrar, manda SIGINT o SIGTERM)");
+ eprintln!("(non-interactive input: send SIGINT or SIGTERM to quit)");
install_signal_handler(session.clone());
}
@@ -197,17 +190,17 @@ fn run(config: Config, args: &Args) -> Result<()> {
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 a server dies mid-session, every following turn would fail with a
+ // transport error without saying why. Better to say it once, with the
+ // log at hand, and quit.
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 {}",
+ "\n{name} exited unexpectedly (code {}). See {}",
code.map(|c| c.to_string())
- .unwrap_or_else(|| "desconocido".into()),
+ .unwrap_or_else(|| "unknown".into()),
supervisor.log_path(name).display()
);
break;
@@ -227,18 +220,18 @@ fn run(config: Config, args: &Args) -> Result<()> {
Ok(())
}
-/// Lista los dispositivos de audio, para poder nombrarlos en la configuración.
+/// Lists the audio devices, so they can be named in the configuration.
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:");
+ println!("Inputs:");
for device in host.input_devices()? {
let name = describe(&device);
let mark = if Some(&name) == default_in.as_ref() {
- " (por defecto)"
+ " (default)"
} else {
""
};
@@ -246,11 +239,11 @@ fn devices() -> Result<()> {
}
let default_out = host.default_output_device().map(|d| describe(&d));
- println!("\nSalidas:");
+ println!("\nOutputs:");
for device in host.output_devices()? {
let name = describe(&device);
let mark = if Some(&name) == default_out.as_ref() {
- " (por defecto)"
+ " (default)"
} else {
""
};
@@ -259,117 +252,117 @@ fn devices() -> Result<()> {
Ok(())
}
-/// Comprueba que está todo en su sitio, sin abrir el micrófono.
+/// Checks that everything is in place, without opening the microphone.
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),
+ ("asr model", &config.asr.model_dir),
+ ("llama binary", &config.supervisor.llama.binary),
+ ("llm model", &config.supervisor.llama.model),
+ ("chat template", &config.supervisor.llama.chat_template),
+ ("tts binary", &config.supervisor.tts.binary),
+ ("tts model", &config.supervisor.tts.model),
];
- println!("Ficheros:");
+ println!("Files:");
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()));
+ println!(" MISSING {label:<20} {}", path.display());
+ problems.push(format!("missing {label}: {}", path.display()));
}
}
if let Some(reference) = &config.tts.reference {
- println!("Voz de referencia «{}»:", reference.name);
+ println!("Reference voice «{}»:", reference.name);
for (label, path) in [
- ("hablante (.spk)", &reference.speaker),
- ("códigos (.rvq)", &reference.codes),
- ("transcripción", &reference.transcript),
+ ("speaker (.spk)", &reference.speaker),
+ ("codes (.rvq)", &reference.codes),
+ ("transcript", &reference.transcript),
] {
if path.exists() {
ok(label, path.display().to_string());
} else {
- println!(" FALTA {label:<22} {}", path.display());
- problems.push(format!("falta {label}"));
+ println!(" MISSING {label:<20} {}", path.display());
+ problems.push(format!("missing {label}"));
}
}
}
- println!("Capacidades:");
- // Ninguna de las dos es fatal: el asistente conversa igual sin ellas, así
- // que se informa y no se cuentan como problema.
+ println!("Capabilities:");
+ // Neither is fatal: the assistant converses just the same without them,
+ // so they are reported and not counted as problems.
if !config.search.enabled {
- println!(" - búsqueda desactivada en la configuración");
+ println!(" - search disabled in the configuration");
} else {
match config.search.backend.trim().to_lowercase().as_str() {
"tavily" => match std::env::var(&config.search.api_key_env) {
Ok(key) if !key.trim().is_empty() => ok(
- "búsqueda (tavily)",
- format!("clave en ${}", config.search.api_key_env),
+ "search (tavily)",
+ format!("key in ${}", config.search.api_key_env),
),
_ => println!(
- " - búsqueda (tavily) falta ${}",
+ " - search (tavily) missing ${}",
config.search.api_key_env
),
},
- "ddgs" | "comando" => match config.search.command.first() {
- // Un nombre suelto se resuelve por el PATH; una ruta tiene que
- // existir, y más vale decirlo aquí que a mitad de una pregunta.
+ "ddgs" | "command" | "comando" => match config.search.command.first() {
+ // A bare name is resolved through PATH; a path must exist, and
+ // better to say so here than in the middle of a question.
Some(program) if !program.contains('/') || PathBuf::from(program).exists() => {
- ok("búsqueda (ddgs)", config.search.command.join(" "))
+ ok("search (ddgs)", config.search.command.join(" "))
}
Some(program) => {
- println!(" - búsqueda (ddgs) no existe {program}")
+ println!(" - search (ddgs) {program} does not exist")
}
- None => println!(" - búsqueda (ddgs) search.command está vacío"),
+ None => println!(" - search (ddgs) search.command is empty"),
},
- "searxng" => ok("búsqueda (searxng)", config.search.base_url.clone()),
- other => println!(" - búsqueda backend desconocido: «{other}»"),
+ "searxng" => ok("search (searxng)", config.search.base_url.clone()),
+ other => println!(" - search unknown backend: «{other}»"),
}
}
if !config.screen.enabled {
- println!(" - pantalla desactivada en la configuración");
+ println!(" - screen disabled in the configuration");
} else {
match config.screen.command.first() {
Some(program) if !program.contains('/') || PathBuf::from(program).exists() => ok(
- "pantalla",
+ "screen",
format!("{} px · {}", config.screen.width, program),
),
- Some(program) => println!(" - pantalla no existe {program}"),
- None => println!(" - pantalla screen.command está vacío"),
+ Some(program) => println!(" - screen {program} does not exist"),
+ None => println!(" - screen screen.command is empty"),
}
if std::env::var_os("WAYLAND_DISPLAY").is_none() && std::env::var_os("DISPLAY").is_none() {
- println!(" - sesión gráfica no hay; la pantalla no se podrá capturar");
+ println!(" - graphical session none; the screen cannot be captured");
}
}
if !config.camera.enabled {
- println!(" - cámara desactivada en la configuración");
+ println!(" - camera disabled in the configuration");
} else if config.camera.device.exists() {
ok(
- "cámara",
+ "camera",
format!(
- "{} a {}x{}",
+ "{} at {}x{}",
config.camera.device.display(),
config.camera.width,
config.camera.height
),
);
if which("ffmpeg").is_none() {
- println!(" - ffmpeg no está; hace falta para capturar");
+ println!(" - ffmpeg missing; needed to capture");
}
} else {
println!(
- " - cámara no existe {}",
+ " - camera {} does not exist",
config.camera.device.display()
);
}
- println!("Servidores:");
+ println!("Servers:");
let llm = LlmClient::new(config.llm_authority(), &config.llm);
let tts = TtsClient::new(config.tts_authority(), &config.tts);
for (label, up, authority) in [
@@ -387,56 +380,56 @@ fn check(config: &Config) -> Result<()> {
if tts.healthy() {
match tts.voices() {
Ok(voices) if voices.contains(&config.tts.voice) => {
- println!(" ok voz «{}» registrada", config.tts.voice);
+ println!(" ok voice «{}» registered", config.tts.voice);
}
Ok(voices) => println!(
- " - voz «{}» aún no registrada (hay: {})",
+ " - voice «{}» not registered yet (available: {})",
config.tts.voice,
voices.join(", ")
),
- Err(err) => println!(" - no se pudieron listar las voces: {err}"),
+ Err(err) => println!(" - could not list the voices: {err}"),
}
}
if problems.is_empty() {
- println!("\nTodo listo.");
+ println!("\nAll set.");
Ok(())
} else {
bail!(
- "{} problema(s):\n - {}",
+ "{} problem(s):\n - {}",
problems.len(),
problems.join("\n - ")
)
}
}
-// cpal se usa aquí sólo para listar dispositivos; llega a través de asist-audio.
+// cpal is only used here to list devices; it comes through 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 <RUTA> Fichero de configuración [config/asistente.toml]
- --log-dir <RUTA> Carpeta de los registros de los servidores [logs/]
- --voice <NOMBRE> 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
+asistente — local voice assistant
+
+USAGE:
+ asistente [COMMAND] [OPTIONS]
+
+COMMANDS:
+ run Listen and answer (default)
+ check Check files, models and servers, then exit
+ devices List the audio devices
+
+OPTIONS:
+ -c, --config <PATH> Configuration file [config/asistente.toml]
+ --log-dir <PATH> Directory for the server logs [logs/]
+ --voice <NAME> Synthesizer voice
+ --no-manage Do not start the servers; assume they are up
+ --no-partials No partial transcription (saves CPU)
+ --barge-in Allow interrupting the assistant by talking over it
+ --shell Enable system command execution
+ -v, --verbose Debug logging (same as RUST_LOG=debug)
+ -h, --help This help
VARIABLES:
- RUST_LOG Filtro de registro, p. ej. «info,tts=debug,latencia=info»
+ RUST_LOG Log filter, e.g. «info,tts=debug,latency=info»
";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -480,25 +473,23 @@ impl Args {
"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()
+ args.config = raw.next().context("--config needs a path")?.into()
}
- "--voice" => args.voice = Some(raw.next().context("--voice necesita un nombre")?),
+ "--log-dir" => args.log_dir = raw.next().context("--log-dir needs a path")?.into(),
+ "--voice" => args.voice = Some(raw.next().context("--voice needs a name")?),
"--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}"),
+ other => bail!("unknown option: {other}\n\n{USAGE}"),
}
}
Ok(args)
}
- /// La línea de órdenes manda sobre el fichero.
+ /// The command line wins over the file.
fn apply(&self, config: &mut Config) {
if let Some(voice) = &self.voice {
config.tts.voice = voice.clone();
@@ -518,8 +509,8 @@ impl Args {
}
}
-/// Busca un ejecutable en el PATH, para avisar de lo que falta antes de que
-/// falle a mitad de una conversación.
+/// Looks an executable up in PATH, to warn about what is missing before it
+/// fails in the middle of a conversation.
fn which(program: &str) -> Option<PathBuf> {
std::env::var_os("PATH").and_then(|path| {
std::env::split_paths(&path)
@@ -528,7 +519,7 @@ fn which(program: &str) -> Option<PathBuf> {
})
}
-/// ¿Hay una persona al otro lado de la entrada estándar?
+/// Is there a person on the other side of standard input?
fn stdin_is_tty() -> bool {
#[cfg(unix)]
{
@@ -541,10 +532,10 @@ fn stdin_is_tty() -> bool {
true
}
-/// Cierre ordenado ante SIGINT/SIGTERM.
+/// Orderly shutdown on 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.
+/// It matters more than it seems: killing the process outright leaves the
+/// child servers holding GPU memory.
#[cfg(unix)]
fn install_signal_handler(session: Session) {
use std::sync::OnceLock;
@@ -570,9 +561,9 @@ 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.
+ // ONNX Runtime logs every graph transformation at INFO level: several
+ // hundred lines that bury the transcription. It is silenced unless
+ // explicitly requested through RUST_LOG.
let default = if args.verbose {
"debug,ort=warn"
} else {
@@ -581,8 +572,8 @@ fn init_logging(args: &Args) {
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.
+ // The transcription and the answer go to the text terminal; the log
+ // goes to stderr so they can be separated with a redirection.
.with_writer(std::io::stderr)
.with_target(true)
.without_time()
diff --git a/crates/asist-app/src/pipeline.rs b/crates/asist-app/src/pipeline.rs
index 69fc66a..e8eef18 100644
--- a/crates/asist-app/src/pipeline.rs
+++ b/crates/asist-app/src/pipeline.rs
@@ -1,23 +1,23 @@
-//! El orquestador: los hilos del asistente y los canales que los unen.
+//! The orchestrator: the assistant threads and the channels that join them.
//!
//! ```text
-//! micrófono ──muestras──> segmentador ──intervención──> ASR ──texto──┐
-//! (cpal, tiempo real) (VAD, turnos) (Canary) │
-//! v
-//! altavoz <──muestras── síntesis <──frases── conversación <──────────┘
-//! (cpal, anillo) (qwentts) (llama.cpp + herramientas)
+//! microphone ──samples──> segmenter ──utterance──> ASR ──text──┐
+//! (cpal, real time) (VAD, turns) (Canary) │
+//! v
+//! speaker <──samples── synthesis <──sentences── conversation <─┘
+//! (cpal, ring) (qwentts) (llama.cpp + tools)
//! ```
//!
-//! Cada caja es un hilo y cada flecha un canal. Dos consecuencias que valen
-//! por todo el diseño:
+//! Each box is a thread and each arrow a channel. Two consequences carry the
+//! whole design:
//!
-//! * **Nada bloquea al que va delante.** El micrófono nunca espera al ASR, y
-//! el modelo nunca espera al sintetizador; quien va sobrado descarta trabajo
-//! en lugar de acumular retraso.
-//! * **Se responde por frases, no por respuestas.** La conversación entrega
-//! cada frase al sintetizador en cuanto está cerrada, así que el asistente
-//! empieza a hablar mientras el modelo sigue escribiendo. Es lo que separa
-//! una latencia de medio segundo de una de cinco.
+//! * **Nothing blocks the stage ahead.** The microphone never waits for the
+//! ASR, and the model never waits for the synthesizer; whoever has spare
+//! capacity drops work instead of accumulating delay.
+//! * **Answers go out per sentence, not per answer.** The conversation hands
+//! each sentence to the synthesizer as soon as it is closed, so the assistant
+//! starts speaking while the model is still writing. That is what separates
+//! half a second of latency from five.
use std::sync::Arc;
use std::thread::{self, JoinHandle};
@@ -41,17 +41,17 @@ use asist_tts::TtsClient;
use crate::session::Session;
-/// Una frase pendiente de sintetizar.
+/// A sentence waiting to be synthesized.
#[derive(Debug)]
pub struct SpeakJob {
pub turn: TurnId,
pub index: usize,
pub text: String,
- /// Origen desde el que se mide la latencia percibida del turno.
+ /// Origin from which the perceived latency of the turn is measured.
pub turn_started: Instant,
}
-/// Los hilos en marcha, para poder esperarlos al cerrar.
+/// The running threads, so they can be joined on shutdown.
pub struct Pipeline {
handles: Vec<JoinHandle<()>>,
pub events: Receiver<Event>,
@@ -61,7 +61,7 @@ pub struct Pipeline {
}
impl Pipeline {
- /// Monta el pipeline entero. `capture_rx` viene del micrófono.
+ /// Builds the whole pipeline. `capture_rx` comes from the microphone.
#[allow(clippy::too_many_arguments)]
pub fn spawn(
config: &Config,
@@ -78,15 +78,15 @@ impl Pipeline {
let (event_tx, event_rx) = unbounded::<Event>();
let (asr_tx, asr_rx) = unbounded::<AsrJob>();
let (result_tx, result_rx) = unbounded::<AsrResult>();
- // Acotado a propósito: si la síntesis se atasca, el hilo de
- // conversación debe notarlo y frenar en lugar de acumular frases que
- // llegarán tarde a un turno que quizá ya se ha interrumpido.
+ // Bounded on purpose: if synthesis gets stuck, the conversation thread
+ // must notice and slow down instead of piling up sentences that will
+ // arrive late to a turn that may already have been interrupted.
let (speak_tx, speak_rx) = bounded::<SpeakJob>(8);
let metrics = Arc::new(Metrics::default());
let mut handles = Vec::new();
- handles.push(spawn_named("segmentador", {
+ handles.push(spawn_named("segmenter", {
let config = config.clone();
let session = session.clone();
let events = event_tx.clone();
@@ -108,7 +108,7 @@ impl Pipeline {
recognizer.run(asr_rx, result_tx)
}));
- handles.push(spawn_named("conversación", {
+ handles.push(spawn_named("conversation", {
let config = config.clone();
let session = session.clone();
let events = event_tx.clone();
@@ -120,7 +120,7 @@ impl Pipeline {
}
}));
- handles.push(spawn_named("síntesis", {
+ handles.push(spawn_named("synthesis", {
let session = session.clone();
let events = event_tx;
move || run_speech(session, tts, speak_rx, playback, events)
@@ -135,8 +135,8 @@ impl Pipeline {
})
}
- /// Cierra en orden: se corta lo que suena, se suelta el emisor del
- /// micrófono y con eso la cadena de hilos se desmonta sola.
+ /// Shuts down in order: what is playing is cut, the microphone sender is
+ /// dropped, and with that the chain of threads takes itself apart.
pub fn shutdown(mut self) {
self.session.request_stop();
self.capture_tx.take();
@@ -150,11 +150,11 @@ fn spawn_named(name: &str, body: impl FnOnce() + Send + 'static) -> JoinHandle<(
thread::Builder::new()
.name(name.to_string())
.spawn(body)
- .expect("no se pudo crear un hilo del pipeline")
+ .expect("could not create a pipeline thread")
}
// ---------------------------------------------------------------------------
-// Segmentador: micrófono -> intervenciones
+// Segmenter: microphone -> utterances
// ---------------------------------------------------------------------------
fn run_segmenter(
@@ -173,27 +173,27 @@ fn run_segmenter(
if session.is_stopping() {
break;
}
- // El micrófono se cierra mientras suena el altavoz. Con barge-in
- // activo no se cierra, sólo sube el listón de volumen.
+ // The microphone is closed while the speaker is playing. With barge-in
+ // on it is not closed; only the volume bar goes up.
//
- // La segunda condición mira la cola y no una bandera a propósito: la
- // cola no puede quedarse desfasada, y una bandera que se olvide de
- // bajar deja el micrófono cerrado para el resto de la sesión.
+ // The second condition looks at the queue and not at a flag on purpose:
+ // the queue cannot fall out of sync, and a flag someone forgets to lower
+ // leaves the microphone closed for the rest of the session.
segmenter.set_gate(if session.is_speaking() || playback.queued_secs() > 0.0 {
Gate::Speaking
} else {
Gate::Open
});
- // El dispositivo se abre a 16 kHz mono siempre que puede, y entonces
- // esto no hace nada; cuando no puede, es aquí donde se convierte.
+ // The device is opened at 16 kHz mono whenever possible, and then this
+ // does nothing; when it cannot be, this is where it gets converted.
let mono = asist_audio::to_mono_at(&block.samples, input_format, ASR_SAMPLE_RATE);
for event in segmenter.push(&mono) {
match event {
VoiceEvent::BargeIn => {
- // Se corta antes de abrir el turno nuevo: así lo que
- // quedaba en el anillo no se cuela por encima.
+ // Cut before opening the new turn: that way whatever was left
+ // in the ring does not leak over it.
playback.stop();
session.interrupt();
let _ = events.send(Event::Interrupted {
@@ -239,7 +239,7 @@ fn run_segmenter(
}
}
}
- // Lo que quedara a medias al cerrar todavía merece transcribirse.
+ // Whatever was left half-done at shutdown still deserves a transcription.
if let Some(utterance) = segmenter.flush() {
let _ = asr.send(AsrJob::Utterance {
turn,
@@ -250,7 +250,7 @@ fn run_segmenter(
}
// ---------------------------------------------------------------------------
-// Conversación: transcripción -> frases
+// Conversation: transcription -> sentences
// ---------------------------------------------------------------------------
#[allow(clippy::too_many_arguments)]
@@ -297,10 +297,10 @@ fn run_brain(
decode,
spoken_at,
} => {
- // Una transcripción de un turno superado llega tarde: el
- // usuario ya ha dicho otra cosa.
+ // A transcription from an outdated turn arrives too late: the
+ // user has already said something else.
if !session.is_current(turn) {
- tracing::debug!(target: "brain", turno = turn.0, "transcripción obsoleta");
+ tracing::debug!(target: "brain", turn = turn.0, "stale transcription");
continue;
}
let mut timer = TurnTimer::new(turn);
@@ -321,7 +321,7 @@ fn run_brain(
);
if config.general.report_latency {
- tracing::info!(target: "latencia", "\n{}", timer.report());
+ tracing::info!(target: "latency", "\n{}", timer.report());
}
metrics.record_turn(&timer);
}
@@ -329,26 +329,26 @@ fn run_brain(
}
}
-/// Las dos instrucciones de sistema entre las que alterna un turno.
+/// The two system prompts a turn alternates between.
///
-/// Que sean dos no es un capricho de diseño sino un resultado medido: con
-/// Qwen3.5-2B, cualquier indicación de estilo junto a la guía de herramientas
-/// hace que el modelo 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 añadida). Así que la
-/// pasada en que el modelo *decide* si actuar lleva la guía a solas, y la
-/// instrucción de voz se reserva para redactar lo que se va a pronunciar.
+/// Having two is not a design whim but a measured result: with Qwen3.5-2B,
+/// any style instruction next to the tool guide makes the model stop calling
+/// tools and make the data up (8/8 hits with the guide alone, 0/8 with the
+/// voice-assistant persona added). So the pass where the model *decides*
+/// whether to act carries the guide alone, and the voice prompt is kept for
+/// writing what will be spoken.
struct Prompts {
- /// Para la pasada en que el modelo decide si llamar a una herramienta.
+ /// For the pass where the model decides whether to call a tool.
deciding: String,
- /// Para redactar la respuesta hablada, ya con los resultados en la mano.
+ /// For writing the spoken answer, with the results already at hand.
///
- /// Lleva pegada la orden de usar lo que la herramienta devolvió. Sin ella
- /// el modelo se limita a anunciar lo que acaba de hacer —«he tomado una
- /// foto, ahora puedo responderte sobre el objeto o color»— y se deja el
- /// dato que tenía delante. Aquí sí se puede añadir estilo sin riesgo: la
- /// llamada ya ocurrió, así que no hay nada que estropear.
+ /// It carries the instruction to use what the tool returned. Without it the
+ /// model just announces what it did («he tomado una foto, ahora puedo
+ /// responderte sobre el objeto o color») and leaves out the data it had in
+ /// front of it. Style can be added safely here: the call already happened,
+ /// so there is nothing left to break.
speaking: String,
- /// `false` cuando no hay herramientas: entonces ambas son la misma.
+ /// `false` when there are no tools: then both are the same.
two_phase: bool,
}
@@ -372,8 +372,8 @@ impl Prompts {
}
}
-/// Genera la respuesta de un turno, resolviendo herramientas si el modelo las
-/// pide, y va soltando frases al sintetizador según se cierran.
+/// Generates the answer of a turn, resolving tools if the model asks for
+/// them, and releases sentences to the synthesizer as they close.
#[allow(clippy::too_many_arguments)]
fn answer(
config: &Config,
@@ -391,12 +391,12 @@ fn answer(
let tools = (!tools.is_empty() && config.tools.enabled).then_some(tools);
let mut splitter = SentenceSplitter::new();
let mut spoken = String::new();
- // Índice propio, y no el del troceador, porque al flujo de frases se le
- // cuelan los acuses de las herramientas. El índice 0 marca el primer
- // sonido del turno, que es de donde se mide la latencia percibida.
+ // Own index, not the chunker's, because tool acknowledgements slip into
+ // the sentence stream. Index 0 marks the first sound