Nucleo propio: oye con whisper.cpp, piensa con un modelo de Ollama, habla con Piper, y hace RAG sobre los apuntes del usuario. 100% local, sin cuentas ni claves. Escrito bajo una restriccion dura, 4 GB de VRAM: el cerebro y whisper comparten tarjeta y solo caben porque estan dimensionados para ello. El RAG usa embeddings estaticos con busqueda hibrida; la voz clonada se sirve de una cache de frases. Incluye instalador (install.sh), requisitos, y documentacion del stack, del manejo de root y de las acciones. Los apuntes indexados y el diario NO se incluyen: son privados y el .gitignore los bloquea.
188 lines
7 KiB
Python
188 lines
7 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 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 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", "-"]
|
|
|
|
|
|
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
|