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 --- .gitignore | 6 +- .gitmodules | 8 +- Cargo.toml | 16 +- LICENSE | 2 +- README.es.md | 217 +++++++++++++ README.md | 299 +++++++++--------- config/asistente.toml | 173 +++++------ crates/asist-app/src/main.rs | 275 ++++++++--------- crates/asist-app/src/pipeline.rs | 176 +++++------ crates/asist-app/src/registry.rs | 34 +-- crates/asist-app/src/render.rs | 47 ++- crates/asist-app/src/session.rs | 50 ++- crates/asist-app/src/supervisor.rs | 78 ++--- crates/asist-app/tests/integracion.rs | 557 ---------------------------------- crates/asist-app/tests/integration.rs | 549 +++++++++++++++++++++++++++++++++ crates/asist-asr/Cargo.toml | 2 +- crates/asist-asr/src/lib.rs | 110 +++---- crates/asist-audio/src/capture.rs | 48 +-- crates/asist-audio/src/lib.rs | 58 ++-- crates/asist-audio/src/playback.rs | 117 +++---- crates/asist-audio/src/vad.rs | 130 ++++---- 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 +++++----- crates/asist-llm/src/chat.rs | 46 +-- crates/asist-llm/src/client.rs | 98 +++--- crates/asist-llm/src/lib.rs | 10 +- crates/asist-tools/Cargo.toml | 6 +- crates/asist-tools/src/camera.rs | 56 ++-- crates/asist-tools/src/lib.rs | 14 +- crates/asist-tools/src/screen.rs | 88 +++--- crates/asist-tools/src/search.rs | 122 ++++---- crates/asist-tools/src/vision.rs | 66 ++-- crates/asist-tts/src/lib.rs | 85 +++--- docs/EXTENDER.md | 18 +- docs/RENDIMIENTO.md | 6 +- scripts/bootstrap.sh | 155 +++++----- scripts/buscar-ddgs.sh | 54 ---- scripts/capturar-pantalla.sh | 52 ---- scripts/capture-screen.sh | 53 ++++ scripts/clonar-voz.sh | 47 --- scripts/clone-voice.sh | 47 +++ scripts/search-ddgs.sh | 54 ++++ scripts/servers.sh | 84 +++++ scripts/servidores.sh | 83 ----- 51 files changed, 2654 insertions(+), 2421 deletions(-) create mode 100644 README.es.md delete mode 100644 crates/asist-app/tests/integracion.rs create mode 100644 crates/asist-app/tests/integration.rs delete mode 100755 scripts/buscar-ddgs.sh delete mode 100755 scripts/capturar-pantalla.sh create mode 100755 scripts/capture-screen.sh delete mode 100755 scripts/clonar-voz.sh create mode 100755 scripts/clone-voice.sh create mode 100755 scripts/search-ddgs.sh create mode 100755 scripts/servers.sh delete mode 100755 scripts/servidores.sh diff --git a/.gitignore b/.gitignore index 23c627e..7e83e52 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ /target/ /logs/ -# Enlaces a los pesos, que se recrean con scripts/bootstrap.sh +# Links to the weights, recreated by scripts/bootstrap.sh /models/* !/models/.gitkeep + +# Cloned voices: they are someone's voice, so they stay local. +# Create one with scripts/clone-voice.sh. +/assets/voices/ diff --git a/.gitmodules b/.gitmodules index 2a8747c..b07c676 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,8 +1,8 @@ -# Los tres motores, fijados al commit contra el que se midió el sistema. +# The three engines, pinned to the commit the system was measured against. # -# «ignore = dirty» es a propósito: scripts/bootstrap.sh aplica sobre cada uno -# el parche de vendor/patches/, así que su árbol de trabajo queda modificado -# siempre. Los cambios se versionan ahí, no como commits del submódulo. +# «ignore = dirty» is on purpose: scripts/bootstrap.sh applies the patch from +# vendor/patches/ on each of them, so their working tree is always modified. +# The changes are versioned there, not as submodule commits. [submodule "canary-rs"] path = vendor/canary-rs url = https://github.com/mmende/canary-rs.git diff --git a/Cargo.toml b/Cargo.toml index fc2b030..788c0ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,10 +9,10 @@ members = [ "crates/asist-tools", "crates/asist-app", ] -# Los submódulos son dependencias por ruta, no miembros del workspace: sin esto -# cargo los adopta por estar dentro del directorio, y `cargo test` acabaría -# ejecutando las pruebas de integración de canary-rs, que esperan un modelo en -# su propia ruta por defecto y fallan aquí. +# The submodules are path dependencies, not workspace members: without this +# cargo adopts them for being inside the directory, and `cargo test` would end +# up running canary-rs's integration tests, which expect a model at their own +# default path and fail here. exclude = ["vendor"] [workspace.package] @@ -31,10 +31,10 @@ asist-tts = { path = "crates/asist-tts" } asist-tools = { path = "crates/asist-tools" } canary-rs = { path = "vendor/canary-rs", default-features = false, features = ["cpu", "ort-defaults"] } -# canary-rs pide "2.0.0-rc.12", que en semver de prelanzamientos también admite -# rc.13 — y ahí el módulo CoreML pasó a estar tras una feature, así que sus -# reexportaciones incondicionales dejan de compilar. Se fija la versión contra -# la que canary-rs se construye de verdad. +# canary-rs asks for "2.0.0-rc.12", which under prerelease semver also admits +# rc.13, and there the CoreML module moved behind a feature, so its +# unconditional re-exports stop compiling. Pin the version canary-rs really +# builds against. ort = "=2.0.0-rc.12" anyhow = "1" diff --git a/LICENSE b/LICENSE index a8836d2..acdf183 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Elvis Claros +Copyright (c) 2026 Elvis Claros Castro Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.es.md b/README.es.md new file mode 100644 index 0000000..3bd21ac --- /dev/null +++ b/README.es.md @@ -0,0 +1,217 @@ +# asist-p — asistente de voz local + +[English](README.md) + +Escucha por el micrófono, piensa y contesta hablando. Todo corre en la +máquina; sólo la búsqueda en internet, que es opcional, sale a la red. + +Une tres motores que ya existían —**Canary** para oír, **llama.cpp** para +pensar y **qwentts.cpp** para hablar— en un pipeline de hilos y canales en +Rust donde ninguna etapa espera a la siguiente. + +``` + 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) +``` + +Cada caja es un hilo; cada flecha, un canal. Dos decisiones sostienen el +resto: + +- **Nada bloquea al que va delante.** El micrófono nunca espera al + reconocedor, y el modelo nunca espera al sintetizador. Quien va sobrado + descarta trabajo en lugar de acumular retraso. +- **Se responde por frases, no por respuestas.** Cada frase sale hacia el + sintetizador en cuanto se cierra, así que el asistente empieza a hablar + mientras el modelo sigue escribiendo. Es lo que separa medio segundo de + cinco. + +## Puesta en marcha + +```bash +git clone --recursive https://git.all.ar/pub/asist-p.git && cd asist-p +scripts/bootstrap.sh # submódulos, parches, binarios y modelos +cargo run --release -- check # comprueba que está todo en su sitio +cargo run --release -- run # a hablar +``` + +`bootstrap.sh` es idempotente y no copia modelos: los enlaza desde donde ya +estén (`~/GIT-MIRRO`, `~/HF`). Si no encuentra los motores construidos, los +construye, y eso sí tarda. + +```bash +cargo run --release -- devices # listar dispositivos de audio +cargo run --release -- run --barge-in # permitir cortar al asistente +cargo run --release -- run --shell # habilitar órdenes del sistema +cargo run --release -- run --no-manage # no lanzar los servidores +``` + +Durante el desarrollo conviene dejar los servidores levantados aparte —cargar +los modelos cuesta más de un minuto— y reiniciar sólo el binario de Rust: + +```bash +scripts/servers.sh start +cargo run --release -- run --no-manage +``` + +## Cómo está organizado + +| Crate | De qué se ocupa | +|---|---| +| `asist-core` | Configuración, eventos, errores, cliente HTTP, telemetría y el registro de herramientas | +| `asist-audio` | Captura y reproducción con cpal, detección de voz, troceado en turnos | +| `asist-asr` | Canary en ONNX: ventana deslizante y transcripción final | +| `asist-llm` | llama-server: streaming SSE, historial, llamadas a herramientas | +| `asist-tts` | qwentts: síntesis en streaming y voces clonadas | +| `asist-tools` | Buscar en internet, mirar por la cámara y ver la pantalla | +| `asist-app` | Supervisor de procesos, orquestador y binario `asistente` | + +Los tres motores viven en `vendor/` como submódulos fijados a un commit +concreto. Los cambios locales sobre ellos están en `vendor/patches/`, y +`bootstrap.sh` los aplica: sin ellos el reconocedor no se comporta como el +que se midió. + +## Configuración + +Todo está en [`config/asistente.toml`](config/asistente.toml), comentado. Los +valores que llevan una cifra en el comentario salen de una medición concreta, +no de una suposición; el detalle está en +[docs/RENDIMIENTO.md](docs/RENDIMIENTO.md). + +Lo que más se toca: + +```toml +[vad] +silence_hold = 0.8 # cuánto silencio cierra tu intervención +barge_in = false # cortar al asistente hablando encima + +[general] +history_turns = 8 # memoria de la conversación +system_prompt = "..." # carácter y estilo + +[tools] +shell = false # ejecutar órdenes del sistema +``` + +## Interrumpir y el eco del altavoz + +Por defecto el asistente es **media dúplex**: mientras habla, el micrófono +está cerrado. No es pereza: con altavoces abiertos el micrófono se oye a sí +mismo, el asistente se transcribe y se responde solo. + +Con auriculares, `--barge-in` deja hablar encima para cortarlo. El umbral se +eleva (`vad.barge_in_factor`) para que el eco no baste y una voz de verdad sí. + +## Latencia + +Un turno real, medido de punta a punta: + +| Etapa | Tiempo | +|---|---| +| Reconocimiento (2,5 s de voz) | 200 ms | +| Modelo, primer token | 1660 ms | +| Modelo, resto | 1362 ms | +| Herramientas | 1 ms | +| **Del final de tu frase al primer audio** | **3213 ms** | + +El binario mide esto en cada turno y señala la etapa más lenta. Tres ajustes +de configuración valieron más que cualquier cambio de código: + +- El TTS decodificaba en bloques de 24 s: **4948 ms → 585 ms** al primer audio. +- La plantilla del modelo dejaba `` abierto: **8630 ms → 413 ms** al + primer token. +- El prompt de estilo anulaba las llamadas a herramientas: **0/8 → 8/8**. +- La cámara capturaba a 720p: **7,8 s → 2,9 s** por imagen a 640x480. +- Las herramientas se ejecutaban en silencio: **~8,4 s → 4,1 s** hasta oír algo. +- La pantalla a 640 px inventaba el texto: **1 de 3 aciertos → 3 de 3** a 1280 px. + +Todos, con el porqué y cómo se midieron, en +[docs/RENDIMIENTO.md](docs/RENDIMIENTO.md). + +## Herramientas + +El asistente puede llamar a funciones. Vienen cuatro: + +| Herramienta | Qué hace | Coste medido | +|---|---|---| +| `hora_actual` | Fecha y hora del sistema | 0 ms | +| `buscar_en_internet` | Busca y resume | 0,5–3,6 s | +| `mirar_por_la_camara` | Hace una foto y responde sobre lo que ve | 4,1–5,2 s | +| `mirar_la_pantalla` | Captura la pantalla y responde sobre lo que hay | 7,6–12,4 s | +| `ejecutar_comando` | Órdenes del sistema, **apagada** por defecto | — | + +Mientras una herramienta trabaja, el asistente dice «Déjame que lo busque» o +«Voy a mirar». No es adorno: sin eso el turno pasa seis segundos en silencio y +se lee como un cuelgue. Con el acuse, el primer audio llega en 3–4 s. + +**Buscar** admite tres buscadores: `tavily` (con clave, devuelve una respuesta +ya redactada), `ddgs` (sin clave, la misma librería que hay bajo +`duckduckgo-mcp`) y `searxng`. El de comando ejecuta un guion que escriba JSON, +así que meter otro es escribir un guion, no tocar Rust. + +**Ver** —cámara y pantalla— aprovecha que el servidor ya carga el proyector +multimodal: el mismo modelo que conversa describe la imagen. Ni la foto ni la +captura se guardan en disco, y no entran en el historial. + +La pantalla usa 1280 px y la cámara 640, y la diferencia importa: una escena se +entiende, pero un texto hay que leerlo. Medido, a 640 px el modelo no dice que +no lee la pantalla, **se inventa lo que pone** —contestó que la reunión era «a +las 10:00» cuando ponía 15:30—. Por eso el triple de coste. + +`ejecutar_comando` está apagada 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. +Cuando se enciende, sólo pasan las órdenes de una lista blanca, sin shell que +interprete metacaracteres y con un plazo máximo. + +Añadir una propia son unas veinte líneas: +[docs/EXTENDER.md](docs/EXTENDER.md). + +## Pruebas + +Más de cien pruebas unitarias que no necesitan ni modelos ni micrófono, y +pruebas de integración contra los servidores de verdad. + +```bash +cargo test # unitarias, sin modelos +scripts/servers.sh start +cargo test --release -p asist-app --test integration -- --nocapture +cargo test --release -p asist-app --test integration -- --ignored # bucle completo +``` + +Las de integración se saltan solas si no hay servidores escuchando, y se +turnan la GPU con un mutex: los dos servidores comparten una tarjeta de 4 GB y +en paralelo miden contención en vez de latencia. + +La prueba marcada `--ignored` cierra el bucle sin micrófono: sintetiza una +frase y comprueba que el reconocedor la recupera. + +## Requisitos + +- Rust 1.85 o posterior +- CUDA para los motores en C++ (funcionan en CPU, pero muy justos) +- PipeWire o ALSA +- ~6 GB de disco para los modelos +- `ffmpeg` para la cámara y para reducir las capturas +- Para la pantalla: `grim` (Wayland) o `maim`/ImageMagick (X11) +- Para buscar: una clave de Tavily en `$TAVILY_API_KEY`, o `uv tool install ddgs` + +## Voz + +El repositorio no trae ninguna voz clonada: una voz es de alguien. Para que el +asistente hable con una, grabá unos segundos de audio con su transcripción y +extraé los latentes: + +```bash +scripts/clone-voice.sh grabacion.wav transcripcion.txt +``` + +Quedan en `assets/voices/` (fuera de git). Sin voz clonada, quitá la sección +`[tts.reference]` de `config/asistente.toml` y el asistente usa una voz del +propio modelo. + +## Licencia + +MIT + diff --git a/README.md b/README.md index bb7c407..6832f32 100644 --- a/README.md +++ b/README.md @@ -1,197 +1,224 @@ -# asist-p — asistente de voz local +# asist-p — local voice assistant -Escucha por el micrófono, piensa y contesta hablando. Todo en la máquina: no -sale un byte a internet. +[Español](README.es.md) -Une tres motores que ya existían —**Canary** para oír, **llama.cpp** para -pensar y **qwentts.cpp** para hablar— en un pipeline de hilos y canales en -Rust donde ninguna etapa espera a la siguiente. +It listens through the microphone, thinks and answers out loud. Everything +runs on the machine; only the optional web search goes online. + +It joins three existing engines (**Canary** to hear, **llama.cpp** to think +and **qwentts.cpp** to speak) in a Rust pipeline of threads and channels +where no stage waits for the next. ``` - 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; cada flecha, un canal. Dos decisiones sostienen el -resto: +Each box is a thread; each arrow, a channel. Two decisions carry the rest: + +- **Nothing blocks the stage ahead.** The microphone never waits for the + recognizer, 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.** Each sentence goes to the + synthesizer as soon as it closes, so the assistant starts talking while the + model is still writing. That is what separates half a second from five. -- **Nada bloquea al que va delante.** El micrófono nunca espera al - reconocedor, y el modelo nunca espera al sintetizador. Quien va sobrado - descarta trabajo en lugar de acumular retraso. -- **Se responde por frases, no por respuestas.** Cada frase sale hacia el - sintetizador en cuanto se cierra, así que el asistente empieza a hablar - mientras el modelo sigue escribiendo. Es lo que separa medio segundo de - cinco. +The assistant speaks Spanish: its prompts, tool descriptions and spoken +acknowledgements are in Spanish on purpose. The code is in English. -## Puesta en marcha +## Getting started ```bash -git clone --recursive && cd asist-p -scripts/bootstrap.sh # submódulos, parches, binarios y modelos -cargo run --release -- check # comprueba que está todo en su sitio -cargo run --release -- run # a hablar +git clone --recursive https://git.all.ar/pub/asist-p.git && cd asist-p +scripts/bootstrap.sh # submodules, patches, binaries and models +cargo run --release -- check # check that everything is in place +cargo run --release -- run # start talking ``` -`bootstrap.sh` es idempotente y no copia modelos: los enlaza desde donde ya -estén (`~/GIT-MIRRO`, `~/HF`). Si no encuentra los motores construidos, los -construye, y eso sí tarda. +`bootstrap.sh` is idempotent and does not copy models: it links them from +wherever they already are (`$ASIST_MIRROR`, default `~/GIT-MIRRO`, and +`$ASIST_HF`, default `~/HF`). If it cannot find the engines built, it builds +them, and that does take a while. ```bash -cargo run --release -- devices # listar dispositivos de audio -cargo run --release -- run --barge-in # permitir cortar al asistente -cargo run --release -- run --shell # habilitar órdenes del sistema -cargo run --release -- run --no-manage # no lanzar los servidores +cargo run --release -- devices # list audio devices +cargo run --release -- run --barge-in # allow interrupting the assistant +cargo run --release -- run --shell # enable system commands +cargo run --release -- run --no-manage # do not start the servers ``` -Durante el desarrollo conviene dejar los servidores levantados aparte —cargar -los modelos cuesta más de un minuto— y reiniciar sólo el binario de Rust: +While developing, keep the servers running separately (loading the models +takes more than a minute) and restart only the Rust binary: ```bash -scripts/servidores.sh arrancar +scripts/servers.sh start cargo run --release -- run --no-manage ``` -## Cómo está organizado +## Layout -| Crate | De qué se ocupa | +| Crate | What it does | |---|---| -| `asist-core` | Configuración, eventos, errores, cliente HTTP, telemetría y el registro de herramientas | -| `asist-audio` | Captura y reproducción con cpal, detección de voz, troceado en turnos | -| `asist-asr` | Canary en ONNX: ventana deslizante y transcripción final | -| `asist-llm` | llama-server: streaming SSE, historial, llamadas a herramientas | -| `asist-tts` | qwentts: síntesis en streaming y voces clonadas | -| `asist-tools` | Buscar en internet, mirar por la cámara y ver la pantalla | -| `asist-app` | Supervisor de procesos, orquestador y binario `asistente` | +| `asist-core` | Configuration, events, errors, HTTP client, telemetry and the tool registry | +| `asist-audio` | Capture and playback with cpal, voice detection, splitting into turns | +| `asist-asr` | Canary on ONNX: sliding window and final transcription | +| `asist-llm` | llama-server: SSE streaming, history, tool calls | +| `asist-tts` | qwentts: streaming synthesis and cloned voices | +| `asist-tools` | Web search, looking through the camera and at the screen | +| `asist-app` | Process supervisor, orchestrator and the `asistente` binary | -Los tres motores viven en `vendor/` como submódulos fijados a un commit -concreto. Los cambios locales sobre ellos están en `vendor/patches/`, y -`bootstrap.sh` los aplica: sin ellos el reconocedor no se comporta como el -que se midió. +The three engines live in `vendor/` as submodules pinned to a specific +commit. The local changes on them are in `vendor/patches/`, and +`bootstrap.sh` applies them: without them the recognizer does not behave like +the one that was measured. -## Configuración +## Configuration -Todo está en [`config/asistente.toml`](config/asistente.toml), comentado. Los -valores que llevan una cifra en el comentario salen de una medición concreta, -no de una suposición; el detalle está en -[docs/RENDIMIENTO.md](docs/RENDIMIENTO.md). +Everything is in [`config/asistente.toml`](config/asistente.toml), with +comments. Values that carry a number in their comment come from a specific +measurement, not an assumption; the details are in +[docs/RENDIMIENTO.md](docs/RENDIMIENTO.md) (in Spanish). -Lo que más se toca: +The most tweaked settings: ```toml [vad] -silence_hold = 0.8 # cuánto silencio cierra tu intervención -barge_in = false # cortar al asistente hablando encima +silence_hold = 0.8 # how much silence ends your utterance +barge_in = false # interrupt the assistant by talking over it [general] -history_turns = 8 # memoria de la conversación -system_prompt = "..." # carácter y estilo +history_turns = 8 # conversation memory +system_prompt = "..." # personality and style [tools] -shell = false # ejecutar órdenes del sistema +shell = false # run system commands ``` -## Interrumpir y el eco del altavoz +## Interrupting and speaker echo -Por defecto el asistente es **media dúplex**: mientras habla, el micrófono -está cerrado. No es pereza: con altavoces abiertos el micrófono se oye a sí -mismo, el asistente se transcribe y se responde solo. +By default the assistant is **half duplex**: while it talks, the microphone +is closed. That is not laziness: with open speakers the microphone hears +itself, and the assistant transcribes itself and answers itself. -Con auriculares, `--barge-in` deja hablar encima para cortarlo. El umbral se -eleva (`vad.barge_in_factor`) para que el eco no baste y una voz de verdad sí. +With headphones, `--barge-in` lets you talk over it to interrupt. The +threshold is raised (`vad.barge_in_factor`) so the echo is not enough but a +real voice is. -## Latencia +## Latency -Un turno real, medido de punta a punta: +A real turn, measured end to end: -| Etapa | Tiempo | +| Stage | Time | |---|---| -| Reconocimiento (2,5 s de voz) | 200 ms | -| Modelo, primer token | 1660 ms | -| Modelo, resto | 1362 ms | -| Herramientas | 1 ms | -| **Del final de tu frase al primer audio** | **3213 ms** | +| Recognition (2.5 s of speech) | 200 ms | +| Model, first token | 1660 ms | +| Model, rest | 1362 ms | +| Tools | 1 ms | +| **From the end of your sentence to the first audio** | **3213 ms** | -El binario mide esto en cada turno y señala la etapa más lenta. Tres ajustes -de configuración valieron más que cualquier cambio de código: +The binary measures this on every turn and flags the slowest stage. A few +configuration settings were worth more than any code change: -- El TTS decodificaba en bloques de 24 s: **4948 ms → 585 ms** al primer audio. -- La plantilla del modelo dejaba `` abierto: **8630 ms → 413 ms** al - primer token. -- El prompt de estilo anulaba las llamadas a herramientas: **0/8 → 8/8**. -- La cámara capturaba a 720p: **7,8 s → 2,9 s** por imagen a 640x480. -- Las herramientas se ejecutaban en silencio: **~8,4 s → 4,1 s** hasta oír algo. -- La pantalla a 640 px inventaba el texto: **1 de 3 aciertos → 3 de 3** a 1280 px. +- The TTS decoded in 24 s blocks: **4948 ms → 585 ms** to first audio. +- The model template left `` open: **8630 ms → 413 ms** to first token. +- The style prompt suppressed tool calls: **0/8 → 8/8**. +- The camera captured at 720p: **7.8 s → 2.9 s** per image at 640x480. +- Tools ran in silence: **~8.4 s → 4.1 s** until something is heard. +- The screen at 640 px made the text up: **1 of 3 right → 3 of 3** at 1280 px. -Los tres, con el porqué y cómo se midieron, en -[docs/RENDIMIENTO.md](docs/RENDIMIENTO.md). +All of them, with the reasons and how they were measured, are in +[docs/RENDIMIENTO.md](docs/RENDIMIENTO.md) (in Spanish). -## Herramientas +## Tools -El asistente puede llamar a funciones. Vienen cuatro: +The assistant can call functions. It ships with these: -| Herramienta | Qué hace | Coste medido | +| Tool | What it does | Measured cost | |---|---|---| -| `hora_actual` | Fecha y hora del sistema | 0 ms | -| `buscar_en_internet` | Busca y resume | 0,5–3,6 s | -| `mirar_por_la_camara` | Hace una foto y responde sobre lo que ve | 4,1–5,2 s | -| `mirar_la_pantalla` | Captura la pantalla y responde sobre lo que hay | 7,6–12,4 s | -| `ejecutar_comando` | Órdenes del sistema, **apagada** por defecto | — | - -Mientras una herramienta trabaja, el asistente dice «Déjame que lo busque» o -«Voy a mirar». No es adorno: sin eso el turno pasa seis segundos en silencio y -se lee como un cuelgue. Con el acuse, el primer audio llega en 3–4 s. - -**Buscar** admite tres buscadores: `tavily` (con clave, devuelve una respuesta -ya redactada), `ddgs` (sin clave, la misma librería que hay bajo -`duckduckgo-mcp`) y `searxng`. El de comando ejecuta un guion que escriba JSON, -así que meter otro es escribir un guion, no tocar Rust. - -**Ver** —cámara y pantalla— aprovecha que el servidor ya carga el proyector -multimodal: el mismo modelo que conversa describe la imagen. Ni la foto ni la -captura se guardan en disco, y no entran en el historial. +| `hora_actual` | System date and time | 0 ms | +| `buscar_en_internet` | Searches and summarizes | 0.5–3.6 s | +| `mirar_por_la_camara` | Takes a photo and answers about what it sees | 4.1–5.2 s | +| `mirar_la_pantalla` | Captures the screen and answers about what is on it | 7.6–12.4 s | +| `ejecutar_comando` | System commands, **off** by default | — | + +The tool names are Spanish because the model reads them in a Spanish +conversation. + +While a tool works, the assistant says «Déjame que lo busque» or «Voy a +mirar». It is not decoration: without it the turn spends six seconds in +silence and reads as a hang. With the acknowledgement, the first audio +arrives in 3–4 s. + +**Search** supports three backends: `tavily` (with a key, returns an already +written answer), `ddgs` (no key, the same library behind `duckduckgo-mcp`) +and `searxng`. The command backend runs a script that writes JSON, so adding +another engine means writing a script, not touching Rust. + +**Vision** (camera and screen) takes advantage of the server already loading +the multimodal projector: the same model that converses describes the image. +Neither the photo nor the capture is saved to disk, and they do not enter the +history. + +The screen uses 1280 px and the camera 640, and the difference matters: a +scene can be understood, but text has to be read. Measured, at 640 px the +model does not say it cannot read the screen, **it makes up what it says** +(it answered that the meeting was «at 10:00» when it said 15:30). Hence the +threefold cost. + +`ejecutar_comando` is off on purpose: giving a shell to a model that obeys +what it hears through the microphone is a change of security posture. When it +is on, only allowlisted commands get through, with no shell to interpret +metacharacters and with a deadline. + +Adding your own takes about twenty lines: [docs/EXTENDER.md](docs/EXTENDER.md) +(in Spanish). + +## Voice + +The repository ships no cloned voice: a voice belongs to someone. To make the +assistant speak with one, record a few seconds of audio with its transcript +and extract the latents: -La pantalla usa 1280 px y la cámara 640, y la diferencia importa: una escena se -entiende, pero un texto hay que leerlo. Medido, a 640 px el modelo no dice que -no lee la pantalla, **se inventa lo que pone** —contestó que la reunión era «a -las 10:00» cuando ponía 15:30—. Por eso el triple de coste. - -`ejecutar_comando` está apagada 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. -Cuando se enciende, sólo pasan las órdenes de una lista blanca, sin shell que -interprete metacaracteres y con un plazo máximo. +```bash +scripts/clone-voice.sh recording.wav transcript.txt +``` -Añadir una propia son unas veinte líneas: -[docs/EXTENDER.md](docs/EXTENDER.md). +They end up in `assets/voices/` (outside git). Without a cloned voice, remove +the `[tts.reference]` section from `config/asistente.toml` and the assistant +uses one of the model's own voices. -## Pruebas +## Tests -68 pruebas unitarias que no necesitan ni modelos ni micrófono, y seis de -integración contra los servidores de verdad. +More than a hundred unit tests that need neither models nor a microphone, +plus integration tests against the real servers. ```bash -cargo test # unitarias, sin modelos -scripts/servidores.sh arrancar -cargo test --release -p asist-app --test integracion -- --nocapture -cargo test --release -p asist-app --test integracion -- --ignored # bucle completo +cargo test # unit tests, no models +scripts/servers.sh start +cargo test --release -p asist-app --test integration -- --nocapture +cargo test --release -p asist-app --test integration -- --ignored # full loop ``` -Las de integración se saltan solas si no hay servidores escuchando, y se -turnan la GPU con un mutex: los dos servidores comparten una tarjeta de 4 GB y -en paralelo miden contención en vez de latencia. +The integration tests skip themselves when no server is listening, and they +take turns on the GPU through a mutex: both servers share a 4 GB card, and in +parallel they measure contention instead of latency. + +The `--ignored` test closes the loop without a microphone: it synthesizes a +sentence and checks that the recognizer gets it back. -La prueba marcada `--ignored` cierra el bucle sin micrófono: sintetiza una -frase y comprueba que el reconocedor la recupera. +## Requirements -## Requisitos +- Rust 1.85 or later +- CUDA for the C++ engines (they run on CPU, but barely) +- PipeWire or ALSA +- ~6 GB of disk for the models +- `ffmpeg` for the camera and to scale down captures +- For the screen: `grim` (Wayland) or `maim`/ImageMagick (X11) +- For search: a Tavily key in `$TAVILY_API_KEY`, or `uv tool install ddgs` -- Rust 1.85 o posterior -- CUDA para los motores en C++ (funcionan en CPU, pero muy justos) -- PipeWire o ALSA -- ~6 GB de disco para los modelos -- `ffmpeg` para la cámara y para reducir las capturas -- Para la pantalla: `grim` (Wayland) o `maim`/ImageMagick (X11) -- Para buscar: una clave de Tavily en `$TAVILY_API_KEY`, o `uv tool install ddgs` +## License +MIT diff --git a/config/asistente.toml b/config/asistente.toml index a47f5b2..bab64c0 100644 --- a/config/asistente.toml +++ b/config/asistente.toml @@ -1,10 +1,12 @@ -# Configuración del asistente de voz. +# Voice assistant configuration. # -# Las rutas relativas se resuelven contra la carpeta de este fichero, no contra -# el directorio desde el que se lanza el binario. +# Relative paths are resolved against the directory of this file, not against +# the directory the binary is launched from. # -# Los valores que llevan un comentario con una medición vienen de -# docs/RENDIMIENTO.md: son los que se midieron en esta máquina, no adivinanzas. +# Values with a comment carrying a measurement come from docs/RENDIMIENTO.md: +# they were measured on this machine, not guessed. +# +# The prompts are in Spanish because the assistant speaks Spanish. [general] language = "es" @@ -18,22 +20,22 @@ URLs ni código salvo que te lo pidan explícitamente. Si no sabes algo, dilo \ en una frase. """ -# SUSTITUYE a system_prompt en la pasada en que el modelo decide si llamar a -# una herramienta. Va sola a propósito: medido con Qwen3.5-2B, añadirle -# cualquier indicación de estilo —dos palabras bastan— hace que deje de llamar -# a las herramientas y se invente el dato. La guía sola acierta 8 de 8; con -# «Responde breve.» detrás, 1 de 8; con la persona de asistente de voz, 0 de 8. -# Ver docs/RENDIMIENTO.md. Se desactiva con tools.dedicated_prompt = false. +# REPLACES system_prompt in the pass where the model decides whether to call +# a tool. It goes alone on purpose: measured with Qwen3.5-2B, adding any style +# instruction (two words are enough) makes it stop calling tools and make the +# data up. The guide alone gets 8 of 8; with «Responde breve.» after it, 1 of 8; +# with the voice-assistant persona, 0 of 8. See docs/RENDIMIENTO.md. Turn it +# off with tools.dedicated_prompt = false. tools_prompt = """ Antes de responder, comprueba si alguna de tus herramientas te da el dato. Si \ es así, llámala primero y espera su resultado; no contestes de memoria. Sólo \ cuando tengas el resultado, resúmelo en una frase. """ -# Se añade a system_prompt para redactar la respuesta cuando una herramienta ya -# devolvió su resultado. Aquí sí se puede añadir estilo —la llamada ya ocurrió— -# y hace falta: sin esto el modelo anuncia lo que acaba de hacer («he tomado una -# foto, ahora puedo responderte sobre el color») en vez de decir lo que averiguó. +# Appended to system_prompt to write the answer once a tool has returned its +# result. Style can be added here (the call already happened) and it is needed: +# without it the model announces what it just did («he tomado una foto, ahora +# puedo responderte sobre el color») instead of saying what it found out. tool_result_prompt = """ Acabas de recibir el resultado de una herramienta. Contesta a la pregunta \ usando ese resultado y nada más. No anuncies lo que has hecho ni lo que \ @@ -42,7 +44,7 @@ otro idioma, tradúcelo al español. """ [audio] -# Vacío = el dispositivo por defecto. `asistente devices` los lista. +# Empty = the default device. `asistente devices` lists them. input_device = "" output_device = "" playback_prebuffer = 0.20 @@ -51,15 +53,15 @@ output_gain = 1.0 [vad] frame_seconds = 0.1 preroll_seconds = 0.3 -silence_hold = 0.8 # silencio que cierra una intervención -min_utterance = 0.3 # se mide sobre la voz, sin contar el preroll +silence_hold = 0.8 # silence that ends an utterance +min_utterance = 0.3 # measured on speech, not counting the preroll max_utterance = 20.0 threshold_factor = 3.0 min_threshold = 0.0008 max_threshold = 0.02 -# Cortar al asistente hablando encima. Apagado por defecto: con altavoces -# abiertos el micrófono se oye a sí mismo y el asistente se interrumpe solo. -# Enciéndelo con auriculares, o con --barge-in. +# Interrupt the assistant by talking over it. Off by default: with open +# speakers the microphone hears itself and the assistant interrupts itself. +# Turn it on with headphones, or with --barge-in. barge_in = false barge_in_factor = 4.0 @@ -72,8 +74,8 @@ step = 0.4 stability = 2 partials = true dedicated_final_model = false -# CPU a propósito: la GPU de 4 GB la ocupa el hablante del TTS y disputársela -# sale más caro que decodificar aquí. Ver docs/RENDIMIENTO.md. +# CPU on purpose: the 4 GB GPU is taken by the TTS talker and fighting over +# it costs more than decoding here. See docs/RENDIMIENTO.md. execution_provider = "cpu" inter_threads = 2 intra_threads = 4 @@ -87,7 +89,7 @@ top_p = 0.9 top_k = 40 min_p = 0.1 repeat_penalty = 1.1 -max_tokens = 400 # una respuesta hablada larga cansa, y la síntesis es lo caro +max_tokens = 400 # a long spoken answer is tiring, and synthesis is the expensive part max_tool_rounds = 4 request_timeout_secs = 120 @@ -101,12 +103,12 @@ top_k = 50 top_p = 1.0 repetition_penalty = 1.05 max_new_tokens = 2048 -warmup = true # la primera síntesis cuesta ~3,5 s más que el resto +warmup = true # the first synthesis costs ~3.5 s more than the rest request_timeout_secs = 180 -# Voz clonada, registrada en el servidor al arrancar. Los tres ficheros salen -# de `qwen-codec --talker` sobre una grabación de referencia; hay un guion en -# scripts/clonar-voz.sh. +# Cloned voice, registered with the server at startup. The three files come +# from `qwen-codec --talker` on a reference recording; there is a script in +# scripts/clone-voice.sh. [tts.reference] name = "asistente" speaker = "../assets/voices/asistente.spk" @@ -115,97 +117,98 @@ transcript = "../assets/voices/asistente.txt" [tools] enabled = true -# Alterna entre tools_prompt (para decidir) y system_prompt (para redactar). -# Es lo único que hace que las herramientas funcionen con este modelo; a -# cambio, las respuestas que no usan herramienta pierden la guía de estilo. -# Ponlo a false para priorizar el estilo sobre las herramientas. +# Alternates between tools_prompt (to decide) and system_prompt (to write). +# It is the only thing that makes tools work with this model; in exchange, +# answers that use no tool lose the style guide. +# Set it to false to favour style over tools. dedicated_prompt = true -# Ejecución de órdenes del sistema. 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 comodidad. Actívala aquí o con --shell. +# System command execution. 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. Turn it on here or with --shell. shell = false shell_allowlist = ["date", "uptime", "free", "df", "ls", "mkdir", "cat"] shell_timeout_secs = 10 shell_dry_run = false shell_working_dir = "" -# Búsqueda en internet. La clave NO va aquí: se lee de la variable de entorno -# que indique api_key_env, porque este fichero se versiona y un secreto dentro -# acaba en el historial de git. +# Web search. The key does NOT go here: it is read from the environment +# variable named by api_key_env, because this file is versioned and a secret +# in it ends up in the git history. # -# Tres buscadores, medidos: -# tavily ~2,4 s. Devuelve una respuesta YA REDACTADA además de los enlaces, -# que es lo que se puede leer en voz alta sin gastar otra vuelta del -# modelo en resumir. Necesita clave. -# ddgs 1,5-5,8 s. Sin clave. Consulta DuckDuckGo, Brave, Mojeek, Startpage -# y compañía a través de scripts/buscar-ddgs.sh. Sólo devuelve -# fragmentos: la síntesis la tiene que hacer el modelo, y con 2B sale -# algo peor que con tavily. -# searxng Sin clave, pero hace falta una instancia propia. Como ddgs, sólo -# devuelve resultados. +# Three backends, measured: +# tavily ~2.4 s. Returns an ALREADY WRITTEN answer besides the links, +# which can be read aloud without spending another model round +# summarizing. Needs a key. +# ddgs 1.5-5.8 s. No key. Queries DuckDuckGo, Brave, Mojeek, Startpage +# and friends through scripts/search-ddgs.sh. Only returns +# snippets: the model has to summarize, and with 2B the result is +# somewhat worse than tavily. +# searxng No key, but needs your own instance. Like ddgs, it only returns +# results. [search] enabled = true backend = "tavily" # "tavily", "ddgs" o "searxng" api_key_env = "TAVILY_API_KEY" -base_url = "" # sólo para searxng, p. ej. "http://127.0.0.1:8888" -# Programa del backend "ddgs". {consulta} y {max} se sustituyen antes de -# ejecutar, y tiene que escribir JSON por la salida estándar. Vale cualquier -# otro programa que respete eso, incluido un puente a un servidor MCP. -command = ["../scripts/buscar-ddgs.sh", "{consulta}", "{max}"] +base_url = "" # searxng only, e.g. "http://127.0.0.1:8888" +# Program for the "ddgs" backend. {query} and {max} are substituted before +# running, and it must write JSON to standard output. Any other program that +# honours that works too, including a bridge to an MCP server. +command = ["../scripts/search-ddgs.sh", "{query}", "{max}"] max_results = 5 timeout_secs = 20 -# Mirar por la cámara. El servidor ya carga el proyector multimodal, así que el -# mismo modelo que conversa describe lo que capta. +# Looking through the camera. The server already loads the multimodal +# projector, so the same model that converses describes what it captures. [camera] enabled = true device = "/dev/video2" -# La resolución manda en la latencia. Medido en esta máquina: 1,3 s a 320x240, -# 2,9 s a 640x480 y 7,8 s a 1280x720. 640x480 es el equilibrio. +# Resolution rules latency. Measured on this machine: 1.3 s at 320x240, +# 2.9 s at 640x480 and 7.8 s at 1280x720. 640x480 is the balance. width = 640 height = 480 -# Fotogramas descartados para que se asiente la exposición automática; el -# primero suele salir quemado y descartar unos pocos es casi gratis. +# Frames discarded so auto-exposure settles; the first one is usually +# blown out and discarding a few is almost free. warmup_frames = 5 timeout_secs = 15 -# Vacío = no se guarda ningún fotograma en disco, que es lo que corresponde. -# Ponle una carpeta sólo para depurar qué está viendo el modelo. +# Empty = no frame is saved to disk, which is right. +# Set a directory only to debug what the model is seeing. save_dir = "" -# Mirar la pantalla. Es la misma visión que la cámara, pero con un ajuste -# distinto porque el problema es distinto: una pantalla es TEXTO. +# Looking at the screen. Same vision as the camera, but tuned differently +# because the problem is different: a screen is TEXT. # -# Medido con tipografía de interfaz de 13 px, preguntando por datos concretos: -# 1280 px 7,6 s acierta 3 de 3 -# 960 px 4,5 s acierta 2 de 3 -# 640 px 2,4 s acierta 1 de 3 +# Measured with 13 px UI type, asking for specific details: +# 1280 px 7.6 s 3 of 3 right +# 960 px 4.5 s 2 of 3 right +# 640 px 2.4 s 1 of 3 right # -# Y cuando falla no dice que no lo lee: se lo inventa. A 640 px contestó que el -# error era «no se pudo abrir el archivo involution» y que la reunión era «a -# las 10:00»; ninguna de las dos cosas estaba en la imagen. Por eso 1280 aunque -# cueste el triple que la cámara. +# And when it fails it does not say it cannot read it: it makes it up. At +# 640 px it answered that the error was «no se pudo abrir el archivo +# involution» and the meeting was «at 10:00»; neither was in the image. Hence +# 1280 even though it costs three times the camera. [screen] enabled = true -# Detecta el entorno gráfico (grim en Wayland, maim/imagemagick en X11), captura -# y reduce a {ancho}. Editar el guion es más fácil que recompilar. -command = ["../scripts/capturar-pantalla.sh", "{ancho}", "{salida}"] +# Detects the graphical environment (grim on Wayland, maim/imagemagick on +# X11), captures and scales down to {width}. Editing the script is easier than +# recompiling. +command = ["../scripts/capture-screen.sh", "{width}", "{output}"] width = 1280 -output = "" # monitor concreto; vacío = todo +output = "" # a specific monitor; empty = all timeout_secs = 20 -# Vacío = no se guarda ninguna captura. Aquí pesa más que en la cámara: en una -# captura de pantalla caben contraseñas, mensajes privados y correo abierto. +# Empty = no capture is saved. It weighs more here than for the camera: a +# screenshot can hold passwords, private messages and open email. save_dir = "" [supervisor] -manage = true # lanzar los servidores; --no-manage los supone arriba +manage = true # start the servers; --no-manage assumes they are up startup_timeout_secs = 180 [supervisor.llama] binary = "../vendor/llama.cpp/build/bin/llama-server" model = "../models/Qwen3.5-2B.Q8_0.gguf" mmproj = "../models/mmproj-BF16.gguf" -# Copia de la plantilla del modelo con el bloque cerrado de entrada. -# Sin esto el modelo razona entre 7 y 9 s antes de la primera palabra audible. +# Copy of the model template with the block closed from the start. +# Without it the model reasons for 7 to 9 s before the first audible word. chat_template = "qwen35-no-think.jinja" extra_args = [ "--threads", "10", "--threads-batch", "10", @@ -213,8 +216,8 @@ extra_args = [ "--gpu-layers", "10", "--split-mode", "layer", "--tensor-split", "1", "--main-gpu", "0", "--no-mmap", - # 8192 en vez del contexto completo del modelo (262144): la caché KV de ese - # tamaño no cabe en 4 GB junto al TTS. Ver docs/RENDIMIENTO.md. + # 8192 instead of the model's full context (262144): a KV cache that size + # does not fit in 4 GB next to the TTS. See docs/RENDIMIENTO.md. "--ctx-size", "8192", "--parallel", "2", "--cache-ram", "6144", "--rope-freq-base", "1000000", "--rope-freq-scale", "0.25", @@ -224,8 +227,8 @@ extra_args = [ binary = "../vendor/qwentts.cpp/build/tts-server" model = "../models/qwen-talker-1.7b-base-Q8_0.gguf" codec = "../models/qwen-tokenizer-12hz-Q8_0.gguf" -# El ajuste con más efecto de todo el sistema: de fábrica son 24 s, y con eso -# el servidor no devuelve nada hasta terminar la frase entera. Medido, baja el -# primer audio de 4948 ms a 585 ms. +# The single most effective setting in the system: the stock value is 24 s, +# and with it the server returns nothing until the whole sentence is done. +# Measured, it lowers the first audio from 4948 ms to 585 ms. codec_chunk_dur = 1.0 extra_args = [] 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",