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
0
nucleo/boca/__init__.py
Normal file
0
nucleo/boca/__init__.py
Normal file
210
nucleo/boca/voz.py
Normal file
210
nucleo/boca/voz.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue