JARVIS/config/banco.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

185 lines
6.9 KiB
Python
Executable file

#!/usr/bin/env python3
"""Un banco de pruebas FIJO para medir si el reconocimiento mejora.
## El problema que resuelve
La mejora nocturna medía así: decía las 150 frases con voces, velocidades y
ruido ALEATORIOS, las transcribía, y comparaba el porcentaje con el de la ronda
anterior. Nueve horas y 39 rondas después:
ruido de medida (desviacion): ±1,9 puntos
mejora acumulada en la noche: +1,45 puntos
El ruido era MAYOR que la mejora. Con eso, la pregunta que el bucle necesita
contestar —"¿este alias mejora o empeora?"— se decidía a cara o cruz, y por eso
en nueve horas aplicó cuatro alias y no dejó ni un commit.
## Cómo lo arregla
Se graba el audio UNA vez, con semillas fijas, y se guardan las
TRANSCRIPCIONES. A partir de ahí, medir el catálogo es volver a pasar el
emparejador sobre esas mismas transcripciones guardadas:
- determinista: la misma entrada siempre, así que dos medidas son comparables
- instantáneo: sin TTS ni Whisper, milisegundos en vez de cinco minutos
- sensible: se nota una frase de diferencia, no hacen falta cuatro puntos
Lo que se pierde: deja de explorar ruidos nuevos. Por eso el banco se REHACE de
vez en cuando (--crear), y entre medias se usa fijo para decidir.
banco.py --crear [--muestras 2] graba y transcribe (lento, una vez)
banco.py --medir puntúa el catálogo actual (instantáneo)
banco.py --fallos enseña qué se sigue fallando
"""
import argparse
import collections
import importlib.util
import json
import os
import random
import subprocess
import sys
import tempfile
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CATALOGO = os.path.join(RAIZ, "newelle", "data", "acciones", "acciones.json")
MOTOR = os.path.join(RAIZ, "newelle", "src", "utility", "acciones.py")
BANCO = os.path.join(RAIZ, "muestras", "banco.json")
PIPER = os.path.join(RAIZ, "config", "jarvis-piper.sh")
MODELO_WHISPER = os.path.expanduser(
"~/.var/app/io.github.qwersyk.Newelle/config/models/whisper/whisper.cpp/models/ggml-small.bin")
BIN_WHISPER = os.path.join(RAIZ, "voz", "whisper-cuda", "whisper.cpp", "build", "bin", "whisper-server")
# Fijas a proposito: son las que hacen el banco reproducible. Cambiarlas obliga
# a rehacerlo, y esa es justamente la señal de que ya no es comparable.
VOCES = ["es_ES-davefx-medium", "es_MX-claude-high", "es_ES-sharvard-medium"]
VELOCIDADES = [0.9, 1.0, 1.15]
SEMILLA = 20260815
def motor():
spec = importlib.util.spec_from_file_location("acciones", MOTOR)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m
def crear(muestras):
m = motor()
cat = m.Catalogo([CATALOGO])
rnd = random.Random(SEMILLA)
casos = []
for a in cat.acciones:
if not a.frases:
continue
for _ in range(muestras):
casos.append({
"id": a.id,
"frase": rnd.choice(a.frases),
"voz": rnd.choice(VOCES),
"velocidad": rnd.choice(VELOCIDADES),
})
print(f" {len(casos)} clips a grabar y transcribir...", flush=True)
puerto = 8993
servidor = subprocess.Popen(
[BIN_WHISPER, "-m", MODELO_WHISPER, "-t", "8", "--host", "127.0.0.1",
"--port", str(puerto), "-l", "es", "--no-gpu"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env={**os.environ, "LD_LIBRARY_PATH": "/usr/local/cuda/lib64"})
import time
time.sleep(10)
import urllib.request
hechos = 0
with tempfile.TemporaryDirectory() as tmp:
for c in casos:
wav = os.path.join(tmp, "c.wav")
entorno = {**os.environ,
"JARVIS_PIPER_VOZ": c["voz"],
"JARVIS_PIPER_TONO": "1.0"}
r = subprocess.run([PIPER, wav, c["frase"]], env=entorno,
capture_output=True, timeout=60)
if not os.path.exists(wav) or os.path.getsize(wav) == 0:
continue
# la velocidad se aplica al vuelo, sin tocar el tono
wav16 = os.path.join(tmp, "c16.wav")
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", wav,
"-filter:a", f"atempo={c['velocidad']}",
"-ar", "16000", "-ac", "1", wav16],
capture_output=True, timeout=60)
try:
with open(wav16, "rb") as f:
datos = f.read()
lim = "----b"
cuerpo = (f"--{lim}\r\nContent-Disposition: form-data; "
f'name="file"; filename="c.wav"\r\n'
f"Content-Type: audio/wav\r\n\r\n").encode() + datos + \
f"\r\n--{lim}--\r\n".encode()
req = urllib.request.Request(
f"http://127.0.0.1:{puerto}/inference", data=cuerpo,
headers={"Content-Type": f"multipart/form-data; boundary={lim}"})
with urllib.request.urlopen(req, timeout=120) as resp:
c["oido"] = json.loads(resp.read().decode()).get("text", "").strip()
hechos += 1
if hechos % 50 == 0:
print(f" {hechos}/{len(casos)}", flush=True)
except Exception as e:
c["oido"] = ""
servidor.terminate()
casos = [c for c in casos if c.get("oido")]
os.makedirs(os.path.dirname(BANCO), exist_ok=True)
json.dump({"semilla": SEMILLA, "casos": casos}, open(BANCO, "w"),
ensure_ascii=False, indent=1)
print(f" banco guardado: {len(casos)} transcripciones en {BANCO}")
return 0
def cargar():
try:
return json.load(open(BANCO))["casos"]
except Exception:
return []
def medir(detalle=False):
casos = cargar()
if not casos:
print("no hay banco; crealo con --crear")
return 1
m = motor()
cat = m.Catalogo([CATALOGO])
aciertos, fallos = 0, []
for c in casos:
a, _ = cat.busca(c["oido"])
if a is not None and a.id == c["id"]:
aciertos += 1
else:
fallos.append((c["id"], c["oido"], a.id if a else None))
pct = 100.0 * aciertos / len(casos)
print(f" acierto {pct:.2f} % ({aciertos}/{len(casos)})")
if detalle:
cuenta = collections.Counter(f[0] for f in fallos)
print(f"\n las acciones que mas se fallan:")
for ident, n in cuenta.most_common(12):
ejemplo = next(f[1] for f in fallos if f[0] == ident)
print(f" {n:3d} {ident:22s} p.ej. se oyo: \"{ejemplo[:44]}\"")
return 0
def main():
p = argparse.ArgumentParser()
p.add_argument("--crear", action="store_true")
p.add_argument("--medir", action="store_true")
p.add_argument("--fallos", action="store_true")
p.add_argument("--muestras", type=int, default=2)
a = p.parse_args()
if a.crear:
return crear(a.muestras)
return medir(detalle=a.fallos)
if __name__ == "__main__":
sys.exit(main())