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:
sito 2026-08-16 15:28:31 +02:00
commit 8e4bc8ad94
125 changed files with 25033 additions and 0 deletions

120
voz/afinar_referencia.py Normal file
View file

@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Segunda vuelta: que referencia condiciona mejor, medido sobre varias frases.
La primera vuelta (comparar_ajustes.py) comparo parametros con UNA frase. Una
sola medida engaña: XTTS muestrea, y dos generaciones de lo mismo no puntuan
igual. Aqui cada candidata se mide sobre varias frases y se promedia.
Lo que se compara es de donde sale el timbre:
- una sola referencia larga (la mejor de las normalizadas)
- todas las normalizadas juntas, promediadas por XTTS
- un unico wav con todo el material pegado, para que el condicionado vea
mas fonemas del mismo hablante de una vez
- solo las tomas de la MISMA sesion de grabacion (las dos "Buenas noches"),
por si mezclar sesiones con tono distinto es lo que diluye
python afinar_referencia.py [cuda|cpu]
"""
import glob
import os
import subprocess
import sys
import tempfile
import torch
DISPOSITIVO = sys.argv[1] if len(sys.argv) > 1 else "cuda"
BASE = os.path.dirname(os.path.abspath(__file__))
REF = os.path.join(BASE, "ref")
DESCARGAS = os.path.expanduser("~/Downloads")
os.environ.setdefault("COQUI_TOS_AGREED", "1")
LARGO = dict(gpt_cond_len=30, gpt_cond_chunk_len=6, max_ref_length=30)
FRASES = [
"Buenas noches, señor. Soy su asistente personal.",
"Un momento, lo miro.",
"He encontrado catorce ficheros, señor. Dígame cuál abro.",
]
def parecido(a, b):
return float(torch.nn.functional.cosine_similarity(
a.squeeze().float(), b.squeeze().float(), dim=0))
def pega(entradas, destino):
"""Concatena varios audios en un wav mono 24k."""
lista = destino + ".txt"
with open(lista, "w") as f:
for e in entradas:
f.write(f"file '{e}'\n")
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "concat",
"-safe", "0", "-i", lista, "-ar", "24000", "-ac", "1",
destino], check=True)
return destino
def main():
from TTS.api import TTS
print(f"cargando XTTS en {DISPOSITIVO}...", flush=True)
api = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(DISPOSITIVO)
modelo = api.synthesizer.tts_model
import soundfile as sf
def emb(rutas):
_, e = modelo.get_conditioning_latents(audio_path=list(rutas), **LARGO)
return e
patron = emb([os.path.join(REF, "jarvis_lento_b.wav")])
with tempfile.TemporaryDirectory() as tmp:
normalizadas = sorted(glob.glob(os.path.join(REF, "*.wav")))
pegadas = pega(normalizadas, os.path.join(tmp, "todo.wav"))
misma_sesion = sorted(glob.glob(
os.path.join(DESCARGAS, "Jarvis-*Buenas-noches*.mp3")))
sesion_wavs = []
for i, m in enumerate(misma_sesion):
d = os.path.join(tmp, f"s{i}.wav")
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", m,
"-ar", "24000", "-ac", "1", d], check=True)
sesion_wavs.append(d)
sesion_pegada = pega(sesion_wavs, os.path.join(tmp, "sesion.wav")) \
if sesion_wavs else None
candidatas = [
("una_larga", [os.path.join(REF, "jarvis_lento_b.wav")]),
("tres_normalizadas", [os.path.join(REF, n) for n in
("jarvis_lento_b.wav", "jarvis_lento_a.wav",
"jarvis_ref.wav")]),
("todo_pegado", [pegadas]),
]
if sesion_pegada:
candidatas.append(("misma_sesion_pegada", [sesion_pegada]))
print(f"\n cada candidata sobre {len(FRASES)} frases:\n")
tabla = []
for nombre, refs in candidatas:
gpt, hablante = modelo.get_conditioning_latents(
audio_path=refs, **LARGO)
puntos = []
for j, frase in enumerate(FRASES):
salida = modelo.inference(
text=frase, language="es", gpt_cond_latent=gpt,
speaker_embedding=hablante, temperature=0.65,
repetition_penalty=2.0, speed=1.0,
enable_text_splitting=True)
d = os.path.join(tmp, f"{nombre}_{j}.wav")
sf.write(d, salida["wav"], 24000)
puntos.append(parecido(patron, emb([d])))
media = sum(puntos) / len(puntos)
tabla.append((media, nombre, puntos))
detalle = " ".join(f"{p:.3f}" for p in puntos)
print(f" {media:.4f} {nombre:22s} [{detalle}]", flush=True)
print("\n mejor:", max(tabla)[1])
if __name__ == "__main__":
main()

80
voz/analiza_voz.py Normal file
View file

@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Compara el original y el clon en lo que el oido nota, no solo en timbre.
La puntuacion de parecido que se venia usando es el coseno entre embeddings de
hablante: mide TIMBRE. Es ciega al tono y al ritmo, y una voz puede tener el
timbre correcto y aun asi "no parecerse nada" porque habla mas rapido, mas
agudo o mas plano que el original. Esto mide eso.
- tono medio y rango (F0): si el clon habla medio tono por encima, se nota
muchisimo mas que una diferencia de timbre
- variacion del tono: un original expresivo contra un clon monotono suena a
otra persona aunque el timbre coincida
- velocidad: silabas por segundo, aproximada por picos de energia
- brillo: centroide espectral, delata si el clon suena mas apagado
python analiza_voz.py fichero.wav [fichero2.wav ...]
"""
import sys
import numpy as np
def carga(ruta, sr=22050):
import librosa
y, _ = librosa.load(ruta, sr=sr, mono=True)
return y, sr
def analiza(ruta):
import librosa
y, sr = carga(ruta)
# F0 con pyin, que es lento pero fiable en voz
f0, sonoro, _ = librosa.pyin(
y, sr=sr, fmin=50, fmax=400, frame_length=2048)
f0v = f0[~np.isnan(f0)]
# velocidad: picos del envelope de energia ~ silabas
env = librosa.onset.onset_strength(y=y, sr=sr)
picos = librosa.util.peak_pick(env, pre_max=3, post_max=3, pre_avg=3,
post_avg=5, delta=0.2, wait=4)
dur = len(y) / sr
silabas_s = len(picos) / dur if dur else 0
# brillo
cent = float(np.mean(librosa.feature.spectral_centroid(y=y, sr=sr)))
# proporcion de voz sonora: cuanto del audio es habla con tono
prop = float(np.mean(sonoro)) if sonoro is not None else 0.0
return {
"dur": dur,
"f0_medio": float(np.median(f0v)) if len(f0v) else 0.0,
"f0_p10": float(np.percentile(f0v, 10)) if len(f0v) else 0.0,
"f0_p90": float(np.percentile(f0v, 90)) if len(f0v) else 0.0,
"f0_desv": float(np.std(f0v)) if len(f0v) else 0.0,
"silabas_s": silabas_s,
"brillo": cent,
"sonoro": prop,
}
def main():
print(f"{'fichero':34s} {'dur':>6s} {'tono':>7s} {'rango':>11s} "
f"{'varia':>7s} {'sil/s':>6s} {'brillo':>7s}")
print("-" * 84)
for ruta in sys.argv[1:]:
try:
a = analiza(ruta)
except Exception as e:
print(f"{ruta[:34]:34s} error: {e}")
continue
nombre = ruta.split("/")[-1][:34]
print(f"{nombre:34s} {a['dur']:5.1f}s {a['f0_medio']:6.1f}Hz "
f"{a['f0_p10']:4.0f}-{a['f0_p90']:<4.0f}Hz {a['f0_desv']:6.1f} "
f"{a['silabas_s']:6.2f} {a['brillo']:6.0f}Hz")
if __name__ == "__main__":
main()

122
voz/antes_despues.py Normal file
View file

@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""Mide el ajuste ACTUAL contra el propuesto, con las mismas frases.
Las dos vueltas anteriores compararon cosas distintas entre si; esto compara lo
que hay hoy en la cache con lo que se quiere poner, para poder decir una cifra
honesta de cuanto mejora.
De paso deja creada la referencia buena: ref/jarvis_sesion.wav, las tomas de
una misma sesion pegadas. Mezclar sesiones distintas puntuaba peor el
promediado de XTTS diluye el timbre cuando los clips no suenan igual, que es
justo lo contrario de lo que se supuso al montarlo.
python antes_despues.py [cuda|cpu]
"""
import glob
import os
import subprocess
import sys
import tempfile
import torch
DISPOSITIVO = sys.argv[1] if len(sys.argv) > 1 else "cuda"
BASE = os.path.dirname(os.path.abspath(__file__))
REF = os.path.join(BASE, "ref")
SALIDA = os.path.join(BASE, "comparativa")
DESCARGAS = os.path.expanduser("~/Downloads")
SESION = os.path.join(REF, "jarvis_sesion.wav")
os.environ.setdefault("COQUI_TOS_AGREED", "1")
FRASES = [
"Buenas noches, señor. Soy su asistente personal.",
"Un momento, lo miro.",
"He encontrado catorce ficheros, señor. Dígame cuál abro.",
]
# lo que hay hoy en generar_cache_voz.py
ANTES = dict(
refs=[os.path.join(REF, n) for n in
("jarvis_lento_b.wav", "jarvis_lento_a.wav", "jarvis_ref.wav")],
cond=dict(gpt_cond_len=6, gpt_cond_chunk_len=6, max_ref_length=30),
gen=dict(temperature=0.65, repetition_penalty=2.0, speed=1.0),
)
# lo propuesto
DESPUES = dict(
refs=[SESION],
cond=dict(gpt_cond_len=30, gpt_cond_chunk_len=6, max_ref_length=30),
gen=dict(temperature=0.65, repetition_penalty=2.0, speed=1.0),
)
def parecido(a, b):
return float(torch.nn.functional.cosine_similarity(
a.squeeze().float(), b.squeeze().float(), dim=0))
def construye_sesion():
"""Pega las tomas de la misma sesion en un unico wav de referencia."""
if os.path.exists(SESION):
return SESION
mp3s = sorted(glob.glob(os.path.join(DESCARGAS, "Jarvis-*Buenas-noches*.mp3")))
if not mp3s:
raise SystemExit("no encuentro los mp3 de la sesion en ~/Downloads")
with tempfile.TemporaryDirectory() as tmp:
trozos = []
for i, m in enumerate(mp3s):
d = os.path.join(tmp, f"{i}.wav")
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", m,
"-ar", "24000", "-ac", "1", d], check=True)
trozos.append(d)
lista = os.path.join(tmp, "lista.txt")
with open(lista, "w") as f:
for t in trozos:
f.write(f"file '{t}'\n")
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "concat",
"-safe", "0", "-i", lista, "-ar", "24000", "-ac", "1",
SESION], check=True)
print(f" referencia nueva: {SESION}")
return SESION
def main():
os.makedirs(SALIDA, exist_ok=True)
construye_sesion()
from TTS.api import TTS
import soundfile as sf
print(f"cargando XTTS en {DISPOSITIVO}...", flush=True)
api = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(DISPOSITIVO)
modelo = api.synthesizer.tts_model
largo = dict(gpt_cond_len=30, gpt_cond_chunk_len=6, max_ref_length=30)
def emb(rutas):
_, e = modelo.get_conditioning_latents(audio_path=list(rutas), **largo)
return e
patron = emb([os.path.join(REF, "jarvis_lento_b.wav")])
print()
for etiqueta, cfg in (("ANTES (lo que suena hoy)", ANTES),
("DESPUES(lo propuesto) ", DESPUES)):
gpt, hablante = modelo.get_conditioning_latents(
audio_path=cfg["refs"], **cfg["cond"])
puntos = []
for j, frase in enumerate(FRASES):
out = modelo.inference(text=frase, language="es",
gpt_cond_latent=gpt, speaker_embedding=hablante,
enable_text_splitting=True, **cfg["gen"])
nombre = ("antes" if cfg is ANTES else "despues") + f"_{j}.wav"
sf.write(os.path.join(SALIDA, nombre), out["wav"], 24000)
puntos.append(parecido(patron, emb([os.path.join(SALIDA, nombre)])))
media = sum(puntos) / len(puntos)
detalle = " ".join(f"{p:.3f}" for p in puntos)
print(f" {etiqueta} {media:.4f} [{detalle}]", flush=True)
print(f"\n wavs para escuchar en {SALIDA} (antes_*.wav / despues_*.wav)")
if __name__ == "__main__":
main()

107
voz/calibrar_parecido.py Normal file
View file

@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Da escala a la puntuacion de parecido: techo, suelo y donde cae el clon.
Un 0,68 no significa nada por si solo. Hace falta saber cuanto puntuan DOS
grabaciones distintas del mismo original (el techo realista: ni el propio
hablante da 1,0 consigo mismo) y cuanto puntua una voz que no tiene nada que
ver (el suelo). Entre esos dos numeros se lee si el clon esta cerca o lejos.
De paso prueba usar los mp3 ORIGINALES descargados como referencia, sin pasar
por los wav normalizados: si normalizar quito informacion, se vera aqui.
python calibrar_parecido.py [cuda|cpu]
"""
import glob
import os
import subprocess
import sys
import tempfile
import time
import torch
DISPOSITIVO = sys.argv[1] if len(sys.argv) > 1 else "cuda"
BASE = os.path.dirname(os.path.abspath(__file__))
REF = os.path.join(BASE, "ref")
COMPARATIVA = os.path.join(BASE, "comparativa")
DESCARGAS = os.path.expanduser("~/Downloads")
os.environ.setdefault("COQUI_TOS_AGREED", "1")
LARGO = dict(gpt_cond_len=30, gpt_cond_chunk_len=6, max_ref_length=30)
def parecido(a, b):
return float(torch.nn.functional.cosine_similarity(
a.squeeze().float(), b.squeeze().float(), dim=0))
def a_wav(origen, destino):
"""mp3 -> wav mono 24k, sin tocar niveles."""
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", origen,
"-ar", "24000", "-ac", "1", destino], check=True)
return destino
def main():
from TTS.api import TTS
print(f"cargando XTTS en {DISPOSITIVO}...", flush=True)
api = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(DISPOSITIVO)
modelo = api.synthesizer.tts_model
def emb(rutas):
_, e = modelo.get_conditioning_latents(audio_path=list(rutas), **LARGO)
return e
patron = emb([os.path.join(REF, "jarvis_lento_b.wav")])
print("\n TECHO — otras grabaciones del MISMO original:")
for nombre in ("jarvis_lento_a.wav", "jarvis_ref.wav", "jarvis_ref2.wav",
"jarvis_ref_corto.wav"):
ruta = os.path.join(REF, nombre)
if os.path.exists(ruta):
print(f" {parecido(patron, emb([ruta])):.4f} {nombre}")
print("\n SUELO — una voz que no es esa (Piper, castellano):")
with tempfile.TemporaryDirectory() as tmp:
piper = os.path.join(tmp, "piper.wav")
guion = os.path.expanduser(
"~/COFRE/CODERS/JARVIS/config/jarvis-piper.sh")
try:
subprocess.run([guion, piper, "Buenas noches, señor. Soy su asistente personal."],
check=True, capture_output=True, timeout=120)
print(f" {parecido(patron, emb([piper])):.4f} piper es_ES")
except Exception as e:
print(f" (no pude generar con Piper: {e})")
print("\n EL CLON — lo generado en comparar_ajustes.py:")
for wav in sorted(glob.glob(os.path.join(COMPARATIVA, "*.wav"))):
print(f" {parecido(patron, emb([wav])):.4f} {os.path.basename(wav)}")
print("\n ORIGINALES sin normalizar (mp3 de fish.audio) como referencia:")
mp3s = sorted(glob.glob(os.path.join(DESCARGAS, "Jarvis-*.mp3")))
if not mp3s:
print(" (no hay mp3 en ~/Downloads)")
return
with tempfile.TemporaryDirectory() as tmp:
convertidos = [a_wav(m, os.path.join(tmp, f"o{i}.wav"))
for i, m in enumerate(mp3s)]
for c, m in zip(convertidos, mp3s):
print(f" {parecido(patron, emb([c])):.4f} {os.path.basename(m)[:52]}")
# y clonar usando los originales como referencia, a ver si mejora
gpt, hablante = modelo.get_conditioning_latents(
audio_path=convertidos, **LARGO)
salida = modelo.inference(
text="Buenas noches, señor. Soy su asistente personal.",
language="es", gpt_cond_latent=gpt, speaker_embedding=hablante,
temperature=0.65, repetition_penalty=2.0, speed=1.0,
enable_text_splitting=True)
import soundfile as sf
destino = os.path.join(COMPARATIVA, "F_originales_sin_normalizar.wav")
sf.write(destino, salida["wav"], 24000)
print(f"\n {parecido(patron, emb([destino])):.4f} "
f"F_originales_sin_normalizar.wav (clon con los mp3 crudos)")
if __name__ == "__main__":
main()

83
voz/calienta_cache.py Normal file
View file

@ -0,0 +1,83 @@
#!/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())

139
voz/clonador.py Normal file
View file

@ -0,0 +1,139 @@
"""La voz de JARVIS, definida en un solo sitio.
Antes la configuracion estaba copiada en generar_cache_voz.py y en
calienta_cache.py. Con dos copias, afinar la voz significa acordarse de tocar
las dos, y si te olvidas de una, las frases que genera el calentador nocturno
suenan distinto de las de la cache inicial con la misma voz, pero no igual.
## Como se llego a estos ajustes (14 ago 2026)
Se midio el parecido de cada variante contra el original con el codificador de
hablante del propio XTTS (coseno entre embeddings). Para que la cifra signifique
algo, primero se calibro la escala:
otra grabacion del MISMO original ..... 0,84 - 0,95 (el techo realista)
Piper en castellano (otra voz) ........ 0,33 (el suelo)
Y sobre esa escala, medido BIEN (8 frases x 3 repeticiones por configuracion,
mismo proceso y mismo dispositivo):
lo que habia ......... media 0,5949 desviacion 0,0462
esto ................. media 0,6241 desviacion 0,0427
diferencia: 0,029 · ruido dentro de cada una: 0,045
**La diferencia es menor que el ruido.** Con tres frases parecia una mejora
clara (0,6025 -> 0,6518) y no lo era: XTTS muestrea en cada generacion, asi que
dos pasadas de la misma frase con la misma configuracion ya difieren. Se deja
esta configuracion porque la media es algo mejor y porque el razonamiento de
abajo se sostiene, pero **no se puede afirmar que suene mejor**: eso lo decide
el oido comparando voz/comparativa/ab/. Quien la cambie, que mida con
duelo_ajustes.py y no con dos ejemplos.
Dos cosas explican la (pequeña) diferencia:
1. **`gpt_cond_len` estaba en 6 segundos**, que es el valor por defecto de la
API de alto nivel (`tts_to_file`). Las referencias duran 25 segundos: se
estaban tirando 19 de cada 25. Por eso aqui se usa el camino de bajo nivel
(`get_conditioning_latents` + `inference`) en vez de `tts_to_file`.
2. **Mezclar clips de sesiones distintas puede diluir el timbre.** XTTS promedia
el latent, y promediar dos tonos distintos da un tercero que no es ninguno de
los dos. Los mp3 crudos sin normalizar tambien se probaron y salieron peor,
asi que normalizar no era el problema.
## El techo
0,62 no es 0,84, y ninguna combinacion de parametros probada se acerco: todas
caen entre 0,59 y 0,68. Ese es el techo de XTTS clonando sin entrenar con dos
minutos de referencia la voz se reconoce pero no engaña, y afinar parametros
no lo va a arreglar. Para acercarse al original hay que hacer fine-tuning del
modelo con bastante mas audio del hablante, que es otro trabajo.
"""
import hashlib
import os
import re
BASE = os.path.dirname(os.path.abspath(__file__))
# VUELTA ATRAS (14 ago 2026, noche). Se habia cambiado a una referencia unica
# (ref/jarvis_sesion.wav, tomas de la misma sesion pegadas) con condicionado
# largo. La medida decia que la diferencia era menor que el ruido, o sea que no
# habia razon para cambiar... y el usuario, al escucharlo, dijo que asi "no se
# parece nada". La comparacion frase a frase de las dos caches lo apuntaba
# (0,6493 -> 0,6267) y se descarto como ruido; no lo era, o no del todo.
#
# Leccion: cuando la medida no distingue dos opciones, la que se queda es la
# que ya estaba, no la nueva. Cambiar sin evidencia es apostar.
REFERENCIA = os.path.join(BASE, "ref", "jarvis_lento_b.wav")
RESPALDO = os.path.join(BASE, "ref", "jarvis_sesion.wav")
# Multi-referencia y condicionado corto: lo del dia 11, que es lo que suena en
# la cache que el usuario prefiere. gpt_cond_len=6 desaprovecha 19 de los 25
# segundos de la referencia y sobre el papel es peor, pero el papel aqui no ha
# ganado ninguna discusion.
MULTI = [os.path.join(BASE, "ref", n) for n in
("jarvis_lento_b.wav", "jarvis_lento_a.wav", "jarvis_ref.wav")]
CONDICIONADO = dict(gpt_cond_len=6, gpt_cond_chunk_len=6, max_ref_length=30)
# Elegidos a oido el 11 de agosto y confirmados por medida: bajar la
# temperatura no mejoraba el parecido de forma apreciable (0,6786 contra
# 0,6795), asi que se deja la que sonaba mas natural.
GENERACION = dict(temperature=0.65, repetition_penalty=2.0, speed=1.0)
FRECUENCIA = 24000
MODELO = "tts_models/multilingual/multi-dataset/xtts_v2"
def referencias() -> list:
"""Las tres del dia 11, que son las que suenan en la cache buena."""
hay = [r for r in MULTI if os.path.exists(r)]
if hay:
return hay
return [RESPALDO] if os.path.exists(RESPALDO) else []
def clave(texto: str) -> str:
"""sha1 del texto con espacios colapsados y recortado.
Tiene que coincidir byte a byte con lo que calcula jarvis-voz.sh en bash
(`tr -s '[:space:]' ' '` mas recorte), o la cache no acierta nunca.
"""
t = re.sub(r"\s+", " ", texto).strip()
return hashlib.sha1(t.encode("utf-8")).hexdigest()
class Clonador:
"""XTTS con el timbre ya condicionado.
El latent del hablante se calcula UNA vez al abrir, no por frase: es lo
caro, y con 38 frases de cache la diferencia es de minutos.
"""
def __init__(self, dispositivo="cuda"):
os.environ.setdefault("COQUI_TOS_AGREED", "1")
from TTS.api import TTS
refs = referencias()
if not refs:
raise SystemExit(f"no hay referencia de voz en {os.path.join(BASE, 'ref')}")
api = TTS(MODELO).to(dispositivo)
self.modelo = api.synthesizer.tts_model
self.gpt_latent, self.hablante = self.modelo.get_conditioning_latents(
audio_path=refs, **CONDICIONADO)
self.referencia = refs[0]
def genera(self, texto: str, destino: str):
import soundfile as sf
salida = self.modelo.inference(
text=texto, language="es",
gpt_cond_latent=self.gpt_latent, speaker_embedding=self.hablante,
# False a proposito: con True, XTTS parte el texto con spacy, que
# no esta instalado, y lanza ImportError en cuanto la frase pasa
# del limite de caracteres. Fallaba solo en las largas, asi que el
# calentador nocturno las descartaba en silencio.
enable_text_splitting=False, **GENERACION)
sf.write(destino, salida["wav"], FRECUENCIA)
return destino

49
voz/clonar_afinar.py Normal file
View file

@ -0,0 +1,49 @@
#!/usr/bin/env python3
# Afinado de XTTS: combina varias referencias (promedia el timbre) y prueba
# distintos ajustes de velocidad/temperatura para acercarse al Jarvis original.
# Genera variantes en ref/afinar/ para comparar de oido y elegir la buena.
import os, sys, time
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__))
OUT = os.path.join(BASE, "ref", "afinar")
os.makedirs(OUT, exist_ok=True)
# varias referencias: XTTS calcula el latent del hablante promediandolas
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)]
FRASES = {
"saludo": "Buenas noches, señor. Todo está listo.",
"pregunta": "He encontrado tres ficheros que coinciden. ¿Cuál abro?",
}
# (etiqueta, kwargs de inferencia)
VARIANTES = [
("v1_natural", dict(speed=1.0, temperature=0.65, repetition_penalty=2.0)),
("v2_sereno", dict(speed=0.92, temperature=0.60, repetition_penalty=3.0)),
("v3_grave", dict(speed=0.88, temperature=0.55, repetition_penalty=5.0)),
]
print(f"referencias ({len(REFS)}): {[os.path.basename(r) for r in REFS]}", flush=True)
t0 = time.time()
from TTS.api import TTS
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(DISP)
print(f"modelo cargado en {time.time()-t0:.1f}s", flush=True)
for fid, frase in FRASES.items():
for etiqueta, kw in VARIANTES:
dst = os.path.join(OUT, f"{fid}_{etiqueta}.wav")
t0 = time.time()
try:
tts.tts_to_file(text=frase, speaker_wav=REFS, language="es",
file_path=dst, **kw)
print(f" {fid}/{etiqueta}: {time.time()-t0:4.1f}s {kw}", flush=True)
except TypeError as e:
# algun kwarg no soportado: reintenta solo con speed+temperature
kw2 = {k: kw[k] for k in ("speed", "temperature") if k in kw}
tts.tts_to_file(text=frase, speaker_wav=REFS, language="es",
file_path=dst, **kw2)
print(f" {fid}/{etiqueta}: (sin rep_penalty) {kw2}", flush=True)
print("listo.", flush=True)

33
voz/clonar_prueba.py Normal file
View file

@ -0,0 +1,33 @@
#!/usr/bin/env python3
# Prueba de clonacion con XTTS-v2. Clona la voz de ref/jarvis_ref.wav y sintetiza
# unas frases de prueba en espanol. Mide el tiempo por frase (GPU vs CPU).
import os, sys, time
os.environ.setdefault("COQUI_TOS_AGREED", "1") # aceptar licencia CPML no interactivo
DISPOSITIVO = sys.argv[1] if len(sys.argv) > 1 else "cuda"
REF = os.path.join(os.path.dirname(__file__), "ref", "jarvis_ref.wav")
SALIDA = os.path.join(os.path.dirname(__file__), "ref", "muestras")
os.makedirs(SALIDA, exist_ok=True)
FRASES = [
"Buenas noches, señor. Todo está listo.",
"He encontrado tres ficheros que coinciden. ¿Cuál abro?",
"El procesador está a sesenta y un grados y quedan cuarenta gigabytes de memoria.",
]
print(f"cargando XTTS-v2 en {DISPOSITIVO}...", flush=True)
t0 = time.time()
import torch
from TTS.api import TTS
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(DISPOSITIVO)
print(f" modelo cargado en {time.time()-t0:.1f} s", flush=True)
for i, frase in enumerate(FRASES, 1):
t0 = time.time()
dst = os.path.join(SALIDA, f"prueba_{DISPOSITIVO}_{i}.wav")
tts.tts_to_file(text=frase, speaker_wav=REF, language="es", file_path=dst)
dur = time.time() - t0
print(f" [{i}] {dur:5.1f} s -> {dst}", flush=True)
print(f" «{frase}»", flush=True)
print("listo.", flush=True)

125
voz/comparar_ajustes.py Normal file
View file

@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Compara ajustes de clonado y mide cuanto se parece cada uno al original.
El problema: "se parece pero no acaba de ser el". Juzgarlo solo de oido es
lento y poco fiable dos escuchas seguidas ya no opinan igual, asi que aqui se
genera la misma frase con varias configuraciones y se puntua cada resultado
contra la referencia con el propio codificador de hablante de XTTS.
La puntuacion es coseno entre embeddings de hablante: 1.0 seria identico. No es
la verdad absoluta el oido manda pero ordena las opciones y evita perder la
tarde comparando cosas que objetivamente van peor.
El hallazgo que motiva esto: la API de alto nivel usa `gpt_cond_len=6`, o sea
que de una referencia de 25 segundos solo condiciona con los 6 primeros. Lo
demas se tira. Subirlo es lo primero que hay que probar.
python comparar_ajustes.py [cuda|cpu]
"""
import os
import sys
import time
import torch
DISPOSITIVO = sys.argv[1] if len(sys.argv) > 1 else "cuda"
BASE = os.path.dirname(os.path.abspath(__file__))
REF_DIR = os.path.join(BASE, "ref")
SALIDA = os.path.join(BASE, "comparativa")
os.environ.setdefault("COQUI_TOS_AGREED", "1")
# La misma frase que dice uno de los audios descargados, para poder comparar
# el clon con el original diciendo exactamente lo mismo.
FRASE = "Buenas noches, señor. Soy su asistente personal."
TODAS = ["jarvis_lento_b.wav", "jarvis_lento_a.wav", "jarvis_ref.wav"]
LARGA = ["jarvis_lento_b.wav"] # la mas larga y limpia (25,8 s)
# cada configuracion: (nombre, referencias, kwargs de latents, kwargs de sintesis)
CONFIGS = [
("A_actual", TODAS,
dict(gpt_cond_len=6, gpt_cond_chunk_len=6, max_ref_length=30),
dict(temperature=0.65, repetition_penalty=2.0, speed=1.0)),
("B_condicion_larga", TODAS,
dict(gpt_cond_len=30, gpt_cond_chunk_len=6, max_ref_length=30),
dict(temperature=0.65, repetition_penalty=2.0, speed=1.0)),
("C_una_ref_larga", LARGA,
dict(gpt_cond_len=30, gpt_cond_chunk_len=6, max_ref_length=30),
dict(temperature=0.65, repetition_penalty=2.0, speed=1.0)),
("D_una_ref_fria", LARGA,
dict(gpt_cond_len=30, gpt_cond_chunk_len=6, max_ref_length=30),
dict(temperature=0.55, repetition_penalty=5.0, speed=1.0)),
("E_una_ref_normalizada", LARGA,
dict(gpt_cond_len=30, gpt_cond_chunk_len=6, max_ref_length=30,
sound_norm_refs=True),
dict(temperature=0.6, repetition_penalty=5.0, speed=1.0)),
]
def rutas(nombres):
return [os.path.join(REF_DIR, n) for n in nombres
if os.path.exists(os.path.join(REF_DIR, n))]
def parecido(a, b):
"""Coseno entre dos embeddings de hablante."""
a = a.squeeze().float()
b = b.squeeze().float()
return float(torch.nn.functional.cosine_similarity(a, b, dim=0))
def main():
os.makedirs(SALIDA, exist_ok=True)
from TTS.api import TTS
print(f"cargando XTTS en {DISPOSITIVO}...", flush=True)
t0 = time.time()
api = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(DISPOSITIVO)
modelo = api.synthesizer.tts_model
print(f"cargado en {time.time()-t0:.1f} s\n", flush=True)
# patron a batir: el embedding de la referencia mas larga
_, patron = modelo.get_conditioning_latents(
audio_path=rutas(LARGA), gpt_cond_len=30, gpt_cond_chunk_len=6,
max_ref_length=30)
resultados = []
for nombre, refs, cond_kw, gen_kw in CONFIGS:
paths = rutas(refs)
if not paths:
print(f" {nombre}: sin referencias, saltada")
continue
t0 = time.time()
gpt_latent, hablante = modelo.get_conditioning_latents(
audio_path=paths, **cond_kw)
salida = modelo.inference(
text=FRASE, language="es",
gpt_cond_latent=gpt_latent, speaker_embedding=hablante,
enable_text_splitting=True, **gen_kw)
tardo = time.time() - t0
destino = os.path.join(SALIDA, f"{nombre}.wav")
import soundfile as sf
sf.write(destino, salida["wav"], 24000)
# puntuar el RESULTADO contra el patron, con el mismo codificador
_, emb_salida = modelo.get_conditioning_latents(
audio_path=[destino], gpt_cond_len=30, gpt_cond_chunk_len=6,
max_ref_length=30)
p = parecido(patron, emb_salida)
resultados.append((p, nombre, tardo))
print(f" {nombre:24s} parecido {p:.4f} ({tardo:.1f}s)", flush=True)
print("\n ranking (mas alto = mas se parece al original):")
for p, nombre, tardo in sorted(resultados, reverse=True):
print(f" {p:.4f} {nombre}")
print(f"\n wavs en {SALIDA}")
if __name__ == "__main__":
main()

110
voz/duelo_ajustes.py Normal file
View file

@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Duelo honesto entre dos configuraciones de voz.
Por que existe esto: la primera comparacion uso UNA frase, y la segunda tres.
Con esos tamaños se concluyo que el ajuste nuevo mejoraba... y al regenerar la
cache entera salio peor. XTTS muestrea en cada generacion (do_sample, temperatura
0,65), asi que dos pasadas de la MISMA frase con la MISMA configuracion ya dan
numeros distintos. Comparar pocas muestras es leer ruido.
Aqui cada configuracion genera las mismas N frases R veces, en el mismo proceso
y el mismo dispositivo, y se comparan las medias. Ademas se mide la dispersion
DENTRO de cada configuracion: si la diferencia entre las dos es menor que lo que
varia una consigo misma, no hay diferencia que defender.
python duelo_ajustes.py [cuda|cpu] [repeticiones]
"""
import json
import os
import statistics
import sys
import tempfile
import torch
DISPOSITIVO = sys.argv[1] if len(sys.argv) > 1 else "cuda"
REPES = int(sys.argv[2]) if len(sys.argv) > 2 else 3
BASE = os.path.dirname(os.path.abspath(__file__))
REF = os.path.join(BASE, "ref")
CATALOGO = os.path.join(BASE, "..", "newelle", "data", "acciones", "acciones.json")
os.environ.setdefault("COQUI_TOS_AGREED", "1")
LARGO = dict(gpt_cond_len=30, gpt_cond_chunk_len=6, max_ref_length=30)
VIEJA = dict(
nombre="vieja (3 refs, cond 6s)",
refs=[os.path.join(REF, n) for n in
("jarvis_lento_b.wav", "jarvis_lento_a.wav", "jarvis_ref.wav")],
cond=dict(gpt_cond_len=6, gpt_cond_chunk_len=6, max_ref_length=30),
)
NUEVA = dict(
nombre="nueva (1 ref sesion, cond 30s)",
refs=[os.path.join(REF, "jarvis_sesion.wav")],
cond=LARGO,
)
GEN = dict(temperature=0.65, repetition_penalty=2.0, speed=1.0)
def frases(n=8):
with open(CATALOGO, encoding="utf-8") as f:
d = json.load(f)
acc = d.get("acciones", d if isinstance(d, list) else [])
return sorted({a["acuse"] for a in acc if a.get("acuse")})[:n]
def main():
from TTS.api import TTS
import soundfile as sf
api = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(DISPOSITIVO)
modelo = api.synthesizer.tts_model
def emb(ruta):
_, e = modelo.get_conditioning_latents(audio_path=[ruta], **LARGO)
return e
patron = emb(os.path.join(REF, "jarvis_lento_b.wav"))
def parecido(ruta):
return float(torch.nn.functional.cosine_similarity(
patron.squeeze().float(), emb(ruta).squeeze().float(), dim=0))
textos = frases()
print(f" {len(textos)} frases x {REPES} repeticiones por configuracion\n")
resumen = {}
with tempfile.TemporaryDirectory() as tmp:
for cfg in (VIEJA, NUEVA):
refs = [r for r in cfg["refs"] if os.path.exists(r)]
gpt, hablante = modelo.get_conditioning_latents(
audio_path=refs, **cfg["cond"])
puntos = []
for i, texto in enumerate(textos):
for r in range(REPES):
out = modelo.inference(
text=texto, language="es", gpt_cond_latent=gpt,
speaker_embedding=hablante, enable_text_splitting=True,
**GEN)
d = os.path.join(tmp, f"{id(cfg)}_{i}_{r}.wav")
sf.write(d, out["wav"], 24000)
puntos.append(parecido(d))
resumen[cfg["nombre"]] = puntos
print(f" {cfg['nombre']:32s} media {statistics.mean(puntos):.4f}"
f" desv {statistics.pstdev(puntos):.4f}"
f" ({len(puntos)} muestras)", flush=True)
a, b = list(resumen.values())
ma, mb = statistics.mean(a), statistics.mean(b)
ruido = (statistics.pstdev(a) + statistics.pstdev(b)) / 2
print(f"\n diferencia entre configuraciones: {abs(mb-ma):.4f}")
print(f" variacion dentro de cada una: {ruido:.4f}")
if abs(mb - ma) < ruido:
print("\n => la diferencia es MENOR que el ruido: no hay mejora que defender")
else:
gana = list(resumen)[1] if mb > ma else list(resumen)[0]
print(f"\n => gana: {gana}")
if __name__ == "__main__":
main()

38
voz/f5_prueba.py Normal file
View file

@ -0,0 +1,38 @@
#!/usr/bin/env python3
# Prueba de clonacion con F5-TTS (modelo espanol jpgallegoar/F5-Spanish).
# Descarga el checkpoint espanol (abierto) y sintetiza las mismas 3 frases que
# XTTS, para comparar de oido. Referencia: ref/jarvis_ref_corto.wav (12s).
import os, sys, time
DISPOSITIVO = sys.argv[1] if len(sys.argv) > 1 else "cuda"
BASE = os.path.dirname(os.path.abspath(__file__))
REF = os.path.join(BASE, "ref", "jarvis_ref_corto.wav")
SALIDA = os.path.join(BASE, "ref", "muestras")
os.makedirs(SALIDA, exist_ok=True)
FRASES = [
"Buenas noches, señor. Todo está listo.",
"He encontrado tres ficheros que coinciden. ¿Cuál abro?",
"El procesador está a sesenta y un grados y quedan cuarenta gigabytes de memoria.",
]
print("descargando modelo espanol F5...", flush=True)
from huggingface_hub import hf_hub_download
ckpt = hf_hub_download("jpgallegoar/F5-Spanish", "model_1250000.safetensors")
vocab = hf_hub_download("jpgallegoar/F5-Spanish", "vocab.txt")
print(f" ckpt: {ckpt}", flush=True)
print(f"cargando F5-TTS en {DISPOSITIVO}...", flush=True)
t0 = time.time()
from f5_tts.api import F5TTS
# El modelo espanol se entreno sobre la arquitectura F5TTS_Base.
f5 = F5TTS(model="F5TTS_Base", ckpt_file=ckpt, vocab_file=vocab, device=DISPOSITIVO)
print(f" cargado en {time.time()-t0:.1f}s", flush=True)
for i, frase in enumerate(FRASES, 1):
t0 = time.time()
dst = os.path.join(SALIDA, f"f5_{i}.wav")
f5.infer(ref_file=REF, ref_text="", gen_text=frase, file_wave=dst,
remove_silence=True)
print(f" [{i}] {time.time()-t0:5.1f}s -> {dst}", flush=True)
print(f" «{frase}»", flush=True)
print("listo.", flush=True)

76
voz/generar_cache_voz.py Normal file
View file

@ -0,0 +1,76 @@
#!/usr/bin/env python3
# Genera, con la voz CLONADA (XTTS-v2), las frases fijas que JARVIS repite:
# los acuses de las acciones y las respuestas de plantilla del panel. Cada WAV
# se guarda en cache_voz/ con nombre = sha1(texto normalizado), que es la misma
# clave que usa el wrapper jarvis-voz.sh para servirlas al instante.
#
# Corre en el host (venv clonador-venv), en GPU, sin prisa: es trabajo de una
# sola vez. En uso, el wrapper solo copia el WAV -> latencia cero, GPU libre.
#
# python generar_cache_voz.py [cuda|cpu]
import os, sys, re, json, glob, hashlib, time
DISPOSITIVO = sys.argv[1] if len(sys.argv) > 1 else "cuda"
BASE = os.path.dirname(os.path.abspath(__file__))
# La voz (referencia, condicionado y parametros) vive en clonador.py: tenerla
# aqui y en calienta_cache.py significaba dos copias que se desincronizan.
sys.path.insert(0, BASE)
from clonador import Clonador, clave
CACHE = os.path.join(BASE, "cache_voz")
CATALOGO = os.path.expanduser(
"~/COFRE/CODERS/JARVIS/newelle/data/acciones/acciones.json")
os.makedirs(CACHE, exist_ok=True)
os.environ.setdefault("COQUI_TOS_AGREED", "1")
def frases_fijas():
"""Acuses del catalogo + respuestas de plantilla del panel."""
fijas = set()
try:
with open(CATALOGO, encoding="utf-8") as f:
cat = json.load(f)
for a in cat.get("acciones", cat if isinstance(cat, list) else []):
ac = a.get("acuse")
if ac:
fijas.add(ac)
except Exception as e:
print(f"aviso: no pude leer el catalogo ({e}); sigo con las fijas")
# las de plantilla del panel que no dependen de argumento
fijas.update({
"Buenas noches, señor. Todo está listo.",
"Lo busco dentro, señor.",
"A la orden, señor.",
"Hecho, señor.",
"No he encontrado nada, señor.",
"¿Cuál de estos, señor?",
})
return sorted(fijas)
def main():
frases = frases_fijas()
print(f"{len(frases)} frases fijas a generar en {DISPOSITIVO}", flush=True)
t0 = time.time()
voz = Clonador(DISPOSITIVO)
print(f"modelo cargado en {time.time()-t0:.1f} s "
f"(referencia: {os.path.basename(voz.referencia)})", flush=True)
hechas = saltadas = 0
for i, frase in enumerate(frases, 1):
dst = os.path.join(CACHE, clave(frase) + ".wav")
if os.path.exists(dst):
saltadas += 1
continue
t0 = time.time()
voz.genera(frase, dst)
print(f" [{i}/{len(frases)}] {time.time()-t0:4.1f}s {frase[:50]}",
flush=True)
hechas += 1
print(f"listo: {hechas} generadas, {saltadas} ya estaban. cache -> {CACHE}",
flush=True)
if __name__ == "__main__":
main()

36
voz/pronuncia.py Normal file
View file

@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Reescribe siglas y tecnicismos a como se pronuncian, usando
pronunciacion.json. Lo usa jarvis-voz.sh antes de mandar texto a Piper (y sirve
para regenerar la cache con XTTS). Sustituye solo palabras completas.
python3 pronuncia.py "conéctate por SSH a la VPN"
-> conéctate por ese ese hache a la uve pe ene
"""
import sys, os, json, re
BASE = os.path.dirname(os.path.abspath(__file__))
DIC = os.path.join(BASE, "pronunciacion.json")
def reescribe(texto, dic):
# claves mas largas primero: evita que VLAN caiga en LAN, HTTPS en HTTP...
for k in sorted(dic, key=len, reverse=True):
# (?<!\w)..(?!\w) = limites de palabra unicode; IGNORECASE por si el
# texto trae la sigla en minuscula
texto = re.sub(rf"(?<!\w){re.escape(k)}(?!\w)", dic[k], texto,
flags=re.IGNORECASE)
return texto
def main():
texto = " ".join(sys.argv[1:])
try:
with open(DIC, encoding="utf-8") as f:
dic = json.load(f)
except Exception:
dic = {}
sys.stdout.write(reescribe(texto, dic))
if __name__ == "__main__":
main()

31
voz/pronunciacion.json Normal file
View file

@ -0,0 +1,31 @@
{
"CPU": "ce pe u",
"CSRF": "ce ese erre efe",
"IPCC": "i pe ce ce",
"GPU": "ge pe u",
"VPN": "uve pe ene",
"UE": "u e",
"HTTPS": "hache te te pe ese",
"FTP": "efe te pe",
"LAN": "ele a ene",
"ONU": "o ene u",
"SSH": "ese ese hache",
"RSA": "erre ese a",
"TLS": "te ele ese",
"WAN": "uve doble a ene",
"XSS": "equis ese ese",
"SSL": "ese ese ele",
"DNS": "de ene ese",
"URL": "u erre ele",
"SQL": "ese cu ele",
"VLAN": "uve ele a ene",
"USB": "u ese be",
"IP": "i pe",
"API": "a pe i",
"nmap": "ene map",
"SQLi": "ese cu ele i",
"DDoS": "de dos",
"DoS": "dos",
"CO2": "ce o dos",
"GPG": "ge pe ge"
}

113
voz/prueba_arrastre.py Normal file
View file

@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Frases cortas generadas con carrerilla, y recortadas despues.
Medido en prueba_longitud.py: un acuse de segundo y medio puntua 0,51 de
parecido, y el mismo estilo dicho en catorce segundos puntua 0,70. XTTS
arranca cada generacion en frio y no le da tiempo a asentar la voz antes de
que la frase termine. Como la cache de JARVIS son casi todo acuses de un
segundo, lo que mas se oye es lo que peor suena.
La idea: no generar "Voy, señor" a secas, sino una frase de arrastre delante,
y quedarse solo con el final. La voz llega al trozo util ya asentada.
Se prueban dos formas de cortar:
- por silencio: XTTS deja pausa entre oraciones, asi que el ultimo tramo
sonoro es la frase que se queria
- repitiendo la frase tres veces y quedandose con la ultima
python prueba_arrastre.py [cuda|cpu] [repeticiones]
"""
import os
import statistics
import sys
import tempfile
import numpy as np
import torch
DISPOSITIVO = sys.argv[1] if len(sys.argv) > 1 else "cuda"
REPES = int(sys.argv[2]) if len(sys.argv) > 2 else 4
BASE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, BASE)
from clonador import CONDICIONADO, GENERACION, FRECUENCIA, referencias
os.environ.setdefault("COQUI_TOS_AGREED", "1")
PATRON = os.path.join(BASE, "ref", "jarvis_lento_b.wav")
MEDIR = dict(gpt_cond_len=30, gpt_cond_chunk_len=6, max_ref_length=30)
# Frase de arrastre: larga, del mismo registro, y que acaba en punto para que
# XTTS deje pausa antes de lo que viene.
ARRASTRE = ("Buenas noches, señor. He revisado el sistema y todo está en orden, "
"sin ninguna anomalía digna de mención.")
CORTAS = ["Voy, señor.", "Enseguida, señor.", "Hecho, señor.", "A la orden, señor."]
def ultimo_tramo(wav, sr, minimo=0.35):
"""El ultimo trozo sonoro, separado por silencios."""
import librosa
tramos = librosa.effects.split(wav, top_db=32, frame_length=1024, hop_length=256)
tramos = [(a, b) for a, b in tramos if (b - a) / sr >= minimo]
if not tramos:
return wav
a, b = tramos[-1]
margen = int(0.05 * sr)
return wav[max(0, a - margen):min(len(wav), b + margen)]
def main():
from TTS.api import TTS
import soundfile as sf
api = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(DISPOSITIVO)
modelo = api.synthesizer.tts_model
def emb(ruta):
_, e = modelo.get_conditioning_latents(audio_path=[ruta], **MEDIR)
return e
patron = emb(PATRON)
def parecido(ruta):
return float(torch.nn.functional.cosine_similarity(
patron.squeeze().float(), emb(ruta).squeeze().float(), dim=0))
gpt, hablante = modelo.get_conditioning_latents(
audio_path=referencias(), **CONDICIONADO)
def genera(texto):
out = modelo.inference(text=texto, language="es", gpt_cond_latent=gpt,
speaker_embedding=hablante,
enable_text_splitting=False, **GENERACION)
return np.asarray(out["wav"], dtype=np.float32)
metodos = {
"directo (como ahora)": lambda f: genera(f),
"con arrastre delante": lambda f: ultimo_tramo(
genera(f"{ARRASTRE} {f}"), FRECUENCIA),
"repetida x3, la ultima": lambda f: ultimo_tramo(
genera(" ".join([f] * 3)), FRECUENCIA),
}
print(f" {len(CORTAS)} frases cortas x {REPES} repeticiones\n")
print(f" {'metodo':24s} {'dur':>6s} {'parecido':>9s} {'desv':>7s}")
print(" " + "-" * 50)
with tempfile.TemporaryDirectory() as tmp:
for nombre, hacer in metodos.items():
puntos, duraciones = [], []
for frase in CORTAS:
for r in range(REPES):
wav = hacer(frase)
d = os.path.join(tmp, f"{abs(hash(nombre))}_{len(puntos)}.wav")
sf.write(d, wav, FRECUENCIA)
duraciones.append(len(wav) / FRECUENCIA)
puntos.append(parecido(d))
print(f" {nombre:24s} {statistics.mean(duraciones):5.1f}s "
f"{statistics.mean(puntos):9.4f} {statistics.pstdev(puntos):7.4f}",
flush=True)
if __name__ == "__main__":
main()

92
voz/prueba_longitud.py Normal file
View file

@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""¿El clon falla porque las frases son cortas?
La referencia son 25 segundos de habla seguida. Lo que JARVIS dice de verdad
son acuses de segundo y medio: "Voy, señor". XTTS arranca cada generacion sin
contexto, y en frase corta no le da tiempo a asentar la voz antes de terminar.
Si es eso, el parecido deberia subir con la longitud del texto y la solucion
no seria tocar parametros sino generar mas largo y recortar.
Se mide con repeticiones porque XTTS muestrea: una sola pasada por longitud
seria ruido, que es el error que ya se cometio dos veces hoy.
python prueba_longitud.py [cuda|cpu] [repeticiones]
"""
import os
import statistics
import sys
import tempfile
import torch
DISPOSITIVO = sys.argv[1] if len(sys.argv) > 1 else "cuda"
REPES = int(sys.argv[2]) if len(sys.argv) > 2 else 4
BASE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, BASE)
from clonador import CONDICIONADO, GENERACION, referencias
os.environ.setdefault("COQUI_TOS_AGREED", "1")
PATRON = os.path.join(BASE, "ref", "jarvis_lento_b.wav")
MEDIR = dict(gpt_cond_len=30, gpt_cond_chunk_len=6, max_ref_length=30)
# mismo registro, longitud creciente
TEXTOS = [
("muy corta (~1s)", "Voy, señor."),
("corta (~2s)", "Enseguida, señor. Ahora mismo."),
("media (~5s)", "Enseguida, señor. He revisado el sistema y todo está en orden."),
("larga (~10s)", "Enseguida, señor. He revisado el sistema y todo está en orden. "
"La temperatura ronda los sesenta y tres grados y quedan "
"cuarenta y un gigabytes de memoria libre."),
("muy larga (~20s)", "Enseguida, señor. He revisado el sistema y todo está en orden. "
"La temperatura ronda los sesenta y tres grados y quedan "
"cuarenta y un gigabytes de memoria libre. El disco principal "
"va al sesenta por ciento y no he detectado ningún proceso "
"anómalo. ¿Desea que ejecute el análisis de red ahora o "
"prefiere que espere?"),
]
def main():
from TTS.api import TTS
import soundfile as sf
api = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(DISPOSITIVO)
modelo = api.synthesizer.tts_model
def emb(ruta):
_, e = modelo.get_conditioning_latents(audio_path=[ruta], **MEDIR)
return e
patron = emb(PATRON)
def parecido(ruta):
return float(torch.nn.functional.cosine_similarity(
patron.squeeze().float(), emb(ruta).squeeze().float(), dim=0))
gpt, hablante = modelo.get_conditioning_latents(
audio_path=referencias(), **CONDICIONADO)
print(f" {REPES} repeticiones por longitud · referencia a batir: 0.84-0.95\n")
print(f" {'texto':18s} {'dur':>6s} {'parecido':>9s} {'desv':>7s}")
print(" " + "-" * 45)
with tempfile.TemporaryDirectory() as tmp:
for etiqueta, texto in TEXTOS:
puntos, duraciones = [], []
for r in range(REPES):
out = modelo.inference(
text=texto, language="es", gpt_cond_latent=gpt,
speaker_embedding=hablante, enable_text_splitting=False,
**GENERACION)
d = os.path.join(tmp, f"{len(puntos)}_{r}.wav")
sf.write(d, out["wav"], 24000)
duraciones.append(len(out["wav"]) / 24000)
puntos.append(parecido(d))
print(f" {etiqueta:18s} {statistics.mean(duraciones):5.1f}s "
f"{statistics.mean(puntos):9.4f} {statistics.pstdev(puntos):7.4f}",
flush=True)
if __name__ == "__main__":
main()

85
voz/prueba_octava.py Normal file
View file

@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""¿El clon no se parece porque esta una octava por debajo?
Medido: los originales de fish.audio rondan los 210 Hz de tono medio con un
recorrido de 105 a 349; los clones salen a 105 Hz y con diez hercios de
recorrido. O sea una octava mas grave y ademas planos.
Esto lo comprueba de la unica forma que vale: subiendo el clon de tono y
midiendo si el parecido sube. Si sube mucho, el problema es el tono y se
arregla; si no se mueve, el tono es un sintoma y el problema esta en otro sitio.
python prueba_octava.py [cuda|cpu]
"""
import glob
import os
import subprocess
import sys
import tempfile
import numpy as np
import torch
DISPOSITIVO = sys.argv[1] if len(sys.argv) > 1 else "cuda"
BASE = os.path.dirname(os.path.abspath(__file__))
REF = os.path.join(BASE, "ref", "jarvis_lento_b.wav")
os.environ.setdefault("COQUI_TOS_AGREED", "1")
LARGO = dict(gpt_cond_len=30, gpt_cond_chunk_len=6, max_ref_length=30)
SEMITONOS = [0, 3, 5, 7, 9, 12]
def desplaza(origen, destino, semitonos):
if semitonos == 0:
subprocess.run(["cp", origen, destino], check=True)
return destino
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", origen,
"-af", f"rubberband=pitch={semitonos}", destino], check=True)
return destino
def tono(ruta):
import librosa
y, sr = librosa.load(ruta, sr=22050, mono=True)
f, _, _ = librosa.pyin(y, sr=sr, fmin=60, fmax=400, frame_length=2048)
v = f[~np.isnan(f)]
return float(np.median(v)) if len(v) else 0.0
def main():
from TTS.api import TTS
api = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(DISPOSITIVO)
modelo = api.synthesizer.tts_model
def emb(ruta):
_, e = modelo.get_conditioning_latents(audio_path=[ruta], **LARGO)
return e
patron = emb(REF)
def parecido(ruta):
return float(torch.nn.functional.cosine_similarity(
patron.squeeze().float(), emb(ruta).squeeze().float(), dim=0))
# los clones mas largos de la cache, que son los que mejor se miden
clones = sorted(glob.glob(os.path.join(BASE, "cache_voz", "*.wav")),
key=os.path.getsize, reverse=True)[:4]
print(f" referencia: {tono(REF):.0f} Hz\n")
print(f" {'semitonos':>10s} {'tono clon':>10s} {'parecido':>9s}")
print(" " + "-" * 33)
with tempfile.TemporaryDirectory() as tmp:
for st in SEMITONOS:
puntos, tonos = [], []
for i, c in enumerate(clones):
d = os.path.join(tmp, f"c{i}_{st}.wav")
desplaza(c, d, st)
puntos.append(parecido(d))
tonos.append(tono(d))
print(f" {st:>+10d} {np.mean(tonos):9.0f}Hz {np.mean(puntos):9.4f}",
flush=True)
if __name__ == "__main__":
main()

467
voz/qa_resultados.json Normal file
View file

@ -0,0 +1,467 @@
[
{
"palabra": "backup",
"oido": "¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal? ¿Qué tal?",
"ratio": 0.0
},
{
"palabra": "ubuntu",
"oido": "O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-O-",
"ratio": 0.0
},
{
"palabra": "CPU",
"oido": "es bueno, realmente, para tener una suerte, es bueno.",
"ratio": 0.08
},
{
"palabra": "CSRF",
"oido": "ser ese eficaz.",
"ratio": 0.11
},
{
"palabra": "IPCC",
"oido": "Y pese a Thé.",
"ratio": 0.12
},
{
"palabra": "GPU",
"oido": "Fearful.",
"ratio": 0.2
},
{
"palabra": "VPN",
"oido": "¡FOR B-I-N!",
"ratio": 0.2
},
{
"palabra": "UE",
"oido": "¡Hueya! ¡Hueya! ¡Chula!",
"ratio": 0.21
},
{
"palabra": "HTTPS",
"oido": "¡Vete de des!",
"ratio": 0.25
},
{
"palabra": "FTP",
"oido": "Este Pipillo.",
"ratio": 0.27
},
{
"palabra": "nmap",
"oido": "Un nap, en dos son.",
"ratio": 0.29
},
{
"palabra": "LAN",
"oido": "¡Sólo tenés!",
"ratio": 0.31
},
{
"palabra": "SQLi",
"oido": "Sé que hay ahí.",
"ratio": 0.33
},
{
"palabra": "ONU",
"oido": "¡Bono! ¡Bono!",
"ratio": 0.33
},
{
"palabra": "SSH",
"oido": "Sí, es sí.",
"ratio": 0.36
},
{
"palabra": "RSA",
"oido": "¡Pero ser!",
"ratio": 0.36
},
{
"palabra": "DDoS",
"oido": "¿Todo es tuya?",
"ratio": 0.38
},
{
"palabra": "TLS",
"oido": "Estoy el ese.",
"ratio": 0.4
},
{
"palabra": "WAN",
"oido": "WEN WEN",
"ratio": 0.4
},
{
"palabra": "XSS",
"oido": "¡Exe Ese!",
"ratio": 0.4
},
{
"palabra": "cracker",
"oido": "¡Gracias!",
"ratio": 0.43
},
{
"palabra": "bootloader",
"oido": "Good to know there.",
"ratio": 0.43
},
{
"palabra": "SSL",
"oido": "C-S-L-I-O-N",
"ratio": 0.44
},
{
"palabra": "DNS",
"oido": "Go N.S.A.",
"ratio": 0.44
},
{
"palabra": "URL",
"oido": "por el.",
"ratio": 0.44
},
{
"palabra": "SQL",
"oido": "¡Sekuel!",
"ratio": 0.44
},
{
"palabra": "malware",
"oido": "¡Malguer! ¡Malguer!",
"ratio": 0.45
},
{
"palabra": "VLAN",
"oido": "¡Blan! ¡Blan!",
"ratio": 0.46
},
{
"palabra": "kernel",
"oido": "Colonel.",
"ratio": 0.46
},
{
"palabra": "CO2",
"oido": "¡Codos!",
"ratio": 0.5
},
{
"palabra": "firewall",
"oido": "¡Fair one!",
"ratio": 0.5
},
{
"palabra": "wallet",
"oido": "¡Vale! ¡Vale!",
"ratio": 0.53
},
{
"palabra": "USB",
"oido": "U.S.A.A.",
"ratio": 0.57
},
{
"palabra": "IP",
"oido": "¡Híper!",
"ratio": 0.57
},
{
"palabra": "API",
"oido": "a ti.",
"ratio": 0.57
},
{
"palabra": "hacking",
"oido": "cutting.",
"ratio": 0.57
},
{
"palabra": "payload",
"oido": "paí lot.",
"ratio": 0.57
},
{
"palabra": "terabyte",
"oido": "Terebarit.",
"ratio": 0.59
},
{
"palabra": "ethereum",
"oido": "Es Serium.",
"ratio": 0.59
},
{
"palabra": "wireshark",
"oido": "Why is a shark?",
"ratio": 0.61
},
{
"palabra": "HTTP",
"oido": "a TTP.",
"ratio": 0.67
},
{
"palabra": "AES",
"oido": "¡Ahí es!",
"ratio": 0.67
},
{
"palabra": "NAT",
"oido": "NUT.",
"ratio": 0.67
},
{
"palabra": "OTAN",
"oido": "OTAN MUY",
"ratio": 0.67
},
{
"palabra": "proxy",
"oido": "Próximo.",
"ratio": 0.67
},
{
"palabra": "router",
"oido": "Luther.",
"ratio": 0.67
},
{
"palabra": "buffer",
"oido": "¡Fer!",
"ratio": 0.67
},
{
"palabra": "exploit",
"oido": "Explore.",
"ratio": 0.71
},
{
"palabra": "máquina",
"oido": "Machina.",
"ratio": 0.71
},
{
"palabra": "hacker",
"oido": "¡Hater!",
"ratio": 0.73
},
{
"palabra": "root",
"oido": "¡Brot!",
"ratio": 0.75
},
{
"palabra": "debian",
"oido": "De bien.",
"ratio": 0.77
},
{
"palabra": "phishing",
"oido": "Fishing.",
"ratio": 0.8
},
{
"palabra": "bytes",
"oido": "Bites.",
"ratio": 0.8
},
{
"palabra": "linux",
"oido": "Linus.",
"ratio": 0.8
},
{
"palabra": "daemon",
"oido": "Daimón.",
"ratio": 0.83
},
{
"palabra": "gigabyte",
"oido": "¡Gigabate!",
"ratio": 0.88
},
{
"palabra": "firmware",
"oido": "¡Filmware!",
"ratio": 0.88
},
{
"palabra": "RAM",
"oido": "Ram.",
"ratio": 1.0
},
{
"palabra": "SSD",
"oido": "S.S.D.",
"ratio": 1.0
},
{
"palabra": "PGP",
"oido": "pgp.",
"ratio": 1.0
},
{
"palabra": "GPG",
"oido": "¡GPG!",
"ratio": 1.0
},
{
"palabra": "ransomware",
"oido": "ransomware",
"ratio": 1.0
},
{
"palabra": "spoofing",
"oido": "Spoofing.",
"ratio": 1.0
},
{
"palabra": "software",
"oido": "software",
"ratio": 1.0
},
{
"palabra": "hardware",
"oido": "Hardware.",
"ratio": 1.0
},
{
"palabra": "shell",
"oido": "Shell.",
"ratio": 1.0
},
{
"palabra": "sudo",
"oido": "¡Sudo!",
"ratio": 1.0
},
{
"palabra": "bash",
"oido": "¡Bash!",
"ratio": 1.0
},
{
"palabra": "script",
"oido": "script.",
"ratio": 1.0
},
{
"palabra": "switch",
"oido": "Switch.",
"ratio": 1.0
},
{
"palabra": "overflow",
"oido": "Overflow.",
"ratio": 1.0
},
{
"palabra": "metasploit",
"oido": "Metasploit",
"ratio": 1.0
},
{
"palabra": "android",
"oido": "Android.",
"ratio": 1.0
},
{
"palabra": "criptografia",
"oido": "criptografia.",
"ratio": 1.0
},
{
"palabra": "cifrado",
"oido": "Cifrado.",
"ratio": 1.0
},
{
"palabra": "hash",
"oido": "¡Hash!",
"ratio": 1.0
},
{
"palabra": "blockchain",
"oido": "blockchain",
"ratio": 1.0
},
{
"palabra": "bitcoin",
"oido": "Bitcoin.",
"ratio": 1.0
},
{
"palabra": "token",
"oido": "token.",
"ratio": 1.0
},
{
"palabra": "calentamiento",
"oido": "calentamiento",
"ratio": 1.0
},
{
"palabra": "invernadero",
"oido": "Invernadero",
"ratio": 1.0
},
{
"palabra": "climatico",
"oido": "Climático.",
"ratio": 1.0
},
{
"palabra": "renovable",
"oido": "Renovable.",
"ratio": 1.0
},
{
"palabra": "emisiones",
"oido": "Emisiones.",
"ratio": 1.0
},
{
"palabra": "reconquista",
"oido": "Reconquista.",
"ratio": 1.0
},
{
"palabra": "carlista",
"oido": "Carlista.",
"ratio": 1.0
},
{
"palabra": "franquismo",
"oido": "franquismo",
"ratio": 1.0
},
{
"palabra": "transicion",
"oido": "Transición.",
"ratio": 1.0
},
{
"palabra": "señor",
"oido": "Señor.",
"ratio": 1.0
},
{
"palabra": "información",
"oido": "información.",
"ratio": 1.0
},
{
"palabra": "galería",
"oido": "Galería.",
"ratio": 1.0
},
{
"palabra": "atención",
"oido": "Atención.",
"ratio": 1.0
}
]

144
voz/qa_voz.py Normal file
View 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()

68
voz/verificar_cache.py Normal file
View file

@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Comprueba que la cache que SUENA es la mejorada, no solo las pruebas.
Compara, frase por frase, el wav de la cache anterior con el de la nueva, y los
puntua contra la referencia. Es la unica forma de saber que lo medido en el
banco de pruebas llego de verdad a lo que JARVIS dice.
python verificar_cache.py <cache_vieja> [cuda|cpu] [cuantas]
"""
import glob
import os
import sys
import torch
BASE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, BASE)
from clonador import CONDICIONADO, REFERENCIA, RESPALDO
VIEJA = sys.argv[1]
DISPOSITIVO = sys.argv[2] if len(sys.argv) > 2 else "cuda"
CUANTAS = int(sys.argv[3]) if len(sys.argv) > 3 else 6
NUEVA = os.path.join(BASE, "cache_voz")
os.environ.setdefault("COQUI_TOS_AGREED", "1")
def main():
from TTS.api import TTS
api = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(DISPOSITIVO)
modelo = api.synthesizer.tts_model
def emb(ruta):
_, e = modelo.get_conditioning_latents(audio_path=[ruta], **CONDICIONADO)
return e
# el patron es la referencia original, la misma contra la que se midio todo
patron = emb(os.path.join(BASE, "ref", "jarvis_lento_b.wav"))
def parecido(ruta):
return float(torch.nn.functional.cosine_similarity(
patron.squeeze().float(), emb(ruta).squeeze().float(), dim=0))
comunes = sorted(set(os.path.basename(p) for p in glob.glob(os.path.join(NUEVA, "*.wav")))
& set(os.path.basename(p) for p in glob.glob(os.path.join(VIEJA, "*.wav"))))
comunes = comunes[:CUANTAS]
print(f" {len(comunes)} frases comparadas (mismas claves en las dos caches)\n")
peor = mejor = 0
va, vn = [], []
for k in comunes:
a = parecido(os.path.join(VIEJA, k))
n = parecido(os.path.join(NUEVA, k))
va.append(a)
vn.append(n)
flecha = "sube" if n > a else "baja"
if n > a:
mejor += 1
else:
peor += 1
print(f" {a:.4f} -> {n:.4f} {flecha} {k[:12]}")
print(f"\n media {sum(va)/len(va):.4f} -> {sum(vn)/len(vn):.4f}")
print(f" mejoran {mejor}, empeoran {peor}")
if __name__ == "__main__":
main()