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
207 lines
7.9 KiB
Python
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
|