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:
sito 2026-08-16 15:28:31 +02:00
commit 8e4bc8ad94
125 changed files with 25033 additions and 0 deletions

188
nucleo/oido/captura.py Normal file
View 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