
Sebastian: voz bidireccional sobre hardware propio
Llevo unas semanas con un cacharro sobre la mesa que ya habla: Sebastian, un altavoz conversacional voz-a-voz montado sobre una placa Seeed ReSpeaker XVF3800 con un XIAO ESP32-S3. La pregunta de laboratorio que lo justifica: ¿qué se aprende si, en vez de comprar un altavoz hecho, controlas el camino completo de la voz, desde el silicio del micrófono hasta el agente que responde? Hablas, el dispositivo captura y limpia tu voz por hardware, la publica por WebRTC en una sala LiveKit, y un agente en Python te contesta por el altavoz. Todo el bucle es voz.
Y funciona: conversación bidireccional validada en hardware real, con el anillo de LEDs apuntando a quien habla (direction of arrival), botón de mute y, que yo sepa, el primer cliente LiveKit sobre ESP32 escrito en Zig. No es un producto: es mi laboratorio. El repo es público y aquí cuento cómo está hecho, qué se rompió por el camino y qué le falta.
El camino del audio
La pieza que manda es el XVF3800, un DSP de voz de XMOS: el array de cuatro micrófonos se conecta a él, y el trabajo sucio —beamforming, cancelación de eco, supresión de ruido— ocurre en su silicio. El ESP32-S3 no procesa señal: mueve PCM entre el bus I2S y la red.
Los detalles que más guerra dieron:
- El XVF es maestro I2S (48 kHz, 32 bits, estéreo) y el ESP32 esclavo, con dos puertos I2S separados para micro y altavoz: un único canal duplex corrompía el DMA y tumbaba la placa.
- La captura va al ritmo del consumidor, sin ring buffer intermedio. Con un buffer libre, la deriva entre el reloj del XVF y el del consumidor sonaba a helicóptero.
- En la cadena hay una sola pasada de supresión de ruido. Dos en serie —la del chip y la del agente— dejaban un artefacto metálico, de lata. Al principio usaba el beam crudo del array y delegaba esa pasada en el BVC de LiveKit; al self-hostear descubrí que BVC es un servicio de Cloud, así que la pasada única se movió al canal procesado del chip. Medido con el probe de doble canal: 38,5 dB de SNR frente a los 30,7 dB del beam crudo. De regalo, eso habilitó el full-duplex con tracking: puedes interrumpirlo mientras habla y el haz te sigue.
- El micro se publica como Opus a 48 kHz; la respuesta baja por WebRTC, se renderiza a 32 bits —a 16 sonaba sucio— y sale por un codec AIC3104 hacia un altavoz de 5 W.
El cancelador de eco merece confesión aparte. No convergía, y el primer diagnóstico culpó a un parámetro de fábrica que venía a cero: publiqué el «fix» —una escritura I2C de cuatro bytes— y lo di por resuelto. Era un placebo. El arreglo real estaba en el gain de referencia y en activar el DSP del far-end, y el comentario del código lo cuenta sin anestesia:
/// FAR_EXTGAIN is dB = external gain past the reference tap; our tap is after the
/// ESP sw-vol and the AIC3104 is 0 dB → 0.0 (factory) is correct. The 1.0 we
/// shipped earlier was a placebo from the FAR_EXTGAIN misdiagnosis.
pub fn applyConfig() bool {
if (!writeF32Verified(RESID_AUDIO_MGR, MGR_REF_GAIN, 1.0, "REF_GAIN")) return false;
if (!writeF32Verified(RESID_AEC, AEC_FAR_EXTGAIN, 0.0, "FAR_EXTGAIN")) return false;
...
if (!writeF32Verified(RESID_AUDIO_MGR, MGR_MIC_GAIN, 90.0, "MIC_GAIN")) return false;
...
if (!writeU8Verified(RESID_AUDIO_MGR, MGR_FAR_END_DSP_ENABLE, 1, "FAR_END_DSP_ENABLE")) return false;El barge-in tiene su miga: el modelo genera audio más rápido que el tiempo real, así que al interrumpir quedaban segundos de respuesta encolados en la FIFO del dispositivo —«no para»—. Cancelar la generación no basta: el agente publica interrupted y el firmware vacía la FIFO de render. Interrupción seca.
La palabra que lo despierta
La escucha es local: un modelo microWakeWord —una CNN de streaming de 62 KB— decide en el propio dispositivo cuándo abrir sesión. Nada sale del cacharro hasta que oye su palabra, y la sala LiveKit (con el modelo de pago que hay detrás) solo se abre bajo demanda: coste cero en reposo. Un pre-roll de 12 segundos en PSRAM guarda lo que dijiste antes de que la sala esté lista, así que «enciende la luz del salón» dicho de corrido llega entero.
Aquí va la parte de laboratorio que duele contar. Entrené una wake word propia en español —«Sebastián», claro— en mi M4 Pro: 18.220 muestras positivas (TTS con nueve voces Piper más 120 grabaciones reales hechas con el propio XVF), unos 20 GB de negativos y un recall del 99,29 % en validación. En el uso real, sin embargo, colaba demasiados falsos positivos, así que hoy el dispositivo embarca el modelo stock en inglés «Okay Nabu» con el umbral subido. Recuperar la wake word en español sigue en el roadmap.
La integración fue una guerra de cinco bugs: el mismo modelo daba 0,996 en Python y un 0 % en el dispositivo. El peor era invisible: el estado interno de la CNN no se reseteaba entre armados, y tras cerrar una sesión el modelo se disparaba al 99 % sin sonido alguno —bucle infinito de detección, sesión y cierre—. El más caro de encontrar fue el aliasing al diezmar de 48 a 16 kHz sin filtro: las grabaciones y el TTS seguían detectándose al 97-99 % porque ya venían limitados a 8 kHz, pero la voz en vivo caía a cero. Lo arregló un FIR paso bajo de 19 taps antes de diezmar.
Recall y precisión tiran del mismo umbral en direcciones opuestas, así que viven en capas distintas: la placa mantiene el umbral laxo para no perder ninguna activación, y el agente re-verifica cada disparo transcribiendo el pre-roll —si no era la wake word, aborta en silencio—. Cada rechazo guarda un clip: el dataset de negativos difíciles se construye solo, con el uso.
Firmware en Zig, con un truco
La capa de aplicación del firmware está escrita en Zig sobre ESP-IDF, con el fork Xtensa de Espressif; el core WebRTC queda en C (el SDK de LiveKit no se reescribe). El translate-c de Zig se atraganta con las cabeceras newlib de ESP-IDF, así que los bindings se escriben a mano, declarando solo lo que se usa. El riesgo obvio —que los layouts se desincronicen— se paga con un fichero de _Static_assert que los verifica en cada compilación:
#define CHECK_SIZE(type, bytes) _Static_assert(sizeof(type) == (bytes), #type " size drifted — sync csdk.zig")
#define CHECK_OFF(type, field, bytes) _Static_assert(offsetof(type, field) == (bytes), #type "." #field " offset drifted — sync csdk.zig")
...
CHECK_SIZE(livekit_pub_options_t, 36);
CHECK_OFF(livekit_pub_options_t, kind, 0);
CHECK_OFF(livekit_pub_options_t, video_encode, 4);
CHECK_OFF(livekit_pub_options_t, audio_encode, 20);
CHECK_OFF(livekit_pub_options_t, capturer, 32);El truco que más me gusta: el propio código Zig flashea el XVF3800 por DFU vía I2C en el primer arranque, instalándole el firmware que lo convierte en maestro I2S. El binario —868 KB— va embebido en el ejecutable con @embedFile; la imagen de fábrica queda intacta como red de seguridad, así que no hay forma de brickearlo, y los arranques siguientes detectan la versión y se lo saltan. Sin ESPHome ni herramientas externas.
El S3 va justísimo de RAM interna, y eso dicta la regla de la casa: el firmware se congela y las funciones nuevas van al servidor. Añadir un micrófono a otra habitación es flashear otra unidad contra el mismo servidor, con cero cambios de firmware.
El otro lado del cable
El agente es Python sobre LiveKit Agents, con un modelo speech-to-speech nativo: Gemini con audio nativo por defecto y gpt-realtime-mini como reserva, conmutables por variable de entorno. Un VAD local (Silero) detecta cuándo hablas por encima del agente para cortarlo al instante, y las luces de casa se tocan vía MCP de Home Assistant. El cierre de sesión lo decide la intención del modelo, no una lista de palabras clave: «enciende la luz para el salón» no puede colgar la llamada.
Antes de elegir modelo hice números, y la matriz de costes del repo deja una conclusión contraintuitiva: el pipeline clásico STT + LLM + TTS no es la ruta barata —el TTS domina la factura— y los modelos speech-to-speech nativos marcan el suelo de precio: en uso moderado, unos 10 $ al mes frente a los 15-26 $ del pipeline, y con menos latencia voz-a-voz. Ese análisis sostiene el default de Gemini.
El plano de control
Detrás del cacharro hay un servicio en Go, contract-first: el openapi.yaml es la fuente y de él se generan tipos y router. Su endpoint principal, POST /v1/sessions, autentica el dispositivo, crea el dispatch explícito del agente en LiveKit y devuelve una credencial de corta vida; el firmware no lleva secretos ni JWT estáticos. Y cada sesión estrena sala: el bug de dos agentes compartiendo sala y respondiéndose entre sí es imposible por construcción.
PostgreSQL es la fuente de verdad, y cada cambio que debe publicarse se registra en la misma transacción como evento de outbox; un worker los entrega como CloudEvents durables en NATS JetStream al panel de administración en React, donde vive el catálogo de grabaciones. El SFU de LiveKit corre self-hosted en mi cluster Talos, desplegado por GitOps: audio local a la LAN y cero euros por minuto de sala.
Todo el sistema se mira desde el mismo sitio. El agente exporta métricas y la transcripción de cada turno, con sus latencias, a un stack Grafana vía OpenTelemetry. La placa tiene dos caminos: en el banco, un bridge lee el puerto serie y convierte sus logs en métricas; en producción, el firmware espeja esos mismos logs por syslog UDP hacia la LAN, fire-and-forget, y los vitales —heap, nivel de micro, probabilidades de wake— llegan a Grafana sin cable. Las dos mitades de la conversación, lado a lado.
Ahí arriba está el dashboard de producción, con el puerto serie suelto: 146 detecciones, 146 sesiones, cero pánicos y cero reinicios en diez días. Esta telemetría fue la que destapó, entre otras cosas, una tormenta de retransmisiones SCTP —cientos de INITs por sesión— que acabó muerta con un parche de una línea y 7 KB de heap recuperados por sesión.
La puerta entre el banco y el salón
El repo tiene escrita la puerta que separa «funciona en el banco» de «vive en el salón»: el milestone living-room-ready. No es una lista de features; son criterios de salida, cada uno con su test. Los gordos:
- Autenticación y rate-limiting en la superficie HTTP expuesta: hoy el endpoint legacy de token responde a cualquier petición anónima y dispara un modelo de pago.
- OTA con rollback y cifrado de flash: hoy un dispositivo desplegado solo se actualiza y se recupera por USB.
- Grabación de sesiones opt-in, con retención y consentimiento claros — hoy viene encendida por defecto, y lo digo tal cual.
- La tele me secuestra las sesiones: con el micro abierto en dúplex, su voz se transcribe como turnos del usuario y el haz adaptativo le apunta a ella. El arreglo va por capas: bloquear el haz en la dirección del wake, filtrar turnos por dirección y verificación de hablante por turno.
Había un quinto criterio —telemetría sin cable, porque los vitales del firmware solo salían por el puerto serie— que acaba de tacharse con el espejo syslog: es el dashboard de más arriba. Para el resto, la definición de hecho: una semana desatendido en el salón, y que cada fallo se pueda reconstruir desde Grafana sin tocar el cacharro.
Sebastian es la punta de hierro de la misma línea de trabajo que el portal de agentes de Zetesis: agentes configurados como datos, con presupuestos y gateway de modelos… y ahora un altavoz que habla. El experimento sigue abierto, y el repo también: github.com/Zetesis-Labs/Sebastian. Cuando cruce la puerta del salón, lo contaré aquí.