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
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue