Rafa confirmó que el backlog historico del #188 se queda con Inma/Mixbot (su equipo está 24/7, aprovecha mejor las ventanas de 5h de MiniMax) y pidió documentarles cómo leer la cuota antes de lanzar tandas grandes.
14 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: completo y desplegado en producción. El endpoint
fea/v1/subir-audio(PR #224) ya está en prod y verificado de extremo a extremo por Claudix con una subida real. El punto 1 del #222 queda cerrado — ver §6 para la llamada real.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 ejecutandoscripts/tts_produce.pyen 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) ospeech-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_idFray Marcos 382 FrayMarcosFeadulta2026José Antonio Pagola 383 PagolaFeadulta2026José Luis Sicre 774 SicreFeadulta2026José 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 (
NicoFeadulta2026y 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
- Sustituir
</p>,<br>/<br/>y</h1>…</h6>por un salto de línea. - Quitar el resto de etiquetas HTML.
- Quitar shortcodes
[...](multimedia, cajas, etc. — no se locutan). - Decodificar entidades HTML (
á→á, etc.). - 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): Mt → Mateo, Mc → Marcos,
Lc → Lucas, Jn → Juan, Rom/Rm → Romanos, 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(concata 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.
Cómo comprobar la cuota disponible antes de lanzar una tanda (importante si vais a procesar el backlog histórico del #188 además de la carta semanal, para repartir bien las ventanas de 5h):
curl -s https://api.minimax.io/v1/token_plan/remains \
-H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json"
Buscad en model_remains la entrada cuyo model_name sea el modelo que vayáis a usar
(speech-2.8-hd/speech-2.8-turbo). Cada entrada trae
current_interval_remaining_percent (lo que queda de la ventana de 5h, y end_time de
cuándo se resetea) y current_weekly_remaining_percent/weekly_end_time (la ventana
semanal). Con eso podéis calcular cuántos audios caben antes de la próxima ventana y
programar el resto para la siguiente, en vez de descubrirlo con un 2056 a mitad de tanda.
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:
- 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. - 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
El endpoint que faltaba ya está desplegado en producción (#224), con el mismo patrón que
fea/v1/subir-avatar (#175) que ya usáis:
POST https://www.feadulta.com/wp-json/fea/v1/subir-audio
Authorization: Basic <usuario:contraseña_de_aplicación> (o el header equivalente que useis)
Content-Type: multipart/form-data
post_id=<ID del artículo>
audio=<fichero .mp3, máx. 25 MiB>
voice_id=<opcional — el voice_id usado, para que quede registrado>
Respuesta (201):
{
"post_id": 56785,
"audio_url": "https://www.feadulta.com/wp-content/uploads/tts/56785.mp3",
"voice_id": "NicoFeadulta2026",
"sha256": "<huella del mp3 subido>",
"previous_audio": {"url": null, "voice": null, "done": null, "sha256": null, "error": null}
}
previous_audio trae el estado de antes de vuestra subida (o todo null si el post no tenía
audio) — guardadlo si vais a reemplazar un audio ya publicado, por si hay que deshacer.
Límite real: 25 MiB por fichero (upload_max_filesize/post_max_size del servidor, y el
propio endpoint valida lo mismo con un mensaje claro). Los audios de la carta semanal rondan
5-6 MB, así que hay margen — pero si algún artículo muy largo genera un mp3 más grande,
avisad en vez de forzarlo: el límite se fijó a propósito en 25 MiB (no en los 256 MiB que se
había propuesto al principio) porque el caso de 198 MB del #212/#190 era un error de
generación a corregir, no un tamaño a soportar.
Solo admite .mp3/audio/mpeg — mismo nivel de permiso que el resto de endpoints fea/v1
de escritura (vuestro usuario Editor/Administrator). Verificado por Claudix el 2026-08-31 con
una subida real de extremo a extremo contra prod (post desechable, borrado después).
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.