Files
feadulta/docs/guia-tts-audio-autoservicio-inma-mixbot.md
T
rafa 5714d60c10 docs: guía de autoservicio TTS para Inma/Mixbot (#222)
Documenta el proceso exacto de preparación de texto y pausas para MiniMax
que hasta ahora solo vivía en scripts/minimax_tts.py, para que Inma/Mixbot
puedan generar audio sin pasar por Hermes/PC de Rafa. Señala el hueco de
REST (fea/v1/subir-audio) que sigue bloqueando el paso de publicación.
2026-08-31 07:06:52 -04:00

12 KiB

Guía de generación de audio (TTS) en autoservicio — para Inma / Mixbot

Para quién es este documento: para Inma y Mixbot, a partir del traspaso pedido por Rafa (issue #222): dejar de pedirle el audio a Hermes y generarlo y subirlo vosotros mismos, con vuestro propio token de MiniMax.

Estado a 2026-08-31: el proceso de generación (este documento) está completo y probado. Subir el resultado al artículo sigue bloqueado — falta un endpoint REST (fea/v1/subir-audio) para enganchar el mp3 al post, tal como pedía el punto 1 del #222. Ese endpoint lo está construyendo Codix (ver aviso al final). Hasta que esté desplegado podéis generar y guardar los mp3, pero no publicarlos en el sitio.

Este documento sustituye, para el caso de uso "Inma/Mixbot generan directamente", a la sección 4 de docs/guia-tts-traduccion-inma.md (que describe el flujo antiguo vía Hermes ejecutando scripts/tts_produce.py en el PC de Rafa). Ese flujo sigue existiendo como alternativa si alguna vez hace falta que Hermes lo dispare, pero deja de ser el camino principal.


1. Por qué este documento existe

La generación de audio con MiniMax no es "coger el texto del artículo y pedirle un mp3". El resultado suena mal (entonación cortada, pausas artificiales o inexistentes, sección de cabecera/firma leída en voz alta) si no se prepara el texto antes. Todo lo de abajo salió de prueba y error real sobre cartas ya publicadas — no es una recomendación genérica de MiniMax, es la receta que ya suena bien en producción. Seguidla tal cual, no la simplifiquéis.

2. La cuenta y las voces

  • Endpoint: https://api.minimax.io/v1/t2a_v2 (modelo de síntesis).

  • Modelo: speech-2.8-hd (calidad) o speech-2.8-turbo (más barato, para pruebas).

  • Voces clonadas por autor (issue #152 — solo estos autores usan su propia voz; el resto cae a la voz por defecto):

    Autor WP user_id voice_id
    Fray Marcos 382 FrayMarcosFeadulta2026
    José Antonio Pagola 383 PagolaFeadulta2026
    José Luis Sicre 774 SicreFeadulta2026
    José Arregi 386 ArregiFeadulta2026
  • Voz por defecto (resto de autores): confirmar con Rafa cuál es la vigente antes de arrancar — hay más de una en la cuenta (NicoFeadulta2026 y una más reciente, nico_feadulta_es_20260802) y usar la que no toca es un error fácil de cometer sin verlo (issue #189, todavía sin cerrar del todo). No deis por hecho ninguna de las dos sin preguntar primero.

  • Un autor nuevo sin voz clonada = voz por defecto. Clonar una voz nueva requiere el procedimiento del #152 (audio limpio 2-5 min, sin música de fondo) — no es autoservicio desde este documento, es un paso aparte.

3. Preparar el texto (la parte que importa)

Partiendo del HTML del artículo en español (el campo content que devuelve GET /wp-json/wp/v2/posts/{id}?context=edit), en este orden exacto:

3.1 Limpiar el HTML a texto plano por párrafos

  1. Sustituir </p>, <br>/<br/> y </h1></h6> por un salto de línea.
  2. Quitar el resto de etiquetas HTML.
  3. Quitar shortcodes [...] (multimedia, cajas, etc. — no se locutan).
  4. Decodificar entidades HTML (&aacute;á, etc.).
  5. Separar en párrafos por línea, colapsar espacios múltiples, descartar líneas de 1 carácter o menos (restos de saltos vacíos).

3.2 Cortar la firma final del autor

Los artículos suelen terminar con una línea corta tipo José Antonio Pagola o Fray Marcos — hay que conservar esa línea y cortar todo lo que venga después (enlaces, notas, anexos), porque eso sí sobra en el audio.

Cómo detectarla (mirando desde el final del artículo hacia atrás, no desde el principio): un párrafo cuenta como firma si tiene entre 2 y 6 palabras, ninguna palabra supera lo razonable para un nombre propio, no contiene dígitos ni :, ;, http, www., @, y cada palabra empieza en mayúscula (se permiten minúsculas para conectores como "de", "del", "la", "las", "los", "y", "e"). Importante: solo cuenta como firma si hay al menos 200 caracteres de contenido real antes de ese párrafo — si no, podéis confundir un encabezado inicial corto (p. ej. "CORPUS (A)", "DOMINGO XI (A)") con la firma y cortar el artículo entero por error. Buscad la firma desde el final, quedaos con el primer párrafo (contando desde atrás) que cumpla la condición, y descartad todo lo posterior.

3.3 Expandir abreviaturas bíblicas

MiniMax lee mal Mt 5, 1-12. Antes de generar el audio, expandir el nombre del libro cuando la abreviatura va seguida de un número (cita bíblica): MtMateo, McMarcos, LcLucas, JnJuan, Rom/RmRomanos, etc. Hay un listado largo de ~70 abreviaturas en scripts/minimax_tts.py::expand_bible_abbreviations (repo joomla-migration) — reutilizadlo tal cual en vez de rehacer la lista a mano, es fácil dejarse alguna.

3.4 Insertar pausas — el hallazgo central de este documento

MiniMax soporta marcas de pausa literales dentro del texto: <#0.3#> inserta 0,3 s de silencio en ese punto exacto. Sin estas marcas el audio suena corrido y sin respiración; poniendo una pausa fija igual en todos los puntos suena robótico. La receta que sí funciona es pausa proporcional a la longitud de la frase que se acaba de cerrar:

  • Tras cada fin de frase (., !, ?, ), calcular el nº de palabras de esa frase:
    • frase corta (< 6 palabras) → pausa 0,1 s
    • frase media (6-12 palabras) → pausa 0,2 s
    • frase larga (> 12 palabras) → pausa 0,3 s (umbrales configurables, pero estos son los que ya suenan bien; no hace falta tocarlos)
  • Entre párrafos (allí donde había una línea en blanco en el original) → pausa fija de 0,7 s, más larga que cualquier pausa de frase — es lo que hace que el oído perciba el cambio de idea/tema.
  • La última pausa de frase de cada párrafo se descarta (queda sustituida por la del párrafo, que ya es más larga — poner las dos seguidas suena a silencio muerto).
  • Todo bloque (título, párrafo) que no termine en signo de puntuación se cierra con un punto antes de generar el audio. Sin esto, MiniMax deja la entonación "abierta" en el aire al final de un título o frase cortada — se nota mucho y es fácil no darse cuenta en texto pero sí al oído.

Ejemplo de transformación (simplificado):

Entrada:  "Jesús les dijo esto. Y añadió una frase mucho más larga sobre lo que significaba
           seguirle de verdad en el día a día.\n\nOtro párrafo distinto."

Salida:   "Jesús les dijo esto. <#0.1#>  Y añadió una frase mucho más larga sobre lo que
           significaba seguirle de verdad en el día a día. <#0.7#>  Otro párrafo distinto."

(la frase corta cerró con 0,1s pero esa marca se sustituye por la de párrafo 0,7s al ser la última del bloque; la larga, al no ser la última del párrafo, habría llevado 0,3s si hubiera seguido más texto en el mismo párrafo).

Referencia de implementación exacta (por si hay dudas de un caso concreto): scripts/minimax_tts.py::add_pauses + _sent_pause + ensure_terminal_punctuation.

3.5 Trocear si el texto es largo

MiniMax limita cada petición a 10.000 caracteres; dejamos margen porque las marcas de pausa también cuentan, así que el límite práctico es 8.000 caracteres por petición. Cartas largas hay que trocearlas:

  • Cortar solo por los puntos donde ya hay una marca de pausa (nunca a mitad de frase).
  • Entre petición y petición, esperar 35 segundos — es el límite de ritmo (TPM) de la cuenta MiniMax; sin esta espera, la segunda petición puede fallar aunque quede cuota.
  • Los fragmentos de audio resultantes se concatenan con ffmpeg (concat a nivel de audio, sin recodificar más de lo necesario) para producir un único mp3 final.

4. Llamar a la API

POST https://api.minimax.io/v1/t2a_v2
Authorization: Bearer <TOKEN>
Content-Type: application/json

{
  "model": "speech-2.8-hd",
  "text": "<texto con pausas <#s#> ya insertadas>",
  "voice_setting": {"voice_id": "<voice_id de la tabla del §2>", "speed": 1.0, "vol": 1.0, "pitch": 0},
  "audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "mp3", "channel": 1},
  "language_boost": "Spanish"
}

La respuesta trae el audio en data.audio como hexadecimal, no base64 — decodificarlo a bytes antes de escribir el mp3.

Errores de cuota — no reintentéis en bucle

Si data.audio viene vacío, mirad base_resp.status_code:

  • 2056 = cuota del plan agotada (se resetea con el tiempo, horas).
  • 1039 = límite de ritmo por minuto (TPM) — bajad la cadencia, no lo repitáis al segundo siguiente.

Un HTTP 200 no significa que haya audio — hay que mirar siempre el base_resp del cuerpo. Ante cualquiera de estos dos códigos: parad y reintentad más tarde, no machaquéis la API en bucle esperando que cambie.

5. Acabado del audio (opcional pero es lo que suena en producción)

El pipeline actual aplica dos retoques más al mp3 antes de darlo por bueno — no son obligatorios para que el audio "funcione", pero sí para que suene igual que el resto del catálogo ya publicado:

  1. Ruido de confort: una capa de ruido marrón muy suave (amplitude=0.004, filtrado paso-alto 120 Hz / paso-bajo 3800 Hz) mezclada con el audio entero. Sin esto, los silencios de las pausas <#…#> contrastan de forma audible con el suelo de ruido del habla generada — se nota como un "hueco" en vez de una pausa natural.
  2. Fade in/out: entrada suave de 0,08 s y salida de 0,5 s en los últimos 0,5 s del audio, para evitar un corte brusco ("bump") al final.

Receta ffmpeg exacta usada hoy (referencia: scripts/minimax_tts.py::t2a, líneas finales):

ffmpeg -y -i entrada.mp3 -filter_complex \
  "anoisesrc=color=brown:amplitude=0.004:sample_rate=32000[n];\
   [n]highpass=f=120,lowpass=f=3800[nf];\
   [0:a][nf]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[m];\
   [m]afade=t=in:st=0:d=0.08,afade=t=out:st=<duración-0.5>:d=0.5[a]" \
  -map "[a]" -b:a 128k salida.mp3

Si vuestro entorno de generación no tiene ffmpeg disponible, generad el audio igual sin este paso y avisad a Rafa/Claudix — es preferible subir un audio sin el acabado a no subir nada, pero que quede constancia de que ese paso se saltó (para que quien revise sepa por qué suena ligeramente distinto al resto).

6. Subir el resultado al artículo — BLOQUEADO hoy

Aquí es donde entra el hueco real que reportó el #222: no hay forma de enganchar el mp3 al post por REST todavía. Los campos que necesita el sitio para reproducir el audio (fea_audio_url, fea_audio_done, más fea_audio_voice para saber qué voz sonó) no están expuestos en /wp/v2/posts/{id}, y el namespace fea/v1 no tiene endpoint de audio.

Esto se resuelve con un endpoint fea/v1/subir-audio, con el mismo patrón que fea/v1/subir-avatar (#175) que ya usáis: multipart con post_id + fichero audio (mp3), autenticación igual que el resto de endpoints fea/v1 (vuestro usuario Editor/Administrator), y como respuesta el valor anterior de esos metas para poder deshacer si algo suena mal. Codix lo está construyendo a partir de esta misma información — en cuanto esté desplegado, se actualiza este documento con el ejemplo de llamada real y se cierra el punto 1 del #222.

Mientras tanto: generad y guardad los mp3 con normalidad (pasos 1-5), pero no hay manera de publicarlos todavía — no es un fallo vuestro, es infraestructura pendiente.

7. Lo que NO cambia con este traspaso

  • Las traducciones (EN/FR/IT/PT) siguen, de momento, por el camino de siempre (docs/guia-tts-traduccion-inma.md, motor Gemma/MiniMax vía Hermes) — este documento cubre solo audio. Si el traspaso de traducciones también avanza, hará falta un documento equivalente a este (y probablemente el mismo tipo de hueco de REST para enlazar las traducciones vía Polylang) — no asumáis que ya está resuelto por analogía con el audio.
  • El backlog histórico de TTS (issue #188, ~1.700 audios pendientes) es un problema aparte de mucho más volumen que la carta semanal (~15-20/semana). Si el traspaso del #222 incluye también ese backlog, es una decisión de Rafa que hay que confirmar explícitamente antes de empezar — no arranquéis el backlog solo porque ya tengáis el mecanismo funcionando para la carta semanal.