JARVIS: asistente de voz local para Linux

Nucleo propio: oye con whisper.cpp, piensa con un modelo de Ollama, habla
con Piper, y hace RAG sobre los apuntes del usuario. 100% local, sin
cuentas ni claves.

Escrito bajo una restriccion dura, 4 GB de VRAM: el cerebro y whisper
comparten tarjeta y solo caben porque estan dimensionados para ello. El
RAG usa embeddings estaticos con busqueda hibrida; la voz clonada se sirve
de una cache de frases.

Incluye instalador (install.sh), requisitos, y documentacion del stack,
del manejo de root y de las acciones. Los apuntes indexados y el diario NO
se incluyen: son privados y el .gitignore los bloquea.
This commit is contained in:
sito 2026-08-16 15:28:31 +02:00
commit 8e4bc8ad94
125 changed files with 25033 additions and 0 deletions

343
nucleo/oido/whisper.py Normal file
View file

@ -0,0 +1,343 @@
"""Transcribir: arrancar whisper-server y mandarle los WAV.
Newelle dedicaba 804 lineas a esto porque tenia que descargar modelos, elegir
entre CLI y servidor, salir del sandbox y ofrecerlo todo en una pantalla de
ajustes. Nosotros ya tenemos el binario compilado con CUDA y el modelo en el
disco: queda arrancar un proceso y hacer un POST.
## Los cuatro flags que Newelle no pasaba
Esto es lo importante de este fichero, y sale de leer el codigo de upstream:
`whispercpp_handler.py` arranca el servidor con `-m -t --host --port -l` y nada
mas. Los defectos de `whisper-server` no son los mismos que los de
`whisper-cli`, asi que se estaba transcribiendo peor de lo que la propia
whisper.cpp hace por defecto:
--beam-size 5 --best-of 5 whisper-server trae beam-size -1, que es
decodificacion GREEDY. El CLI usa 5. Es la causa
mas probable de transcripciones como "esta con el
tanutusto" en un audio limpio.
-sns suprime los tokens que no son habla. Es
literalmente el flag que evita los "[Musica]" y
"(aplausos)", que son el 16,2 % de las frases que
JARVIS no entendio.
--prompt + --carry-initial-prompt
vocabulario del dominio. MEDIDO Y DESCARTADO por
defecto: ver abajo.
El prompt se genera DESDE el catalogo (`prompt_dominio`), no escrito a mano, o
se queda viejo en cuanto se añada una accion.
## Lo que dijo la medida, que no fue lo que yo esperaba
40 frases del catalogo dichas por Piper con tres voces, transcritas con cada
configuracion y contando cuantas llegan a su accion:
como lo hace Newelle (greedy, sin sns) 50,0 % 0,98 s
+ beam 5 55,0 % 1,01 s
+ beam 5 + sns 55,0 % 1,01 s
+ beam 5 + sns + prompt 52,5 % 1,12 s
Tres lecturas, por orden de importancia:
1. **El prompt EMPEORA.** Era el riesgo anotado Whisper empieza a "oir" las
palabras que le has puesto donde no las hay y se cumplio. Va apagado por
defecto; se deja el codigo porque con un prompt mas corto podria ganar, pero
hay que demostrarlo antes de encenderlo.
2. **`-sns` no se puede medir con este banco.** El audio de Piper no tiene
silencios ni musica, asi que no hay nada que suprimir. Se deja puesto porque
no cuesta nada y ataca un problema real que solo se ve con microfono: el
16,2 % de "[Musica]" del registro de uso.
3. **El beam sube 5 puntos y cuesta 30 ms.** Es lo unico claramente a favor, y
aun asi con 40 muestras esos 5 puntos son dos frases: esta en el limite del
ruido.
"""
import atexit
import json
import os
import re
import signal
import socket
import subprocess
import time
import urllib.error
import urllib.request
RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
BINARIO = os.path.join(RAIZ, "voz", "whisper-cuda", "whisper.cpp", "build", "bin",
"whisper-server")
MODELOS = os.path.expanduser(
"~/.var/app/io.github.qwersyk.Newelle/config/models/whisper/whisper.cpp/models")
# Las libs de ggml estan al lado del binario y CUDA en /usr/local. Fuera del
# flatpak esto es una linea; dentro era media pagina de flatpak-spawn.
LIBS = "/usr/local/cuda/lib64"
# Un prompt largo no ayuda: Whisper empieza a "oir" esas palabras donde no las
# hay. Se corta.
TOPE_PROMPT = 220
# El prompt MINIMO, y el unico que va puesto por defecto: solo el nombre.
#
# Sin el, Whisper no escribe "JARVIS" nunca: se lo come y lo sustituye por la
# palabra española que mejor le cuadre acusticamente. Medido diciendoselo con
# Piper:
#
# "Jarvis, cuanto espacio queda" -> "¿Sabeis cuanto espacio queda?"
# "Yarvis, que hora es" -> "¿Ya viste que hora es?"
# "Jarvis pon musica" -> "Carbis Pond Musica."
#
# Y ninguna de esas se parece al nombre ni por letra ni por sonido, asi que el
# centinela no despertaba JAMAS. Era la causa de que el asistente no contestara.
#
# Con este prompt de cinco palabras, cuatro de cuatro. Y ojo al matiz, porque
# contradice lo de arriba solo en apariencia: lo que empeora es el prompt LARGO
# con todo el vocabulario del catalogo. Este cuesta una frase de ochenta —90,0 a
# 88,8 % de acierto general, dentro del ruido— y arregla el nombre entero.
PROMPT_NOMBRE = "JARVIS. Hablando con JARVIS."
def prompt_dominio(catalogo=None) -> str:
"""Vocabulario que JARVIS espera oir, sacado del catalogo.
Se cogen las palabras poco comunes las que un modelo de castellano general
no espera y no las frases enteras: el prompt es un sesgo, no una lista de
ordenes, y llenarlo de "cuanto espacio queda" haria que las oyera en
cualquier ruido.
"""
comunes = {
"abre", "cierra", "busca", "pon", "quita", "sube", "baja", "dime",
"cuanto", "cuanta", "cuantos", "que", "el", "la", "los", "las", "de",
"del", "en", "con", "por", "para", "un", "una", "y", "o", "a", "al",
"me", "se", "lo", "es", "esta", "hay", "mi", "tu", "su",
}
# Por grupos y en rueda, no por orden del catalogo. Recorriendolo en orden,
# el tope de caracteres se lo comian entero las acciones de disco —que van
# primeras— y no llegaba ni una palabra de firefox, ventanas o pentest.
por_grupo, vistas = {}, set()
for a in (catalogo.acciones if catalogo else []):
for frase in a.frases:
for p in frase.split():
if len(p) < 4 or p in comunes or p in vistas:
continue
vistas.add(p)
por_grupo.setdefault(a.grupo or "otras", []).append(p)
palabras = []
grupos = list(por_grupo.values())
for i in range(max((len(g) for g in grupos), default=0)):
for g in grupos:
if i < len(g):
palabras.append(g[i])
# Los nombres propios importan mas que nada y no salen del catalogo tal cual
fijas = ["JARVIS", "firefox", "nmap", "ollama", "whisper", "terminal",
"escritorio", "pantalla", "wifi", "bluetooth", "github", "youtube"]
texto = ", ".join(fijas + palabras)
return texto[:TOPE_PROMPT].rsplit(",", 1)[0]
def _mata_huerfanos():
"""whisper-server de arranques anteriores que se quedaron con la grafica.
Se buscan por la RUTA COMPLETA del binario, no por el nombre: `pkill -f
whisper` casaria con el propio pkill y con cualquier cosa que lleve la
palabra, y eso ya ha costado depuraciones. Y se leen los cmdline de /proc en
vez de llamar a pgrep, que es lo mismo pero sin proceso de por medio.
"""
yo = os.getpid()
for pid in os.listdir("/proc"):
if not pid.isdigit() or int(pid) == yo:
continue
try:
with open(f"/proc/{pid}/cmdline", "rb") as f:
orden = f.read().split(b"\0")
except OSError:
continue
if orden and orden[0].decode(errors="ignore") == BINARIO:
try:
os.killpg(os.getpgid(int(pid)), signal.SIGTERM)
except (OSError, ProcessLookupError):
pass
def _puerto_libre() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class Oido:
"""whisper-server vivo, y un metodo para transcribir un WAV."""
def __init__(self, modelo="small", idioma="es", hilos=8, prompt=PROMPT_NOMBRE,
beam=5, gpu=True):
self.modelo = modelo
self.idioma = idioma
self.hilos = hilos
self.prompt = prompt
self.beam = beam
self.gpu = gpu
self._proc = None
self._puerto = None
@property
def ruta_modelo(self) -> str:
return os.path.join(MODELOS, f"ggml-{self.modelo}.bin")
def disponible(self) -> bool:
return os.path.exists(BINARIO) and os.path.exists(self.ruta_modelo)
# ------------------------------------------------------------- arrancar
def arranca(self, espera=40) -> bool:
if self._proc and self._proc.poll() is None:
return True
if not self.disponible():
return False
# Los huerfanos de arranques anteriores, antes de nada.
#
# whisper-server se lanza en su propio grupo de procesos para poder
# matarlo entero, pero eso mismo hace que sobreviva si el nucleo muere
# de golpe (un kill, un cuelgue, cerrar la terminal). Y cada uno se
# queda con ~880 MiB de una tarjeta de 4 GB: al cuarto, el siguiente ya
# no arranca y el sintoma es "whisper no arranco", que no apunta a
# ningun sitio. Medido en esta maquina: cuatro huerfanos, 3,5 GB.
_mata_huerfanos()
self._puerto = _puerto_libre()
orden = [
BINARIO,
"-m", self.ruta_modelo,
"-t", str(self.hilos),
"--host", "127.0.0.1",
"--port", str(self._puerto),
"-l", self.idioma,
# los cuatro que Newelle no pasaba
"-sns",
"--beam-size", str(self.beam),
"--best-of", "5",
]
if self.prompt:
orden += ["--prompt", self.prompt, "--carry-initial-prompt"]
if not self.gpu:
orden.append("--no-gpu")
entorno = {**os.environ,
"LD_LIBRARY_PATH": LIBS + ":" + os.environ.get("LD_LIBRARY_PATH", "")}
self._proc = subprocess.Popen(
orden, env=entorno, cwd=os.path.dirname(BINARIO),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
# grupo propio, para poder matarlo entero como hace tareas.py
start_new_session=True)
# Y que se vaya con nosotros: sin esto se queda con su parte de la
# grafica hasta el siguiente reinicio.
atexit.register(self.para)
# Esperar a que escuche de verdad, no a que el proceso exista: cargar
# el modelo en la grafica tarda, y un POST antes de tiempo da un error
# de conexion que parece un fallo de configuracion.
limite = time.monotonic() + espera
while time.monotonic() < limite:
if self._proc.poll() is not None:
return False
try:
with socket.create_connection(("127.0.0.1", self._puerto), 0.5):
return True
except OSError:
time.sleep(0.3)
self.para()
return False
def para(self):
if not self._proc:
return
try:
os.killpg(os.getpgid(self._proc.pid), signal.SIGTERM)
self._proc.wait(timeout=5)
except Exception:
try:
os.killpg(os.getpgid(self._proc.pid), signal.SIGKILL)
except Exception:
pass
self._proc = None
@property
def vivo(self) -> bool:
return bool(self._proc and self._proc.poll() is None)
# ---------------------------------------------------------- transcribir
def transcribe(self, wav: str) -> str:
if not self.vivo and not self.arranca():
return ""
try:
with open(wav, "rb") as f:
datos = f.read()
except OSError:
return ""
lim = "----jarvis"
cuerpo = (
f"--{lim}\r\n"
f'Content-Disposition: form-data; name="file"; filename="a.wav"\r\n'
f"Content-Type: audio/wav\r\n\r\n"
).encode() + datos + f"\r\n--{lim}--\r\n".encode()
req = urllib.request.Request(
f"http://127.0.0.1:{self._puerto}/inference", data=cuerpo,
headers={"Content-Type": f"multipart/form-data; boundary={lim}"})
try:
with urllib.request.urlopen(req, timeout=120) as r:
return json.loads(r.read().decode()).get("text", "").strip()
except (urllib.error.URLError, json.JSONDecodeError, OSError):
return ""
# --------------------------------------------------------------- el filtro
# Lo que Whisper devuelve cuando no habia nadie hablando. No es un fallo suyo:
# se entreno con subtitulos de YouTube, donde el silencio se anota asi.
SOLO_ETIQUETA = re.compile(r"^\s*[\[\(\*].{0,40}[\]\)\*]\s*[.!¡]*\s*$")
MULETILLAS = (
"suscribete", "suscríbete", "gracias por ver", "hasta la proxima",
"hasta la próxima", "no olvides suscribirte", "dale like",
)
# Anotaciones que Whisper escribe cuando no hay nadie hablando. Salen de contar
# el registro real de JARVIS, no de imaginarlas: son las que mas se repiten
# entre las frases cortas que no llegan a ninguna accion.
#
# A veces vienen entre corchetes —y entonces las coge SOLO_ETIQUETA— y a veces
# sueltas, que es para lo que sirve esta lista.
ANOTACIONES = {
"musica", "música", "motor", "risas", "risa", "aplausos", "cantando",
"ruido", "silencio", "tos", "suspiro", "aplauso", "llanto", "gritos",
"ah", "ahh", "ahhh", "eh", "ehh", "mmm", "hmm", "uf", "ay",
}
# Lo que SI se deja pasar aunque no lleve a ninguna accion: "hola", "adios",
# "gracias", "buenas". Una persona las dice de verdad y merecen respuesta; lo
# unico que cuesta dejarlas pasar es una consulta al modelo. Filtrar de mas
# aqui es peor que filtrar de menos: JARVIS parecería sordo a un saludo.
def es_ruido(texto: str) -> bool:
"""Si esto es Whisper alucinando en vez de alguien hablando.
Se aplica ANTES del emparejador y antes de anotar el fallo. Sin esto, el
16,2 % del registro de "no entendido" son "[Musica]" y "(aplausos)", y la
mejora nocturna acaba generando alias a partir de ellos.
Conservador a proposito. Lo que se cuela solo cuesta una consulta al modelo;
lo que se filtra de mas es una orden que JARVIS ignora, y eso se vive como
que no funciona.
"""
if not texto or not texto.strip():
return True
if SOLO_ETIQUETA.match(texto):
return True
limpio = texto.lower().strip(" .,!¡?¿*-—")
if limpio in ANOTACIONES:
return True
return any(m in limpio for m in MULETILLAS)