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
144
voz/qa_voz.py
Normal file
144
voz/qa_voz.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
#!/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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue