1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
|
# asist-p — asistente de voz local
Escucha por el micrófono, piensa y contesta hablando. Todo en la máquina: no
sale un byte a internet.
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 <este-repo> && 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/servidores.sh arrancar
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 `<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.
Los tres, 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
68 pruebas unitarias que no necesitan ni modelos ni micrófono, y seis de
integración contra los servidores de verdad.
```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
```
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`
|