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.
139 lines
6 KiB
Python
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
|