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.
210 lines
7.6 KiB
Python
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
|