Susurro: construí control por voz 100% local para Claude Code
Tenía diez sesiones de Claude Code abiertas a la vez y no daba abasto mirando pantallas. Construí un sistema de voz que dicta, avisa y cambia de sesión — transcripción y voz corriendo 100% local en el Mac, sin API keys.
Llevaba un tiempo trabajando con varias sesiones de Claude Code en paralelo — distintos proyectos, distintas ventanas — y el cuello de botella dejó de ser escribir código. Era mirar pantallas todo el rato para ver si alguna sesión necesitaba un permiso o se había quedado esperando.
Así que en vez de aguantarlo, me puse a construir Susurro.
La idea
Push-to-talk para dictar en cualquier app, sí, pero el problema real que quería resolver era otro: que Claude Code me hablara cuando necesitara algo — qué proyecto, qué ventana, qué está pidiendo — y poder responder o saltar a esa sesión sin tocar el ratón.
Nada de esto pasa por la nube. La transcripción (NVIDIA Parakeet) y la voz (Kokoro) corren en local en Apple Silicon vía MLX. Sin API keys, sin depender de que un servicio externo esté despierto.
Cómo quedó montado
Un daemon en Python escucha una tecla (⌘ derecha), graba, corta sola por silencio (VAD por energía) y pega el texto en la app activa. Si terminas diciendo "enviar", pulsa Enter también.
Por el otro lado, hooks de Claude Code (PermissionRequest, Notification, Stop) mandan el evento a una cola. El daemon expone POST 127.0.0.1:8765/speak y avisa por voz: "Oye, en el proyecto X, Claude quiere ejecutar un comando: instalar dependencias. Es la ventana 'Fix login bug'. ¿Aceptas o rechazas?".
El truco para identificar sesiones fue usar el título de la ventana de la terminal, que Claude Code ya pone con el tema de la sesión — se recupera del transcript y sirve para enfocar la ventana exacta por título (AXRaise). Con eso, decir "llévame" te lleva a la sesión que acaba de avisar, o "llévame a X" a cualquier otra reciente, con matching difuso porque el STT deforma nombres de proyecto.
"Acepta" y "rechaza" hacen lo mismo pero de verdad: enfocan la ventana y pulsan Enter o Escape — solo si hay un permiso pendiente de verdad, si no, te lo dice.
Bugs de campo, no de laboratorio
Lo interesante de construir esto no fue el pipeline de voz en sí, fue todo lo que rompía en el uso real:
- El daemon se colgaba al hablar mucho rato seguido — macOS mataba el event tap por un callback lento. Solución: el callback solo encola, un worker aparte hace el trabajo pesado.
- Bajo
launchddejó de transcribir sin avisar — faltaba/opt/homebrew/binen el PATH (ffmpeg, que usa Parakeet). Y si fallaba el pegado por falta de permiso de Accesibilidad, fallaba en silencio. Ahora avisa por voz en los dos casos. - MLX es per-thread: cada modelo (STT, TTS) vive en su propio
ThreadPoolExecutor(max_workers=1)o revienta conThere is no Stream(cpu,N) in current thread. - "Llévame a X" solo miraba la cola de avisos pendientes — lo normal es querer saltar a cualquier sesión reciente, no solo a las que están pidiendo algo.
Ninguno de estos son bugs que salgan en un demo de cinco minutos. Salen a la hora tres de uso real, con diez ventanas abiertas.
Lo que sigue: de Susurro a KAI
Una vez tenía la voz resuelta en local, el siguiente paso natural fue preguntarme qué más podía hacer con ella. Ahora mismo estoy montando KAI: un runner de Claude Code en remoto sobre un VPS propio, con wake-word ("Kai, …"), briefing matutino, conectores a calendario y correo, y control por voz de cosas fuera del código — más cerca de un asistente tipo JARVIS que de un simple dictado.
Susurro fue resolver el problema inmediato. KAI es la pregunta de "¿y si esto no fuera solo para escribir código?".