diff --git a/README.md b/README.md index 6e61121..a05d057 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,8 @@ Diseñado para correr en **cualquier Linux**, no en un equipo concreto: | Distro | cualquiera. `install.sh` detecta apt / dnf / pacman / zypper | | GPU | **opcional**. Ollama reparte el modelo entre GPU y CPU solo: va con NVIDIA (CUDA), AMD (ROCm) o **solo CPU**. Ningún parámetro está atado a una tarjeta | | Escritorio | GNOME, KDE, lo que sea. Las acciones de **ventanas** necesitan X11 + `xdotool`; en Wayland esas órdenes se desactivan solas y el resto funciona igual | -| Audio | PipeWire o PulseAudio | +| Audio | PipeWire, PulseAudio o ALSA. El micro prueba `pw-record`, `parec` y `arecord`, la primera que haya; la voz prueba `paplay`, `pw-play` y `aplay` | +| whisper | el binario se toma del build local o del `PATH`. Los modelos se buscan solos, o los fijas con `JARVIS_WHISPER_MODELOS=/ruta` | **Requisito real:** que quepa el modelo. `qwen3.5:4b` va cómodo desde ~4 GB de VRAM; con menos, usa un modelo más pequeño (`qwen3.5:2b`) o tira de CPU (hay RAM diff --git a/nucleo/cara/panel.py b/nucleo/cara/panel.py index b6eb77c..386749d 100644 --- a/nucleo/cara/panel.py +++ b/nucleo/cara/panel.py @@ -220,6 +220,11 @@ class Panel: self.telemetria = Telemetria() self._parar = threading.Event() + # Pausa a peticion del usuario (boton PARAR): mientras esta puesta, el + # bucle de escucha ignora lo que oiga. Distinto de self._parar, que corta + # los HILOS del panel al cerrar. Lo lee jarvis.py en cada frase. + self.parado = False + # Las tarjetas: una por orden en marcha, con su reloj y su boton de # parar. El prefijo va vacio porque aqui ya estamos en el host; dentro # del flatpak era `flatpak-spawn --host --watch-bus`. @@ -317,6 +322,10 @@ class Panel: self.cara.manda("controles", { # encendido = atiende todo; apagado = solo si le llaman "sueno": not (self.centinela and self.centinela.activo), + # encendido = escuchando; apagado = en pausa (boton PARAR). Sin este + # campo el HUD no sabe alternar el boton a "reanudar" ni marcar el + # documento como "parado", y el boton parece que no hace nada. + "parar": not self.parado, }) def estado(self, cual: str): @@ -404,13 +413,24 @@ class Panel: self.estado("escuchando" if not self.centinela.activo else "dormido") elif dato.startswith("parar"): - # Cortar lo que este pasando AHORA: las tareas en marcha y la voz. - n = 0 - for t in self.tareas.todas(): - if t.estado == "corriendo" and self.tareas.para(t.id): - n += 1 - self.boca.di_si_libre(f"Cortadas {n} tareas, señor." if n - else "No habia nada corriendo, señor.") + # El boton alterna PAUSA. No es solo "cortar lo de ahora": el titulo + # promete "cierra el microfono y corta la voz; pulsa otra vez para + # reanudar", asi que tiene que ALTERNAR y avisar al HUD de como quedo, + # que es lo que hacia que pareciera que no funcionaba. + self.parado = not self.parado + if self.parado: + # cortar lo que este corriendo; la escucha la ignora jarvis.py + # mirando self.parado en cada frase. Pausa en silencio: si el + # usuario pulsa PARAR es para que se calle, no para que hable. + for t in self.tareas.todas(): + if t.estado == "corriendo": + self.tareas.para(t.id) + self.estado("parado") + else: + self.boca.di_si_libre("A sus órdenes, señor.") + self.estado("escuchando") + # el HUD alterna el boton y marca el documento SOLO con esto + self.manda_controles() # Cuanto se espera la respuesta a una pregunta antes de darla por olvidada. # Corto cansa —hay que pensar que fichero era—; largo hace que una frase diff --git a/nucleo/jarvis.py b/nucleo/jarvis.py index e8beca9..91844cc 100755 --- a/nucleo/jarvis.py +++ b/nucleo/jarvis.py @@ -119,6 +119,15 @@ def escucha(boca: Boca, modelo="small", fuente=None, callado=False, 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: diff --git a/nucleo/oido/captura.py b/nucleo/oido/captura.py index 94b3690..21c7ad9 100644 --- a/nucleo/oido/captura.py +++ b/nucleo/oido/captura.py @@ -26,6 +26,7 @@ con tres agujeros, y aqui se cierran los tres: """ import collections import os +import shutil import signal import struct import subprocess @@ -50,13 +51,31 @@ MAXIMO_S = 25.0 def _grabador(fuente=None): - """La orden para capturar crudo a la salida estandar.""" - if fuente: - pw = ["pw-record", "--target", fuente] - else: - pw = ["pw-record"] - return pw + ["--rate", str(FRECUENCIA), "--channels", str(CANALES), - "--format", "s16", "-"] + """La orden para capturar crudo (s16 mono) a la salida estandar. + + Se prueba pw-record (PipeWire), parec (PulseAudio) y arecord (ALSA), en ese + orden, la primera que exista en el sistema. Asi el microfono funciona en + cualquier Linux —una torre con PulseAudio, un equipo con solo ALSA— y no + solo donde hay PipeWire. Los tres sacan el mismo crudo (s16 little-endian, + mono, 16 kHz) por la salida estandar, que es lo que lee el detector.""" + r, c = str(FRECUENCIA), str(CANALES) + if shutil.which("pw-record"): + cmd = ["pw-record", "--rate", r, "--channels", c, "--format", "s16"] + if fuente: + cmd += ["--target", fuente] + return cmd + ["-"] + if shutil.which("parec"): + cmd = ["parec", "--rate=" + r, "--channels=" + c, "--format=s16le"] + if fuente: + cmd += ["-d", fuente] + return cmd + if shutil.which("arecord"): + cmd = ["arecord", "-q", "-r", r, "-c", c, "-f", "S16_LE", "-t", "raw"] + if fuente: + cmd += ["-D", fuente] + return cmd + # ninguno instalado: se devuelve pw-record para que el fallo sea claro + return ["pw-record", "--rate", r, "--channels", c, "--format", "s16", "-"] class Captura: diff --git a/nucleo/oido/whisper.py b/nucleo/oido/whisper.py index b161e34..97be50f 100644 --- a/nucleo/oido/whisper.py +++ b/nucleo/oido/whisper.py @@ -56,6 +56,7 @@ import atexit import json import os import re +import shutil import signal import socket import subprocess @@ -64,13 +65,40 @@ import urllib.error import urllib.request RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -BINARIO = os.path.join(RAIZ, "voz", "whisper-cuda", "whisper.cpp", "build", "bin", - "whisper-server") -MODELOS = os.path.expanduser( - "~/.var/app/io.github.qwersyk.Newelle/config/models/whisper/whisper.cpp/models") -# Las libs de ggml estan al lado del binario y CUDA en /usr/local. Fuera del -# flatpak esto es una linea; dentro era media pagina de flatpak-spawn. +# El binario de whisper-server: el build local del repo si esta compilado, y si +# no, el que haya en el PATH (asi vale un whisper.cpp instalado del sistema, en +# cualquier distro). Cada quien lo compila para su plataforma —CUDA, ROCm, +# Metal o CPU—; aqui solo se busca donde esta. +_LOCAL = os.path.join(RAIZ, "voz", "whisper-cuda", "whisper.cpp", "build", "bin", + "whisper-server") +BINARIO = _LOCAL if os.path.exists(_LOCAL) else (shutil.which("whisper-server") or _LOCAL) + + +def _dir_modelos(): + """Donde estan los ggml-*.bin. Se puede fijar con JARVIS_WHISPER_MODELOS; + si no, se prueba junto al build, el sitio estandar del usuario, y por + compatibilidad la ruta del antiguo flatpak. Gana el primero que TENGA un + modelo de verdad —el dir del source de whisper.cpp existe pero suele estar + vacio, asi que mirar solo si el dir existe elegiria mal.""" + import glob + env = os.environ.get("JARVIS_WHISPER_MODELOS") + if env: + return os.path.expanduser(env) + for c in (os.path.join(os.path.dirname(_LOCAL), "..", "..", "models"), + "~/.local/share/jarvis/whisper/models", + "~/.var/app/io.github.qwersyk.Newelle/config/models/whisper/whisper.cpp/models"): + ruta = os.path.normpath(os.path.expanduser(c)) + if glob.glob(os.path.join(ruta, "ggml-*.bin")): + return ruta + return os.path.expanduser("~/.local/share/jarvis/whisper/models") + + +MODELOS = _dir_modelos() + +# Las libs de CUDA, solo si estan: en un equipo AMD o CPU no hay /usr/local/cuda, +# y meterlo en LD_LIBRARY_PATH a la fuerza no aporta nada. Se añade mas abajo +# solo si el directorio existe. LIBS = "/usr/local/cuda/lib64" # Un prompt largo no ayuda: Whisper empieza a "oir" esas palabras donde no las @@ -223,8 +251,10 @@ class Oido: if not self.gpu: orden.append("--no-gpu") - entorno = {**os.environ, - "LD_LIBRARY_PATH": LIBS + ":" + os.environ.get("LD_LIBRARY_PATH", "")} + # CUDA en LD_LIBRARY_PATH solo si existe; en AMD/CPU no estorba. + entorno = dict(os.environ) + if os.path.isdir(LIBS): + entorno["LD_LIBRARY_PATH"] = LIBS + ":" + os.environ.get("LD_LIBRARY_PATH", "") self._proc = subprocess.Popen( orden, env=entorno, cwd=os.path.dirname(BINARIO), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,