"""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 shutil 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__)))) # El binario de whisper-server: el build local del repo si esta compilado, y si # no, el que haya en el PATH (asi vale un whisper.cpp instalado del sistema, en # cualquier distro). Cada quien lo compila para su plataforma —CUDA, ROCm, # Metal o CPU—; aqui solo se busca donde esta. _LOCAL = os.path.join(RAIZ, "voz", "whisper-cuda", "whisper.cpp", "build", "bin", "whisper-server") BINARIO = _LOCAL if os.path.exists(_LOCAL) else (shutil.which("whisper-server") or _LOCAL) def _dir_modelos(): """Donde estan los ggml-*.bin. Se puede fijar con JARVIS_WHISPER_MODELOS; si no, se prueba junto al build, el sitio estandar del usuario, y por compatibilidad la ruta del antiguo flatpak. Gana el primero que TENGA un modelo de verdad —el dir del source de whisper.cpp existe pero suele estar vacio, asi que mirar solo si el dir existe elegiria mal.""" import glob env = os.environ.get("JARVIS_WHISPER_MODELOS") if env: return os.path.expanduser(env) for c in (os.path.join(os.path.dirname(_LOCAL), "..", "..", "models"), "~/.local/share/jarvis/whisper/models", "~/.var/app/io.github.qwersyk.Newelle/config/models/whisper/whisper.cpp/models"): ruta = os.path.normpath(os.path.expanduser(c)) if glob.glob(os.path.join(ruta, "ggml-*.bin")): return ruta return os.path.expanduser("~/.local/share/jarvis/whisper/models") MODELOS = _dir_modelos() # Las libs de CUDA, solo si estan: en un equipo AMD o CPU no hay /usr/local/cuda, # y meterlo en LD_LIBRARY_PATH a la fuerza no aporta nada. Se añade mas abajo # solo si el directorio existe. 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") # CUDA en LD_LIBRARY_PATH solo si existe; en AMD/CPU no estorba. entorno = dict(os.environ) if os.path.isdir(LIBS): entorno["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)