JARVIS/voz/clonador.py
sito 8e4bc8ad94 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.
2026-08-16 15:34:37 +02:00

139 lines
6 KiB
Python

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