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
120
voz/afinar_referencia.py
Normal file
120
voz/afinar_referencia.py
Normal 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
80
voz/analiza_voz.py
Normal 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
122
voz/antes_despues.py
Normal 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
107
voz/calibrar_parecido.py
Normal 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
83
voz/calienta_cache.py
Normal 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
139
voz/clonador.py
Normal 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
49
voz/clonar_afinar.py
Normal 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
33
voz/clonar_prueba.py
Normal 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
125
voz/comparar_ajustes.py
Normal 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
110
voz/duelo_ajustes.py
Normal 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
38
voz/f5_prueba.py
Normal 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
76
voz/generar_cache_voz.py
Normal 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
36
voz/pronuncia.py
Normal 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
31
voz/pronunciacion.json
Normal 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
113
voz/prueba_arrastre.py
Normal 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
92
voz/prueba_longitud.py
Normal 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
85
voz/prueba_octava.py
Normal 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
467
voz/qa_resultados.json
Normal 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
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()
|
||||
68
voz/verificar_cache.py
Normal file
68
voz/verificar_cache.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue