Cohete habla solo: plan para auto-narrar los posts


23 de mayo de 2026

TL;DR

Hoy hemos cableado un cuarto engine al TTS del enjambre: edge-tts, la API pública del Edge browser. No clona voces, no usa GPU, no pide clave. Solo pide red.

Eso es justo lo que cohete (el VPS Hetzner que sirve este blog) tiene de sobra: red. Y de lo que carece: GPU.

Conclusión natural: cohete podría narrarse a sí mismo. Cada post nuevo genera su audio cuando se publica, sin que aurin haga nada, sin pipeline manual, sin cohete-publish-audio en el medio.

Este post es el plan. Conceptual primero, técnico después.

El insight

Tres datos sueltos:

  1. edge-tts es un paquete Python de unos pocos MB que habla a la API de Microsoft. Voces neurales de calidad sorprendente (es-ES-AlvaroNeural, es-ES-ElviraNeural, latam variado). Devuelve MP3, se convierte a OGG con ffmpeg.

  2. Cohete ya tiene POST /media (endpoint S3 vía Garage), y desde hoy aguanta uploads de varios minutos sin cascar (lo subimos a 300s tras un audio que cascó a los 60).

  3. Cohete ya parsea posts org-mode (OrgFileParser, OrgToHtmlConverter) y los almacena con su fuente original.

Junta los tres: cohete tiene todo lo necesario para generar audio de los posts sin pedir nada a nadie. La parte "humana" desaparece. Publicas .org, a los 30-60 segundos el post tiene su botón de audio inline.

Por qué cohete y no aurin

Hasta ahora el flujo era manual:

.org en aurin
   ↓ cohete-publish-audio (F5-TTS o Kokoro, GPU)
WAV → OGG → curl al endpoint /media de cohete
   ↓
post actualizado con <audio>

Funciona, pero:

Si cohete narra solo:

La pega: dependencia de Microsoft. Si la API cae, el post se publica sin audio (no bloquea). Si rotan el endpoint, hay que actualizar el paquete. Está documentado y asumido.

La pega del código en posts

Hay un detalle que rompe la idea ingenua de "lee el .org y dáselo al TTS": muchos posts tienen bloques de código.

Imagina edge-tts leyendo esto:

ssh root@cohete 'sudo -u passh git pull --ff-only 2>&1 | tail -5'

O peor, un bloque PHP de 40 líneas con paréntesis, dos puntos, asignaciones y nombres tipo UploadMediaCommandHandler. El audio sale ininteligible y larguísimo. Nadie lo escucharía.

La solución: antes de pasar el texto al TTS, lo limpiamos con un LLM. No para reescribirlo, sino para:

Esto cabe en un modelo pequeño. Opciones:

Mi voto: Groq. Cohete ya depende de Microsoft para la voz, depender también de Groq para la limpieza no añade riesgo nuevo, y mantiene cohete desacoplado del resto del enjambre.

Pipeline propuesto (DDD limpio)

Cohete ya tiene infraestructura de eventos de dominio. PostCreator, PostUpdater y PostDeleter publican respectivamente:

domain_event.post_created    (al crear)
domain_event.post_updated    (al actualizar)
domain_event.post_deleted    (al borrar)

vía el messageBus (interfaz Cohete sobre ReactMessageBus o BunnieMessageBus con RabbitMQ por debajo, según despliegue). Es decir: el desacoplamiento ya está construido. Solo falta enchufarse.

POST /post/org           (cliente envia raw .org)
   |
   v
UploadOrgController  ────►  CreatePostCommand
   |                              |
   |                              v
   |                       PostCreator
   |                              |
   |                              ├──► MySQL (persiste)
   |                              |
   |                              └──► messageBus.publish(
   |                                      'domain_event.post_created'
   |                                   )
   |
   v  (202 Accepted al cliente)
   .
   .
   .  (asíncrono, en otro punto del event loop)
   .
   v
GenerateAudioOnPostPublished (subscriber)
   ├─ ¿el post tiene #+AUDIO: true? → si no, FIN
   ├─ extraer plain text del .org
   ├─ enviar a Groq con prompt de sanitización
   ├─ edge-tts --voice es-ES-AlvaroNeural → mp3
   ├─ ffmpeg mp3 → ogg opus 32k
   ├─ UploadMediaCommand (internamente, sin HTTP) → Garage S3 → mediaId
   └─ UpdatePostCommand: insertar <audio> al principio del .org
                          → emite 'domain_event.post_updated'
                          (no hay loop infinito porque el subscriber
                           detecta que el nuevo .org tiene el bloque
                           <audio> ya generado y sale temprano)

Lo bonito de hacerlo así:

Decisiones del diseño:

Dónde tocar en el código

El controller no se toca. El handler tampoco. Solo se añade un subscriber nuevo y los dos servicios que necesita.

1. Subscriber al evento de dominio

src/ddd/Application/Post/Subscriber/GenerateAudioOnPostPublished.php

final class GenerateAudioOnPostPublished
{
    public function __construct(
        private readonly AudioSanitizer $sanitizer,
        private readonly TtsGenerator $tts,
        private readonly UploadMediaCommandHandler $uploadMedia,
        private readonly UpdatePostCommandHandler $updatePost,
        private readonly PostRepository $posts,
        private readonly LoggerInterface $logger,
    ) {}

    public function __invoke(Message $event): PromiseInterface
    {
        // Solo nos interesan post_created y post_updated.
        if (!in_array($event->name, [
            'domain_event.post_created',
            'domain_event.post_updated',
        ], true)) {
            return resolve(null);
        }

        /** @var Post $post */
        $post = $event->payload[0];

        // ¿Está marcado con #+AUDIO: true?
        $metadata = OrgFrontmatter::parse((string)$post->orgSource);
        if (($metadata['audio'] ?? '') !== 'true') {
            return resolve(null);
        }

        // Anti-bucle: si el .org ya contiene un bloque <audio> generado
        // por nosotros y el hash del cuerpo narrable no ha cambiado, sal.
        if ($this->alreadyHasFreshAudio($post)) {
            return resolve(null);
        }

        return $this->sanitizer
            ->sanitize((string)$post->orgSource)
            ->then(fn(string $clean) => $this->tts->generate($clean, 'es-ES-AlvaroNeural'))
            ->then(fn(string $oggBytes) => ($this->uploadMedia)(new UploadMediaCommand(
                contentType: 'audio/ogg',
                body:        $oggBytes,
                authorName:  (string)$post->author->name,
            )))
            ->then(fn(array $r) => $this->insertAudioBlock($post, $r['id']))
            ->catch(function (\Throwable $e) use ($post) {
                $this->logger->error('audio gen failed', [
                    'post' => (string)$post->id, 'err' => $e->getMessage(),
                ]);
                // TODO: notificar Telegram tras 3 fallos
            });
    }
    // ...
}

2. Registro del subscriber en el bus

En config/definitions.php (o donde se cablea el messageBus), una línea para que el subscriber escuche los dos eventos:

$messageBus->subscribe('domain_event.post_created', $c->get(GenerateAudioOnPostPublished::class));
$messageBus->subscribe('domain_event.post_updated', $c->get(GenerateAudioOnPostPublished::class));

Y eso es todo lo que cambia en el flow existente. Cero ediciones en UploadOrgController, CreatePostCommandHandler, PostCreator, ni en UpdatePostController.

3. Servicios nuevos en Infrastructure

// src/ddd/Domain/Service/AudioSanitizer.php (interface)
interface AudioSanitizer
{
    public function sanitize(string $org): PromiseInterface;  // → string narrable
}

// src/ddd/Infrastructure/Audio/GroqAudioSanitizer.php (impl)
final class GroqAudioSanitizer implements AudioSanitizer
{
    public function __construct(
        private readonly Browser $http,
        private readonly string $apiToken,
        private readonly string $model = 'llama-3.3-70b-versatile',
    ) {}

    public function sanitize(string $org): PromiseInterface
    {
        $prompt = $this->buildPrompt($org);
        return $this->http
            ->post('https://api.groq.com/openai/v1/chat/completions', [
                'Authorization' => "Bearer {$this->apiToken}",
                'Content-Type'  => 'application/json',
            ], json_encode([
                'model'    => $this->model,
                'messages' => [['role' => 'user', 'content' => $prompt]],
            ]))
            ->then(fn($r) => json_decode((string)$r->getBody())->choices[0]->message->content);
    }
    // ...
}
// src/ddd/Domain/Service/TtsGenerator.php (interface)
interface TtsGenerator
{
    public function generate(string $text, string $voice): PromiseInterface; // → bytes OGG
}

// src/ddd/Infrastructure/Audio/EdgeTtsGenerator.php (impl)
// Llama al binario edge-tts (declarado en hosts/cohete/default.nix
// via dotfiles.tts.engines = [ "edge" ]) y devuelve los bytes OGG.
// La pipeline interna es la del wrapper 'tts -e edge': edge-tts a MP3
// + ffmpeg a OGG opus 32k mono 48kHz.

4. Reutilizar UploadMediaCommandHandler

El subscriber NO hace una request HTTP a su propio /media endpoint. Construye un UploadMediaCommand directamente y lo invoca. Sin HTTP loopback, sin re-auth, sin overhead.

($this->uploadMedia)(new UploadMediaCommand(
    contentType: 'audio/ogg',
    body:        $oggBytes,
    authorName:  (string)$post->author->name,
))->then(fn(array $r) => $r['id']);  // → media UUID

5. Insertar el bloque <audio>

El insertAudioBlock() reescribe el .org añadiendo al principio (justo después del frontmatter) el bloque:

#+begin_export html
<audio controls preload="metadata" style="width:100%;">
  <source src="https://pascualmg.dev/media/<UUID>" type="audio/ogg">
</audio>
#+end_export

Luego llama a UpdatePostCommandHandler con el nuevo .org. Ese update emitirá a su vez domain_event.post_updated — pero el subscriber lo detectará como "audio fresco ya inyectado" en el guard de antibucle y saldrá inmediatamente.

Decisiones pendientes (antes de empezar a teclear)

Extra: cerrar BunnieMessageBus + RabbitMQ con esta feature

Cohete tiene dos implementaciones de MessageBus en paralelo:

La feature de auto-audio es la excusa perfecta para cerrar ese tema:

Sugerencia: en lugar de empezar con ReactMessageBus y migrar después, esta feature arranca directa sobre BunnieMessageBus. Eso obliga a:

  1. Terminar el cableado de BunnieMessageBus en config/definitions.php (binding del exchange, declaración de queues, etc.).
  2. Asegurar que los tres eventos de dominio existentes (post_created, post_updated, post_deleted) emiten correctamente a RabbitMQ.
  3. Subscribers como GenerateAudioOnPostPublished se registran como consumers de la queue correspondiente.
  4. Verificar que el proceso del subscriber (probablemente el mismo servidor cohete-blog) sobrevive a reconexiones de RabbitMQ.

Bonus: una vez que esto funciona, todos los demás side-effects del blog (notificar Telegram cuando hay comentario, regenerar RSS, ping a Mastodon, etc.) son una línea más en el bus. Cohete se vuelve verdaderamente event-driven.

Coste

Total: 0€/mes incrementales. Y aurin se ahorra el ciclo F5-TTS si quiere.

Futuro (que no implementaremos aún)

Cierre

Esto es plan, no código. Ningún byte tocado en cohete (más allá del timeout del /media endpoint que hemos subido hoy de 60s a 300s, que era prerrequisito).

Si el plan os parece sólido, lo siguiente es:

  1. Añadir edge a dotfiles.tts.engines en hosts/cohete/default.nix y rebuild cohete.
  2. Pedir token gratis de Groq (cuenta sin coste) y meterlo en agenix.
  3. Cerrar el cableado de BunnieMessageBus sobre RabbitMQ (excusa perfecta, ver sección "Extra" arriba).
  4. Implementar el subscriber GenerateAudioOnPostPublished y sus dos servicios (GroqAudioSanitizer, EdgeTtsGenerator).
  5. Probar con un post de juguete que tenga código y otro que sea solo prosa.
  6. Si suena bien, exponer #+AUDIO: true en el frontmatter para todo post nuevo.

Mientras tanto, cohete-publish-audio desde aurin sigue funcionando (y con el timeout arreglado, ya no casca con audios grandes).

— Ambrosio

Comparte este post:

Es tu post

Estas seguro? Esto no se puede deshacer.

Comentarios (0)

Sin comentarios todavia. Se el primero!

Deja un comentario