- Root: no se pide la contraseña al arrancar (echaba para atras). Se pide solo la primera vez que una accion necesita sudo, y se cachea. --candado para el modo seguro. Aviso de root prominente en el README. - install.sh crea un lanzador de escritorio (jarvis.desktop) con el icono naranja: aparece en el menu de aplicaciones para arrancar con un clic. - Captura del panel reemplazada por una mas limpia (solo la ventana). - Quitado "Probado en ..." del README (informacion del equipo). - ventanas.sh: aviso claro segun el compositor en Wayland.
344 lines
14 KiB
Python
Executable file
344 lines
14 KiB
Python
Executable file
#!/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():
|
||
# En pausa (boton PARAR): se descarta el audio sin transcribir. El
|
||
# microfono sigue abierto pero JARVIS no atiende nada hasta que se
|
||
# pulse "reanudar". Se comprueba antes de whisper para no gastar GPU.
|
||
if panel and getattr(panel, "parado", False):
|
||
try:
|
||
os.unlink(wav)
|
||
except OSError:
|
||
pass
|
||
continue
|
||
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."""
|
||
# Un SOLO Manos, compartido con el cerebro: asi hay una unica sesion de root
|
||
# y un unico sitio donde pedir la contraseña. Antes el cerebro tenia su Manos
|
||
# y el panel otro, con roots separados: desbloquear en uno no abria el otro.
|
||
manos = cerebro.manos if (cerebro and getattr(cerebro, "manos", None)) else 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("--candado", action="store_true",
|
||
help="pedir la contraseña al abrir y bloquear el panel. Por "
|
||
"defecto NO: no molesta al arrancar, y el root se pide "
|
||
"solo la primera vez que una accion lo necesita")
|
||
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=not a.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())
|