pablo formoso FUTURE / DATA & AI
ES EN Transmitiendo –:–:– UTC

ApolloAgents: cómo se construye un DJ artificial agente a agente

Empecé queriendo una sesión de lofi para estudiar y acabé con seis agentes mitológicos discutiendo entre sí cómo encadenar tracks. Este es el recorrido de ApolloAgents desde el primer script de febrero hasta la versión 3.1 que pincha en directo dentro del navegador.

Logo de ApolloAgents

El picor que lo arrancó todo

A finales de febrero de 2026 hice algo trivial: un script que cogía cuatro WAVs de lofi, los empalmaba con un crossfade de pydub y los exportaba a MP4 con una onda de fondo. El primer commit del repositorio se llama, literalmente, yup. No tenía agentes, no tenía catálogo, no tenía nada. Era una sesión de estudio para mí.

Pero el experimento dejó dos cosas claras y muy desagradables.

La primera: encadenar dos canciones bien no es una macro. Si pegas dos pistas en compases distintos, con tonalidades incompatibles o con BPM dispares, no obtienes "una mezcla". Obtienes un choque. Un crossfade lineal aplicado a dos señales a 0 dBFS suma +6 dBFS — fuera de rango — y produce distorsión digital. Sin un pre-mix gain de −3 dB y monitoreo de pico, el archivo final clipa.

La segunda: las decisiones musicales no caben en un prompt único. "Hazme una sesión de techno oscuro" parece una instrucción atómica, pero por dentro son cinco preguntas distintas: ¿qué tracks del catálogo encajan?, ¿en qué orden harmónico?, ¿hay un arco de energía o todo plano?, ¿qué transiciones van a sonar mal?, ¿está limpia la mezcla final? Un LLM general responde a las cinco a la vez y a ninguna en serio. Suena plausible y musicalmente es plano.

De ese picor — quiero mezclas que no choquen, y no me las hace un solo agente — sale todo lo demás.

v0.x: el primer pipeline (marzo)

El 15 y 16 de marzo aparecen dos commits que cambian la forma del proyecto: Initial public release — deep session generator y Add smart session generation with unified track catalog. La idea era simple pero ya no trivial: separar los WAVs por carpeta de género, calcular BPM con librosa y tonalidad en notación Camelot una sola vez por archivo, y guardarlo todo en un tracks.json que sirviera de fuente única de verdad.

A partir de ese catálogo, una función de selección clusteriza por tempo y hace un random walk harmónico sobre la rueda Camelot. Lo elegante de la rueda Camelot — desarrollada por Mark Davis en 2004 — es que reduce toda la teoría de armonía a un grafo de vecinos:

def _camelot_neighbors(key: str) -> set[str]:
    num = int(key[:-1])
    letter = key[-1].upper()
    opposite = "B" if letter == "A" else "A"
    return {
        key,
        f"{(num % 12) + 1}{letter}",       # +1 horario
        f"{((num - 2) % 12) + 1}{letter}", # -1 antihorario
        f"{num}{opposite}",                 # llave paralela
    }

Dos tracks son compatibles si están al mismo número, a una posición horaria o en su paralela mayor/menor. Eso es todo. El ordenador hace en milisegundos lo que un DJ humano resuelve por intuición acumulada.

Con eso ya tenía sesiones que sonaban "bien" — pero la decisión de qué tracks proponer, en qué orden y para qué mood seguía siendo monolítica. La cosa pedía romperse.

v1.0: el panteón empieza a hablar (abril)

El 7 de abril nace ApolloAgents propiamente dicho. Con el lanzamiento llegan cuatro decisiones que aún sostienen todo el proyecto.

Decisión 1 — agentes con rol acotado, no un super-LLM. La sesión se descompone en un pipeline de 8 fases, cada una a cargo de un agente con un system prompt específico, una lista cerrada de tools y un formato de salida estructurado. Cada uno lleva nombre mitológico. No por capricho estético — porque obliga a pensar en su rol antes que en su implementación:

Agente Nombre Función
Genre Guard Janus Confirma género, duración y mood antes de planificar nada
Catalog Manager Hermes Sincroniza WAVs, detecta BPM y tonalidad
Planner Muse Propone el playlist y diseña el arco de energía
Critic Momus Revisión fría: PROBLEMS / VERDICT
Editor (REPL) Permuta, mueve, inserta bridges, lanza el build
Validator Themis Analiza la calidad del audio renderizado
Orchestrator Apollo Conduce la secuencia y guarda la memoria

Apollo no es un nombre decorativo: es el director del coro. Como en el panteón griego, cada deidad tiene un trozo del mundo. Janus mira en dos direcciones — la del usuario y la del catálogo — antes de dejar pasar nada.

Infografía vertical resumiendo la arquitectura multi-agente de ApolloAgents: seis agentes con rol acotado (Janus, Hermes, Muse, Momus, Themis, Editor), la rueda Camelot, el problema del clipping en crossfades y la memoria persistente.
La arquitectura de ApolloAgents resumida: seis agentes con rol acotado, la rueda Camelot como grafo de compatibilidad armónica, la mitigación del clipping en crossfades y la memoria persistente que aprende de tu feedback.

Decisión 2 — protocolos de texto estructurado, no JSON. Pedirle a un LLM que produzca JSON entre agentes es frágil: el modelo se inventa comas, mete prosa antes del bloque, escapa mal las comillas. ApolloAgents usa bloques de texto con palabras clave centinela. Janus contesta así:

CONFIRMED
genre: techno
duration_min: 60
mood: dark industrial build to a hard peak

Y Momus así:

PROBLEMS:
- [pos 2→3] key clash 5A → 11A — fix: swap pos 3 for a 6A track
- [pos 7→8] BPM jump 132 → 148 — fix: insert bridge track

VERDICT: NEEDS_FIXES

El parser es una iteración línea a línea de unas pocas decenas de líneas de Python. Si el modelo añade prosa, no rompe. Si se sale de formato, hay fallbacks. Es feo en lo teórico y robustísimo en la práctica.

Decisión 3 — dos checkpoints humanos dentro del pipeline, no al final. La automatización completa era tentadora pero equivocada. El checkpoint 1 va después del Planner y antes del Critic. El checkpoint 2, después del Critic. ¿Por qué dos y no uno? Porque las preguntas son distintas: en el primero estás dando forma al arco de energía; en el segundo, decidiendo qué problemas concretos te merece la pena arreglar y cuáles asumir. Mezclar las dos conversaciones añade carga cognitiva y empeora ambas decisiones. Los checkpoints son hard gates: ningún agente aplica un fix sin tu visto bueno explícito.

Decisión 4 — un solo main.py de ~2.600 líneas. Esto sigue siendo polémico y lo sigo defendiendo. Para un proyecto de este alcance, un único fichero inspeccionable con secciones bien marcadas es más mantenible que una jerarquía de módulos por la que hay que saltar para entender un cambio de tres líneas. La capa de agentes (agent/) sí va separada — porque su ciclo de iteración (prompts, signaturas de tools, esquema de memoria) es de naturaleza diferente al del pipeline DSP.

v1.1–v1.3: pulir el sonido, no las capas (abril)

A finales de abril el sistema funcionaba pero seguía haciendo dos cosas mal. Cada cosa generó un mini-ciclo de mejoras.

Las duraciones eran estimaciones. El Planner calculaba la longitud de la sesión asumiendo 5 minutos por track. Para una sesión de 60 min pedía 12 tracks y la realidad podía salir 50 o 75. Solución en v1.1: leer duration_sec del header WAV una vez al construir el catálogo, almacenarlo en tracks.json y usarlo para todos los cálculos posteriores. Coste: cero decode. Beneficio: la duración prometida y la entregada se acercan.

La detección de BPM mentía en lofi. Librosa tendía a detectar todo el lofi a 110 BPM por culpa del clásico problema del octavado (tomar el off-beat por el beat y duplicar el tempo). La v1.1.1 corrige detect_bpm() para sesgar start_bpm al punto medio del género y probar bpm/2 y bpm*2 antes de hacer el clamp. Resultado: el lofi ahora se detecta a 70–85 BPM, que es lo que tiene que ser.

Los crossfades extremos sonaban a moledora. Pyrubberband empieza a producir artefactos audibles a partir de ratios de 1.5×. Antes, si el Planner ponía un track de 90 BPM seguido de uno de 140, el sistema lo intentaba estirar y el resultado parecía un cassette estropeado. La v1.3 introdujo tres mecanismos:

  • _STRETCH_MAX = 1.5 — bound duro de seguridad. Cualquier transición que requiera más se marca como problema obligatorio.
  • suggest_bridge_track(from_pos, to_pos) — busca en el catálogo un track con BPM intermedio, lo puntúa por min(ratio_a, 1/ratio_a) × min(ratio_b, 1/ratio_b) y devuelve los 3 mejores candidatos.
  • insert_bridge_track(after_position, track_id) — inserta el bridge elegido, partiendo una transición imposible en dos transiciones individualmente seguras.

Y un cuarto detalle que cambia la sensación más de lo que parece: EQ matching en el crossfade. Cuando la distancia harmónica entre dos tracks es > 2 pasos Camelot, se aplica un high-shelf cut de −3 dB a 8 kHz en el saliente y un low-shelf cut de −3 dB a 250 Hz en el entrante, solo durante el solape. Eso reduce el enmascaramiento frecuencial sin tocar el audio fuera de la transición. Es el tipo de truco que un ingeniero de mezcla aplica de oído y que, formulado, es media docena de líneas.

v1.4–v1.5: del batch al directo (abril)

A mitad de abril el agente sabía construir sesiones pero no sabía escucharlas. Se podía rendear un MP4 de 60 minutos sin tener forma de previsualizar una transición antes de comprometer al render completo. v1.4 mete tres tools que cambian el ritmo del workflow: play_mix, preview_transition y play_track. De repente puedes auditar dos tracks solapados ±15s antes de decidir si están bien encadenados, sin esperar 40 minutos de render.

Pero la conclusión real de ese movimiento llegó dos días después, en v1.5: si puedes reproducir, puedes pinchar en vivo.

LiveDJ es un agente proactivo con su propio motor de audio. Mientras suena una pista, otro hilo está estirando temporalmente la siguiente con pyrubberband en background, de modo que cuando llega el momento del crossfade la siguiente pista ya está en memoria al BPM correcto. El motor corre cuatro hilos:

Hilo Cadencia Responsabilidad
Callback de sounddevice Por bloque (2048 samples) Salida de audio en baja latencia; mezcla del crossfade
Watchdog 50 ms Detecta el cruce del umbral y emite eventos
Pre-stretch daemon Continuo Estira la siguiente pista con pyrubberband
Event loop principal 100 ms Drena eventos y los pasa al agente LiveDJ

El watchdog corre al doble de frecuencia que el event loop del agente. Eso garantiza que ningún evento crítico (cruce de umbral, fin de pista) se pierda entre dos polls del LLM. Y el agente tiene un presupuesto duro de 5 turnos por batch de eventos — si lo agota sin llamar a una tool terminal, el motor cae al comportamiento automático. Sin ese tope, una situación ambigua podría arrastrar al agente a una cadena de razonamientos mientras la música sigue sonando.

La regla de decisión que aplica LiveDJ cuando se acerca un crossfade cabe en tres líneas:

Calidad de la transición Acción
Camelot ≤1 paso y ΔBPM ≤ 8 Silencio — déjalo pasar
Camelot 2 pasos o ΔBPM 8–20 extend_track(20) — gana 20s para reevaluar
Camelot > 2 pasos o ΔBPM > 20 crossfade_now() o queue_swap() por algo mejor

Y mientras suena, tú puedes escribir cosas como next, stay 60, more energetic o wind down. Algunas (las literales) se ejecutan sin mediación del LLM, por latencia. Otras (las naturales) pasan por el agente para que traduzca a llamadas de tool.

v2.0–v2.6: abrir la cabina (abril–mayo)

Hasta v1.5 todo vivía en la terminal. v2.0 fue el primer salto fuerte: convertir cada fase del pipeline en endpoints de FastAPI, abrir un canal WebSocket para el streaming del agente, y montar un cliente Next.js + React 19 con vista de playlist arrastrable y sidebar de Critic. El print() muere; sale el evento tipado.

De v2.1 a v2.5 vinieron capas finas pero cargadas de detalles:

  • v2.1 — visuales reactivos al beat en el navegador, con eventos del LiveEngine puenteados al frontend como JSON tipado.
  • v2.2playlists nombrados con CRUD + reorder por drag-and-drop. Estandarización de puertos de desarrollo: 4010 frontend, 4020 backend.
  • v2.3user_id propagado por todo el thread, ratings por track y favorites, bias del Planner según las puntuaciones del propio usuario. Los agentes empiezan a tener oído por persona.
  • v2.5 — el LiveEngine cruza al navegador: tres modos (Audience, Booth, Immersive), capa visual sincronizada al beat, y modo improvisación con micro + peticiones del público.

Y entonces, el 11 de mayo, v2.6.0 Ember. La escalera de 9 fases del frontend anterior se colapsa en cinco rutas planas con un vocabulario visual común — italic-serif, acento ember-rojo, una sola línea de mando:

/dashboard   → la sesión de esta noche + el último póster impreso
/brief       → una frase entra, un brief estructurado sale
/curate      → arco, playlist, notas del crítico (apply / ignore en línea)
/editor      → reordenar, swap, insertar bridge tracks
/render      → backend ffmpeg → MP4 1080p con progreso por SSE
/live        → reproducción real, modos Audience / Booth / Immersive

El brief lo parsea Claude Haiku en menos de 300 ms a {genre, duration, mood, venue, energy, tempo}. Si algo es ambiguo, Apollo pregunta en la misma pantalla y retoma cuando confirmas. Los checkpoints siguen existiendo, pero dejan de ser muros que cruzar fase a fase: ahora son anotaciones en el margen que aplicas con un click o ignoras.

v2.7–v3.1: el último kilómetro (mayo)

Las últimas semanas el proyecto se ha movido en el espacio entre "esto funciona en mi máquina" y "esto funciona en directo delante de gente".

  • v2.7 — ingesta de YouTube Live Chat como peticiones del público en directo. La audiencia escribe en YouTube, el motor lo lee y el agente decide si meterlo en la siguiente decisión.
  • v2.7.2 — feed de OBS, waveform peaks en el navegador, polling de YouTube más amable con la API.
  • v2.7.3 / v2.7.4 — reconexión robusta de WebSocket en vivo, disciplina del agente, observabilidad.
  • v3.0precision beat matching offline y live con paridad. Las transiciones se enganchan a downbeats reales (detectados con madmom) en lugar de aproximaciones.
  • v3.0.1 — un critic_warning cuando el phase-lock no encuentra el downbeat y cae al linear fade. Pequeño cambio, gran efecto en confianza: ahora sabes cuándo el sistema está improvisando.
  • v3.1live beat matching con paridad en el navegador via playbackRate. El tempo en el HTML5 audio del frontend coincide con el del motor offline. Lo que sale por el OBS suena igual que lo que sale por el render.

Y un último detalle de la semana pasada que cambia la vida para quien viene nuevo al proyecto: un stack de Docker Compose con hot reload tanto para backend como para frontend. docker compose up --build y tienes todo corriendo, sin pelearte con uv ni con npm.

Lo que aprendí construyendo esto

Hay tres cosas que me llevo del recorrido, y que mantengo cuando empiezo proyectos nuevos.

Los roles acotados ganan a los súper-agentes. Es tentador escribir un system prompt gigante y dejar que un único modelo "lo haga todo". La experiencia con Apollo dice lo contrario: cuanto más estrecho es el rol — Janus solo valida, Momus solo critica, Themis solo analiza — más predecible y depurable es el resultado. La modularidad no es elegancia: es la única forma de saber en qué fase se rompió algo.

El texto estructurado le come la tostada al JSON entre agentes. Pedirle a un LLM bloques con palabras centinela (CONFIRMED, PROBLEMS, VERDICT, Status:) y parsearlos línea a línea suena primitivo. Pero sobrevive a la prosa de más, a las comillas mal escapadas, a los modelos que cambian de proveedor. JSON parecía la respuesta correcta y, en producción, no lo era.

Los humanos en checkpoints concretos, no como aprobación final. El valor del crítico no es enforzar el fix. Es señalar el problema. La decisión sobre qué arreglar — y qué asumir — es del usuario, y tiene que vivir dentro del pipeline, no después. Cuando moví los checkpoints de "al final, una vez" a "después del Planner y después del Critic", la calidad de las sesiones subió de golpe.


ApolloAgents es open source bajo MIT, está en github.com/pabloformoso/apollo-agents, y todo lo que ves en este canal de YouTube ha sido generado por el sistema en alguna de sus versiones. Si lo pruebas — una star en el repo y, sobre todo, lo que encuentres roto en los issues, me harían el día.

Pablo Formoso
autor

Pablo Formoso

Notas de campo desde la intersección de datos, IA, y filosofía aplicada.

entradas
11
desde
2024

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *