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:
sito 2026-08-16 15:28:31 +02:00
commit 8e4bc8ad94
125 changed files with 25033 additions and 0 deletions

330
nucleo/jarvis.py Executable file
View file

@ -0,0 +1,330 @@
#!/usr/bin/env python3
"""JARVIS. Asistente de voz local, sin framework debajo.
jarvis.py --di "Buenas noches, señor" prueba la boca
jarvis.py --voces las que hay
jarvis.py --accion "cuanto espacio queda" prueba las manos, sin voz
Esto sustituye a la capa que corria dentro de Newelle. Lo que se trajo tal cual
el emparejador, las tareas, el sudo, el HUD esta en `manos/` y `cara/`; lo que
habia que reescribir porque era de Newelle esta en `oido/`, `boca/` y `cerebro/`.
## Por que fuera del flatpak
No es dogma, es que el sandbox costaba mas de lo que daba: escondia `/tmp`,
obligaba a `LD_LIBRARY_PATH` a mano para las libs de CUDA de whisper, metia un
`flatpak-spawn --host` en cada accion, y cambiar una linea de Python pedia
recompilar el paquete entero. Aqui un cambio es editar y reiniciar.
"""
import argparse
import os
import shutil
import subprocess
import sys
import time
AQUI = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, AQUI)
from boca.voz import Boca, VOCES # noqa: E402
from cara.panel import Panel # noqa: E402
from cara.servidor import Cara # noqa: E402
from cerebro.ollama import Cerebro, MODELO # noqa: E402
from manos import Manos # noqa: E402
from manos.diario import anota # noqa: E402
from manos.acciones import Catalogo # noqa: E402
from oido.captura import Captura, fuentes # noqa: E402
from oido.despierta import Centinela, NOMBRE # noqa: E402
from oido.whisper import Oido, prompt_dominio, es_ruido # noqa: E402
CATALOGO = os.path.join(AQUI, "datos", "acciones.json")
PROPIO = os.path.expanduser("~/.config/jarvis/acciones.json")
def catalogo() -> Catalogo:
# El del usuario primero: puede redefinir cualquiera del paquete. Mismo
# orden que tenia el panel de Newelle.
return Catalogo([PROPIO, CATALOGO])
def atiende(texto, manos: Manos, cerebro: Cerebro | None, traza=print):
"""La cadena entera para una frase: catalogo primero, cerebro despues.
El orden no es un detalle de eficiencia, es lo que hace usable esto. Las 150
acciones se emparejan sin tocar ningun modelo y contestan al instante; el
cerebro solo ve lo que no encaja. Por eso un modelo lento se soporta: la
mayoria de las ordenes ni pasan por el.
"""
t0 = time.time()
accion, argumento = manos.busca(texto)
if accion is not None:
traza(f" catalogo: {accion.id}" + (f" <- {argumento}" if argumento else ""))
dicho, _ = manos.haz(accion, argumento)
anota(texto, "catalogo", accion.id, argumento, time.time() - t0, dicho)
return dicho
if cerebro is None:
traza(" (sin cerebro conectado)")
anota(texto, "sin-cerebro", seg=time.time() - t0)
return ""
traza(" al cerebro...")
dicho = cerebro.responde(texto, al_ejecutar=lambda i, a: traza(
f" usa {i}" + (f"({a[:52]})" if a else "")))
# La via mas valiosa del diario: lo que el catalogo NO cubre es justo la
# lista de acciones que faltan por escribir.
anota(texto, "cerebro", seg=time.time() - t0, dicho=dicho)
return dicho
def manda_accion(frase: str, boca: Boca | None = None, cerebro=None) -> int:
manos = Manos(catalogo())
dicho = atiende(frase, manos, cerebro, traza=lambda t: print(t))
if not dicho:
print(f' sin accion para "{frase}"')
return 1
print(f" {dicho}")
if boca:
boca.di(dicho)
return 0
def escucha(boca: Boca, modelo="small", fuente=None, callado=False,
cerebro=None, panel=None, centinela=None) -> int:
"""El bucle de verdad: oir, entender, hacer, contestar.
Es la version desnuda de lo que hacia el panel, sin GTK y sin HUD todavia.
Sirve para comprobar que la cadena entera funciona antes de ponerle cara.
"""
manos = Manos(catalogo())
# Con el prompt del NOMBRE, no con el del catalogo entero: sin el, Whisper
# nunca escribe "JARVIS" y el centinela no despierta jamas (ver whisper.py).
oreja = Oido(modelo=modelo)
if not oreja.disponible():
print(f" no encuentro whisper o el modelo {modelo}")
return 1
print(f" arrancando whisper ({modelo}, beam {oreja.beam}, sns, nombre)...")
if not oreja.arranca():
print(" whisper no arranco")
return 1
aviso = (f"esperando a que le llamen: «{NOMBRE.upper()}»"
if centinela and centinela.activo
else "atendiendo todo lo que oiga (--palabra para exigir el nombre)")
print(f" escuchando por {fuente or 'la entrada por defecto'}, {aviso}.")
print(" Ctrl+C para parar.\n")
if panel:
panel.estado("dormido" if centinela and centinela.activo else "escuchando")
# La captura consulta a la boca en cada trozo: mientras JARVIS habla, ese
# audio no se mira siquiera. Es lo que evita que se oiga a si mismo.
oreja_mic = Captura(boca=boca, fuente=fuente)
try:
for wav in oreja_mic.frases():
try:
texto = oreja.transcribe(wav)
finally:
try:
os.unlink(wav)
except OSError:
pass
if es_ruido(texto):
if texto:
print(f" · ruido descartado: {texto!r}")
anota(texto, "ruido")
if panel:
panel.dicho(texto, atendido=False, motivo="ruido")
continue
# ¿Hay una pregunta en el aire? Entonces esto es la respuesta.
#
# Va antes que TODO —antes de la palabra de activacion y antes del
# emparejador— porque si JARVIS acaba de preguntarte que fichero
# buscas, obligarte a decir "JARVIS, informes" seria absurdo: ya
# esta hablando contigo.
if panel and panel.esperando():
print(f" » {texto} (respondiendo a lo preguntado)")
anota(texto, "respuesta")
panel.dicho(texto)
if panel.responde(texto):
continue
# La palabra de activacion, antes de nada: sin esto obedece a
# cualquiera que pase, y a la tele.
if centinela:
atender, texto_orden, recien = centinela.filtra(texto)
if not atender:
if recien:
print(f" » {texto} (le han llamado)")
if panel:
panel.dicho(texto)
panel.estado("escuchando") # ya despierto
boca.di_si_libre("¿Señor?")
else:
print(f" · {texto} (no le han llamado)")
anota(texto, "sin-nombre")
# Se enseña IGUAL, marcado: que se vea que te ha oido
# bien y que lo unico que falta es llamarle.
if panel:
panel.dicho(texto, atendido=False,
motivo=f"diga «{NOMBRE.upper()}» primero")
continue
texto = texto_orden
# El candado tapa el panel, pero lo que hay que impedir es que
# OBEDEZCA: si no, quien pase por delante da ordenes hablando, que
# es justo lo que el candado existe para evitar.
if panel and getattr(panel, "candado", False) and not panel.desbloqueado:
print(f" · {texto} (bloqueado)")
anota(texto, "bloqueado")
panel.dicho(texto, atendido=False, motivo="hace falta la contraseña")
continue
print(f" » {texto}")
if panel:
panel.dicho(texto)
panel.estado("pensando")
dicho = atiende(texto, manos, cerebro)
if not dicho:
if panel:
panel.estado("escuchando")
continue
print(f" {dicho}")
if panel:
panel.respuesta(dicho)
panel.estado("hablando")
if not callado:
boca.di(dicho)
if panel:
# Sigue despierto un rato tras atender, para poder encadenar sin
# repetir el nombre: el rotulo tiene que decir eso y no "dormido".
panel.estado("escuchando" if not centinela or centinela.despierto
else "dormido")
except KeyboardInterrupt:
print("\n parado")
finally:
oreja_mic.para()
oreja.para()
return 0
def panel(boca: Boca, cerebro=None, escuchando=False, modelo="small",
fuente=None, abre=True, centinela=None, sin_candado=False) -> int:
"""Levanta el panel y, si se pide, ademas escucha el microfono."""
manos = Manos(catalogo())
cara = Cara()
p = Panel(cara, boca, manos, cerebro, candado=not sin_candado,
centinela=centinela)
cara.al_pulsar = p.pulsado
url = cara.arranca()
if not url:
return 1
p.arranca()
print(f" panel en {url}")
if abre:
# Una sola forma de abrir la ventana, en ventana.sh, para que sea igual
# se llegue por donde se llegue. Tener aqui su propia lista de
# navegadores fue lo que dejo que arranca.sh abriera una pestaña de
# Firefox cuando JARVIS ya estaba en marcha.
abridor = os.path.join(AQUI, "ventana.sh")
if os.access(abridor, os.X_OK):
subprocess.Popen([abridor, url], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
if not escuchando:
print(" sin microfono (--escucha para que ademas oiga). Ctrl+C para parar.")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\n parado")
p.para()
return 0
return escucha(boca, modelo, fuente, False, cerebro, p, centinela)
def main() -> int:
p = argparse.ArgumentParser(description="JARVIS")
p.add_argument("--di", metavar="TEXTO", help="decir algo en voz alta")
p.add_argument("--voz", default=None, help="con que voz")
p.add_argument("--voces", action="store_true", help="listar las voces")
p.add_argument("--accion", metavar="FRASE", help="ejecutar una orden del catalogo")
p.add_argument("--callado", action="store_true", help="no hablar el resultado")
p.add_argument("--escucha", action="store_true", help="escuchar el microfono")
p.add_argument("--modelo", default="small", help="modelo de whisper")
p.add_argument("--fuente", default=None, help="entrada de audio (ver --fuentes)")
p.add_argument("--fuentes", action="store_true", help="listar las entradas")
p.add_argument("--cerebro", default=MODELO,
help="modelo de ollama; 'no' para trabajar solo con el catalogo")
p.add_argument("--ram", action="store_true",
help="el modelo entero en RAM, sin usar la grafica")
p.add_argument("--panel", action="store_true", help="abrir el panel")
p.add_argument("--sin-abrir", action="store_true",
help="levantar el panel pero no abrir el navegador")
p.add_argument("--palabra", action="store_true",
help=f"exigir que le llamen «{NOMBRE}» antes de cada orden")
p.add_argument("--sin-candado", action="store_true",
help="no pedir la contraseña al abrir (solo para probar)")
a = p.parse_args()
boca = Boca(a.voz or "davefx")
cerebro = None
if a.cerebro and a.cerebro != "no":
cerebro = Cerebro(catalogo(), modelo=a.cerebro, en_ram=a.ram,
manos=Manos(catalogo()))
if a.voces:
hay = boca.disponibles()
for nombre in VOCES:
marca = "·" if nombre in hay else "×"
actual = " <- puesta" if nombre == boca.voz else ""
print(f" {marca} {nombre}{actual}")
return 0
if a.di:
if a.voz and not boca.cambia(a.voz):
print(f" no existe la voz {a.voz}")
return 1
print(f" [{boca.voz}] {a.di}")
if not boca.di(a.di):
print(" no sono. mira que el modelo de la voz este en su sitio")
return 1
return 0
if a.fuentes:
for f in fuentes():
print(f" {f}")
return 0
# La palabra de activacion va APAGADA por defecto.
#
# Decirla en cada frase es un peaje, y medido no evitaba nada: sobre 412
# frases reales oidas —con todo el eco y el ruido ambiente dentro— solo 3
# habrian disparado una accion, y las tres eran ordenes de verdad. Cero
# falsos positivos del ruido, porque el emparejador ya es conservador.
#
# El candado cubre lo otro, que es quien puede darle ordenes.
#
# Se queda disponible con --palabra y en el boton del panel, por si algun
# dia hay una tele cerca y el ruido empieza a colarse.
centinela = Centinela(activo=a.palabra)
if a.panel:
return panel(boca, cerebro, a.escucha, a.modelo, a.fuente,
abre=not a.sin_abrir, centinela=centinela,
sin_candado=a.sin_candado)
if a.escucha:
return escucha(boca, a.modelo, a.fuente, a.callado, cerebro,
centinela=centinela)
if a.accion:
return manda_accion(a.accion, None if a.callado else boca, cerebro)
p.print_help()
return 0
if __name__ == "__main__":
sys.exit(main())