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

159 lines
5.3 KiB
Python
Executable file

#!/usr/bin/env python3
"""La prueba que Newelle no pasa: que JARVIS no se oiga a si mismo.
En Newelle, mas del 21 % de lo que oye y no entiende es su propia voz. Esta
prueba lo comprueba de verdad, con el microfono y los altavoces, en dos partes:
A. Suena una frase POR FUERA de JARVIS (un reproductor a pelo, como si
hablara una persona). Tiene que captarla. Si no, el microfono no llega a
los altavoces y la parte B no demuestra nada.
B. La misma frase, pero dicha POR JARVIS. NO tiene que captarla.
Las dos partes juntas son la prueba: A sola no dice nada, B sola podria pasar
simplemente porque el microfono no oye nada.
./venv/bin/python pruebas/prueba_eco.py
Necesita altavoces encendidos y a un volumen normal. Si estan en silencio, la
parte A falla y avisa.
"""
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 oido.captura import Captura # noqa: E402
from oido.whisper import Oido, es_ruido # noqa: E402
FRASE = "El sistema esta operativo y a su disposicion, señor."
ESPERA = 14 # cuanto se escucha en cada parte
def _se_parece(oido: str, dicho: str) -> bool:
"""Si lo transcrito es la frase que se dijo, aunque venga maltratada.
Se compara por palabras y no por parecido de cadena: un eco recortado
("operativo y a su disposicion") no se parece a la frase entera como
cadena, pero comparte casi todas sus palabras, y eso es inequivoco.
"""
import unicodedata
def palabras(t):
t = unicodedata.normalize("NFD", t.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)
return bool(b) and len(a & b) >= 2
def escucha_un_rato(captura, oreja, segundos):
"""Devuelve lo que se haya oido en ese rato."""
oido = []
fin = time.monotonic() + segundos
hilo_paro = threading.Event()
def bucle():
for wav in captura.frases(para_cuando=lambda: hilo_paro.is_set()):
try:
t = oreja.transcribe(wav)
finally:
try:
os.unlink(wav)
except OSError:
pass
if t and not es_ruido(t):
oido.append(t)
h = threading.Thread(target=bucle, daemon=True)
h.start()
while time.monotonic() < fin:
time.sleep(0.2)
hilo_paro.set()
captura.para()
h.join(timeout=5)
return oido
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
# Se genera el WAV una vez para poder reproducirlo por los dos caminos con
# exactamente el mismo audio.
wav = boca._genera(FRASE)
if not wav:
print(" no se pudo generar la voz")
return 2
fallos = 0
# --- A: suena por fuera, tiene que oirse -----------------------------
print("\n A · suena por fuera (como si hablara alguien)")
captura = Captura(boca=boca)
def suelta_fuera():
time.sleep(2.5)
subprocess.run(["paplay", wav], capture_output=True)
threading.Thread(target=suelta_fuera, daemon=True).start()
oido_a = escucha_un_rato(captura, oreja, ESPERA)
for t in oido_a:
print(f" oido: {t!r}")
if oido_a:
print(" OK el microfono llega a los altavoces")
else:
print(" MAL no se oyo nada: ¿altavoces en silencio? sin esto la")
print(" parte B no demuestra nada")
fallos += 1
time.sleep(1.5)
# --- B: lo dice JARVIS, NO tiene que oirse ---------------------------
print("\n B · lo dice JARVIS (la guarda tiene que taparlo)")
captura = Captura(boca=boca)
def suelta_jarvis():
time.sleep(2.5)
boca.di(FRASE)
threading.Thread(target=suelta_jarvis, daemon=True).start()
oido_b = escucha_un_rato(captura, oreja, ESPERA)
# Lo que se juzga es si se colo LA FRASE, no si se transcribio algo.
#
# Esto costo una conclusion equivocada: probando con colas de 350, 700 y
# 1100 ms el resultado no era monotono —1100 fallaba y 700 no—, lo cual es
# imposible si el unico factor fuera el eco. Mirando lo que se colaba, eran
# "¡Adios!" y un parrafo inventado: Whisper ALUCINANDO sobre el silencio,
# que es otro problema distinto y da igual lo larga que sea la guarda.
eco = [t for t in oido_b if _se_parece(t, FRASE)]
alucinado = [t for t in oido_b if t not in eco]
for t in eco:
print(f" ECO: {t!r}")
for t in alucinado:
print(f" (whisper alucinando sobre el silencio: {t[:52]!r})")
if not eco:
print(" OK JARVIS no se oye a si mismo")
else:
print(" MAL se ha oido hablar: la guarda no tapa")
fallos += 1
oreja.para()
print(f"\n {'todo en orden' if not fallos else str(fallos) + ' fallan'}")
return 1 if fallos else 0
if __name__ == "__main__":
sys.exit(main())