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
This commit is contained in:
sito 2026-08-16 16:06:43 +02:00
parent 55e967b940
commit a0b9afe2f9
5 changed files with 102 additions and 23 deletions

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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,