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