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
243
nucleo/pruebas/prueba_voz.py
Executable file
243
nucleo/pruebas/prueba_voz.py
Executable file
|
|
@ -0,0 +1,243 @@
|
|||
#!/usr/bin/env python3
|
||||
"""La cadena de voz entera, con altavoces y microfono de verdad.
|
||||
|
||||
./venv/bin/python pruebas/prueba_voz.py
|
||||
|
||||
Las otras pruebas miran piezas: que el emparejador acierte, que el candado
|
||||
cierre, que el puente mande los campos que el HUD lee. Ninguna contesta a la
|
||||
pregunta que de verdad importa —**si le hablo, ¿me contesta?**— y por eso el
|
||||
fallo de que oia bien pero descartaba en silencio llego hasta el usuario.
|
||||
|
||||
Esta habla por los altavoces y escucha por el microfono, como una persona. Es
|
||||
lenta y necesita el volumen puesto, pero es la unica que prueba lo que se usa.
|
||||
|
||||
## Como funciona
|
||||
|
||||
Se genera la frase con Piper, se reproduce POR FUERA de la boca de JARVIS —con
|
||||
un reproductor a pelo, para que la guarda del eco no la tape— y se mira que la
|
||||
cadena entera reaccione. Que el microfono llega a los altavoces ya lo demuestra
|
||||
prueba_eco.py; aqui se da por hecho y se avisa si falla.
|
||||
|
||||
## Lo que se comprueba, en orden
|
||||
|
||||
1. sin llamarle se oye pero NO se atiende, y se dice por que
|
||||
2. llamandole se atiende y contesta
|
||||
3. encadenando la siguiente sin repetir el nombre
|
||||
4. como lo oye Whisper "Yarvis" y "Charles" tambien valen
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, AQUI)
|
||||
|
||||
from boca.voz import Boca # noqa: E402
|
||||
from manos import Manos # noqa: E402
|
||||
from oido.captura import Captura # noqa: E402
|
||||
from oido.despierta import Centinela, NOMBRE # noqa: E402
|
||||
from oido.whisper import Oido, es_ruido # noqa: E402
|
||||
from jarvis import atiende # noqa: E402
|
||||
|
||||
ESPERA = 16 # cuanto se escucha por cada frase dicha
|
||||
fallos = []
|
||||
ajenas = [] # lo que se oyo y no era de la prueba: ruido de la sala
|
||||
|
||||
|
||||
def _se_parece(oido: str, dicho: str) -> bool:
|
||||
"""Si lo transcrito es la frase que se reprodujo, aunque venga maltratada.
|
||||
|
||||
Hace falta porque la prueba usa el microfono de la habitacion: si hay una
|
||||
tele, una conversacion o un video sonando, el bucle oye ESO y una prueba
|
||||
ingenua lo da por bueno. Ha pasado —una pasada dio por valida "¡No te
|
||||
olvides de todo ese chico!"— y una prueba que se cree cualquier ruido no
|
||||
prueba nada.
|
||||
"""
|
||||
import unicodedata
|
||||
|
||||
def palabras(t):
|
||||
t = unicodedata.normalize("NFD", (t or "").lower())
|
||||
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
|
||||
return {p.strip(".,¡!¿?«»") for p in t.split() if len(p) > 3}
|
||||
|
||||
a, b = palabras(oido), palabras(dicho)
|
||||
if not b:
|
||||
return False
|
||||
return len(a & b) >= max(1, min(2, len(b) - 1))
|
||||
|
||||
|
||||
def afirma(condicion, queja):
|
||||
print(f" {'OK ' if condicion else 'MAL'} {queja}")
|
||||
if not condicion:
|
||||
fallos.append(queja)
|
||||
|
||||
|
||||
class Escucha:
|
||||
"""El bucle de voz de jarvis.py, reducido a lo que hace falta probar."""
|
||||
|
||||
def __init__(self, boca, oreja, centinela):
|
||||
self.boca, self.oreja, self.centinela = boca, oreja, centinela
|
||||
self.manos = Manos()
|
||||
self.oido = [] # (texto, atendido, motivo)
|
||||
self.dicho = [] # lo que ha contestado
|
||||
self._para = threading.Event()
|
||||
self._hilo = None
|
||||
|
||||
def arranca(self):
|
||||
self._hilo = threading.Thread(target=self._bucle, daemon=True)
|
||||
self._hilo.start()
|
||||
|
||||
def _bucle(self):
|
||||
cap = Captura(boca=self.boca)
|
||||
for wav in cap.frases(para_cuando=lambda: self._para.is_set()):
|
||||
try:
|
||||
texto = self.oreja.transcribe(wav)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(wav)
|
||||
except OSError:
|
||||
pass
|
||||
if es_ruido(texto):
|
||||
continue
|
||||
|
||||
atender, orden, _ = self.centinela.filtra(texto)
|
||||
if not atender:
|
||||
self.oido.append((texto, False, "no le han llamado"))
|
||||
continue
|
||||
self.oido.append((texto, True, ""))
|
||||
respuesta = atiende(orden, self.manos, None, traza=lambda t: None)
|
||||
if respuesta:
|
||||
self.dicho.append(respuesta)
|
||||
cap.para()
|
||||
|
||||
def para(self):
|
||||
self._para.set()
|
||||
if self._hilo:
|
||||
self._hilo.join(timeout=6)
|
||||
|
||||
def suelta(self, frase, espera=ESPERA):
|
||||
"""Dice la frase POR FUERA de JARVIS y espera a que reaccione.
|
||||
|
||||
Solo cuenta lo que se PARECE a lo reproducido: si hay ruido en la
|
||||
habitacion, el bucle lo oye tambien y darlo por bueno haria pasar la
|
||||
prueba sin haber probado nada.
|
||||
"""
|
||||
antes_oido, antes_dicho = len(self.oido), len(self.dicho)
|
||||
wav = self.boca._genera(frase)
|
||||
if not wav:
|
||||
return None, None
|
||||
time.sleep(0.6)
|
||||
subprocess.run(["paplay", wav], capture_output=True)
|
||||
|
||||
fin = time.monotonic() + espera
|
||||
mio = None
|
||||
while time.monotonic() < fin:
|
||||
for entrada in self.oido[antes_oido:]:
|
||||
if _se_parece(entrada[0], frase):
|
||||
mio = entrada
|
||||
break
|
||||
if mio and (not mio[1] or len(self.dicho) > antes_dicho):
|
||||
break
|
||||
time.sleep(0.2)
|
||||
|
||||
nuevo_dicho = self.dicho[antes_dicho:]
|
||||
ajeno = [o[0] for o in self.oido[antes_oido:] if o is not mio]
|
||||
if ajeno:
|
||||
ajenas.extend(ajeno)
|
||||
print(f" (ruido de la habitacion, ignorado: {ajeno[0][:44]!r})")
|
||||
return mio, (nuevo_dicho[-1] if nuevo_dicho else None)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
boca = Boca("davefx")
|
||||
oreja = Oido(modelo="small")
|
||||
if not oreja.disponible():
|
||||
print(" no esta whisper o el modelo; no se puede probar")
|
||||
return 2
|
||||
print(" arrancando whisper...")
|
||||
if not oreja.arranca():
|
||||
print(" whisper no arranco")
|
||||
return 2
|
||||
|
||||
centinela = Centinela(activo=True)
|
||||
esc = Escucha(boca, oreja, centinela)
|
||||
esc.arranca()
|
||||
time.sleep(1.5)
|
||||
|
||||
print("\n 0 · con la palabra APAGADA (el defecto): atiende directo")
|
||||
centinela.activo = False
|
||||
oido, dicho = esc.suelta("Cuanta memoria queda.")
|
||||
if oido:
|
||||
print(f" oyo: {oido[0]!r}")
|
||||
if dicho:
|
||||
print(f" dijo: {dicho}")
|
||||
afirma(oido is not None and oido[1],
|
||||
"sin exigir el nombre, atiende lo que oiga")
|
||||
afirma(dicho is not None, "y contesta")
|
||||
centinela.activo = True
|
||||
centinela.duerme()
|
||||
|
||||
print("\n 1 · con la palabra puesta y sin llamarle: oye pero no atiende")
|
||||
oido, dicho = esc.suelta("Cuanto espacio queda en el disco.")
|
||||
if oido is None:
|
||||
print(" ·· no se oyo nada: ¿altavoces en silencio? sin esto el resto")
|
||||
print(" de la prueba no demuestra nada")
|
||||
fallos.append("el microfono no capta los altavoces")
|
||||
else:
|
||||
print(f" oyo: {oido[0]!r}")
|
||||
afirma(not oido[1], "lo oye pero no lo atiende, porque no le han llamado")
|
||||
afirma(dicho is None, "y no contesta")
|
||||
|
||||
centinela.duerme()
|
||||
print(f"\n 2 · llamandole por su nombre")
|
||||
oido, dicho = esc.suelta(f"{NOMBRE.capitalize()}, cuanto espacio queda.")
|
||||
if oido:
|
||||
print(f" oyo: {oido[0]!r}")
|
||||
if dicho:
|
||||
print(f" dijo: {dicho}")
|
||||
afirma(oido is not None and oido[1], "lo atiende")
|
||||
afirma(dicho is not None, "y contesta algo")
|
||||
afirma(dicho is not None and any(c.isdigit() for c in dicho),
|
||||
"con una cifra de verdad, no una excusa")
|
||||
|
||||
print("\n 3 · encadenando, sin repetir el nombre")
|
||||
oido, dicho = esc.suelta("Cuanta memoria queda.")
|
||||
if oido:
|
||||
print(f" oyo: {oido[0]!r}")
|
||||
afirma(oido is not None and oido[1],
|
||||
"sigue despierto y atiende sin que le vuelvan a llamar")
|
||||
|
||||
print("\n 4 · como lo oye Whisper de verdad")
|
||||
centinela.duerme()
|
||||
oido, dicho = esc.suelta("Yarvis, que hora es.")
|
||||
if oido:
|
||||
print(f" oyo: {oido[0]!r}")
|
||||
afirma(oido is not None and oido[1],
|
||||
"'Yarvis' tambien le despierta (Whisper escribe el nombre de mil formas)")
|
||||
|
||||
esc.para()
|
||||
oreja.para()
|
||||
|
||||
# Una prueba que usa el microfono de la sala no puede fallar en silencio por
|
||||
# culpa de la sala. Si ha entrado mucho ruido, el resultado no dice nada del
|
||||
# codigo y hay que decirlo, no soltar una pared de MAL.
|
||||
if fallos and len(ajenas) >= 2:
|
||||
print(f"\n ┌─ SALA RUIDOSA ─────────────────────────────────────────")
|
||||
print(f" │ Han entrado {len(ajenas)} frases que no eran de la prueba.")
|
||||
print(f" │ Con audio sonando cerca del microfono, este resultado NO")
|
||||
print(f" │ dice nada del codigo. Ejemplos de lo que se colo:")
|
||||
for a in ajenas[:3]:
|
||||
print(f" │ {a[:52]!r}")
|
||||
print(f" │ Repitelo en silencio.")
|
||||
print(f" └────────────────────────────────────────────────────────")
|
||||
print(f"\n {len(fallos)} fallan, pero no son concluyentes\n")
|
||||
return 2
|
||||
|
||||
print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n")
|
||||
return 1 if fallos else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue