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.
143 lines
5.2 KiB
Python
143 lines
5.2 KiB
Python
"""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
|