"""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