"""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)