JARVIS/nucleo/boca/voz.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

210 lines
7.6 KiB
Python

"""La boca de JARVIS: convertir texto en sonido, y saber cuando esta sonando.
Esto es la version de `voces.py` + la mitad de `tts.py` de Newelle, pero sin el
motor de handlers: aqui solo hablamos por Piper y por el clon, y los dos son un
script de shell que escribe un WAV. Todo lo demas sobraba.
## Lo que hay que hacer bien y en Newelle estaba mal
**Saber que esta hablando, y saberlo A TIEMPO.** El eco que envenena el registro
de JARVIS —el 21 % confirmado de las frases que no entiende son suyas— viene de
que Newelle marca `assistant_speaking` con `GLib.idle_add`, que es asincrono: el
audio ya esta sonando mientras el hilo de grabacion sigue leyendo `False`. Aqui
la bandera se pone ANTES de lanzar el reproductor y se quita con retardo, porque
cuando el proceso termina el sonido todavia esta viajando hasta el microfono.
**Un solo altavoz.** Con tareas en paralelo, dos respuestas a la vez se pisan.
Se habla por turnos con un cerrojo, igual que hacia `_habla` en el panel.
## La cache
Clave `sha1(voz|texto normalizado)`. Piper tarda entre medio segundo y dos por
frase, y JARVIS repite mucho ("Enseguida, señor", "No he podido, señor"): con
cache eso es un `cp`.
Se guarda en `datos/cache_voz/`, no en la del clon: aquella la genera
`generar_cache_voz.py` con su propia clave (sha1 solo del texto) y es de solo
lectura para nosotros. `jarvis-voz.sh` ya la consulta por dentro.
"""
import hashlib
import os
import re
import shutil
import subprocess
import threading
import time
RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
CONFIG = os.path.join(RAIZ, "config")
MODELOS = os.path.join(RAIZ, "voz", "modelos")
CACHE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"datos", "cache_voz")
# Cuanto se sigue considerando "hablando" despues de que el reproductor termine.
# El sonido tarda en salir del buffer de la tarjeta y en llegar al microfono; sin
# esta cola, la ultima silaba de JARVIS se cuela en la siguiente transcripcion.
#
# El valor sale de medirlo con pruebas/prueba_eco.py, no de suponerlo: con 350
# la frase entera dejaba de colarse pero seguia escapandose un fragmento.
COLA_MS = int(os.environ.get("JARVIS_COLA_MS", "700"))
def _piper(modelo):
return {
"orden": [os.path.join(CONFIG, "jarvis-piper.sh")],
"entorno": {"JARVIS_PIPER_VOZ": modelo, "JARVIS_PIPER_TONO": "1.0"},
"necesita": os.path.join(MODELOS, modelo + ".onnx"),
}
# El orden es el de rotacion. davefx primero porque es la que se eligio a oido.
VOCES = {
"davefx": _piper("es_ES-davefx-medium"),
"clon": {
"orden": [os.path.join(CONFIG, "jarvis-voz.sh")],
"entorno": {},
"necesita": os.path.join(CONFIG, "jarvis-voz.sh"),
},
"claude": _piper("es_MX-claude-high"),
"ald": _piper("es_MX-ald-medium"),
"sharvard": _piper("es_ES-sharvard-medium"),
}
POR_DEFECTO = "davefx"
# Se prueban en orden. paplay y pw-play respetan el enrutado de PipeWire, que es
# lo que hace falta para que el cancelador de eco tenga la referencia de lo que
# sale por los altavoces. aplay habla con ALSA directamente y se la salta.
REPRODUCTORES = (["paplay"], ["pw-play"], ["aplay", "-q"])
def _normaliza(texto: str) -> str:
return re.sub(r"\s+", " ", texto or "").strip()
def _clave(voz: str, texto: str) -> str:
return hashlib.sha1(f"{voz}|{_normaliza(texto)}".encode()).hexdigest()[:16]
def _reproductor():
for r in REPRODUCTORES:
if shutil.which(r[0]):
return r
return None
class Boca:
"""Todo lo que suena sale de aqui, y de uno en uno."""
def __init__(self, voz: str = POR_DEFECTO, al_cambiar=None):
self.voz = voz if voz in VOCES else POR_DEFECTO
self._turno = threading.Lock()
self._hablando = False
self._hasta = 0.0 # instante hasta el que sigue contando como hablando
self._al_cambiar = al_cambiar # se avisa al oido para que se calle
os.makedirs(CACHE, exist_ok=True)
# ------------------------------------------------------------ el estado
@property
def hablando(self) -> bool:
"""Si esta sonando algo, incluida la cola.
El oido consulta esto en cada trozo de audio. Tiene que ser barato y,
sobre todo, no puede mentir por defecto: mas vale sobrar 300 ms de
silencio que colar la propia voz en la transcripcion.
"""
return self._hablando or time.monotonic() < self._hasta
def _marca(self, hablando: bool):
self._hablando = hablando
if not hablando:
self._hasta = time.monotonic() + COLA_MS / 1000.0
if self._al_cambiar:
try:
self._al_cambiar(hablando)
except Exception:
pass
# ------------------------------------------------------------- generar
def _genera(self, texto: str) -> str | None:
"""Devuelve la ruta del WAV, de cache o recien hecho."""
voz = VOCES[self.voz]
destino = os.path.join(CACHE, _clave(self.voz, texto) + ".wav")
if os.path.exists(destino) and os.path.getsize(destino) > 0:
return destino
entorno = {**os.environ, **voz["entorno"]}
try:
subprocess.run(voz["orden"] + [destino, texto], env=entorno,
capture_output=True, timeout=120)
except (subprocess.TimeoutExpired, OSError):
return None
if not os.path.exists(destino) or os.path.getsize(destino) == 0:
# Que no se quede un fichero vacio en la cache: el siguiente intento
# lo daria por bueno y JARVIS se quedaria mudo para esa frase.
if os.path.exists(destino):
os.unlink(destino)
return None
return destino
# -------------------------------------------------------------- hablar
def di(self, texto: str) -> bool:
"""Habla, esperando turno. Devuelve si sono."""
texto = _normaliza(texto)
if not texto:
return False
with self._turno:
return self._suelta(texto)
def di_si_libre(self, texto: str) -> bool:
"""Habla solo si nadie tiene el turno. Para avisos que no merecen cola."""
if not self._turno.acquire(blocking=False):
return False
try:
return self._suelta(_normaliza(texto))
finally:
self._turno.release()
def _suelta(self, texto: str) -> bool:
wav = self._genera(texto)
if not wav:
return False
orden = _reproductor()
if not orden:
return False
# La bandera ANTES de lanzar el reproductor, no despues y no en otro
# hilo. Este es exactamente el fallo de Newelle que causa el eco.
self._marca(True)
try:
subprocess.run(orden + [wav], capture_output=True, timeout=300)
except (subprocess.TimeoutExpired, OSError):
return False
finally:
self._marca(False)
return True
# --------------------------------------------------------------- voces
def disponibles(self) -> list:
return [n for n, v in VOCES.items() if os.path.exists(v["necesita"])]
def cambia(self, nombre: str) -> bool:
if nombre not in VOCES or not os.path.exists(VOCES[nombre]["necesita"]):
return False
self.voz = nombre
return True
def siguiente(self) -> str:
lista = self.disponibles()
if not lista:
return self.voz
try:
i = lista.index(self.voz)
except ValueError:
i = -1
self.voz = lista[(i + 1) % len(lista)]
return self.voz