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.
243 lines
9.2 KiB
Python
Executable file
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())
|