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.
144 lines
5.5 KiB
Python
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()
|