Voxlyn-ai: 11 días después — Kokoro, follow-up y memoria semántica
Hace un par de semanas publiqué el post sobre voxlyn-ai, mi asistente de voz para Linux. En ese momento tenía una versión funcional pero con varios puntos por mejorar. La respuesta fue mejor de lo que esperaba, así que aproveché el impulso para resolver exactamente esos puntos y algunos otros que surgieron en el camino.
En el post original mencionaba que quería lograr conversaciones continuas sin tener que reactivar el asistente cada vez, una memoria que recordara conversaciones viejas por su contenido y no solo las últimas, y reemplazar Piper por algo que sonara más natural en español. Hoy quiero contarles qué cambió, cómo lo implementé y qué aprendí en el proceso.
De Piper a Kokoro
El cambio más visible para el usuario final fue el motor de TTS. Piper funcionaba bien para inglés, pero en español se sentía robótico, con una entonación plana que hacía que las respuestas sonaran artificiales.
Investigando en Internet, encontré Kokoro, un modelo de TTS que prometía voces mucho más naturales y con soporte multilenguaje. La API era sorprendentemente simple:
from kokoro import KPipeline
pipeline = KPipeline(lang_code="e") # e = español
for result in pipeline("Hola, ¿en qué puedo ayudarte?", voice="ef_dora"):
sd.play(result.audio, 24000)
sd.wait()El reemplazo fue casi quirúrgico: Kokoro expone un KPipeline que itera sobre fragmentos de audio, y Piper hacía exactamente lo mismo con su interfaz voice.synthesize(). El cambio en audio.py se limitó a reemplazar una función por otra, manteniendo toda la lógica de detección de idioma, cancelación y notificaciones.
El salto de calidad en español es notable. La voz ef_dora suena natural, con entonación y pausas en los lugares correctos. Para inglés usamos af_heart, que también está muy por encima de lo que Piper ofrecía y lo mejor permite mezclar frases en español con palabras en inglés.
def _get_pipeline(lang: str):
if lang not in _pipelines:
from kokoro import KPipeline
_pipelines[lang] = KPipeline(lang_code=lang)
return _pipelines[lang]Las pipelines se precargan al iniciar el servicio, así que la primera respuesta no paga el costo de carga del modelo. (Lo que se traduce en una mejor experiencia con menos tiempo de espera para el usuario)
Conversaciones continuas (multi-turn follow-up)
Este era quizá el feature que más quería. En el post original me quejaba de tener que presionar el atajo de teclado para cada interacción. La solución fue un ciclo de escucha que se activa automáticamente cuando el asistente hace una pregunta.
El detector es simple: si la respuesta del LLM contiene un ?, entramos en modo AWAITING y escuchamos por un breve período.
def _has_question(text: str) -> bool:
return "?" in textUna vez en modo seguimiento, el daemon ejecuta _quick_listen() con tiempos más cortos que la grabación normal: 1.5 segundos de silencio para cortar (vs 2s en modo normal) y un timeout de 3 segundos.
La parte interesante fue manejar los diferentes casos sin desperdiciar tokens del LLM:
- Negativo ("no", "gracias", "eso es todo") → vuelve a IDLE inmediatamente. Sin llamada al LLM.
- Afirmativo silencioso ("sí", "dale", "ok") → reproduce "¿Alguna otra pregunta?" vía TTS local. Sin llamada al LLM.
- Timeout → vuelve a IDLE. Sin llamada al LLM.
- Pregunta real → se envía al LLM dentro de la misma sesión de OpenCode.
lower = fu_text.lower()
if lower in FOLLOWUP_NEGATIVE:
break
if lower in FOLLOWUP_SILENT_AFFIRMATIVE:
speak("¿Alguna otra pregunta?")
continueEl loop permite hasta 3 reintentos por ruido y 3 afirmativos consecutivos antes de salir. Y en cualquier momento se puede cancelar con el mismo atajo de teclado, lo que detiene la reproducción y sale del loop.
MemPalace v3: memoria por significado
En el post inicial contaba que había reemplazado ChromaDB por un buffer circular JSON porque las dependencias eran demasiado pesadas. Pero la memoria sin búsqueda semántica me seguía molestando: podía recuperar los últimos turnos, pero no encontrar una conversación de hace días por su contenido.
La solución llegó con MemPalace v3, que usa ChromaDB pero con una huella mucho más ligera. El modelo de embeddings all-MiniLM-L6-v2 es muy eficiente con procesamiento en CPU y es sorprendentemente rápido: lo probé en mis equipos con Intel Core i5 gen 10 y Ryzen 7 5700X y funciona perfecto, una búsqueda semántica toma menos de 100ms.
memory = MemPalace(
wing="voxlyn",
embedding_model="all-MiniLM-L6-v2",
storage="chroma",
)
memory.save_turn(user="¿cómo configuro el firewall?", assistant="usando nftables...")
results = memory.search("configuración de red") # encuentra la conversación anteriorCada turno se guarda con redacción automática de secretos — API keys, tokens y contraseñas se filtran antes de almacenarse.
Lo mejor es que la integración no requirió cambios en el pipeline principal: get_response() llama a memory.search() antes de enviar el mensaje al LLM, inyecta los resultados como contexto, y guarda el turno actual al final. Todo transparente.
def save_turn(user_text: str, assistant_text: str) -> None:
data = _load()
data.append({
"id": f"turn:{int(time.time())}",
"user": _redact_secrets(user_text),
"assistant": _redact_secrets(assistant_text),
"timestamp": time.time(),
})
_dump(data)El servidor remoto de OpenCode
Originalmente Voxlyn ejecutaba opencode serve localmente en el mismo equipo. Pero mi Raspberry Pi 4 en casa estaba disponible, así que moví el agente a la red local. Ahora el LLM corre en una Raspberry Pi 4 en mi red local, que también ejecuta otros servicios.
def _is_opencode_remote() -> bool:
host = urlparse(OPENCODE_URL).hostname or "localhost"
return host not in ("localhost", "127.0.0.1", "::1")Esto trajo un par de ventajas interesantes:
- El LLM no consume recursos de mi PC de escritorio o laptop, y puedo compartir sesiones entre mis equipos.
- La Raspberry Pi está siempre encendida, la sesión de OpenCode persiste entre usos.
- Puedo tener skills específicas del servidor que no tienen sentido en local (diagnóstico remoto, mantenimiento, seguridad del servidor).
El sistema de skills se adapta automáticamente según dónde corre el agente. Si el servidor es remoto, se incluyen remote-diagnostics, remote-security y remote-maintenance. Si es local, se activan system-control y system-info. El prompt del sistema se construye dinámicamente:
if is_remote:
skills.extend([
"- remote-diagnostics: CPU temp, throttling, services, zram.",
"- remote-security: fail2ban, SSH auth, ports, firewall.",
"- remote-maintenance: updates, journal, SD health.",
])Hay un sistema de ruteo que decide si una consulta va al servidor remoto o se procesa localmente. Palabras como "apagar", "reiniciar" o "diagnóstico" tienen destinos fijos, y el resto va al LLM remoto.
Otras mejoras
Cancelación en cualquier punto
La pulsación del atajo de teclado mientras el asistente está hablando detiene la reproducción al instante y vuelve a IDLE. Esto fue más complejo de lo que parece: hay que coordinarlo entre el hilo de reproducción, la captura de audio y el socket. La solución fue usar threading.Event compartidos:
_cancel_playback: threading.Event = threading.Event()
_capture_stop: threading.Event = threading.Event()Cuando el trigger envía "record" mientras _busy=True, se setean ambos eventos, el hilo de reproducción detecta el cambio en su loop y detiene sd.play(), el pipeline se aborta, y el sistema queda listo para una nueva grabación.
Esfuerzo de razonamiento variable
El LLM responde más lento cuando la consulta es compleja. Implementé un sistema que detecta palabras clave como "analiza", "explica detalladamente", "código" o "arquitectura" y ajusta el nivel de razonamiento automáticamente:
AUTO_VARIANT_KEYWORDS = (
"analiza", "explica detalladamente",
"código", "implementa", "arquitectura",
)Para preguntas simples, variant="low" da respuestas casi instantáneas. Para código o análisis, sube a high y el LLM tarda unos segundos más, pero entrega respuestas mucho más elaboradas.
Lo que salió bien
- Kokoro fue un reemplazo impecable — la migración tomó una tarde y el resultado en calidad de voz es dramáticamente mejor.
- El follow-up loop funcionó desde el primer prototipo — los casos borde (negativos, afirmativos, timeout) se cubrieron con tests unitarios y el comportamiento en producción fue exactamente el esperado.
- MemPalace v3 resolvió el problema de memoria sin añadir complejidad — la API es tan simple como la del buffer JSON anterior.
- El sistema de interrupción por tecla es sólido — nunca falla, incluso cuando el asistente está en medio de una respuesta larga.
Lo que cambiaría
- El seguimiento post-respuesta debería ser más inteligente — actualmente el loop se activa con cualquier
?en la respuesta, pero hay casos donde el LLM hace una pregunta retórica y el asistente se queda esperando una respuesta que no llegará. (No es tan traumático, ya que si no recibe respuesta en 3 segundos el flujo pasa a espera nuevamente, sin embargo hay oportunidad de mejora) - Implementar modelos locales que realmente sean funcionales — He probado un par de modelos locales usando LM Studio y Llama.cpp pero no he logrado encontrar uno que se comporte bien en mis equipos.
El código sigue en AudelDiaz/voxlyn-ai, ahora con más tests (59 y sumando), documentación actualizada y un AGENTS.md que cuenta toda la arquitectura.
Si quieren leer el post original donde empezó todo, acá está. Y si construyen algo similar o tienen ideas, los leo en los comentarios o en las issues del repo.