asist-p — asistente de voz local
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
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.
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:
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, 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.
Lo que más se toca:
[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
<think>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.
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.
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.
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
ffmpegpara la cámara y para reducir las capturas- Para la pantalla:
grim(Wayland) omaim/ImageMagick (X11) - Para buscar: una clave de Tavily en
$TAVILY_API_KEY, ouv 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:
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