JARVIS/nucleo/pruebas/prueba_voz.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

243 lines
9.2 KiB
Python
Executable file

#!/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())