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
330
nucleo/jarvis.py
Executable file
330
nucleo/jarvis.py
Executable 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue