Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| babff260a1 | |||
| 0abaacfcc0 |
@@ -1,161 +0,0 @@
|
|||||||
# Guía de traducción (EN/FR/IT/PT) en autoservicio — para Inma / Mixbot
|
|
||||||
|
|
||||||
> **Para quién es este documento:** para Inma y Mixbot, a partir de la conversación del
|
|
||||||
> 2026-08-31 en Buzz: además del audio ([#222](https://gitea.feadulta.com/rafa/feadulta/issues/222),
|
|
||||||
> ver `docs/guia-tts-audio-autoservicio-inma-mixbot.md`), Rafa quiere traspasar también la
|
|
||||||
> traducción. A diferencia del audio, hoy **no hay un issue abierto pidiéndolo** — este
|
|
||||||
> documento adelanta el trabajo para cuando se dispare.
|
|
||||||
>
|
|
||||||
> **Motores autorizados por Rafa (2026-08-31):** un agente local con Haiku desde el propio
|
|
||||||
> Claude/Cowork de Inma (no la API de pago de Rafa — es otra cuenta, otro caso), o MiniMax
|
|
||||||
> con el mismo token ya compartido para el audio. **Gemma queda fuera para vosotros**: es un
|
|
||||||
> modelo local que corre en la GPU del PC de Rafa, no accesible desde fuera.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. Antes de nada: esto también tiene un tope bloqueante
|
|
||||||
|
|
||||||
Igual que pasaba con el audio, **hoy no hay forma de decirle a WordPress "esta traducción
|
|
||||||
va en inglés y es la versión de este post en español"** desde fuera. Ese enlace (idioma +
|
|
||||||
grupo de traducción) lo gestiona Polylang con dos funciones PHP internas
|
|
||||||
(`pll_set_post_language`, `pll_save_post_translations`) que **no están expuestas por REST** —
|
|
||||||
ni siquiera de lectura (el único endpoint que hay, `fea/v1/lang/{id}`, solo lee el idioma, no
|
|
||||||
permite fijarlo ni enlazar grupos).
|
|
||||||
|
|
||||||
Podéis crear el post traducido por `POST /wp/v2/posts` sin problema (igual que cualquier
|
|
||||||
artículo), pero **quedaría suelto**: sin idioma asignado y sin enlace Polylang al original en
|
|
||||||
español, así que el selector de idioma del sitio no lo encontraría y no contaría como "la
|
|
||||||
traducción EN de este artículo".
|
|
||||||
|
|
||||||
**Esto necesita el mismo tipo de solución que el audio**: un endpoint `fea/v1/crear-traduccion`
|
|
||||||
(o registrar la taxonomía `language` de Polylang en REST con permisos de administrador) que
|
|
||||||
haga internamente lo mismo que ya hace `scripts/fea_translate_helper.php::create` en local:
|
|
||||||
crear el post, `pll_set_post_language($id, $lang)`, y `pll_save_post_translations($grupo)`
|
|
||||||
enlazándolo con el ES. No lo he encargado todavía — es una pieza más grande que la del audio
|
|
||||||
(toca la lógica central de multiidioma del sitio) y prefiero que Rafa decida el momento y el
|
|
||||||
alcance antes de que Codix la construya. Documento aquí el resto del proceso para que, en
|
|
||||||
cuanto ese hueco se cierre, solo falte la llamada final.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Las reglas de traducción (igual las use vuestro Claude/Haiku o MiniMax)
|
|
||||||
|
|
||||||
Esto es el prompt real que usa el pipeline de Rafa (`scripts/translate_post.py`), verificado
|
|
||||||
palabra por palabra el 2026-08-31 contra el código — usadlo tal cual, no lo resumáis:
|
|
||||||
|
|
||||||
> Eres un traductor profesional de textos religiosos cristianos (espiritualidad y teología
|
|
||||||
> católica). Traduce del español al {idioma destino}. REGLAS ESTRICTAS:
|
|
||||||
> 1. Conserva EXACTAMENTE el marcado HTML (etiquetas y atributos) y los shortcodes entre `[ ]`
|
|
||||||
> y `{ }`. No los traduzcas ni los reordenes.
|
|
||||||
> 2. NO traduzcas las referencias bíblicas ni sus abreviaturas (p. ej. "Jn 3, 16", "Isaías 5,
|
|
||||||
> 1-7", "Mt 5"). Déjalas idénticas.
|
|
||||||
> 3. Conserva los nombres propios de persona y lugar (salvo exónimos establecidos).
|
|
||||||
> 4. Glosario fijo: "Fe Adulta" → "Fe Adulta" (en/fr/it/pt, no se traduce); "EFFA" → "EFFA"
|
|
||||||
> (igual, en los 4 idiomas).
|
|
||||||
> 5. Traducción FIEL: no resumas, no añadas, no comentes.
|
|
||||||
|
|
||||||
Para el **título**, además: que quede natural en el idioma destino (no lo dejéis en español
|
|
||||||
salvo que sea un nombre propio o una marca), y si es un modismo, traducid el sentido, no lo
|
|
||||||
calquéis literal.
|
|
||||||
|
|
||||||
## 2. El "learning" del título — dadle el primer párrafo como contexto
|
|
||||||
|
|
||||||
Esto es lo que pedía Rafa explícitamente documentar. **Aviso de precisión:** este cambio
|
|
||||||
existe como commit (`4475ebb`, rama `feat/issue-200-title-context`) pero **no está fusionado
|
|
||||||
a `main`** — verificado el 2026-08-31 comparando el histórico. O sea: si alguna vez volvéis a
|
|
||||||
usar `scripts/translate_post.py` tal cual desde main, el título se traduce SIN este contexto.
|
|
||||||
Para vuestro proceso propio (agente Haiku o llamada directa a MiniMax) aplicad la técnica de
|
|
||||||
todas formas, es la que ya dio mejor resultado en pruebas:
|
|
||||||
|
|
||||||
- **Traducid primero el cuerpo del artículo, el título al final** — necesitáis el cuerpo ya
|
|
||||||
traducido (o al menos leído) para poder darle contexto al título.
|
|
||||||
- Al traducir el título, añadid al prompt el **primer párrafo del cuerpo en español**
|
|
||||||
(recortado a 2000 caracteres), como contexto de significado — no se traduce ni se incluye
|
|
||||||
literal en la salida, solo ayuda al modelo a entender modismos o dobles sentidos del
|
|
||||||
título que sin ese contexto salen mal traducidos.
|
|
||||||
- Instrucción exacta añadida al prompt del título: *"Si es un modismo, interpreta su sentido
|
|
||||||
según el contexto, no lo calques."*
|
|
||||||
|
|
||||||
## 3. Troceo de artículos largos
|
|
||||||
|
|
||||||
Si usáis MiniMax para traducir (no Haiku, que admite el artículo entero de una vez gracias a
|
|
||||||
su contexto de 200k), trocead el HTML por párrafos completos (nunca a mitad de una etiqueta)
|
|
||||||
en bloques de ~5.000 caracteres, y unid las traducciones de cada trozo en el mismo orden.
|
|
||||||
|
|
||||||
## 4. QA — checklist antes de dar una traducción por buena
|
|
||||||
|
|
||||||
Reformulado en REST puro (el script original, `gate_mec2.php`, corre dentro de WP y usa
|
|
||||||
funciones de Polylang no disponibles desde fuera — pero las comprobaciones sí son igual de
|
|
||||||
válidas mirando el JSON de cada post por la API):
|
|
||||||
|
|
||||||
Para cada origen ES y cada idioma que hayáis traducido, comparad contra el post ES:
|
|
||||||
|
|
||||||
1. **Cobertura**: los 4 idiomas presentes (o los que toque — Pagola nunca lleva traducción
|
|
||||||
automática, ver §5).
|
|
||||||
2. **Estado**: la traducción en `draft` hasta que se revise (salvo que el ES ya esté
|
|
||||||
`publish` y la carta lo pida — ver `docs/guia-publicacion-carta-inma.md`).
|
|
||||||
3. **Sin residuos de generación**: buscar literalmente en el `content` devuelto por la API
|
|
||||||
cadenas como `</strong>**`, `</strong></strong>`, `<>`, `<<<INI>>>`, `<<<FIN>>>`,
|
|
||||||
`|||FIN`, o cualquier resto de las marcas del prompt.
|
|
||||||
4. **Sin alfabetos ajenos**: ningún carácter árabe/cirílico/CJK en el resultado (residuo
|
|
||||||
típico de generación que se cuela cuando el modelo "piensa" en otro idioma).
|
|
||||||
5. **Mismo nº de párrafos** que el ES (contar apariciones de `<p` en el HTML). Un desajuste
|
|
||||||
suele significar que el modelo se comió contenido en el troceado.
|
|
||||||
6. **Mismo nº de enlaces `<a href>`** que el ES — **es el check que más falla y el más
|
|
||||||
importante**: en la carta 739, el italiano perdió 15 de 39 enlaces en uno de los trozos
|
|
||||||
troceados mientras EN/FR salieron bien, y nada posterior lo detecta si no lo comprobáis
|
|
||||||
aquí. Si hay menos enlaces en la traducción que en el ES, **no la deis por buena**.
|
|
||||||
7. **Referencias bíblicas equivalentes**: extraer patrones tipo `capítulo,versículo` de
|
|
||||||
ambos textos y comparar solo capítulo:versículo, **no la abreviatura del libro completa**
|
|
||||||
(el inglés cambia la coma por dos puntos y traduce el nombre: `Mt 15,21-28` en ES pasa a
|
|
||||||
`Matthew 15:21-28` en EN — comparar literal daría un falso positivo de "referencia
|
|
||||||
perdida").
|
|
||||||
8. **Título distinto del ES** (salvo portugués, donde algunos nombres propios/citas
|
|
||||||
coinciden de forma legítima).
|
|
||||||
|
|
||||||
Capa editorial adicional si tenéis acceso a un modelo capaz de juzgar fidelidad (vuestro
|
|
||||||
Claude/Haiku local sirve para esto): pedidle que lea origen y traducción y devuelva un
|
|
||||||
veredicto `approve` / `needs_review` / `rewrite` con la cita exacta del problema — descartad
|
|
||||||
cualquier hallazgo cuya cita no aparezca literal en el texto (evita ediciones basadas en
|
|
||||||
alucinaciones del propio revisor).
|
|
||||||
|
|
||||||
## 5. Excepciones que no lleváis por la vía automática
|
|
||||||
|
|
||||||
- **Autores que aportan su propia traducción humana** (hoy, Pagola: 4 DOCX por carta,
|
|
||||||
adjuntos en el issue de la semana) — nunca generéis fallback automático para ellos aunque
|
|
||||||
falte alguno de los 4 idiomas.
|
|
||||||
- **Textos litúrgicos enlazados desde la carta** (evangelio, lecturas) — confirmar con Rafa
|
|
||||||
si entran en el alcance de "todo lo de la carta" o se dejan fuera; ha habido confusión
|
|
||||||
antes sobre esto (no dar por hecho que "ya deben estar traducidos").
|
|
||||||
|
|
||||||
## 6. Cómo leer la cuota de MiniMax
|
|
||||||
|
|
||||||
Con el mismo token que ya tenéis para el audio:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s https://api.minimax.io/v1/token_plan/remains \
|
|
||||||
-H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json"
|
|
||||||
```
|
|
||||||
|
|
||||||
Del JSON de respuesta:
|
|
||||||
- `base_resp.status_code` debe ser `0` (cualquier otro valor es error de cuenta/plan, ver
|
|
||||||
`base_resp.status_msg`).
|
|
||||||
- `model_remains` es una lista por modelo. El de texto/traducción es el que tiene
|
|
||||||
`model_name == "general"`; los de audio son los `speech-2.8-*` que ya usáis para TTS.
|
|
||||||
- De cada entrada: `current_interval_remaining_percent` (cuánto queda de la ventana de 5h
|
|
||||||
que se resetea sola — `end_time` es cuándo) y `current_weekly_remaining_percent` /
|
|
||||||
`weekly_end_time` (la ventana semanal, más amplia). Restad a 100 para saber "cuánto habéis
|
|
||||||
gastado" si preferís verlo así.
|
|
||||||
|
|
||||||
Igual que con el audio: un HTTP 200 no es garantía de cuota — cuando `t2a_v2` o el endpoint
|
|
||||||
de traducción devuelvan `base_resp.status_code=2056` (plan agotado) o `1039` (límite de
|
|
||||||
ritmo), parad y reintentad más tarde, no machaquéis la API.
|
|
||||||
|
|
||||||
## 7. Qué falta para que esto sea autoservicio de verdad
|
|
||||||
|
|
||||||
- **Bloqueante (§0):** endpoint o mecanismo para fijar idioma + enlazar grupo Polylang de
|
|
||||||
cada traducción — sin esto podéis generar el texto pero no "engancharlo" al sitio como
|
|
||||||
traducción real, igual que pasaba con el audio antes del #222.
|
|
||||||
- Decisión pendiente de Rafa (no la resuelvo yo aquí): si vuestro agente de traducción va a
|
|
||||||
tener acceso de shell/SSH al WordPress (como tenía Hermes) o va a ser puramente REST como
|
|
||||||
el resto de vuestro trabajo — cambia bastante el diseño del endpoint que haría falta.
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
# 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 `</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 (`á` → `á`, 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 <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):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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:
|
|
||||||
|
|
||||||
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=<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`):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"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.
|
|
||||||
@@ -8,19 +8,6 @@
|
|||||||
> audio solo al publicar — esa automatización es la propuesta abierta
|
> audio solo al publicar — esa automatización es la propuesta abierta
|
||||||
> [issue #23](https://gitea.feadulta.com/rafa/feadulta/issues/23), sin implementar. Todo lo de
|
> [issue #23](https://gitea.feadulta.com/rafa/feadulta/issues/23), sin implementar. Todo lo de
|
||||||
> abajo es un proceso que **hay que pedir**, hoy solo ejecutable en el servidor/PC de Rafa.
|
> abajo es un proceso que **hay que pedir**, hoy solo ejecutable en el servidor/PC de Rafa.
|
||||||
>
|
|
||||||
> **Actualización 2026-08-31 (issue [#222](https://gitea.feadulta.com/rafa/feadulta/issues/222)):**
|
|
||||||
> el **audio (TTS)** deja de depender de Hermes — Inma/Mixbot lo generan y suben con su propio
|
|
||||||
> token de MiniMax. La §4 de abajo describe el flujo viejo (Hermes ejecutando
|
|
||||||
> `scripts/tts_produce.py` en el PC de Rafa); para el flujo nuevo de autoservicio, con la receta
|
|
||||||
> exacta de preparación de texto y pausas, usar
|
|
||||||
> **`docs/guia-tts-audio-autoservicio-inma-mixbot.md`**. Rafa confirmó el mismo día que las
|
|
||||||
> **traducciones** van también hacia autoservicio (agente local con Haiku desde el Claude de
|
|
||||||
> Inma, o MiniMax con el mismo token) aunque todavía sin un issue como el #222 que lo dispare
|
|
||||||
> — ver **`docs/guia-traduccion-autoservicio-inma-mixbot.md`** para las reglas, el QA y el
|
|
||||||
> hueco de infraestructura equivalente (falta enlazar idioma/grupo Polylang por REST). La §3
|
|
||||||
> de abajo sigue describiendo el flujo viejo vía Hermes, todavía válido mientras no se cierre
|
|
||||||
> ese hueco.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — API subir audio TTS
|
||||||
|
* Description: Endpoint REST acotado para publicar el MP3 TTS de un artículo.
|
||||||
|
* Version: 1.0
|
||||||
|
*
|
||||||
|
* POST /wp-json/fea/v1/subir-audio
|
||||||
|
* multipart/form-data: post_id=<id>, audio=<MP3>, voice_id=<opcional>
|
||||||
|
*
|
||||||
|
* Ver issue gitea.feadulta.com/rafa/feadulta#222.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
const FEA_AUDIO_MAX_BYTES = 26214400; // 25 MiB: límite operativo de producción.
|
||||||
|
|
||||||
|
add_action('rest_api_init', function () {
|
||||||
|
register_rest_route('fea/v1', '/subir-audio', [
|
||||||
|
'methods' => WP_REST_Server::CREATABLE,
|
||||||
|
'callback' => 'fea_subir_audio_handle',
|
||||||
|
'permission_callback' => 'fea_subir_audio_can_call',
|
||||||
|
// La validación de post_id ocurre en el handler, después de autorizar:
|
||||||
|
// una llamada anónima siempre recibe 401 aunque omita parámetros.
|
||||||
|
'args' => [
|
||||||
|
'post_id' => [
|
||||||
|
'sanitize_callback' => 'absint',
|
||||||
|
],
|
||||||
|
'voice_id' => [
|
||||||
|
'sanitize_callback' => 'sanitize_text_field',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Editor o superior: mismo nivel que los endpoints de escritura existentes. */
|
||||||
|
function fea_subir_audio_can_call(WP_REST_Request $request) {
|
||||||
|
if (!is_user_logged_in()) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_audio_not_authenticated',
|
||||||
|
'Debes autenticarte para subir un audio.',
|
||||||
|
['status' => 401]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!current_user_can('edit_others_posts')) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_audio_forbidden',
|
||||||
|
'No tienes permiso para subir audios.',
|
||||||
|
['status' => 403]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_subir_audio_handle(WP_REST_Request $request) {
|
||||||
|
$post_id = absint($request->get_param('post_id'));
|
||||||
|
if (!$post_id) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_audio_invalid_post',
|
||||||
|
'post_id es obligatorio.',
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!get_post($post_id)) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_audio_post_not_found',
|
||||||
|
'No existe el artículo indicado.',
|
||||||
|
['status' => 404]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = $request->get_file_params();
|
||||||
|
$file = $files['audio'] ?? null;
|
||||||
|
$validation = fea_subir_audio_validate_file($file);
|
||||||
|
if (is_wp_error($validation)) return $validation;
|
||||||
|
|
||||||
|
$previous = [
|
||||||
|
'url' => get_post_meta($post_id, 'fea_audio_url', true) ?: null,
|
||||||
|
'voice' => get_post_meta($post_id, 'fea_audio_voice', true) ?: null,
|
||||||
|
'done' => get_post_meta($post_id, 'fea_audio_done', true) ?: null,
|
||||||
|
'sha256' => get_post_meta($post_id, 'fea_audio_sha256', true) ?: null,
|
||||||
|
'error' => get_post_meta($post_id, 'fea_audio_error', true) ?: null,
|
||||||
|
];
|
||||||
|
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||||||
|
$upload_dir_filter = 'fea_subir_audio_upload_dir';
|
||||||
|
add_filter('upload_dir', $upload_dir_filter);
|
||||||
|
try {
|
||||||
|
$uploaded = wp_handle_upload($file, [
|
||||||
|
'test_form' => false,
|
||||||
|
'mimes' => ['mp3' => 'audio/mpeg'],
|
||||||
|
'unique_filename_callback' => function ($dir, $name, $ext) use ($post_id) {
|
||||||
|
return $post_id . '.mp3';
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
remove_filter('upload_dir', $upload_dir_filter);
|
||||||
|
}
|
||||||
|
if (!empty($uploaded['error']) || empty($uploaded['file'])) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_audio_upload_failed',
|
||||||
|
'No se pudo guardar el audio: ' . ($uploaded['error'] ?? 'error desconocido'),
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sha256 = hash_file('sha256', $uploaded['file']);
|
||||||
|
if ($sha256 === false) {
|
||||||
|
wp_delete_file($uploaded['file']);
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_audio_hash_failed',
|
||||||
|
'No se pudo calcular la huella del audio subido.',
|
||||||
|
['status' => 500]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// El path es estable para que los players y el proceso de backfill no dependan
|
||||||
|
// de las carpetas por mes que normalmente crea WordPress.
|
||||||
|
$audio_url = home_url('/wp-content/uploads/tts/' . $post_id . '.mp3');
|
||||||
|
update_post_meta($post_id, 'fea_audio_url', $audio_url);
|
||||||
|
update_post_meta($post_id, 'fea_audio_voice', (string) $request->get_param('voice_id'));
|
||||||
|
update_post_meta($post_id, 'fea_audio_done', '1');
|
||||||
|
update_post_meta($post_id, 'fea_audio_sha256', $sha256);
|
||||||
|
delete_post_meta($post_id, 'fea_audio_error');
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'post_id' => $post_id,
|
||||||
|
'audio_url' => $audio_url,
|
||||||
|
'voice_id' => (string) $request->get_param('voice_id'),
|
||||||
|
'sha256' => $sha256,
|
||||||
|
'previous_audio' => $previous,
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Guarda el binario en uploads/tts sin cambiar globalmente la configuración. */
|
||||||
|
function fea_subir_audio_upload_dir($uploads) {
|
||||||
|
$uploads['subdir'] = '/tts';
|
||||||
|
$uploads['path'] = $uploads['basedir'] . $uploads['subdir'];
|
||||||
|
$uploads['url'] = $uploads['baseurl'] . $uploads['subdir'];
|
||||||
|
if (!wp_mkdir_p($uploads['path'])) {
|
||||||
|
$uploads['error'] = 'No se pudo crear el directorio de audios.';
|
||||||
|
}
|
||||||
|
return $uploads;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fea_subir_audio_validate_file($file) {
|
||||||
|
if (!is_array($file) || empty($file['tmp_name']) || !isset($file['error'])) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_audio_missing_file',
|
||||||
|
'audio es obligatorio.',
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ((int) $file['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_audio_file_error',
|
||||||
|
'La subida de audio falló.',
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ((int) $file['size'] <= 0 || (int) $file['size'] > FEA_AUDIO_MAX_BYTES) {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_audio_file_too_large',
|
||||||
|
'El audio debe ocupar entre 1 byte y 25 MiB.',
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = wp_check_filetype((string) ($file['name'] ?? ''), ['mp3' => 'audio/mpeg']);
|
||||||
|
if (($type['ext'] ?? '') !== 'mp3' || ($type['type'] ?? '') !== 'audio/mpeg') {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_audio_unsupported_type',
|
||||||
|
'Solo se admiten archivos MP3 (audio/mpeg).',
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (strtolower((string) ($file['type'] ?? '')) !== 'audio/mpeg') {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_audio_unsupported_type',
|
||||||
|
'audio debe declararse como audio/mpeg.',
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (function_exists('finfo_open')) {
|
||||||
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||||
|
$mime = $finfo ? finfo_file($finfo, $file['tmp_name']) : false;
|
||||||
|
if ($finfo) finfo_close($finfo);
|
||||||
|
if ($mime !== false && $mime !== 'audio/mpeg') {
|
||||||
|
return new WP_Error(
|
||||||
|
'fea_subir_audio_invalid_audio',
|
||||||
|
'audio debe contener datos MPEG válidos.',
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
Executable
+74
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Integración local para POST /wp-json/fea/v1/subir-audio (#222).
|
||||||
|
# Requiere FEA_TEST_URL, FEA_TEST_AUTH y FEA_TEST_POST_ID (post temporal).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${FEA_TEST_URL:?Falta FEA_TEST_URL}"
|
||||||
|
: "${FEA_TEST_AUTH:?Falta FEA_TEST_AUTH}"
|
||||||
|
: "${FEA_TEST_POST_ID:?Falta FEA_TEST_POST_ID}"
|
||||||
|
|
||||||
|
endpoint="${FEA_TEST_URL%/}/wp-json/fea/v1/subir-audio"
|
||||||
|
tmpdir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmpdir"' EXIT
|
||||||
|
|
||||||
|
# MP3 pequeño y válido (silencio MPEG-1 Layer III), sin depender de ffmpeg.
|
||||||
|
printf '\377\373\220\144' > "$tmpdir/audio.mp3"
|
||||||
|
dd if=/dev/zero bs=1 count=413 >> "$tmpdir/audio.mp3" 2>/dev/null
|
||||||
|
printf 'no es un mp3' > "$tmpdir/not-audio.txt"
|
||||||
|
# Un byte por encima del límite de aplicación de 25 MiB. Se comprueba antes
|
||||||
|
# del tipo de fichero, por lo que puede ser disperso y no necesita ser audio.
|
||||||
|
truncate -s 26214401 "$tmpdir/too-large.mp3"
|
||||||
|
|
||||||
|
assert_status() {
|
||||||
|
local expected="$1" actual="$2" label="$3" response_file="$4"
|
||||||
|
if [[ "$actual" != "$expected" ]]; then
|
||||||
|
echo "FAIL: $label — esperado HTTP $expected, recibido $actual" >&2
|
||||||
|
cat "$response_file" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
status="$(curl -sS -o "$tmpdir/unauth.json" -w '%{http_code}' -X POST "$endpoint")"
|
||||||
|
assert_status 401 "$status" 'llamada sin autenticar' "$tmpdir/unauth.json"
|
||||||
|
|
||||||
|
status="$(curl -sS -o "$tmpdir/no-post.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -X POST "$endpoint")"
|
||||||
|
assert_status 400 "$status" 'falta post_id' "$tmpdir/no-post.json"
|
||||||
|
|
||||||
|
status="$(curl -sS -o "$tmpdir/no-audio.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "post_id=$FEA_TEST_POST_ID" "$endpoint")"
|
||||||
|
assert_status 400 "$status" 'falta audio' "$tmpdir/no-audio.json"
|
||||||
|
|
||||||
|
status="$(curl -sS -o "$tmpdir/too-large.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "post_id=$FEA_TEST_POST_ID" -F "audio=@$tmpdir/too-large.mp3;type=audio/mpeg" "$endpoint")"
|
||||||
|
assert_status 400 "$status" 'audio por encima de 25 MiB' "$tmpdir/too-large.json"
|
||||||
|
|
||||||
|
status="$(curl -sS -o "$tmpdir/wrong-type.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "post_id=$FEA_TEST_POST_ID" -F "audio=@$tmpdir/not-audio.txt;type=text/plain" "$endpoint")"
|
||||||
|
assert_status 400 "$status" 'extensión o mime no MP3' "$tmpdir/wrong-type.json"
|
||||||
|
|
||||||
|
status="$(curl -sS -o "$tmpdir/no-such-post.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F 'post_id=999999999' -F "audio=@$tmpdir/audio.mp3;type=audio/mpeg" "$endpoint")"
|
||||||
|
assert_status 404 "$status" 'post inexistente' "$tmpdir/no-such-post.json"
|
||||||
|
|
||||||
|
status="$(curl -sS -o "$tmpdir/success.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "post_id=$FEA_TEST_POST_ID" -F 'voice_id=NicoFeadulta2026' -F "audio=@$tmpdir/audio.mp3;type=audio/mpeg" "$endpoint")"
|
||||||
|
assert_status 201 "$status" 'subida válida' "$tmpdir/success.json"
|
||||||
|
|
||||||
|
python3 - "$tmpdir/success.json" "$FEA_TEST_POST_ID" "$tmpdir/audio.mp3" <<'PY'
|
||||||
|
import hashlib, json, sys
|
||||||
|
payload = json.load(open(sys.argv[1]))
|
||||||
|
assert int(payload['post_id']) == int(sys.argv[2]), payload
|
||||||
|
assert payload['audio_url'].endswith(f'/wp-content/uploads/tts/{sys.argv[2]}.mp3'), payload
|
||||||
|
assert payload['voice_id'] == 'NicoFeadulta2026', payload
|
||||||
|
assert payload['sha256'] == hashlib.sha256(open(sys.argv[3], 'rb').read()).hexdigest(), payload
|
||||||
|
assert 'previous_audio' in payload, payload
|
||||||
|
print('PASS: audio subido', payload['audio_url'])
|
||||||
|
PY
|
||||||
|
|
||||||
|
# La siguiente subida del mismo post debe exponer el estado anterior, para que
|
||||||
|
# el cliente pueda restaurarlo si su flujo posterior falla.
|
||||||
|
status="$(curl -sS -o "$tmpdir/replaced.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "post_id=$FEA_TEST_POST_ID" -F 'voice_id=NicoFeadulta2026' -F "audio=@$tmpdir/audio.mp3;type=audio/mpeg" "$endpoint")"
|
||||||
|
assert_status 201 "$status" 'reemplazo de audio' "$tmpdir/replaced.json"
|
||||||
|
|
||||||
|
python3 - "$tmpdir/replaced.json" "$FEA_TEST_POST_ID" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
payload = json.load(open(sys.argv[1]))
|
||||||
|
assert payload['previous_audio']['url'].endswith(f'/wp-content/uploads/tts/{sys.argv[2]}.mp3'), payload
|
||||||
|
assert payload['previous_audio']['done'] == '1', payload
|
||||||
|
print('PASS: estado anterior devuelto')
|
||||||
|
PY
|
||||||
Reference in New Issue
Block a user