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