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

144 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""QA de pronunciacion de la voz clonada, en silencio y sin intervencion.
Idea: si sintetizo una palabra con XTTS y luego se la paso a Whisper y NO la
reconoce, es que la voz la pronuncia mal. Sirve para cazar siglas y anglicismos
(GPU, SSH, hacking, firewall...) y construir un diccionario de correcciones.
Dos fases en un mismo proceso para no cargar dos modelos en los 4 GB a la vez:
1) XTTS (GPU) sintetiza todas las palabras a WAV.
2) se libera XTTS y Whisper (GPU/CPU) las transcribe y compara.
python qa_voz.py [cuda|cpu]
Salida: voz/qa_resultados.json (palabras sospechosas ordenadas por rareza) y
un arranque de voz/pronunciacion.json con las siglas deletreadas.
No reproduce nada.
"""
import os, sys, re, json, time, unicodedata, difflib, tempfile
os.environ.setdefault("COQUI_TOS_AGREED", "1")
DISP = sys.argv[1] if len(sys.argv) > 1 else "cuda"
BASE = os.path.dirname(os.path.abspath(__file__))
REFS = [os.path.join(BASE, "ref", n) for n in
("jarvis_lento_b.wav", "jarvis_lento_a.wav", "jarvis_ref.wav")]
REFS = [r for r in REFS if os.path.exists(r)]
PARAMS = dict(speed=1.0, temperature=0.65, repetition_penalty=2.0)
# Vocabulario donde se concentran los fallos: siglas, anglicismos y tecnicos.
# (los terminos normales en espanol XTTS los dice bien; no hace falta el
# diccionario entero, que serian 55 h de GPU)
VOCAB = """
GPU CPU RAM SSD USB SSH SSL TLS HTTP HTTPS FTP DNS IP VPN API URL SQL RSA AES
PGP GPG VLAN LAN WAN NAT DDoS XSS CSRF SQLi IPCC ONU UE OTAN CO2
hacking hacker cracker exploit payload malware ransomware phishing spoofing
firewall software hardware kernel shell root sudo bash script daemon backup
proxy router switch bytes gigabyte terabyte firmware bootloader overflow buffer
nmap metasploit wireshark linux debian ubuntu android
criptografia cifrado hash blockchain bitcoin ethereum wallet token
calentamiento invernadero climatico renovable emisiones
reconquista carlista franquismo transicion
señor máquina información galería atención
""".split()
# siglas que casi siempre se deletrean en espanol
DELETREO = {
"a": "a", "b": "be", "c": "ce", "d": "de", "e": "e", "f": "efe",
"g": "ge", "h": "hache", "i": "i", "j": "jota", "k": "ka", "l": "ele",
"m": "eme", "n": "ene", "o": "o", "p": "pe", "q": "cu", "r": "erre",
"s": "ese", "t": "te", "u": "u", "v": "uve", "w": "uve doble",
"x": "equis", "y": "i griega", "z": "zeta", "0": "cero", "1": "uno",
"2": "dos", "3": "tres",
}
def normaliza(s):
s = unicodedata.normalize("NFD", s.lower())
s = "".join(c for c in s if unicodedata.category(c) != "Mn")
return re.sub(r"[^a-z0-9 ]", "", s).strip()
def es_sigla(w):
return w.isupper() and 2 <= len(w) <= 6 and w.isalpha()
def deletrea(w):
return " ".join(DELETREO.get(c, c) for c in w.lower())
def main():
palabras = []
vistos = set()
for w in VOCAB:
if w and w.lower() not in vistos:
vistos.add(w.lower()); palabras.append(w)
print(f"{len(palabras)} palabras a revisar en {DISP}", flush=True)
tmp = tempfile.mkdtemp(prefix="qa_voz_")
import torch
from TTS.api import TTS
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(DISP)
# Fase 1: sintetizar todas (GPU)
wavs = {}
t0 = time.time()
for i, w in enumerate(palabras, 1):
dst = os.path.join(tmp, f"{i:03d}.wav")
try:
tts.tts_to_file(text=w + ".", speaker_wav=REFS, language="es",
file_path=dst, **PARAMS)
wavs[w] = dst
except Exception as e:
print(f" fallo sintesis '{w}': {e}", flush=True)
print(f"fase 1 (sintesis): {len(wavs)} en {time.time()-t0:.0f}s", flush=True)
# liberar XTTS antes de cargar Whisper
del tts
if DISP == "cuda":
torch.cuda.empty_cache()
# Fase 2: transcribir y comparar
from transformers import pipeline
asr = pipeline("automatic-speech-recognition",
model="openai/whisper-small",
device=0 if DISP == "cuda" else -1)
resultados = []
t0 = time.time()
for i, (w, path) in enumerate(wavs.items(), 1):
try:
oido = asr(path, generate_kwargs={"language": "spanish"})["text"]
except Exception as e:
oido = f"<error: {e}>"
ratio = difflib.SequenceMatcher(
None, normaliza(w), normaliza(oido)).ratio()
resultados.append({"palabra": w, "oido": oido.strip(), "ratio": round(ratio, 2)})
if i % 20 == 0:
print(f" ...{i}/{len(wavs)}", flush=True)
print(f"fase 2 (asr): {time.time()-t0:.0f}s", flush=True)
resultados.sort(key=lambda r: r["ratio"])
with open(os.path.join(BASE, "qa_resultados.json"), "w", encoding="utf-8") as f:
json.dump(resultados, f, ensure_ascii=False, indent=2)
# arranque del diccionario de pronunciacion: siglas deletreadas cuyo
# reconocimiento fue pobre
dic = {}
for r in resultados:
w = r["palabra"]
if es_sigla(w) and r["ratio"] < 0.6:
dic[w] = deletrea(w)
dic_path = os.path.join(BASE, "pronunciacion.json")
if not os.path.exists(dic_path):
with open(dic_path, "w", encoding="utf-8") as f:
json.dump(dic, f, ensure_ascii=False, indent=2)
malas = [r for r in resultados if r["ratio"] < 0.6]
print(f"\nlisto: {len(malas)} sospechosas (ratio<0.6). "
f"detalle en qa_resultados.json", flush=True)
for r in malas[:15]:
print(f" {r['ratio']:.2f} {r['palabra']:14s} -> «{r['oido']}»", flush=True)
if __name__ == "__main__":
main()