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.
125 lines
4.6 KiB
Python
125 lines
4.6 KiB
Python
#!/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()
|