# 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](https://gitea.feadulta.com/rafa/feadulta/issues/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](https://gitea.feadulta.com/rafa/feadulta/pulls/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 > 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 `

`, `
`/`
` y ``…`` 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 (`á` → `á`, 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): `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` (`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 Content-Type: application/json { "model": "speech-2.8-hd", "text": " ya insertadas>", "voice_setting": {"voice_id": "", "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): ```bash 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=: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 (o el header equivalente que useis) Content-Type: multipart/form-data post_id= audio= voice_id= ``` Respuesta (`201`): ```json { "post_id": 56785, "audio_url": "https://www.feadulta.com/wp-content/uploads/tts/56785.mp3", "voice_id": "NicoFeadulta2026", "sha256": "", "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.