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