JARVIS/nucleo/oido/despierta.py
sito 8e4bc8ad94 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.
2026-08-16 15:34:37 +02:00

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