From a0b9afe2f9b42071203ae4bf22cca156ce25c33e Mon Sep 17 00:00:00 2001 From: sito Date: Sun, 16 Aug 2026 16:06:43 +0200 Subject: [PATCH] Arreglado el boton PARAR y repaso de portabilidad PARAR no pausaba de verdad: el HUD estaba entero para el estado "parado" pero el backend nunca mandaba el campo "parar" en el evento controles, asi que el boton no alternaba ni pausaba la escucha. Ahora pausa (el bucle de escucha descarta el audio en pausa) y alterna a "reanudar". Portabilidad (para que encaje en otros portatiles y torres, no solo en el equipo del autor): - microfono: pw-record / parec / arecord (PipeWire, PulseAudio o ALSA) - whisper: binario del build local o del PATH; modelos por JARVIS_WHISPER_MODELOS o busqueda en varios sitios; CUDA en LD_LIBRARY_PATH solo si existe - README: la tabla de portabilidad refleja audio y whisper --- README.md | 3 ++- nucleo/cara/panel.py | 34 ++++++++++++++++++++++++------- nucleo/jarvis.py | 9 +++++++++ nucleo/oido/captura.py | 33 +++++++++++++++++++++++------- nucleo/oido/whisper.py | 46 ++++++++++++++++++++++++++++++++++-------- 5 files changed, 102 insertions(+), 23 deletions(-) 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,