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

0
nucleo/oido/__init__.py Normal file
View file

188
nucleo/oido/captura.py Normal file
View file

@ -0,0 +1,188 @@
"""Escuchar el microfono y recortar lo que es habla.
## Por que pw-record y no pyaudio
Newelle usa pyaudio, que necesita portaudio compilado. En esta maquina no esta,
asi que instalarlo pediria sudo. `pw-record` y `parec` ya vienen con PipeWire y
sirven igual: se lanzan como subproceso y se lee su salida estandar.
Y hay una ventaja que no es casual: la fuente se elige con un argumento. Cuando
se ponga el cancelador de eco de PipeWire, apuntar ahi es cambiar una cadena, no
tocar codigo de audio.
## La guarda contra el eco
Esta es la razon de ser de este fichero. En Newelle, el 21 % largo de lo que
JARVIS oye y no entiende es su propia voz. Tiene mecanismo para evitarlo pero
con tres agujeros, y aqui se cierran los tres:
1. **Se consulta a la boca en cada trozo**, no una bandera que alguien actualiza
desde otro hilo. Si `boca.hablando` es cierto, ese audio se tira sin mirar.
2. **La cola de 350 ms** vive en la boca (`COLA_MS`): cuando el reproductor
termina, el sonido sigue llegando al microfono un rato.
3. **El prebufer se vacia** al salir de "hablando". Newelle guarda 1 s circular
y lo antepone al detectar voz; ese segundo era justo el final de la frase de
JARVIS, asi que se colaba aunque la guarda funcionase.
"""
import collections
import os
import signal
import struct
import subprocess
import tempfile
import wave
FRECUENCIA = 16000 # lo que quiere whisper.cpp
CANALES = 1
ANCHO = 2 # s16
TROZO = 512 # muestras: lo que pide Silero de una vez
# Cuanto silencio hace falta para dar la frase por terminada. Muy corto corta a
# quien piensa a media frase; muy largo hace que JARVIS parezca lento.
SILENCIO_PARA_CERRAR = 0.8
# Cuanto se guarda de ANTES de detectar voz. La primera silaba siempre llega
# antes de que el detector se entere.
PREBUFER_S = 0.5
# Frases mas cortas que esto no son ordenes, son ruidos.
MINIMO_S = 0.35
# Y mas largas que esto es que el detector se ha quedado enganchado.
MAXIMO_S = 25.0
def _grabador(fuente=None):
"""La orden para capturar crudo a la salida estandar."""
if fuente:
pw = ["pw-record", "--target", fuente]
else:
pw = ["pw-record"]
return pw + ["--rate", str(FRECUENCIA), "--channels", str(CANALES),
"--format", "s16", "-"]
class Captura:
"""Trozos de microfono -> frases sueltas en WAV."""
def __init__(self, boca=None, fuente=None, agresividad=0.5):
self.boca = boca # para saber si JARVIS esta hablando
self.fuente = fuente
self.agresividad = agresividad
self._proc = None
self._vad = None
self._energia = None
# ------------------------------------------------------------ el detector
def _detector(self):
if self._vad is not None or self._energia is not None:
return
try:
from pysilero_vad import SileroVoiceActivityDetector
self._vad = SileroVoiceActivityDetector()
except Exception:
# Respaldo por energia: peor, pero mejor que no escuchar. Se avisa,
# porque la diferencia se nota y conviene saber en cual se esta.
print("oido: sin Silero, usando deteccion por energia")
self._energia = True
def _hay_voz(self, trozo: bytes) -> bool:
if self._vad is not None:
try:
return self._vad(trozo) >= self.agresividad
except Exception:
return False
muestras = struct.unpack(f"{len(trozo) // 2}h", trozo)
if not muestras:
return False
media = sum(m * m for m in muestras) / len(muestras)
return (media ** 0.5) / 32768.0 > 0.015
# -------------------------------------------------------------- escuchar
def frases(self, para_cuando=None):
"""Generador: devuelve la ruta de un WAV por cada frase oida.
El WAV es temporal y de quien lo recibe: se borra cuando quiera.
"""
self._detector()
self._proc = subprocess.Popen(
_grabador(self.fuente), stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, start_new_session=True)
bytes_trozo = TROZO * ANCHO
por_segundo = FRECUENCIA / TROZO
prebufer = collections.deque(maxlen=max(1, int(PREBUFER_S * por_segundo)))
cerrar_tras = int(SILENCIO_PARA_CERRAR * por_segundo)
tope = int(MAXIMO_S * por_segundo)
frase, callados, hablando = [], 0, False
try:
while True:
if para_cuando and para_cuando():
break
trozo = self._proc.stdout.read(bytes_trozo)
if not trozo or len(trozo) < bytes_trozo:
break
# --- la guarda: si JARVIS habla, esto es su propia voz -------
if self.boca is not None and self.boca.hablando:
# Tirar tambien lo acumulado: si estaba a media frase cuando
# JARVIS empezo a hablar, lo que quede lleva su voz encima.
prebufer.clear()
frase, callados, hablando = [], 0, False
continue
prebufer.append(trozo)
voz = self._hay_voz(trozo)
if voz and not hablando:
hablando = True
frase = list(prebufer) # con lo de antes de detectarla
callados = 0
elif hablando:
frase.append(trozo)
callados = 0 if voz else callados + 1
if hablando and (callados >= cerrar_tras or len(frase) >= tope):
dur = len(frase) * TROZO / FRECUENCIA
ruta = self._a_wav(frase) if dur >= MINIMO_S else None
frase, callados, hablando = [], 0, False
prebufer.clear()
if ruta:
yield ruta
finally:
self.para()
def _a_wav(self, trozos) -> str:
fd, ruta = tempfile.mkstemp(suffix=".wav", prefix="jarvis-oido-")
os.close(fd)
with wave.open(ruta, "wb") as w:
w.setnchannels(CANALES)
w.setsampwidth(ANCHO)
w.setframerate(FRECUENCIA)
w.writeframes(b"".join(trozos))
return ruta
def para(self):
if not self._proc:
return
try:
os.killpg(os.getpgid(self._proc.pid), signal.SIGTERM)
self._proc.wait(timeout=3)
except Exception:
pass
self._proc = None
def fuentes() -> list:
"""Las entradas que hay, para poder elegir la del cancelador de eco."""
try:
r = subprocess.run(["pactl", "list", "short", "sources"],
capture_output=True, text=True, timeout=10)
except (OSError, subprocess.TimeoutExpired):
return []
salida = []
for linea in r.stdout.splitlines():
campos = linea.split("\t")
if len(campos) > 1 and not campos[1].endswith(".monitor"):
salida.append(campos[1])
return salida

143
nucleo/oido/despierta.py Normal file
View file

@ -0,0 +1,143 @@
"""La palabra de activacion: "JARVIS".
Sin esto, el asistente obedece a cualquier cosa que se diga en la habitacion y
a la tele. Con esto, solo escucha de verdad durante un rato despues de oir su
nombre.
## Por que sobre el texto y no sobre el audio
Lo obvio seria un detector como openwakeword, que mira la forma de onda antes de
transcribir. Aqui se hace despues, sobre lo que Whisper ya escribio, y es a
proposito:
- **Ya tenemos la transcripcion.** El audio pasa por Whisper de todas formas
porque la captura la dispara el detector de voz; no hay ahorro que ganar.
- **Un modelo menos.** openwakeword son mas dependencias, otro modelo en
memoria y otro afinado que mantener.
- **Y sobre todo: Whisper oye "JARVIS" de mil maneras.** Medido en el registro
de uso real, el nombre sale como "Jarvis", "Yarvis", "Charles", "Chavez",
"Harvest"... Sobre el texto eso se arregla comparando como SUENA, que es lo
que ya hace `manos/fonetica.py` para el catalogo. Sobre la onda habria que
reentrenar.
Lo que se pierde: la transcripcion corre siempre, tambien cuando nadie ha
llamado. En una maquina donde Whisper va en la grafica y tarda dos segundos, es
un coste que ya estabamos pagando.
## Como se comporta
Se llama una vez y se queda despierto un rato, para poder encadenar ordenes sin
repetir el nombre en cada frase. Y si la frase que trae el nombre lleva ademas
la orden "JARVIS, cuanto espacio queda" se atiende esa misma, sin obligar a
decirlo dos veces.
"""
import difflib
import re
import time
# Absolutos, como el resto del nucleo: jarvis.py mete su carpeta en sys.path y
# todos los paquetes son de primer nivel. Con `..manos` esto solo se podria
# importar desde fuera, y las pruebas lo cargan suelto.
from manos.acciones import normaliza
from manos.fonetica import fonetica
NOMBRE = "jarvis"
# Cuanto sigue escuchando tras oir su nombre. Corto obliga a repetirlo en cada
# frase; largo devuelve al problema de origen, que obedezca a la tele.
DESPIERTO_S = 25.0
# Lo que Whisper escribe cuando oye "JARVIS". Salen del registro real de uso,
# no de imaginarlas.
PARECIDAS = (
"jarvis", "yarvis", "charvis", "jarbis", "yarbis", "harvis", "jervis",
"jarvi", "yarvi", "javis", "jarves", "charles", "chavez", "harvest",
"jarvix", "sharvis", "arvis",
)
# Se compara palabra a palabra, asi que el umbral puede ser alto sin perder
# aciertos: "yarvis" contra "jarvis" da 0,83 por letra y sube por sonido.
UMBRAL = 0.80
_LIMPIA = re.compile(r"^[\s,.:;¡!¿?]+|[\s,.:;¡!¿?]+$")
# Lo que suele acompañar al nombre sin ser una orden. Si al quitar el nombre
# solo queda esto, es que llamaron y ya: "oye jarvis" no es "haz oye".
LLAMADAS = {"oye", "eh", "hey", "ey", "hola", "escucha", "atiende", "perdona",
"por favor", "porfa", "venga", "a ver"}
def _suena_al_nombre(palabra: str) -> bool:
p = _LIMPIA.sub("", palabra)
if len(p) < 4:
return False
if p in PARECIDAS:
return True
for candidata in (NOMBRE,) + PARECIDAS[:6]:
if difflib.SequenceMatcher(None, p, candidata).ratio() >= UMBRAL:
return True
if difflib.SequenceMatcher(None, fonetica(p), fonetica(candidata)).ratio() >= UMBRAL:
return True
return False
def parte_el_nombre(texto: str):
"""(lo llamaron, lo que queda de la frase sin el nombre).
Se devuelve el resto porque "JARVIS, cuanto espacio queda" tiene que
ejecutarse en la misma frase: obligar a decir el nombre y luego la orden
aparte es exactamente lo que hace que un asistente canse.
"""
palabras = normaliza(texto).split()
if not palabras:
return False, ""
for i, p in enumerate(palabras):
if _suena_al_nombre(p):
sobra = [q for q in palabras[:i] + palabras[i + 1:]
if q not in LLAMADAS]
return True, " ".join(sobra).strip()
return False, ""
class Centinela:
"""Decide si una frase hay que atenderla o dejarla pasar."""
def __init__(self, activo=True, ventana=DESPIERTO_S):
self.activo = activo
self.ventana = ventana
self._hasta = 0.0
@property
def despierto(self) -> bool:
return not self.activo or time.monotonic() < self._hasta
def despierta(self):
self._hasta = time.monotonic() + self.ventana
def duerme(self):
self._hasta = 0.0
def filtra(self, texto: str):
"""(hay que atenderla, texto a atender, acaba de despertar).
- Sin palabra de activacion configurada: se atiende todo.
- Dormido y sin nombre: se ignora.
- Dormido y con nombre: despierta. Si la frase traia ademas la orden,
se devuelve; si solo traia el nombre, se contesta con un acuse.
- Despierto: se atiende y se renueva la ventana, para poder encadenar.
"""
if not self.activo:
return True, texto, False
llamado, resto = parte_el_nombre(texto)
if llamado:
recien = not self.despierto
self.despierta()
return (True, resto, recien) if resto else (False, "", recien)
if self.despierto:
self.despierta() # encadenar sin repetir el nombre
return True, texto, False
return False, "", False

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)