From 8518a63f55153e7f45fd49ad6caff5555f4e374f Mon Sep 17 00:00:00 2001 From: elvis Date: Sat, 26 Sep 2026 20:20:19 -0300 Subject: Translate code, comments, logs and terminal UI to English; add English README; rename scripts --- crates/asist-core/src/config.rs | 271 ++++++++++++++++++------------------- crates/asist-core/src/error.rs | 30 ++-- crates/asist-core/src/event.rs | 52 +++---- crates/asist-core/src/http.rs | 66 ++++----- crates/asist-core/src/lib.rs | 10 +- crates/asist-core/src/proc.rs | 80 +++++------ crates/asist-core/src/telemetry.rs | 88 ++++++------ crates/asist-core/src/text.rs | 124 ++++++++--------- crates/asist-core/src/tools.rs | 158 ++++++++++----------- 9 files changed, 440 insertions(+), 439 deletions(-) (limited to 'crates/asist-core') diff --git a/crates/asist-core/src/config.rs b/crates/asist-core/src/config.rs index c2a9d55..0a8cc04 100644 --- a/crates/asist-core/src/config.rs +++ b/crates/asist-core/src/config.rs @@ -1,5 +1,5 @@ -//! Configuración del asistente: un TOML con valores por defecto que ya -//! incorporan lo aprendido midiendo el pipeline (ver `docs/RENDIMIENTO.md`). +//! Assistant configuration: a TOML file whose defaults already include what +//! was learned by measuring the pipeline (see `docs/RENDIMIENTO.md`). use std::path::{Path, PathBuf}; @@ -26,32 +26,31 @@ pub struct Config { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct General { - /// Idioma de la conversación; se propaga a ASR y TTS. + /// Conversation language; propagated to ASR and TTS. pub language: String, - /// Instrucción de sistema. Pide frases cortas y sin markdown a propósito: - /// el TTS lee literalmente los asteriscos y las viñetas. + /// System prompt. It asks for short sentences without markdown on purpose: + /// the TTS reads asterisks and bullets literally. pub system_prompt: String, - /// Instrucción de sistema de la pasada en que el modelo decide si usar - /// una herramienta. + /// System prompt for the pass where the model decides whether to use a + /// tool. /// - /// Va aparte, y **sustituye** a `system_prompt` en esa pasada, por una - /// razón medida y no por gusto: con Qwen3.5-2B, añadir cualquier - /// indicación de estilo a esta guía —en cualquier posición, incluso dos - /// palabras— hace que el modelo deje de llamar a las herramientas y se - /// invente el dato. Medido sobre 8 intentos: la guía sola acierta 8/8; - /// con «Responde breve.» detrás, 1/8; con la persona de asistente de voz, - /// 0/8. Ver docs/RENDIMIENTO.md. + /// It is separate, and **replaces** `system_prompt` in that pass, for a + /// measured reason rather than taste: with Qwen3.5-2B, adding any style + /// instruction to this guide (in any position, even two words) makes the + /// model stop calling tools and make the data up. Measured over 8 tries: + /// the guide alone gets 8/8; with «Responde breve.» after it, 1/8; with the + /// voice-assistant persona, 0/8. See docs/RENDIMIENTO.md. pub tools_prompt: String, - /// Se añade a `system_prompt` para redactar la respuesta cuando una - /// herramienta ya ha devuelto su resultado. + /// Appended to `system_prompt` to write the answer once a tool has + /// returned its result. /// - /// Aquí sí se puede añadir estilo sin miedo —la llamada ya ocurrió—, y - /// hace falta: sin esta orden el modelo anuncia lo que acaba de hacer en - /// vez de contar lo que averiguó. + /// Here style can be added safely (the call already happened), and it is + /// needed: without this instruction the model announces what it just did + /// instead of telling what it found out. pub tool_result_prompt: String, - /// Turnos de historial que se envían al modelo (0 = sin memoria). + /// History turns sent to the model (0 = no memory). pub history_turns: usize, - /// Imprime un resumen de latencias por turno al terminar cada respuesta. + /// Prints a per-turn latency summary at the end of each answer. pub report_latency: bool, } @@ -89,14 +88,14 @@ impl Default for General { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct AudioConfig { - /// Nombre (o subcadena) del dispositivo de entrada; vacío = el de por defecto. + /// Name (or substring) of the input device; empty = the default one. pub input_device: String, - /// Ídem para la salida. + /// Same for the output. pub output_device: String, - /// Segundos de audio que la reproducción mantiene en cola antes de - /// arrancar. Amortigua los baches del TTS sin añadir latencia perceptible. + /// Seconds of audio playback keeps queued before starting. It absorbs + /// TTS hiccups without adding noticeable latency. pub playback_prebuffer: f32, - /// Ganancia aplicada a la reproducción. + /// Gain applied to playback. pub output_gain: f32, } @@ -114,28 +113,28 @@ impl Default for AudioConfig { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct VadConfig { - /// Ventana de audio por decisión del detector. + /// Audio window per detector decision. pub frame_seconds: f32, - /// Audio conservado antes del inicio de voz para no cortar la primera sílaba. + /// Audio kept before speech onset so the first syllable is not cut. pub preroll_seconds: f32, - /// Silencio que cierra una intervención. + /// Silence that ends an utterance. pub silence_hold: f32, - /// Intervención más corta que merezca una transcripción final. + /// Shortest utterance worth a final transcription. pub min_utterance: f32, - /// Corte forzado, que acota el coste de la decodificación final. + /// Forced cut, which bounds the cost of the final decode. pub max_utterance: f32, - /// Múltiplo del suelo de ruido a partir del cual se considera voz. + /// Multiple of the noise floor above which audio counts as speech. pub threshold_factor: f32, pub min_threshold: f32, pub max_threshold: f32, - /// Permite hablar encima del asistente para cortarlo. + /// Lets the user talk over the assistant to interrupt it. /// - /// Desactivado por defecto: con altavoces abiertos el micrófono se oye a sí - /// mismo y el asistente se interrumpe solo. Actívalo con auriculares o con - /// cancelación de eco del sistema. + /// Off by default: with open speakers the microphone hears itself and the + /// assistant interrupts itself. Turn it on with headphones or with system + /// echo cancellation. pub barge_in: bool, - /// Con barge-in activo, cuánto más fuerte que el umbral normal debe sonar - /// la voz para cortar. Sube el listón frente al eco del altavoz. + /// With barge-in on, how much louder than the normal threshold the voice + /// must be to interrupt. Raises the bar against speaker echo. pub barge_in_factor: f32, } @@ -159,23 +158,23 @@ impl Default for VadConfig { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct AsrConfig { - /// Carpeta del modelo Canary en ONNX. + /// Directory of the Canary ONNX model. pub model_dir: PathBuf, pub source_lang: String, pub target_lang: String, - /// Ventana deslizante de las transcripciones provisionales. + /// Sliding window for the partial transcriptions. pub window: f32, - /// Avance de la ventana entre decodificaciones. + /// Window step between decodes. pub step: f32, - /// Ventanas que deben coincidir para dar una palabra por estable. + /// Windows that must agree for a word to be considered stable. pub stability: usize, - /// Emitir transcripciones provisionales. Cuestan CPU y sólo sirven para - /// verlas en pantalla: el turno se dispara con la final. + /// Emit partial transcriptions. They cost CPU and are only there to be + /// shown on screen: the turn is triggered by the final one. pub partials: bool, - /// Carga una segunda instancia del modelo para que las decodificaciones - /// finales no bloqueen a las provisionales. Duplica la memoria. + /// Loads a second model instance so final decodes do not block the + /// partial ones. Doubles the memory. pub dedicated_final_model: bool, - /// Proveedor de ejecución de ONNX Runtime: `cpu`, `cuda`, ... + /// ONNX Runtime execution provider: `cpu`, `cuda`, ... pub execution_provider: String, pub inter_threads: usize, pub intra_threads: usize, @@ -192,8 +191,8 @@ impl Default for AsrConfig { stability: 2, partials: true, dedicated_final_model: false, - // CPU a propósito: la GPU de 4 GB está ocupada por el hablante del - // TTS, y disputársela sale más caro que decodificar en CPU. + // CPU on purpose: the 4 GB GPU is taken by the TTS talker, and fighting + // over it costs more than decoding on the CPU. execution_provider: "cpu".into(), inter_threads: 2, intra_threads: 4, @@ -213,7 +212,7 @@ pub struct LlmConfig { pub min_p: f32, pub repeat_penalty: f32, pub max_tokens: u32, - /// Vueltas máximas del bucle de herramientas antes de rendirse. + /// Maximum rounds of the tool loop before giving up. pub max_tool_rounds: usize, pub request_timeout_secs: u64, } @@ -229,8 +228,8 @@ impl Default for LlmConfig { top_k: 40, min_p: 0.1, repeat_penalty: 1.1, - // Una respuesta hablada larga cansa; el recorte también acota el - // coste de la síntesis, que es la etapa lenta. + // A long spoken answer is tiring; the cap also bounds the cost of + // synthesis, which is the slow stage. max_tokens: 300, max_tool_rounds: 4, request_timeout_secs: 120, @@ -243,9 +242,9 @@ impl Default for LlmConfig { pub struct TtsConfig { pub host: String, pub port: u16, - /// Voz registrada en el servidor, o una del modelo. + /// Voice registered on the server, or one of the model's. pub voice: String, - /// Voz clonada que se registra al arrancar, si se define. + /// Cloned voice registered at startup, if defined. pub reference: Option, pub language: String, pub temperature: f32, @@ -253,9 +252,8 @@ pub struct TtsConfig { pub top_p: f32, pub repetition_penalty: f32, pub max_new_tokens: u32, - /// Sintetiza una frase corta al arrancar. La primera petición paga la - /// construcción de los grafos: ~3,5 s que conviene no gastar en el - /// primer turno real. + /// Synthesizes a short sentence at startup. The first request pays for + /// building the graphs: ~3.5 s better not spent on the first real turn. pub warmup: bool, pub request_timeout_secs: u64, } @@ -279,52 +277,51 @@ impl Default for TtsConfig { } } -/// Latentes de una voz clonada, tal y como los produce `qwen-codec --talker`. +/// Latents of a cloned voice, as produced by `qwen-codec --talker`. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ReferenceVoice { - /// Nombre con el que se registra en el servidor. + /// Name it is registered under on the server. pub name: String, - /// Embedding del hablante (`.spk`). + /// Speaker embedding (`.spk`). pub speaker: PathBuf, - /// Códigos de referencia (`.rvq`), que activan el clonado ICL. + /// Reference codes (`.rvq`), which enable ICL cloning. pub codes: PathBuf, - /// Transcripción de la referencia (`.txt`). + /// Transcript of the reference (`.txt`). pub transcript: PathBuf, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct ToolsConfig { - /// Deja que el modelo llame a herramientas. + /// Lets the model call tools. pub enabled: bool, - /// Usa `general.tools_prompt` en solitario para la pasada en que el - /// modelo decide si llamar a una herramienta, y `general.system_prompt` - /// sólo para redactar la respuesta hablada. + /// Uses `general.tools_prompt` alone for the pass where the model decides + /// whether to call a tool, and `general.system_prompt` only to write the + /// spoken answer. /// - /// Es lo único que hace que las herramientas funcionen de verdad con este - /// modelo (ver `tools_prompt`), a cambio de que las respuestas que no - /// usan herramienta pierdan la guía de estilo. Se compensa en parte - /// porque el limpiador de texto quita el markdown antes de hablar. - /// Ponlo a `false` para priorizar el estilo sobre las herramientas. + /// It is the only thing that makes tools really work with this model (see + /// `tools_prompt`), at the cost of answers that use no tool losing the + /// style guide. That is partly offset by the text cleaner removing + /// markdown before speaking. Set it to `false` to favour style over tools. pub dedicated_prompt: bool, - /// Pronuncia una frase corta al empezar una herramienta lenta, para que la - /// espera no se confunda con un cuelgue. + /// Says a short sentence when a slow tool starts, so the wait is not + /// mistaken for a hang. pub spoken_ack: bool, - /// Habilita la herramienta de ejecución de órdenes del sistema. + /// Enables the tool that runs system commands. /// - /// Apagada por defecto a conciencia: darle una shell a un modelo que - /// obedece a lo que oye por el micrófono es un cambio de postura de - /// seguridad, no una opción de comodidad. + /// Off by default on purpose: giving a shell to a model that obeys what + /// it hears through the microphone is a change of security posture, not a + /// convenience option. pub shell: bool, - /// Órdenes admitidas, comparadas contra el ejecutable (argv[0]). - /// Una lista vacía deniega todo aunque `shell` esté activo. + /// Allowed commands, matched against the executable (argv[0]). + /// An empty list denies everything even if `shell` is on. pub shell_allowlist: Vec, - /// Segundos que puede durar una orden antes de que se la mate. + /// Seconds a command may run before it is killed. pub shell_timeout_secs: u64, - /// Registra la orden pero no la ejecuta. Útil para estrenar la lista blanca. + /// Logs the command but does not run it. Useful to break in the allowlist. pub shell_dry_run: bool, - /// Directorio de trabajo de las órdenes; vacío = el del proceso. + /// Working directory of the commands; empty = the process one. pub shell_working_dir: String, } @@ -349,29 +346,28 @@ impl Default for ToolsConfig { } } -/// Búsqueda en internet. +/// Web search. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct SearchConfig { pub enabled: bool, - /// `tavily` (necesita clave), `ddgs` (sin clave) o `searxng` (necesita - /// una instancia propia). + /// `tavily` (needs a key), `ddgs` (no key) or `searxng` (needs your own + /// instance). pub backend: String, - /// Variable de entorno de la que se lee la clave. + /// Environment variable the key is read from. /// - /// La clave se lee del entorno y no del fichero a propósito: la - /// configuración se versiona y se comparte, y un secreto ahí dentro acaba - /// en el historial de git. + /// The key comes from the environment and not from the file on purpose: + /// the configuration is versioned and shared, and a secret in it ends up in + /// the git history. pub api_key_env: String, - /// URL base de la instancia de SearXNG. + /// Base URL of the SearXNG instance. pub base_url: String, - /// Programa que ejecuta el backend `ddgs`, con sus argumentos. + /// Program that runs the `ddgs` backend, with its arguments. /// - /// `{consulta}` y `{max}` se sustituyen antes de ejecutar. Tiene que - /// escribir JSON por la salida estándar; se admiten tanto la lista suelta - /// que devuelve ddgs como la forma `{answer, results}`. Cualquier otro - /// programa que respete eso sirve igual, incluido un puente a un servidor - /// MCP. + /// `{query}` and `{max}` are substituted before running. It must write + /// JSON to standard output; both the bare list ddgs returns and the + /// `{answer, results}` shape are accepted. Any other program that honours + /// that works too, including a bridge to an MCP server. pub command: Vec, pub max_results: usize, pub timeout_secs: u64, @@ -385,35 +381,35 @@ impl Default for SearchConfig { api_key_env: "TAVILY_API_KEY".into(), base_url: String::new(), command: vec![ - "scripts/buscar-ddgs.sh".into(), - "{consulta}".into(), + "scripts/search-ddgs.sh".into(), + "{query}".into(), "{max}".into(), ], - // Cinco resultados llenan bien el contexto sin ahogar a un modelo - // de 2B, que con más se pierde entre fuentes. + // Five results fill the context well without drowning a 2B model, which + // gets lost between sources with more. max_results: 5, timeout_secs: 20, } } } -/// Mirar por la cámara. +/// Looking through the camera. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct CameraConfig { - /// Si se apaga, la herramienta no llega a registrarse y el modelo ni - /// sabe que existe. También se apaga sola si no hay dispositivo. + /// If off, the tool is never registered and the model does not even know + /// it exists. It also turns itself off if there is no device. pub enabled: bool, pub device: PathBuf, - /// Resolución de captura. Manda mucho en la latencia: medido, el modelo - /// tarda 1,3 s a 320x240, 2,9 s a 640x480 y 7,8 s a 1280x720. + /// Capture resolution. It weighs heavily on latency: measured, the model + /// takes 1.3 s at 320x240, 2.9 s at 640x480 and 7.8 s at 1280x720. pub width: u32, pub height: u32, - /// Fotogramas descartados para que la exposición automática se asiente. + /// Frames discarded so auto-exposure can settle. pub warmup_frames: u32, pub timeout_secs: u64, - /// Carpeta donde guardar cada fotograma capturado. Vacío = no se guarda - /// ninguno, que es lo que corresponde por defecto. + /// Directory where each captured frame is saved. Empty = none is saved, + /// which is the right default. pub save_dir: String, } @@ -431,28 +427,27 @@ impl Default for CameraConfig { } } -/// Mirar la pantalla. +/// Looking at the screen. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct ScreenConfig { pub enabled: bool, - /// Programa de captura. `{ancho}` y `{salida}` se sustituyen antes de - /// ejecutar, y tiene que escribir un JPEG por la salida estándar. Está - /// fuera del binario para que añadir un compositor sea editar un guion. + /// Capture program. `{width}` and `{output}` are substituted before + /// running, and it must write a JPEG to standard output. It lives outside + /// the binary so supporting another compositor means editing a script. pub command: Vec, - /// Ancho al que se reduce la captura antes de mandarla al modelo. + /// Width the capture is scaled down to before sending it to the model. /// - /// **No lo bajes a la ligera.** Medido con tipografía de interfaz de 13 px - /// y preguntando por datos concretos: a 1280 px acierta 3 de 3 en 7,6 s; a - /// 960, 2 de 3; a 640, 1 de 3. Y cuando falla no dice que no lo lee: se - /// inventa el contenido con aplomo. + /// **Do not lower it lightly.** Measured with 13 px UI type and asking for + /// specific details: at 1280 px it gets 3 of 3 in 7.6 s; at 960, 2 of 3; + /// at 640, 1 of 3. And when it fails it does not say it cannot read it: it + /// confidently makes the content up. pub width: u32, - /// Monitor concreto. Vacío = todo lo que haya. + /// A specific monitor. Empty = everything there is. pub output: String, pub timeout_secs: u64, - /// Carpeta donde guardar las capturas. Vacío = no se guarda ninguna, que - /// es lo que corresponde: en una captura caben contraseñas y mensajes - /// privados. + /// Directory where captures are saved. Empty = none is saved, which is + /// right: a capture can hold passwords and private messages. pub save_dir: String, } @@ -461,9 +456,9 @@ impl Default for ScreenConfig { Self { enabled: true, command: vec![ - "scripts/capturar-pantalla.sh".into(), - "{ancho}".into(), - "{salida}".into(), + "scripts/capture-screen.sh".into(), + "{width}".into(), + "{output}".into(), ], width: 1280, output: String::new(), @@ -476,9 +471,9 @@ impl Default for ScreenConfig { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct SupervisorConfig { - /// Lanza los servidores en lugar de suponer que ya están arriba. + /// Starts the servers instead of assuming they are already up. pub manage: bool, - /// Segundos de espera a que un servidor conteste a `/health`. + /// Seconds to wait for a server to answer `/health`. pub startup_timeout_secs: u64, pub llama: LlamaProcess, pub tts: TtsProcess, @@ -501,11 +496,11 @@ pub struct LlamaProcess { pub binary: PathBuf, pub model: PathBuf, pub mmproj: PathBuf, - /// Plantilla de chat que se pasa con `--chat-template-file`. + /// Chat template passed with `--chat-template-file`. /// - /// La del modelo abre `` sin cerrarlo nunca, y el asistente se pasa - /// entonces varios segundos razonando antes de decir la primera palabra. - /// Esta copia deja el bloque cerrado de entrada. + /// The model's own template opens `` and never closes it, so the + /// assistant spends several seconds reasoning before saying the first + /// word. This copy starts with the block already closed. pub chat_template: PathBuf, pub extra_args: Vec, } @@ -560,11 +555,11 @@ pub struct TtsProcess { pub binary: PathBuf, pub model: PathBuf, pub codec: PathBuf, - /// Segundos de audio que el codec acumula antes de decodificar un bloque. + /// Seconds of audio the codec accumulates before decoding a block. /// - /// El valor de fábrica son 24 s, que en la práctica significa «no - /// devuelvas nada hasta terminar la frase entera»: medido, baja el primer - /// audio de 4,9 s a 0,6 s. Es el ajuste con más efecto de todo el sistema. + /// The stock value is 24 s, which in practice means «return nothing until + /// the whole sentence is done»: measured, it lowers the first audio from + /// 4.9 s to 0.6 s. It is the single most effective setting in the system. pub codec_chunk_dur: f32, pub extra_args: Vec, } @@ -588,8 +583,8 @@ impl Config { .map_err(|e| Error::Config(format!("no se pudo leer {}: {e}", path.display())))?; let mut config: Config = toml::from_str(&raw).map_err(|e| Error::Config(format!("{}: {e}", path.display())))?; - // Las rutas relativas se resuelven contra la carpeta del TOML, no - // contra el directorio desde el que se lanza el binario. + // Relative paths are resolved against the TOML directory, not against + // the directory the binary is launched from. if let Some(base) = path.parent().filter(|p| !p.as_os_str().is_empty()) { config.rebase(base); } @@ -597,7 +592,7 @@ impl Config { Ok(config) } - /// Reinterpreta las rutas relativas respecto de `base`. + /// Reinterprets relative paths relative to `base`. pub fn rebase(&mut self, base: &Path) { let fix = |p: &mut PathBuf| { if p.is_relative() { @@ -667,7 +662,7 @@ impl Config { )); } } - "ddgs" | "comando" => { + "ddgs" | "command" | "comando" => { if self.search.command.is_empty() { return Err(Error::Config( "search.backend = «ddgs» necesita search.command".into(), diff --git a/crates/asist-core/src/error.rs b/crates/asist-core/src/error.rs index b559bee..f0dfbe2 100644 --- a/crates/asist-core/src/error.rs +++ b/crates/asist-core/src/error.rs @@ -2,12 +2,12 @@ use std::fmt; pub type Result = std::result::Result; -/// Un fallo de una etapa del pipeline. El orquestador decide si es fatal o si -/// basta con abortar el turno en curso, así que el error lleva esa distinción -/// en lugar de dejarla al criterio de quien lo recibe. +/// A failure in a pipeline stage. The orchestrator decides whether it is +/// fatal or whether aborting the current turn is enough, so the error carries +/// that distinction instead of leaving it to whoever receives it. #[derive(Debug, thiserror::Error)] pub enum Error { - #[error("configuración inválida: {0}")] + #[error("invalid configuration: {0}")] Config(String), #[error("audio: {0}")] @@ -22,24 +22,24 @@ pub enum Error { #[error("TTS: {0}")] Tts(String), - #[error("HTTP {status} en {url}: {body}")] + #[error("HTTP {status} at {url}: {body}")] Http { status: u16, url: String, body: String, }, - #[error("transporte hacia {url}: {source}")] + #[error("transport to {url}: {source}")] Transport { url: String, #[source] source: std::io::Error, }, - #[error("herramienta «{tool}»: {message}")] + #[error("tool «{tool}»: {message}")] Tool { tool: String, message: String }, - #[error("operación cancelada")] + #[error("operation cancelled")] Cancelled, #[error(transparent)] @@ -50,17 +50,17 @@ pub enum Error { } impl Error { - /// Un error fatal tumba el asistente; el resto sólo aborta el turno. + /// A fatal error brings the assistant down; the rest only abort the turn. /// - /// Los servidores locales se caen y se reinician, y el usuario prefiere - /// oír «no te he entendido» a que el proceso muera, así que sólo la - /// configuración y el audio (que no se pueden reintentar) son fatales. + /// The local servers crash and restart, and the user would rather hear «no + /// te he entendido» than have the process die, so only configuration and + /// audio (which cannot be retried) are fatal. pub fn is_fatal(&self) -> bool { matches!(self, Error::Config(_) | Error::Audio(_)) } - /// `true` cuando reintentar tiene sentido: el servidor aún está cargando - /// el modelo, o se ha caído la conexión a mitad de una petición. + /// `true` when retrying makes sense: the server is still loading the + /// model, or the connection dropped mid-request. pub fn is_retryable(&self) -> bool { match self { Error::Transport { .. } => true, @@ -70,7 +70,7 @@ impl Error { } } -/// Contexto legible para los `Result` que cruzan una frontera de crate. +/// Readable context for `Result`s that cross a crate boundary. pub trait Context { fn ctx(self, f: impl FnOnce() -> String) -> Result; } diff --git a/crates/asist-core/src/event.rs b/crates/asist-core/src/event.rs index 549bec0..bf9a6e6 100644 --- a/crates/asist-core/src/event.rs +++ b/crates/asist-core/src/event.rs @@ -1,9 +1,9 @@ use std::time::{Duration, Instant}; -/// Identifica un turno de conversación (una intervención del usuario y la -/// respuesta que provoca). Todo lo que viaja por el bus lo lleva, para que un -/// resultado que llega tarde no se confunda con el turno que ya está en curso -/// —el caso típico cuando el usuario interrumpe al asistente. +/// Identifies a conversation turn (one user utterance and the answer it +/// triggers). Everything travelling on the bus carries it, so a result that +/// arrives late is not mistaken for the turn in progress, the typical case +/// being the user interrupting the assistant. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] pub struct TurnId(pub u64); @@ -13,32 +13,32 @@ impl std::fmt::Display for TurnId { } } -/// Motivo por el que se corta la reproducción en curso. +/// Why the current playback is cut. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InterruptReason { - /// El usuario ha empezado a hablar encima del asistente (barge-in). + /// The user started talking over the assistant (barge-in). UserSpoke, - /// Petición explícita: tecla, señal o cierre. + /// Explicit request: key, signal or shutdown. Requested, } -/// Todo lo que las etapas se cuentan entre sí. Un único enum mantiene el bus -/// observable: el renderizador y la telemetría ven exactamente los mismos -/// eventos que el orquestador, sin canales paralelos que se desincronicen. +/// Everything the stages tell each other. A single enum keeps the bus +/// observable: the renderer and the telemetry see exactly the same events as +/// the orchestrator, with no parallel channels that fall out of sync. #[derive(Debug, Clone)] pub enum Event { - /// El VAD ha detectado el arranque de una intervención. + /// The VAD detected the start of an utterance. SpeechStarted { turn: TurnId, at: Instant }, - /// Transcripción provisional de la ventana deslizante: `committed` ya es - /// estable, `volatile` todavía puede cambiar. + /// Partial transcription of the sliding window: `committed` is already + /// stable, `volatile` may still change. Partial { turn: TurnId, committed: String, volatile: String, }, - /// Transcripción definitiva de la intervención completa. + /// Final transcription of the whole utterance. Transcript { turn: TurnId, text: String, @@ -46,33 +46,33 @@ pub enum Event { decode: Duration, }, - /// La intervención no contenía nada transcribible. + /// The utterance had nothing transcribable. Discarded { turn: TurnId }, - /// Primer fragmento de texto que devuelve el modelo. + /// First text fragment returned by the model. ReplyStarted { turn: TurnId, ttft: Duration }, - /// Un trozo de respuesta según llega del modelo. + /// A piece of the answer as it arrives from the model. ReplyDelta { turn: TurnId, text: String }, - /// Una frase completa lista para sintetizar. + /// A complete sentence ready to be synthesized. Sentence { turn: TurnId, index: usize, text: String, }, - /// Respuesta completa del modelo para este turno. + /// Complete model answer for this turn. ReplyDone { turn: TurnId, text: String }, - /// El modelo ha pedido ejecutar una herramienta. + /// The model asked to run a tool. ToolRequested { turn: TurnId, name: String, arguments: String, }, - /// Resultado de esa ejecución. + /// Result of that run. ToolFinished { turn: TurnId, name: String, @@ -81,22 +81,22 @@ pub enum Event { took: Duration, }, - /// Primer audio audible del turno: la métrica que de verdad percibe quien habla. + /// First audible audio of the turn: the metric the speaker really perceives. AudioStarted { turn: TurnId, latency: Duration }, - /// Se ha terminado de reproducir todo lo del turno. + /// Everything of the turn has finished playing. AudioFinished { turn: TurnId }, - /// Se ha cortado la reproducción. + /// Playback was cut. Interrupted { turn: TurnId, reason: InterruptReason, }, - /// Aviso no fatal; el turno continúa. + /// Non-fatal warning; the turn continues. Warning { turn: TurnId, message: String }, - /// Fallo que aborta el turno. + /// Failure that aborts the turn. Failed { turn: TurnId, message: String }, /// Cierre ordenado. diff --git a/crates/asist-core/src/http.rs b/crates/asist-core/src/http.rs index 32b6680..3273bb8 100644 --- a/crates/asist-core/src/http.rs +++ b/crates/asist-core/src/http.rs @@ -1,12 +1,12 @@ -//! Cliente HTTP/1.1 mínimo para hablar con los servidores locales. +//! Minimal HTTP/1.1 client to talk to the local servers. //! -//! Los tres motores (llama-server, tts-server) escuchan en localhost sobre HTTP -//! plano, así que no hace falta TLS ni un runtime asíncrono. A cambio de las -//! ~300 líneas de aquí se gana lo único que ninguna librería genérica da -//! cómodo y que este pipeline necesita de verdad: **abortar una respuesta a -//! media descarga**. Cuando el usuario interrumpe al asistente hay que dejar de -//! leer audio ya generado y cerrar el socket en ese mismo instante; con un -//! cliente que sólo expone `read_to_end` eso no se puede hacer. +//! The engines (llama-server, tts-server) listen on localhost over plain +//! HTTP, so neither TLS nor an async runtime is needed. In exchange for the +//! ~300 lines here we get the one thing no generic library makes easy and +//! this pipeline really needs: **aborting a response mid-download**. When the +//! user interrupts the assistant, reading already-generated audio must stop +//! and the socket must close that very instant; a client that only exposes +//! `read_to_end` cannot do that. use std::io::{BufRead, BufReader, Read, Write}; use std::net::TcpStream; @@ -16,11 +16,11 @@ use std::time::Duration; use crate::error::{Error, Result}; -/// Bandera compartida que aborta una lectura en curso. +/// Shared flag that aborts a read in progress. /// -/// Se comprueba entre bloques, así que el corte ocurre como muy tarde un -/// `read` después de activarla —del orden de milisegundos con los tamaños de -/// bloque que usan los servidores. +/// It is checked between blocks, so the cut happens at most one `read` after +/// it is set, in the order of milliseconds with the block sizes the servers +/// use. #[derive(Clone, Default)] pub struct Cancel(Arc); @@ -44,11 +44,11 @@ impl Cancel { #[derive(Clone, Debug)] pub struct HttpClient { - /// `host:puerto` del servidor local. + /// `host:port` of the local server. pub authority: String, pub connect_timeout: Duration, - /// Tiempo máximo sin recibir un solo byte. No es el tiempo total: una - /// síntesis larga puede tardar minutos siempre que siga fluyendo. + /// Maximum time without receiving a single byte. It is not the total time: + /// a long synthesis can take minutes as long as it keeps flowing. pub read_timeout: Duration, } @@ -75,7 +75,7 @@ impl HttpClient { } })?; stream.set_read_timeout(Some(self.read_timeout))?; - // Los cuerpos son pequeños y la latencia manda: nada de Nagle. + // Bodies are small and latency rules: no Nagle. let _ = stream.set_nodelay(true); Ok(stream) } @@ -106,22 +106,22 @@ impl HttpClient { Response::read_head(BufReader::new(stream), url) } - /// GET que devuelve el cuerpo completo ya decodificado. + /// GET returning the whole, already decoded body. pub fn get(&self, path: &str) -> Result> { self.send("GET", path, None)?.into_body() } - /// POST de JSON que devuelve el cuerpo completo. + /// JSON POST returning the whole body. pub fn post_json(&self, path: &str, body: &serde_json::Value) -> Result> { let payload = serde_json::to_vec(body)?; self.send("POST", path, Some(&payload))?.into_body() } - /// POST de JSON cuya respuesta se consume a trozos según llega. + /// JSON POST whose response is consumed in chunks as it arrives. /// - /// `on_chunk` recibe cada bloque en cuanto está disponible y devuelve - /// `false` para cortar; junto a `cancel` son las dos vías por las que el - /// barge-in detiene una síntesis a medias. + /// `on_chunk` gets each block as soon as it is available and returns + /// `false` to stop; together with `cancel` they are the two ways barge-in + /// stops a synthesis midway. pub fn post_json_streaming( &self, path: &str, @@ -135,7 +135,7 @@ impl HttpClient { response.stream(cancel, &mut on_chunk) } - /// POST de JSON que entrega la respuesta línea a línea (SSE de llama-server). + /// JSON POST that delivers the response line by line (llama-server SSE). pub fn post_json_lines( &self, path: &str, @@ -157,7 +157,7 @@ impl HttpClient { }) } - /// Sondea `/health` y devuelve `true` si el servidor está listo. + /// Polls `/health` and returns `true` if the server is ready. pub fn healthy(&self, path: &str) -> bool { self.get(path).is_ok() } @@ -172,14 +172,14 @@ fn resolve(authority: &str, path: &str) -> Result { source: e, })? .next() - .ok_or_else(|| Error::Config(format!("no se pudo resolver «{authority}»"))) + .ok_or_else(|| Error::Config(format!("could not resolve «{authority}»"))) } -/// Codificación del cuerpo anunciada por el servidor. +/// Body encoding announced by the server. enum Body { Chunked, Length(usize), - /// Sin longitud ni chunked: el cuerpo acaba cuando cierra el socket. + /// No length and not chunked: the body ends when the socket closes. UntilClose, } @@ -203,7 +203,9 @@ impl Response { .split_whitespace() .nth(1) .and_then(|s| s.parse::().ok()) - .ok_or_else(|| Error::Config(format!("respuesta HTTP ilegible de {url}: {line:?}")))?; + .ok_or_else(|| { + Error::Config(format!("unreadable HTTP response from {url}: {line:?}")) + })?; let mut body = Body::UntilClose; loop { @@ -246,7 +248,7 @@ impl Response { } let status = self.status; let url = self.url.clone(); - // Leer el cuerpo del error es lo que convierte un «500» en un mensaje útil. + // Reading the error body is what turns a «500» into a useful message. let body = self.drain().unwrap_or_default(); Err(Error::Http { status, @@ -291,11 +293,11 @@ impl Response { if size_line.is_empty() { continue; } - // Las extensiones de chunk van tras un ';' y aquí no interesan. + // Chunk extensions come after a ';' and do not matter here. let size = usize::from_str_radix(size_line.split(';').next().unwrap_or("0").trim(), 16) .map_err(|_| { - Error::Config(format!("tamaño de chunk ilegible: {size_line:?}")) + Error::Config(format!("unreadable chunk size: {size_line:?}")) })?; if size == 0 { return Ok(()); @@ -312,7 +314,7 @@ impl Response { } left -= want; } - // CRLF de cierre del chunk. + // Closing CRLF of the chunk. let mut crlf = [0u8; 2]; self.reader.read_exact(&mut crlf).map_err(io)?; }, diff --git a/crates/asist-core/src/lib.rs b/crates/asist-core/src/lib.rs index 87fbc75..176b934 100644 --- a/crates/asist-core/src/lib.rs +++ b/crates/asist-core/src/lib.rs @@ -1,9 +1,9 @@ -//! Tipos, configuración y utilidades compartidas por todo el asistente. +//! Types, configuration and utilities shared by the whole assistant. //! -//! Este crate no habla con ningún motor: sólo define el vocabulario común -//! (eventos, errores, configuración, métricas) y las dos piezas de -//! infraestructura que los demás crates necesitan: un cliente HTTP mínimo -//! para hablar con los servidores locales, y el registro de herramientas. +//! This crate talks to no engine: it only defines the common vocabulary +//! (events, errors, configuration, metrics) and the two pieces of +//! infrastructure the other crates need: a minimal HTTP client to talk to +//! the local servers, and the tool registry. pub mod config; pub mod error; diff --git a/crates/asist-core/src/proc.rs b/crates/asist-core/src/proc.rs index 8e38108..976a2a8 100644 --- a/crates/asist-core/src/proc.rs +++ b/crates/asist-core/src/proc.rs @@ -1,12 +1,15 @@ -//! Ejecutar un programa con un plazo máximo. +//! Running a program with a deadline. //! -//! Tres sitios lo necesitaban con los mismos cuidados —no dejar nunca un -//! proceso colgado, no pasar por una shell, distinguir «falló» de «tardó -//! demasiado»— y cada uno lo tenía escrito a su manera. Aquí está una sola vez. +//! Three places needed it with the same care (never leave a process hanging, +//! never go through a shell, tell «failed» from «took too long») and each had +//! its own version. Here it is, once. //! -//! Nada de esto pasa por `sh`: los argumentos van al `execve` tal cual. Importa -//! más de lo que parece, porque lo que acaba en ellos viene, en última -//! instancia, de lo que se ha oído por el micrófono. +//! None of this goes through `sh`: the arguments go to `execve` as they are. +//! It matters more than it seems, because what ends up in them comes, +//! ultimately, from what the microphone heard. +//! +//! The error messages are in Spanish on purpose: they end up in tool results +//! that the model reads and speaks. use std::ffi::OsStr; use std::io::Read; @@ -14,7 +17,7 @@ use std::path::Path; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; -/// Cómo terminó. +/// How it ended. #[derive(Debug)] pub struct Output { pub stdout: Vec, @@ -28,8 +31,8 @@ impl Output { self.status == Some(0) } - /// Última línea del error, que es donde los programas suelen poner el - /// motivo de verdad. + /// Last line of stderr, which is where programs usually put the real + /// reason. pub fn last_error_line(&self) -> &str { self.stderr .trim() @@ -57,11 +60,11 @@ pub enum ProcError { }, } -/// Lanza `program`, espera hasta `timeout` y devuelve lo que haya escrito. +/// Launches `program`, waits up to `timeout` and returns whatever it wrote. /// -/// Si vence el plazo, mata el proceso antes de rendirse: un ffmpeg contra una -/// cámara ocupada, o un buscador que no contesta, se quedarían ahí para -/// siempre. +/// If the deadline passes, it kills the process before giving up: an ffmpeg +/// against a busy camera, or a search engine that does not answer, would stay +/// there forever. pub fn run( program: impl AsRef, args: impl IntoIterator>, @@ -87,14 +90,13 @@ pub fn run( source, })?; - // Las tuberías se vacían en hilos aparte, y no después de esperar. + // The pipes are drained on separate threads, not after waiting. // - // Esto no es una optimización: un hijo que escribe más de lo que cabe en el - // búfer de la tubería (64 KB en Linux) se queda bloqueado escribiendo, no - // termina nunca, y el bucle de espera acaba matándolo por plazo vencido - // aunque estuviera haciendo su trabajo. Costó descubrirlo porque los tres - // primeros usos —una fecha, un JSON de búsqueda, un fotograma de 9 KB— - // cabían de sobra; la primera captura de pantalla, de 180 KB, no. + // This is not an optimization: a child that writes more than fits in the + // pipe buffer (64 KB on Linux) blocks writing, never finishes, and the wait + // loop ends up killing it for missing the deadline even though it was doing + // its job. It was hard to find because the first three uses (a date, a + // search JSON, a 9 KB frame) fit easily; the first 180 KB screenshot did not. let mut stdout = child.stdout.take(); let mut stderr = child.stderr.take(); let stdout_reader = std::thread::spawn(move || { @@ -117,8 +119,8 @@ pub fn run( match child.try_wait() { Ok(Some(status)) => break status, Ok(None) if Instant::now() >= deadline => { - // Matar cierra las tuberías, así que los lectores terminan y - // se pueden recoger sin quedarse colgados. + // Killing closes the pipes, so the readers finish and can be + // joined without hanging. let _ = child.kill(); let _ = child.wait(); let _ = stdout_reader.join(); @@ -154,75 +156,75 @@ mod tests { use super::*; #[test] - fn se_recoge_la_salida_estandar() { + fn stdout_is_collected() { let out = run("echo", ["hola"], Duration::from_secs(5), None).unwrap(); assert!(out.success()); assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hola"); } #[test] - fn un_proceso_colgado_se_mata_al_vencer_el_plazo() { + fn hung_process_is_killed_at_the_deadline() { let started = Instant::now(); let err = run("sleep", ["30"], Duration::from_millis(300), None).unwrap_err(); assert!(matches!(err, ProcError::Timeout { .. }), "{err}"); assert!( started.elapsed() < Duration::from_secs(3), - "debió cortar enseguida, tardó {:?}", + "should have stopped right away, took {:?}", started.elapsed() ); } #[test] - fn una_salida_grande_no_bloquea_al_hijo() { - // La regresión que dejó colgada la captura de pantalla: sin vaciar la - // tubería mientras se espera, un hijo que escribe más de 64 KB se - // bloquea y acaba muriendo por plazo vencido. + fn large_output_does_not_block_the_child() { + // The regression that hung the screenshot: without draining the pipe + // while waiting, a child that writes more than 64 KB blocks and ends up + // dying for missing the deadline. let out = run( "dd", ["if=/dev/zero", "bs=1024", "count=512", "status=none"], Duration::from_secs(10), None, ) - .expect("no debió vencer el plazo"); + .expect("the deadline should not have passed"); assert!(out.success()); assert_eq!(out.stdout.len(), 512 * 1024); } #[test] - fn el_error_grande_tampoco_bloquea() { - // Mismo problema por la otra tubería: mucho ruido en stderr y poca - // salida es justo lo que hace ffmpeg cuando algo va mal. + fn large_stderr_does_not_block_either() { + // Same problem on the other pipe: lots of noise on stderr and little + // output is exactly what ffmpeg does when something goes wrong. let out = run( "sh", ["-c", "yes error | head -c 200000 >&2; echo ok"], Duration::from_secs(10), None, ) - .expect("no debió vencer el plazo"); + .expect("the deadline should not have passed"); assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "ok"); assert!(out.stderr.len() > 100_000); } #[test] - fn un_programa_inexistente_da_un_error_con_su_nombre() { + fn missing_program_error_includes_its_name() { let err = run("/no/existe/nada", ["x"], Duration::from_secs(1), None).unwrap_err(); assert!(err.to_string().contains("/no/existe/nada"), "{err}"); } #[test] - fn un_fallo_conserva_el_codigo_y_el_mensaje() { + fn failure_keeps_the_code_and_message() { let out = run("ls", ["/no/existe"], Duration::from_secs(5), None).unwrap(); assert!(!out.success()); assert!(!out.last_error_line().is_empty()); } #[test] - fn los_metacaracteres_no_los_interpreta_ninguna_shell() { + fn no_shell_interprets_metacharacters() { let out = run("echo", ["a; echo b"], Duration::from_secs(5), None).unwrap(); assert_eq!( String::from_utf8_lossy(&out.stdout).trim(), "a; echo b", - "el punto y coma debe llegar como texto, no como separador de órdenes" + "the semicolon must arrive as text, not as a command separator" ); } } diff --git a/crates/asist-core/src/telemetry.rs b/crates/asist-core/src/telemetry.rs index 964aaa6..43a1de0 100644 --- a/crates/asist-core/src/telemetry.rs +++ b/crates/asist-core/src/telemetry.rs @@ -1,9 +1,9 @@ -//! Medición de latencia por turno. +//! Per-turn latency measurement. //! -//! El objetivo no es un histograma bonito sino contestar a una pregunta -//! concreta cada vez que el asistente responde: *¿quién se ha comido el -//! tiempo?* Por eso se marcan los cinco instantes que separan las etapas y se -//! imprime el desglose, en lugar de un único total que no dice dónde mirar. +//! The goal is not a pretty histogram but answering one concrete question every +//! time the assistant replies: *who ate the time?* That is why the five +//! instants separating the stages are marked and the breakdown is printed, +//! instead of a single total that does not say where to look. use std::collections::BTreeMap; use std::sync::Mutex; @@ -11,22 +11,22 @@ use std::time::{Duration, Instant}; use crate::event::TurnId; -/// Etapas del pipeline, en el orden en que ocurren. +/// Pipeline stages, in the order they happen. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Stage { - /// Del final del habla a la transcripción definitiva. + /// From end of speech to the final transcription. Asr, - /// De la transcripción al primer fragmento de texto del modelo. + /// From the transcription to the model's first text fragment. LlmFirstToken, - /// Del primer fragmento a la respuesta completa. + /// From the first fragment to the complete answer. LlmRest, - /// Ejecución de herramientas. + /// Tool execution. Tools, - /// De la primera frase al primer audio recibido. + /// From the first sentence to the first audio received. TtsFirstAudio, - /// Síntesis del resto del turno. + /// Synthesis of the rest of the turn. TtsRest, - /// Del final del habla al primer audio: lo que de verdad se percibe. + /// From end of speech to first audio: what is really perceived. PerceivedLatency, } @@ -34,26 +34,26 @@ impl Stage { pub fn label(self) -> &'static str { match self { Stage::Asr => "asr.final", - Stage::LlmFirstToken => "llm.primer_token", - Stage::LlmRest => "llm.resto", - Stage::Tools => "herramientas", - Stage::TtsFirstAudio => "tts.primer_audio", - Stage::TtsRest => "tts.resto", - Stage::PerceivedLatency => "LATENCIA PERCIBIDA", + Stage::LlmFirstToken => "llm.first_token", + Stage::LlmRest => "llm.rest", + Stage::Tools => "tools", + Stage::TtsFirstAudio => "tts.first_audio", + Stage::TtsRest => "tts.rest", + Stage::PerceivedLatency => "PERCEIVED LATENCY", } } } -/// Cronómetro de un turno. Se rellena a medida que el turno avanza y se -/// vuelca de una vez al terminar. +/// Stopwatch for one turn. Filled in as the turn progresses and dumped +/// in one go at the end. #[derive(Debug)] pub struct TurnTimer { pub turn: TurnId, started: Instant, marks: BTreeMap, - /// Segundos de audio hablados por el usuario, para calcular el RTF del ASR. + /// Seconds of audio spoken by the user, for the ASR RTF. pub input_secs: f32, - /// Segundos de audio sintetizados, para el RTF del TTS. + /// Seconds of audio synthesized, for the TTS RTF. pub output_secs: f32, pub sentences: usize, pub tool_calls: usize, @@ -72,15 +72,15 @@ impl TurnTimer { } } - /// Instante de referencia del turno: el fin del habla del usuario. + /// Reference instant of the turn: the end of the user's speech. pub fn started(&self) -> Instant { self.started } pub fn record(&mut self, stage: Stage, took: Duration) { - // Etapas que se repiten (una síntesis por frase) se acumulan; las que - // marcan un hito (primer audio) se quedan con la primera medida, que - // es la que describe la latencia de arranque. + // Stages that repeat (one synthesis per sentence) accumulate; the ones + // that mark a milestone (first audio) keep the first measurement, which + // is the one describing startup latency. match stage { Stage::TtsFirstAudio | Stage::LlmFirstToken | Stage::PerceivedLatency => { self.marks.entry(stage).or_insert(took); @@ -89,7 +89,7 @@ impl TurnTimer { } } - /// Marca una etapa con el tiempo transcurrido desde el inicio del turno. + /// Marks a stage with the time elapsed since the start of the turn. pub fn mark_since_start(&mut self, stage: Stage) { let took = self.started.elapsed(); self.record(stage, took); @@ -103,7 +103,7 @@ impl TurnTimer { self.started.elapsed() } - /// Etapa que más ha tardado, excluyendo el total percibido (que las agrega). + /// Slowest stage, excluding the perceived total (which aggregates them). pub fn bottleneck(&self) -> Option<(Stage, Duration)> { self.marks .iter() @@ -112,13 +112,13 @@ impl TurnTimer { .map(|(stage, took)| (*stage, *took)) } - /// Informe de una línea por etapa, con el cuello de botella señalado. + /// One-line-per-stage report, with the bottleneck flagged. pub fn report(&self) -> String { - let mut out = format!("turno {} — desglose de latencia\n", self.turn); + let mut out = format!("turn {} — latency breakdown\n", self.turn); let worst = self.bottleneck().map(|(stage, _)| stage); for (stage, took) in &self.marks { let flag = if Some(*stage) == worst { - " <== cuello de botella" + " <== bottleneck" } else { "" }; @@ -132,7 +132,7 @@ impl TurnTimer { if self.input_secs > 0.0 { if let Some(asr) = self.get(Stage::Asr) { out.push_str(&format!( - " {:<20} {:>8.2} x tiempo real ({:.1} s de voz)\n", + " {:<20} {:>8.2} x real time ({:.1} s of speech)\n", "asr.rtf", asr.as_secs_f32() / self.input_secs, self.input_secs @@ -143,7 +143,7 @@ impl TurnTimer { let tts: Duration = self.get(Stage::TtsFirstAudio).unwrap_or_default() + self.get(Stage::TtsRest).unwrap_or_default(); out.push_str(&format!( - " {:<20} {:>8.2} x tiempo real ({:.1} s de audio, {} frases)\n", + " {:<20} {:>8.2} x real time ({:.1} s of audio, {} sentences)\n", "tts.rtf", tts.as_secs_f32() / self.output_secs, self.output_secs, @@ -152,14 +152,14 @@ impl TurnTimer { } out.push_str(&format!( " {:<20} {:>8.0} ms\n", - "total del turno", + "turn total", self.total().as_secs_f64() * 1000.0 )); out } } -/// Agregado de todos los turnos de la sesión, para el resumen del cierre. +/// Aggregate of every turn in the session, for the closing summary. #[derive(Debug, Default)] pub struct Metrics { inner: Mutex, @@ -186,13 +186,13 @@ impl Metrics { self.inner.lock().unwrap_or_else(|e| e.into_inner()).turns } - /// Media por etapa sobre toda la sesión. + /// Per-stage average over the whole session. pub fn summary(&self) -> String { let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); if inner.turns == 0 { - return "sin turnos completados\n".into(); + return "no completed turns\n".into(); } - let mut out = format!("resumen de {} turno(s) — media por etapa\n", inner.turns); + let mut out = format!("summary of {} turn(s) — average per stage\n", inner.turns); for (stage, (total, count)) in &inner.totals { out.push_str(&format!( " {:<20} {:>8.0} ms\n", @@ -209,7 +209,7 @@ mod tests { use super::*; #[test] - fn el_cuello_de_botella_es_la_etapa_mas_lenta() { + fn bottleneck_is_the_slowest_stage() { let mut timer = TurnTimer::new(TurnId(1)); timer.record(Stage::Asr, Duration::from_millis(300)); timer.record(Stage::LlmFirstToken, Duration::from_millis(500)); @@ -218,7 +218,7 @@ mod tests { } #[test] - fn la_latencia_percibida_no_compite_como_cuello_de_botella() { + fn perceived_latency_is_not_a_bottleneck_candidate() { let mut timer = TurnTimer::new(TurnId(1)); timer.record(Stage::Asr, Duration::from_millis(300)); timer.record(Stage::PerceivedLatency, Duration::from_secs(9)); @@ -226,7 +226,7 @@ mod tests { } #[test] - fn las_etapas_repetidas_se_acumulan_y_los_hitos_no() { + fn repeated_stages_accumulate_and_milestones_do_not() { let mut timer = TurnTimer::new(TurnId(1)); timer.record(Stage::TtsRest, Duration::from_millis(100)); timer.record(Stage::TtsRest, Duration::from_millis(150)); @@ -237,12 +237,12 @@ mod tests { assert_eq!( timer.get(Stage::TtsFirstAudio), Some(Duration::from_millis(600)), - "el primer audio describe el arranque; una frase posterior no debe rebajarlo" + "first audio describes startup; a later sentence must not lower it" ); } #[test] - fn el_resumen_promedia_entre_turnos() { + fn summary_averages_across_turns() { let metrics = Metrics::default(); for ms in [100, 300] { let mut timer = TurnTimer::new(TurnId(1)); diff --git a/crates/asist-core/src/text.rs b/crates/asist-core/src/text.rs index e799561..30eef9d 100644 --- a/crates/asist-core/src/text.rs +++ b/crates/asist-core/src/text.rs @@ -1,29 +1,30 @@ -//! Preparación del texto que va a leerse en voz alta. +//! Preparing the text that will be read aloud. //! -//! Dos problemas distintos, ambos en el camino crítico de la latencia: +//! Two different problems, both on the latency critical path: //! -//! 1. **Trocear en frases mientras el modelo escribe.** Esperar a la respuesta -//! completa antes de sintetizar suma el tiempo del LLM al del TTS. Cortando -//! por frases, el asistente empieza a hablar mientras sigue pensando. -//! 2. **Quitar lo que no se pronuncia.** El sintetizador lee los asteriscos y -//! las almohadillas tal cual, así que el markdown que se le escapa al modelo -//! hay que limpiarlo antes. +//! 1. **Splitting into sentences while the model writes.** Waiting for the +//! whole answer before synthesizing adds the LLM time to the TTS time. +//! Cutting per sentence, the assistant starts speaking while still thinking. +//! 2. **Removing what is not pronounced.** The synthesizer reads asterisks and +//! hashes literally, so markdown that slips out of the model must be +//! cleaned first. -/// Trocea un flujo de texto en frases pronunciables. +/// Splits a text stream into speakable sentences. /// -/// La primera frase sale en cuanto es mínimamente decente y las siguientes -/// esperan a tener más cuerpo: el arranque manda en la latencia percibida, -/// pero una vez que suena la voz, las frases largas se entonan mejor. +/// The first sentence goes out as soon as it is minimally decent and the next +/// ones wait until they have more body: the start dominates perceived +/// latency, but once the voice is playing, longer sentences get better +/// intonation. #[derive(Debug)] pub struct SentenceSplitter { buffer: String, emitted: usize, - /// Longitud mínima de la primera frase. + /// Minimum length of the first sentence. first_min: usize, - /// Longitud mínima de las siguientes. + /// Minimum length of the following ones. rest_min: usize, - /// Longitud a partir de la cual se corta aunque no haya puntuación, para - /// que un modelo que no puntúa no deje al asistente mudo. + /// Length after which the text is cut even without punctuation, so a + /// model that does not punctuate does not leave the assistant mute. hard_max: usize, } @@ -57,7 +58,7 @@ impl SentenceSplitter { self.emitted } - /// Añade texto recién llegado y devuelve las frases que ya están completas. + /// Adds newly arrived text and returns the sentences already complete. pub fn push(&mut self, delta: &str) -> Vec { self.buffer.push_str(delta); let mut out = Vec::new(); @@ -67,7 +68,7 @@ impl SentenceSplitter { out } - /// Entrega lo que quede al terminar la respuesta. + /// Delivers whatever is left when the answer ends. pub fn flush(&mut self) -> Option { let rest = clean_for_speech(&std::mem::take(&mut self.buffer)); if !is_speakable(&rest) { @@ -90,15 +91,15 @@ impl SentenceSplitter { let head: String = self.buffer.drain(..cut).collect(); let head = clean_for_speech(&head); if !is_speakable(&head) { - // Sólo era puntuación o markdown: se descarta sin gastar un turno - // de síntesis, pero el corte ya se ha consumido. + // It was only punctuation or markdown: dropped without spending a + // synthesis turn, but the cut has been consumed. return self.take_ready(); } self.emitted += 1; Some(head) } - /// Índice de byte por el que cortar, si hay alguno. + /// Byte index to cut at, if any. fn boundary(&self) -> Option { let min = self.min_len(); let mut last_soft = None; @@ -109,20 +110,20 @@ impl SentenceSplitter { continue; } if is_terminator(c) { - // El punto de una abreviatura o de un decimal no cierra frase. + // The dot of an abbreviation or a decimal does not end a sentence. if c == '.' && !ends_sentence(&self.buffer, i) { continue; } - // Sólo cierra si viene un espacio detrás, o si ya no queda - // nada: si no, se estaría cortando a mitad de «3.14». + // It only closes if a space follows, or if nothing is left: + // otherwise it would cut in the middle of «3.14». match self.buffer[end..].chars().next() { Some(next) if next.is_whitespace() => return Some(end), None => {} Some(_) => continue, } } - // Una coma o un punto y coma valen como corte de emergencia si la - // frase ya se ha pasado de largo. + // A comma or semicolon counts as an emergency cut if the sentence + // has already run too long. if matches!(c, ',' | ';' | ':') { last_soft = Some(end); } @@ -134,11 +135,11 @@ impl SentenceSplitter { } } -/// ¿Hay algo que pronunciar? +/// Is there anything to pronounce? /// -/// Un fragmento que sólo tiene puntuación —lo que queda al limpiar un «**» o -/// unos puntos suspensivos sueltos— cuesta una síntesis entera y no suena a -/// nada, así que no llega a salir del troceador. +/// A fragment with only punctuation (what is left after cleaning a «**» or a +/// stray ellipsis) costs a whole synthesis and sounds like nothing, so it +/// never leaves the splitter. pub fn is_speakable(text: &str) -> bool { text.chars().any(char::is_alphanumeric) } @@ -147,10 +148,11 @@ fn is_terminator(c: char) -