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.
83 lines
2.9 KiB
Python
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())
|