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.
This commit is contained in:
commit
8e4bc8ad94
125 changed files with 25033 additions and 0 deletions
139
voz/clonador.py
Normal file
139
voz/clonador.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue