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:
commit
8e4bc8ad94
125 changed files with 25033 additions and 0 deletions
343
nucleo/oido/whisper.py
Normal file
343
nucleo/oido/whisper.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue