JARVIS/voz/calienta_cache.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

83 lines
2.9 KiB
Python

#!/usr/bin/env python3
"""Calienta la cache de voz: coge las frases que cayeron a Piper (misses.log,
que escribe jarvis-voz.sh) y las regenera con la voz CLONADA (XTTS) en
cache_voz/. La proxima vez que JARVIS diga esa frase, sonara con tu voz al
instante. Cuanto mas usas JARVIS, mas habla con tu voz.
python calienta_cache.py [cuda|cpu] [--min N]
--min N: solo frases vistas al menos N veces (por defecto 1 = todas las nuevas).
No reproduce nada. Pensado para correr de noche (timer de systemd).
"""
import os, sys, re, json, time, hashlib, collections
os.environ.setdefault("COQUI_TOS_AGREED", "1")
DISP = "cuda"
MIN = 1
for i, a in enumerate(sys.argv[1:]):
if a in ("cuda", "cpu"):
DISP = a
elif a == "--min" and i + 2 <= len(sys.argv) - 1:
MIN = int(sys.argv[i + 2])
BASE = os.path.dirname(os.path.abspath(__file__))
MISSES = os.path.join(BASE, "misses.log")
CACHE = os.path.join(BASE, "cache_voz")
sys.path.insert(0, BASE)
from clonador import Clonador, clave # la voz, definida en un solo sitio
os.makedirs(CACHE, exist_ok=True)
def cacheable(f):
# ni muy corta ni muy larga; nada con rutas (frases unicas que no repiten);
# ni mayoria de digitos (numeros sueltos de telemetria, casi unicos)
if not (3 <= len(f) <= 180):
return False
if "/" in f or "\\" in f:
return False
letras = sum(c.isalpha() for c in f)
return letras >= max(3, len(f) * 0.5)
def main():
if not os.path.exists(MISSES):
print("no hay misses.log todavia; nada que calentar."); return 0
with open(MISSES, encoding="utf-8") as f:
frases = [ln.rstrip("\n") for ln in f if ln.strip()]
cuenta = collections.Counter(frases)
pendientes = []
for frase, n in cuenta.items():
if n < MIN or not cacheable(frase):
continue
dst = os.path.join(CACHE, clave(frase) + ".wav")
if not os.path.exists(dst):
pendientes.append((frase, n))
pendientes.sort(key=lambda x: -x[1]) # las mas repetidas primero
print(f"{len(cuenta)} frases distintas en el log · "
f"{len(pendientes)} nuevas a generar (min={MIN})", flush=True)
if not pendientes:
return 0
t0 = time.time()
voz = Clonador(DISP)
print(f"modelo cargado en {time.time()-t0:.1f}s", flush=True)
hechas = 0
for i, (frase, n) in enumerate(pendientes, 1):
dst = os.path.join(CACHE, clave(frase) + ".wav")
try:
t0 = time.time()
voz.genera(frase, dst)
hechas += 1
print(f" [{i}/{len(pendientes)}] x{n} {time.time()-t0:.1f}s "
f"{frase[:50]}", flush=True)
except Exception as e:
print(f" fallo '{frase[:40]}': {e}", flush=True)
print(f"\nlisto: {hechas} frases nuevas en la voz clonada. "
f"cache -> {CACHE}", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())