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