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
0
nucleo/oido/__init__.py
Normal file
0
nucleo/oido/__init__.py
Normal file
188
nucleo/oido/captura.py
Normal file
188
nucleo/oido/captura.py
Normal 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
143
nucleo/oido/despierta.py
Normal 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
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