JARVIS/nucleo/oido/captura.py
sito a0b9afe2f9 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
2026-08-16 16:06:43 +02:00

207 lines
7.9 KiB
Python

"""Escuchar el microfono y recortar lo que es habla.
## Por que pw-record y no pyaudio
Newelle usa pyaudio, que necesita portaudio compilado. En esta maquina no esta,
asi que instalarlo pediria sudo. `pw-record` y `parec` ya vienen con PipeWire y
sirven igual: se lanzan como subproceso y se lee su salida estandar.
Y hay una ventaja que no es casual: la fuente se elige con un argumento. Cuando
se ponga el cancelador de eco de PipeWire, apuntar ahi es cambiar una cadena, no
tocar codigo de audio.
## La guarda contra el eco
Esta es la razon de ser de este fichero. En Newelle, el 21 % largo de lo que
JARVIS oye y no entiende es su propia voz. Tiene mecanismo para evitarlo pero
con tres agujeros, y aqui se cierran los tres:
1. **Se consulta a la boca en cada trozo**, no una bandera que alguien actualiza
desde otro hilo. Si `boca.hablando` es cierto, ese audio se tira sin mirar.
2. **La cola de 350 ms** vive en la boca (`COLA_MS`): cuando el reproductor
termina, el sonido sigue llegando al microfono un rato.
3. **El prebufer se vacia** al salir de "hablando". Newelle guarda 1 s circular
y lo antepone al detectar voz; ese segundo era justo el final de la frase de
JARVIS, asi que se colaba aunque la guarda funcionase.
"""
import collections
import os
import shutil
import signal
import struct
import subprocess
import tempfile
import wave
FRECUENCIA = 16000 # lo que quiere whisper.cpp
CANALES = 1
ANCHO = 2 # s16
TROZO = 512 # muestras: lo que pide Silero de una vez
# Cuanto silencio hace falta para dar la frase por terminada. Muy corto corta a
# quien piensa a media frase; muy largo hace que JARVIS parezca lento.
SILENCIO_PARA_CERRAR = 0.8
# Cuanto se guarda de ANTES de detectar voz. La primera silaba siempre llega
# antes de que el detector se entere.
PREBUFER_S = 0.5
# Frases mas cortas que esto no son ordenes, son ruidos.
MINIMO_S = 0.35
# Y mas largas que esto es que el detector se ha quedado enganchado.
MAXIMO_S = 25.0
def _grabador(fuente=None):
"""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:
"""Trozos de microfono -> frases sueltas en WAV."""
def __init__(self, boca=None, fuente=None, agresividad=0.5):
self.boca = boca # para saber si JARVIS esta hablando
self.fuente = fuente
self.agresividad = agresividad
self._proc = None
self._vad = None
self._energia = None
# ------------------------------------------------------------ el detector
def _detector(self):
if self._vad is not None or self._energia is not None:
return
try:
from pysilero_vad import SileroVoiceActivityDetector
self._vad = SileroVoiceActivityDetector()
except Exception:
# Respaldo por energia: peor, pero mejor que no escuchar. Se avisa,
# porque la diferencia se nota y conviene saber en cual se esta.
print("oido: sin Silero, usando deteccion por energia")
self._energia = True
def _hay_voz(self, trozo: bytes) -> bool:
if self._vad is not None:
try:
return self._vad(trozo) >= self.agresividad
except Exception:
return False
muestras = struct.unpack(f"{len(trozo) // 2}h", trozo)
if not muestras:
return False
media = sum(m * m for m in muestras) / len(muestras)
return (media ** 0.5) / 32768.0 > 0.015
# -------------------------------------------------------------- escuchar
def frases(self, para_cuando=None):
"""Generador: devuelve la ruta de un WAV por cada frase oida.
El WAV es temporal y de quien lo recibe: se borra cuando quiera.
"""
self._detector()
self._proc = subprocess.Popen(
_grabador(self.fuente), stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, start_new_session=True)
bytes_trozo = TROZO * ANCHO
por_segundo = FRECUENCIA / TROZO
prebufer = collections.deque(maxlen=max(1, int(PREBUFER_S * por_segundo)))
cerrar_tras = int(SILENCIO_PARA_CERRAR * por_segundo)
tope = int(MAXIMO_S * por_segundo)
frase, callados, hablando = [], 0, False
try:
while True:
if para_cuando and para_cuando():
break
trozo = self._proc.stdout.read(bytes_trozo)
if not trozo or len(trozo) < bytes_trozo:
break
# --- la guarda: si JARVIS habla, esto es su propia voz -------
if self.boca is not None and self.boca.hablando:
# Tirar tambien lo acumulado: si estaba a media frase cuando
# JARVIS empezo a hablar, lo que quede lleva su voz encima.
prebufer.clear()
frase, callados, hablando = [], 0, False
continue
prebufer.append(trozo)
voz = self._hay_voz(trozo)
if voz and not hablando:
hablando = True
frase = list(prebufer) # con lo de antes de detectarla
callados = 0
elif hablando:
frase.append(trozo)
callados = 0 if voz else callados + 1
if hablando and (callados >= cerrar_tras or len(frase) >= tope):
dur = len(frase) * TROZO / FRECUENCIA
ruta = self._a_wav(frase) if dur >= MINIMO_S else None
frase, callados, hablando = [], 0, False
prebufer.clear()
if ruta:
yield ruta
finally:
self.para()
def _a_wav(self, trozos) -> str:
fd, ruta = tempfile.mkstemp(suffix=".wav", prefix="jarvis-oido-")
os.close(fd)
with wave.open(ruta, "wb") as w:
w.setnchannels(CANALES)
w.setsampwidth(ANCHO)
w.setframerate(FRECUENCIA)
w.writeframes(b"".join(trozos))
return ruta
def para(self):
if not self._proc:
return
try:
os.killpg(os.getpgid(self._proc.pid), signal.SIGTERM)
self._proc.wait(timeout=3)
except Exception:
pass
self._proc = None
def fuentes() -> list:
"""Las entradas que hay, para poder elegir la del cancelador de eco."""
try:
r = subprocess.run(["pactl", "list", "short", "sources"],
capture_output=True, text=True, timeout=10)
except (OSError, subprocess.TimeoutExpired):
return []
salida = []
for linea in r.stdout.splitlines():
campos = linea.split("\t")
if len(campos) > 1 and not campos[1].endswith(".monitor"):
salida.append(campos[1])
return salida