JARVIS: asistente de voz local para Linux
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.
This commit is contained in:
commit
8e4bc8ad94
125 changed files with 25033 additions and 0 deletions
188
nucleo/oido/captura.py
Normal file
188
nucleo/oido/captura.py
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue