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.
This commit is contained in:
commit
8e4bc8ad94
125 changed files with 25033 additions and 0 deletions
185
config/banco.py
Executable file
185
config/banco.py
Executable file
|
|
@ -0,0 +1,185 @@
|
|||
#!/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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue