"""La boca de JARVIS: convertir texto en sonido, y saber cuando esta sonando. Esto es la version de `voces.py` + la mitad de `tts.py` de Newelle, pero sin el motor de handlers: aqui solo hablamos por Piper y por el clon, y los dos son un script de shell que escribe un WAV. Todo lo demas sobraba. ## Lo que hay que hacer bien y en Newelle estaba mal **Saber que esta hablando, y saberlo A TIEMPO.** El eco que envenena el registro de JARVIS —el 21 % confirmado de las frases que no entiende son suyas— viene de que Newelle marca `assistant_speaking` con `GLib.idle_add`, que es asincrono: el audio ya esta sonando mientras el hilo de grabacion sigue leyendo `False`. Aqui la bandera se pone ANTES de lanzar el reproductor y se quita con retardo, porque cuando el proceso termina el sonido todavia esta viajando hasta el microfono. **Un solo altavoz.** Con tareas en paralelo, dos respuestas a la vez se pisan. Se habla por turnos con un cerrojo, igual que hacia `_habla` en el panel. ## La cache Clave `sha1(voz|texto normalizado)`. Piper tarda entre medio segundo y dos por frase, y JARVIS repite mucho ("Enseguida, señor", "No he podido, señor"): con cache eso es un `cp`. Se guarda en `datos/cache_voz/`, no en la del clon: aquella la genera `generar_cache_voz.py` con su propia clave (sha1 solo del texto) y es de solo lectura para nosotros. `jarvis-voz.sh` ya la consulta por dentro. """ import hashlib import os import re import shutil import subprocess import threading import time RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) CONFIG = os.path.join(RAIZ, "config") MODELOS = os.path.join(RAIZ, "voz", "modelos") CACHE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "datos", "cache_voz") # Cuanto se sigue considerando "hablando" despues de que el reproductor termine. # El sonido tarda en salir del buffer de la tarjeta y en llegar al microfono; sin # esta cola, la ultima silaba de JARVIS se cuela en la siguiente transcripcion. # # El valor sale de medirlo con pruebas/prueba_eco.py, no de suponerlo: con 350 # la frase entera dejaba de colarse pero seguia escapandose un fragmento. COLA_MS = int(os.environ.get("JARVIS_COLA_MS", "700")) def _piper(modelo): return { "orden": [os.path.join(CONFIG, "jarvis-piper.sh")], "entorno": {"JARVIS_PIPER_VOZ": modelo, "JARVIS_PIPER_TONO": "1.0"}, "necesita": os.path.join(MODELOS, modelo + ".onnx"), } # El orden es el de rotacion. davefx primero porque es la que se eligio a oido. VOCES = { "davefx": _piper("es_ES-davefx-medium"), "clon": { "orden": [os.path.join(CONFIG, "jarvis-voz.sh")], "entorno": {}, "necesita": os.path.join(CONFIG, "jarvis-voz.sh"), }, "claude": _piper("es_MX-claude-high"), "ald": _piper("es_MX-ald-medium"), "sharvard": _piper("es_ES-sharvard-medium"), } POR_DEFECTO = "davefx" # Se prueban en orden. paplay y pw-play respetan el enrutado de PipeWire, que es # lo que hace falta para que el cancelador de eco tenga la referencia de lo que # sale por los altavoces. aplay habla con ALSA directamente y se la salta. REPRODUCTORES = (["paplay"], ["pw-play"], ["aplay", "-q"]) def _normaliza(texto: str) -> str: return re.sub(r"\s+", " ", texto or "").strip() def _clave(voz: str, texto: str) -> str: return hashlib.sha1(f"{voz}|{_normaliza(texto)}".encode()).hexdigest()[:16] def _reproductor(): for r in REPRODUCTORES: if shutil.which(r[0]): return r return None class Boca: """Todo lo que suena sale de aqui, y de uno en uno.""" def __init__(self, voz: str = POR_DEFECTO, al_cambiar=None): self.voz = voz if voz in VOCES else POR_DEFECTO self._turno = threading.Lock() self._hablando = False self._hasta = 0.0 # instante hasta el que sigue contando como hablando self._al_cambiar = al_cambiar # se avisa al oido para que se calle os.makedirs(CACHE, exist_ok=True) # ------------------------------------------------------------ el estado @property def hablando(self) -> bool: """Si esta sonando algo, incluida la cola. El oido consulta esto en cada trozo de audio. Tiene que ser barato y, sobre todo, no puede mentir por defecto: mas vale sobrar 300 ms de silencio que colar la propia voz en la transcripcion. """ return self._hablando or time.monotonic() < self._hasta def _marca(self, hablando: bool): self._hablando = hablando if not hablando: self._hasta = time.monotonic() + COLA_MS / 1000.0 if self._al_cambiar: try: self._al_cambiar(hablando) except Exception: pass # ------------------------------------------------------------- generar def _genera(self, texto: str) -> str | None: """Devuelve la ruta del WAV, de cache o recien hecho.""" voz = VOCES[self.voz] destino = os.path.join(CACHE, _clave(self.voz, texto) + ".wav") if os.path.exists(destino) and os.path.getsize(destino) > 0: return destino entorno = {**os.environ, **voz["entorno"]} try: subprocess.run(voz["orden"] + [destino, texto], env=entorno, capture_output=True, timeout=120) except (subprocess.TimeoutExpired, OSError): return None if not os.path.exists(destino) or os.path.getsize(destino) == 0: # Que no se quede un fichero vacio en la cache: el siguiente intento # lo daria por bueno y JARVIS se quedaria mudo para esa frase. if os.path.exists(destino): os.unlink(destino) return None return destino # -------------------------------------------------------------- hablar def di(self, texto: str) -> bool: """Habla, esperando turno. Devuelve si sono.""" texto = _normaliza(texto) if not texto: return False with self._turno: return self._suelta(texto) def di_si_libre(self, texto: str) -> bool: """Habla solo si nadie tiene el turno. Para avisos que no merecen cola.""" if not self._turno.acquire(blocking=False): return False try: return self._suelta(_normaliza(texto)) finally: self._turno.release() def _suelta(self, texto: str) -> bool: wav = self._genera(texto) if not wav: return False orden = _reproductor() if not orden: return False # La bandera ANTES de lanzar el reproductor, no despues y no en otro # hilo. Este es exactamente el fallo de Newelle que causa el eco. self._marca(True) try: subprocess.run(orden + [wav], capture_output=True, timeout=300) except (subprocess.TimeoutExpired, OSError): return False finally: self._marca(False) return True # --------------------------------------------------------------- voces def disponibles(self) -> list: return [n for n, v in VOCES.items() if os.path.exists(v["necesita"])] def cambia(self, nombre: str) -> bool: if nombre not in VOCES or not os.path.exists(VOCES[nombre]["necesita"]): return False self.voz = nombre return True def siguiente(self) -> str: lista = self.disponibles() if not lista: return self.voz try: i = lista.index(self.voz) except ValueError: i = -1 self.voz = lista[(i + 1) % len(lista)] return self.voz