From f8f98e83481e7376a235fb305a095552433f79ab Mon Sep 17 00:00:00 2001 From: elvis Date: Sun, 6 Sep 2026 19:21:16 -0300 Subject: Local voice assistant on top of Canary, llama.cpp and qwentts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- scripts/bootstrap.sh | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++ scripts/clonar-voz.sh | 47 ++++++++++++++++++ scripts/servidores.sh | 83 +++++++++++++++++++++++++++++++ 3 files changed, 262 insertions(+) create mode 100755 scripts/bootstrap.sh create mode 100755 scripts/clonar-voz.sh create mode 100755 scripts/servidores.sh (limited to 'scripts') diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100755 index 0000000..9ba694e --- /dev/null +++ b/scripts/bootstrap.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# Deja el repositorio en condiciones de ejecutarse: submódulos, parches, +# binarios y modelos. +# +# Es idempotente: se puede volver a lanzar sin miedo. Y no copia modelos —son +# más de 5 GB—, sino que los enlaza desde donde ya estén. +set -euo pipefail + +cd "$(dirname "$0")/.." +RAIZ="$PWD" + +# Repositorios ya clonados en el sistema, para no volver a bajar de la red ni +# recompilar lo que ya está compilado. +ESPEJO="${ASIST_ESPEJO:-$HOME/GIT-MIRRO}" +HF="${ASIST_HF:-$HOME/HF}" + +azul() { printf '\033[36m%s\033[0m\n' "$*"; } +verde() { printf '\033[32m%s\033[0m\n' "$*"; } +aviso() { printf '\033[33m%s\033[0m\n' "$*" >&2; } +malo() { printf '\033[31m%s\033[0m\n' "$*" >&2; } + +enlazar() { # origen destino descripción + local origen="$1" destino="$2" que="$3" + if [ ! -e "$origen" ]; then + aviso " no está $que: $origen" + return 1 + fi + mkdir -p "$(dirname "$destino")" + if [ -L "$destino" ] || [ -e "$destino" ]; then + rm -rf "$destino" + fi + ln -s "$origen" "$destino" + verde " $que -> $(basename "$origen")" +} + +# --------------------------------------------------------------------------- +azul "1/5 Submódulos" +# Si el repositorio está clonado al lado, se usa como referencia: llama.cpp son +# 406 MB de objetos que no hace falta volver a descargar. +for nombre in canary-rs qwentts.cpp llama.cpp; do + ruta="vendor/$nombre" + if [ -f "$ruta/.git" ] || [ -d "$ruta/.git" ]; then + continue + fi + if [ -d "$ESPEJO/$nombre/.git" ]; then + git -c protocol.file.allow=always submodule update --init \ + --reference "$ESPEJO/$nombre" "$ruta" + else + git submodule update --init "$ruta" + fi +done +git submodule status | sed 's/^/ /' + +# --------------------------------------------------------------------------- +azul "2/5 Parches y ficheros sueltos" +# Los parches recogen los cambios locales sobre cada repositorio: sin ellos, el +# ASR se comporta distinto al que se midió. +for nombre in canary-rs qwentts.cpp llama.cpp; do + parche="$RAIZ/vendor/patches/$nombre.patch" + [ -s "$parche" ] || continue + ( + cd "vendor/$nombre" + if git apply --check "$parche" 2>/dev/null; then + git apply "$parche" + verde " $nombre: parche aplicado" + elif git apply --reverse --check "$parche" 2>/dev/null; then + verde " $nombre: parche ya aplicado" + else + malo " $nombre: el parche no aplica; revísalo a mano" + fi + ) +done +# Ficheros que sólo existían en el árbol de trabajo local y que un parche no +# puede llevar. canary-rs no compila sin bench_live.rs: su Cargo.toml lo declara. +if [ -d vendor/extra ]; then + for nombre in canary-rs qwentts.cpp; do + [ -d "vendor/extra/$nombre" ] || continue + cp -rn "vendor/extra/$nombre/." "vendor/$nombre/" 2>/dev/null || true + done + verde " ficheros sueltos copiados" +fi + +# --------------------------------------------------------------------------- +azul "3/5 Motores en C++" +enlazar_o_construir() { # nombre ruta_build orden_de_construccion... + local nombre="$1" build="$2"; shift 2 + if [ -e "vendor/$nombre/$build" ]; then + verde " $nombre ya está construido" + return + fi + if [ -e "$ESPEJO/$nombre/$build" ]; then + enlazar "$ESPEJO/$nombre/$(dirname "$build")" \ + "$RAIZ/vendor/$nombre/$(dirname "$build")" "build de $nombre" + return + fi + aviso " $nombre sin construir. Construyendo (esto tarda)…" + ( cd "vendor/$nombre" && "$@" ) +} +enlazar_o_construir llama.cpp build/bin/llama-server \ + bash -c 'cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_CUDA=ON && cmake --build build -j --target llama-server' +enlazar_o_construir qwentts.cpp build/tts-server \ + bash -c './buildcuda.sh' + +# --------------------------------------------------------------------------- +azul "4/5 Modelos" +# Se enlazan, no se copian: son más de 5 GB entre todos. +enlazar "$HF/Qwen/Qwen3.5-2B.Q5_K_M.gguf" "$RAIZ/models/Qwen3.5-2B.Q5_K_M.gguf" "modelo del LLM" || true +enlazar "$HF/Qwen/mmproj-BF16.gguf" "$RAIZ/models/mmproj-BF16.gguf" "proyector multimodal" || true +for m in qwen-talker-1.7b-base-Q8_0.gguf qwen-tokenizer-12hz-Q8_0.gguf; do + enlazar "$ESPEJO/qwentts.cpp/models/$m" "$RAIZ/models/$m" "$m" || true +done +if [ ! -d vendor/canary-rs/models/canary-180m-flash-onnx ] \ + && [ -d "$ESPEJO/canary-rs/models/canary-180m-flash-onnx" ]; then + enlazar "$ESPEJO/canary-rs/models/canary-180m-flash-onnx" \ + "$RAIZ/vendor/canary-rs/models/canary-180m-flash-onnx" "modelo Canary" || true +fi + +# --------------------------------------------------------------------------- +azul "5/5 Voz de referencia" +# El asistente habla con una voz clonada. Si no hay una preparada, se dice cómo +# hacerla en vez de fallar: el resto ya funciona con una voz del modelo. +if [ -f assets/voices/asistente.spk ]; then + verde " ya hay una voz en assets/voices/" +else + aviso " sin voz clonada. Prepárala con:" + aviso " scripts/clonar-voz.sh " + aviso " o quita la sección [tts.reference] de config/asistente.toml para" + aviso " usar una voz del propio modelo." +fi + +echo +azul "Listo. Comprueba con: cargo run --release -- check" diff --git a/scripts/clonar-voz.sh b/scripts/clonar-voz.sh new file mode 100755 index 0000000..e68215d --- /dev/null +++ b/scripts/clonar-voz.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Extrae los latentes de una voz a partir de una grabación, para que el +# asistente hable con ella. +# +# scripts/clonar-voz.sh grabacion.wav transcripcion.txt [nombre] +# +# Produce assets/voices/.{spk,rvq,txt}, que es lo que el asistente +# registra en tts-server al arrancar. Se hace una vez: en cada arranque se +# mandan los latentes ya extraídos y no la grabación. +set -euo pipefail + +cd "$(dirname "$0")/.." + +WAV="${1:?uso: clonar-voz.sh [nombre]}" +TXT="${2:?falta la transcripción de la grabación}" +NOMBRE="${3:-asistente}" + +CODEC=vendor/qwentts.cpp/build/qwen-codec +TOKENIZER=models/qwen-tokenizer-12hz-Q8_0.gguf + +for f in "$CODEC" "$TOKENIZER" "$WAV" "$TXT"; do + [ -e "$f" ] || { echo "falta: $f" >&2; exit 1; } +done + +SALIDA="assets/voices" +mkdir -p "$SALIDA" + +echo "Extrayendo la voz de $WAV…" +# --talker produce el embedding del hablante (.spk) y los códigos de +# referencia (.rvq); con la transcripción, el servidor activa el clonado ICL, +# que se parece bastante más al original que sólo el embedding. +"$CODEC" --model "$TOKENIZER" --talker \ + --input "$WAV" \ + -o "$SALIDA/$NOMBRE" + +cp "$TXT" "$SALIDA/$NOMBRE.txt" + +echo +echo "Listo:" +ls -la "$SALIDA/$NOMBRE".{spk,rvq,txt} +echo +echo "Apunta config/asistente.toml a esta voz:" +echo " [tts.reference]" +echo " name = \"$NOMBRE\"" +echo " speaker = \"../$SALIDA/$NOMBRE.spk\"" +echo " codes = \"../$SALIDA/$NOMBRE.rvq\"" +echo " transcript = \"../$SALIDA/$NOMBRE.txt\"" diff --git a/scripts/servidores.sh b/scripts/servidores.sh new file mode 100755 index 0000000..2e3b769 --- /dev/null +++ b/scripts/servidores.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Arranca o para los dos servidores por separado, sin el asistente. +# +# Útil para desarrollar: cargar los modelos cuesta más de un minuto y así se +# reinicia el binario de Rust las veces que haga falta sin volver a pagarlo. +# El asistente detecta que ya están escuchando y los reutiliza. +# +# scripts/servidores.sh arrancar | parar | estado +set -euo pipefail +cd "$(dirname "$0")/.." + +LOGS="${ASIST_LOGS:-logs}" +LLM_PUERTO="${ASIST_LLM_PUERTO:-8012}" +TTS_PUERTO="${ASIST_TTS_PUERTO:-8013}" +mkdir -p "$LOGS" + +vivo() { curl -sf -m 2 "http://127.0.0.1:$1/health" >/dev/null 2>&1; } + +esperar() { # puerto nombre segundos + local fin=$(( SECONDS + $3 )) + while [ $SECONDS -lt $fin ]; do + vivo "$1" && { echo " $2 listo"; return 0; } + sleep 1 + done + echo " $2 NO respondió en $3 s; mira $LOGS/$2.log" >&2 + return 1 +} + +arrancar() { + if vivo "$LLM_PUERTO"; then + echo " llama-server ya estaba arriba" + else + echo " arrancando llama-server…" + nohup vendor/llama.cpp/build/bin/llama-server \ + --model models/Qwen3.5-2B.Q5_K_M.gguf \ + --mmproj models/mmproj-BF16.gguf \ + --host 127.0.0.1 --port "$LLM_PUERTO" \ + --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 --ctx-size 8192 --parallel 2 --cache-ram 6144 \ + --rope-freq-base 1000000 --rope-freq-scale 0.25 \ + --jinja --chat-template-file config/qwen35-no-think.jinja \ + > "$LOGS/llama-server.log" 2>&1 & + fi + + if vivo "$TTS_PUERTO"; then + echo " tts-server ya estaba arriba" + else + echo " arrancando tts-server…" + # --codec-chunk-dur 1.0 es lo que hace que el audio salga por bloques + # según se genera en vez de todo al final. Ver docs/RENDIMIENTO.md. + nohup vendor/qwentts.cpp/build/tts-server \ + --model models/qwen-talker-1.7b-base-Q8_0.gguf \ + --codec models/qwen-tokenizer-12hz-Q8_0.gguf \ + --host 127.0.0.1 --port "$TTS_PUERTO" \ + --lang spanish --codec-chunk-dur 1.0 \ + > "$LOGS/tts-server.log" 2>&1 & + fi + + esperar "$LLM_PUERTO" llama-server 240 + esperar "$TTS_PUERTO" tts-server 240 +} + +parar() { + # SIGTERM, no SIGKILL: hay que darles ocasión de soltar la GPU. + pkill -TERM -f 'llama-server .*--port '"$LLM_PUERTO" 2>/dev/null && echo " llama-server parado" || true + pkill -TERM -f 'tts-server .*--port '"$TTS_PUERTO" 2>/dev/null && echo " tts-server parado" || true +} + +estado() { + vivo "$LLM_PUERTO" && echo " llama-server arriba :$LLM_PUERTO" || echo " llama-server parado" + vivo "$TTS_PUERTO" && echo " tts-server arriba :$TTS_PUERTO" || echo " tts-server parado" + command -v nvidia-smi >/dev/null && \ + nvidia-smi --query-gpu=memory.used,memory.total --format=csv,noheader | sed 's/^/ GPU: /' +} + +case "${1:-estado}" in + arrancar|start) arrancar ;; + parar|stop) parar ;; + estado|status) estado ;; + *) echo "uso: $0 {arrancar|parar|estado}" >&2; exit 1 ;; +esac -- cgit v1.2.3