diff options
| author | elvis <elvis@claros.ar> | 2026-09-06 19:21:16 -0300 |
|---|---|---|
| committer | elvis <elvis@claros.ar> | 2026-09-06 19:23:37 -0300 |
| commit | f8f98e83481e7376a235fb305a095552433f79ab (patch) | |
| tree | 6d0ecddb26bef8430a086431de40f8ce9b1039e6 /config | |
| download | asist-p-f8f98e83481e7376a235fb305a095552433f79ab.tar.gz asist-p-f8f98e83481e7376a235fb305a095552433f79ab.zip | |
Local voice assistant on top of Canary, llama.cpp and qwentts
Pipeline en Rust de hilos y canales que une los tres motores: el micrófono
alimenta un segmentador con VAD, las intervenciones cerradas van al
reconocedor, la transcripción al modelo y cada frase que este cierra sale
hacia el sintetizador sin esperar al resto de la respuesta.
Dos reglas sostienen el diseño: ninguna etapa bloquea a la anterior —quien va
sobrado descarta trabajo en lugar de acumular retraso— y todo lo que viaja por
los canales lleva el turno al que pertenece, así que interrumpir es subir el
contador y levantar dos banderas de cancelación.
Seis crates: core (configuración, eventos, HTTP, telemetría, herramientas),
audio (cpal, VAD, anillo de reproducción), asr, llm, tts y app (supervisor de
procesos y orquestador). Los motores van como submódulos fijados a un commit,
con los cambios locales en vendor/patches.
Midiendo el pipeline aparecieron tres cuellos de botella de configuración que
valieron más que cualquier cambio de código, todos documentados en
docs/RENDIMIENTO.md:
- tts-server decodificaba el audio en bloques de 24 s, de modo que el modo
«streaming» llegaba de una pieza: 4948 ms -> 585 ms hasta el primer audio.
- La plantilla de chat del modelo abre <think> y no lo cierra nunca, sin
variable que lo apague: 8630 ms -> 413 ms hasta el primer token, con una
copia de la plantilla que deja el bloque cerrado de entrada.
- Cualquier indicación de estilo junto a la guía de herramientas hace que este
modelo de 2B deje de llamarlas y se invente el dato (8/8 aciertos con la
guía sola, 0/8 con la persona de asistente de voz). El turno alterna ahora
entre dos instrucciones de sistema.
La ejecución de órdenes del sistema queda implementada y apagada, tras cuatro
barreras: lista blanca sobre el ejecutable, rutas rechazadas, sin shell que
interprete metacaracteres y plazo máximo.
68 pruebas unitarias sin modelos, más seis de integración que se saltan solas
si no hay servidores y se turnan la GPU: en paralelo, los dos servidores no
caben en 4 GB y miden contención en vez de latencia.
Claude-Session: https://claude.ai/code/session_01FNxz5cSdQSscJH9H7b8uGU
Diffstat (limited to 'config')
| -rw-r--r-- | config/asistente.toml | 153 | ||||
| -rw-r--r-- | config/qwen35-no-think.jinja | 91 |
2 files changed, 244 insertions, 0 deletions
diff --git a/config/asistente.toml b/config/asistente.toml new file mode 100644 index 0000000..b638acd --- /dev/null +++ b/config/asistente.toml @@ -0,0 +1,153 @@ +# Configuración del asistente de voz. +# +# Las rutas relativas se resuelven contra la carpeta de este fichero, no contra +# el directorio desde el que se lanza el binario. +# +# 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. + +[general] +language = "es" +history_turns = 8 +report_latency = true +system_prompt = """ +Eres un asistente de voz en español. Tus respuestas se leen en voz alta, así \ +que responde en una o dos frases cortas, en texto plano corrido. No uses \ +markdown, ni listas, ni asteriscos, ni emojis, ni encabezados. No escribas \ +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. +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. +""" + +[audio] +# Vacío = el dispositivo por defecto. `asistente devices` los lista. +input_device = "" +output_device = "" +playback_prebuffer = 0.20 +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 +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. +barge_in = false +barge_in_factor = 4.0 + +[asr] +model_dir = "../vendor/canary-rs/models/canary-180m-flash-onnx" +source_lang = "es" +target_lang = "es" +window = 6.0 +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. +execution_provider = "cpu" +inter_threads = 2 +intra_threads = 4 + +[llm] +host = "127.0.0.1" +port = 8012 +model = "" +temperature = 0.7 +top_p = 0.9 +top_k = 40 +min_p = 0.1 +repeat_penalty = 1.1 +max_tokens = 300 # una respuesta hablada larga cansa, y la síntesis es lo caro +max_tool_rounds = 4 +request_timeout_secs = 120 + +[tts] +host = "127.0.0.1" +port = 8013 +voice = "asistente" +language = "spanish" +temperature = 0.9 +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 +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. +[tts.reference] +name = "asistente" +speaker = "../assets/voices/asistente.spk" +codes = "../assets/voices/asistente.rvq" +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. +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. +shell = false +shell_allowlist = ["date", "uptime", "free", "df", "ls"] +shell_timeout_secs = 10 +shell_dry_run = false +shell_working_dir = "" + +[supervisor] +manage = true # lanzar los servidores; --no-manage los supone arriba +startup_timeout_secs = 180 + +[supervisor.llama] +binary = "../vendor/llama.cpp/build/bin/llama-server" +model = "../models/Qwen3.5-2B.Q5_K_M.gguf" +mmproj = "../models/mmproj-BF16.gguf" +# Copia de la plantilla del modelo con el bloque <think> cerrado de entrada. +# Sin esto el modelo razona entre 7 y 9 s antes de la primera palabra audible. +chat_template = "qwen35-no-think.jinja" +extra_args = [ + "--threads", "10", "--threads-batch", "10", + "--batch-size", "512", "--ubatch-size", "256", + "--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. + "--ctx-size", "8192", "--parallel", "2", + "--cache-ram", "6144", + "--rope-freq-base", "1000000", "--rope-freq-scale", "0.25", +] + +[supervisor.tts] +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. +codec_chunk_dur = 1.0 +extra_args = [] diff --git a/config/qwen35-no-think.jinja b/config/qwen35-no-think.jinja new file mode 100644 index 0000000..40f64c2 --- /dev/null +++ b/config/qwen35-no-think.jinja @@ -0,0 +1,91 @@ +{%- if tools %} + {{- '<|im_start|>system\n' }} + {%- if messages[0].role == 'system' %} + {{- messages[0].content + '\n\n' }} + {%- endif %} + {{- "# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>" }} + {%- for tool in tools %} + {{- "\n" }} + {{- tool | tojson }} + {%- endfor %} + {{- "\n</tools>\n\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call><|im_end|>\n" }} +{%- else %} + {%- if messages[0].role == 'system' %} + {{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }} + {%- endif %} +{%- endif %} +{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %} +{%- for message in messages[::-1] %} + {%- set index = (messages|length - 1) - loop.index0 %} + {%- if ns.multi_step_tool and message.role == "user" and message.content is string and not(message.content.startswith('<tool_response>') and message.content.endswith('</tool_response>')) %} + {%- set ns.multi_step_tool = false %} + {%- set ns.last_query_index = index %} + {%- endif %} +{%- endfor %} +{%- for message in messages %} + {%- if message.content is string %} + {%- set content = message.content %} + {%- else %} + {%- set content = '' %} + {%- endif %} + {%- if (message.role == "user") or (message.role == "system" and not loop.first) %} + {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }} + {%- elif message.role == "assistant" %} + {%- set reasoning_content = '' %} + {%- if message.reasoning_content is string %} + {%- set reasoning_content = message.reasoning_content %} + {%- else %} + {%- if '</think>' in content %} + {%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %} + {%- set content = content.split('</think>')[-1].lstrip('\n') %} + {%- endif %} + {%- endif %} + {%- if loop.index0 > ns.last_query_index %} + {%- if loop.last or (not loop.last and reasoning_content) %} + {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content.strip('\n') + '\n</think>\n\n' + content.lstrip('\n') }} + {%- else %} + {{- '<|im_start|>' + message.role + '\n' + content }} + {%- endif %} + {%- else %} + {{- '<|im_start|>' + message.role + '\n' + content }} + {%- endif %} + {%- if message.tool_calls %} + {%- for tool_call in message.tool_calls %} + {%- if (loop.first and content) or (not loop.first) %} + {{- '\n' }} + {%- endif %} + {%- if tool_call.function %} + {%- set tool_call = tool_call.function %} + {%- endif %} + {{- '<tool_call>\n{"name": "' }} + {{- tool_call.name }} + {{- '", "arguments": ' }} + {%- if tool_call.arguments is string %} + {{- tool_call.arguments }} + {%- else %} + {{- tool_call.arguments | tojson }} + {%- endif %} + {{- '}\n</tool_call>' }} + {%- endfor %} + {%- endif %} + {{- '<|im_end|>\n' }} + {%- elif message.role == "tool" %} + {%- if loop.first or (messages[loop.index0 - 1].role != "tool") %} + {{- '<|im_start|>user' }} + {%- endif %} + {{- '\n<tool_response>\n' }} + {{- content }} + {{- '\n</tool_response>' }} + {%- if loop.last or (messages[loop.index0 + 1].role != "tool") %} + {{- '<|im_end|>\n' }} + {%- endif %} + {%- endif %} +{%- endfor %} +{%- if add_generation_prompt %} + {{- '<|im_start|>assistant +<think> + +</think> + +' }} +{%- endif %}
\ No newline at end of file |