diff --git a/scripts/fea_post_io.php b/scripts/fea_post_io.php
new file mode 100644
index 0000000..7f4cf22
--- /dev/null
+++ b/scripts/fea_post_io.php
@@ -0,0 +1,74 @@
+ -> escribe /tmp/fea_es.json {title, content, status}
+ * update -> actualiza post_title/post_content desde ficheros
+ * Carga wp-load; portable (local docker o prod via FEA_WP_LOAD).
+ */
+$WP = getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php';
+require $WP;
+
+$action = $argv[1] ?? '';
+
+if ($action === 'get') {
+ $id = (int)$argv[2];
+ $p = get_post($id);
+ if (!$p) { fwrite(STDERR, "no existe $id\n"); exit(1); }
+ file_put_contents('/tmp/fea_es.json', json_encode([
+ 'id' => $id,
+ 'title' => $p->post_title,
+ 'content' => $p->post_content,
+ 'status' => $p->post_status,
+ 'author' => (int)$p->post_author,
+ ], JSON_UNESCAPED_UNICODE));
+ exit(0);
+}
+
+if ($action === 'update') {
+ $id = (int)$argv[2];
+ $title = rtrim(file_get_contents($argv[3]), "\r\n");
+ $body = file_get_contents($argv[4]);
+ if (!get_post($id)) { fwrite(STDERR, "no existe $id\n"); exit(1); }
+ $r = wp_update_post([
+ 'ID' => $id,
+ 'post_title' => $title,
+ 'post_content' => $body,
+ ], true);
+ if (is_wp_error($r)) { fwrite(STDERR, "error: " . $r->get_error_message() . "\n"); exit(1); }
+ fwrite(STDOUT, "ok actualizado $id\n");
+ exit(0);
+}
+
+if ($action === 'getmeta') {
+ echo get_post_meta((int)$argv[2], $argv[3], true);
+ exit(0);
+}
+
+if ($action === 'setaudio') { // setaudio [voice_id]
+ $id = (int)$argv[2];
+ $voice = $argv[4] ?? 'NicoFeadulta2026';
+ update_post_meta($id, 'fea_audio_url', home_url($argv[3]));
+ update_post_meta($id, 'fea_audio_voice', $voice);
+ update_post_meta($id, 'fea_audio_done', '1');
+ delete_post_meta($id, 'fea_audio_error');
+ fwrite(STDOUT, "ok " . home_url($argv[3]) . "\n");
+ exit(0);
+}
+
+if ($action === 'setflag') { // setflag
+ update_post_meta((int)$argv[2], $argv[3], $argv[4]);
+ exit(0);
+}
+
+if ($action === 'unsetaudio') { // unsetaudio (rollback: despublica el audio)
+ $id = (int)$argv[2];
+ delete_post_meta($id, 'fea_audio_url');
+ delete_post_meta($id, 'fea_audio_voice');
+ delete_post_meta($id, 'fea_audio_done');
+ delete_post_meta($id, 'fea_audio_error');
+ fwrite(STDOUT, "ok despublicado $id\n");
+ exit(0);
+}
+
+fwrite(STDERR, "uso: get|update|getmeta|setaudio|setflag|unsetaudio\n");
+exit(2);
diff --git a/scripts/minimax_tts.py b/scripts/minimax_tts.py
new file mode 100644
index 0000000..39faad1
--- /dev/null
+++ b/scripts/minimax_tts.py
@@ -0,0 +1,454 @@
+#!/usr/bin/env python3
+"""TTS con MiniMax (clonación de voz + síntesis de calidad). Issue #76.
+
+Credenciales en /home/rafa/Feadulta/minimax.txt:
+ - la API key (línea que empieza por 'sk-api-')
+ - el GroupId (línea 'GroupId=...' o 'group_id ...' o un número suelto)
+
+Subcomandos:
+ clone sube y clona (voice_id: >=8 chars, letras+números)
+ carta [model] [nombre] locuta una carta entera
+ text "" [model] [nombre] locuta texto suelto
+models: speech-2.8-turbo (barato) | speech-2.8-hd (calidad)
+"""
+import html
+import json
+import os
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+import requests
+
+CRED = "/home/rafa/Feadulta/minimax.txt"
+BASE = "https://api.minimax.io/v1"
+OUT = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts-samples"
+CONTAINER = "wordpress-web"
+
+
+def creds():
+ key = gid = None
+ for ln in open(CRED):
+ ln = ln.strip()
+ if not ln:
+ continue
+ if ln.startswith("sk-"):
+ key = ln # coge la última key del fichero (la más reciente)
+ elif "groupid" in ln.lower() or "group_id" in ln.lower():
+ gid = re.split(r"[=:\s]+", ln, 1)[1].strip()
+ elif ln.isdigit():
+ gid = ln
+ return key, gid
+
+
+KEY, GID = creds()
+H_JSON = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
+
+# Voz clonada por autor (WP user_id -> voice_id). Issue #152: solo se aplica a
+# los artículos de ese autor; el resto sigue con la voz por defecto (Nico).
+AUTHOR_VOICES = {
+ 382: "FrayMarcosFeadulta2026", # Fray Marcos
+ 383: "PagolaFeadulta2026", # José Antonio Pagola
+ 774: "SicreFeadulta2026", # José Luis Sicre
+ 386: "ArregiFeadulta2026", # José Arregi
+}
+
+
+def voice_for_author(author_id, default_voice):
+ try:
+ return AUTHOR_VOICES.get(int(author_id), default_voice)
+ except (TypeError, ValueError):
+ return default_voice
+
+
+def _q(url):
+ return f"{url}?GroupId={GID}" if GID else url
+
+
+def upload(path, purpose="voice_clone"):
+ r = requests.post(_q(f"{BASE}/files/upload"),
+ headers={"Authorization": f"Bearer {KEY}"},
+ data={"purpose": purpose},
+ files={"file": open(path, "rb")})
+ j = r.json()
+ fid = (j.get("file") or {}).get("file_id")
+ if not fid:
+ sys.exit(f"upload falló: {json.dumps(j)[:400]}")
+ print(f" file_id={fid}")
+ return fid
+
+
+def clone(audio, voice_id):
+ print(f"Subiendo {audio}…", flush=True)
+ fid = upload(audio, "voice_clone")
+ print(f"Clonando como voice_id={voice_id}…", flush=True)
+ r = requests.post(_q(f"{BASE}/voice_clone"), headers=H_JSON,
+ json={"file_id": fid, "voice_id": voice_id, "model": "speech-2.8-hd"})
+ print(json.dumps(r.json(), ensure_ascii=False)[:500])
+
+
+def get_post_text(pid):
+ subprocess.run(["docker", "exec", CONTAINER, "php", "/tmp/fea_post_io.php", "get", str(pid)],
+ check=True, capture_output=True)
+ subprocess.run(["docker", "cp", f"{CONTAINER}:/tmp/fea_es.json", "/tmp/fea_es.json"], check=True)
+ d = json.load(open("/tmp/fea_es.json"))
+ raw = re.sub(r"(?i)
|
|", "\n", d["content"])
+ raw = re.sub(r"<[^>]+>", "", raw)
+ raw = re.sub(r"\[[^\]]+\]", "", raw)
+ raw = html.unescape(raw)
+ paras = [re.sub(r"\s+", " ", p).strip() for p in raw.split("\n") if len(p.strip()) > 1]
+ paras = trim_after_author_signature(paras)
+ return d["title"], "\n\n".join(paras), d.get("author")
+
+
+def is_author_signature(text):
+ """Heurística simple para detectar la firma final del autor.
+
+ Queremos conservar la línea del nombre y cortar todo lo que venga detrás
+ (URLs, notas, anexos o bloques extra), pero sin confundirla con títulos
+ internos del artículo.
+ """
+ text = text.strip()
+ if not text or len(text) > 80 or any(ch.isdigit() for ch in text):
+ return False
+ if any(mark in text for mark in [":", ";", "http", "www.", "@"]):
+ return False
+ words = text.split()
+ if len(words) < 2 or len(words) > 6:
+ return False
+ allowed_lower = {"de", "del", "la", "las", "los", "y", "e"}
+ for word in words:
+ clean = re.sub(r"[^\wÁÉÍÓÚÜÑáéíóúüñ-]", "", word)
+ if not clean:
+ return False
+ if clean.lower() in allowed_lower:
+ continue
+ if not clean[0].isupper():
+ return False
+ return True
+
+
+def trim_after_author_signature(paras):
+ """Corta tras la firma final del autor, buscando desde el final hacia
+ atrás para no confundirla con encabezados iniciales (p.ej. "CORPUS (A)",
+ "DOMINGO XI (A)") que también matchean la heurística de is_author_signature
+ pero aparecen como primer párrafo, no como firma. Solo cuenta como firma
+ si hay al menos 200 caracteres de contenido real antes de ella."""
+ prefix_len = 0
+ prefix_lens = []
+ for p in paras:
+ prefix_lens.append(prefix_len)
+ prefix_len += len(p)
+ for i in range(len(paras) - 1, -1, -1):
+ if prefix_lens[i] > 200 and is_author_signature(paras[i]):
+ return paras[: i + 1]
+ return paras
+
+
+def _sent_pause(n_words, short, long_):
+ """Pausa (s) tras un punto, proporcional a la longitud de la frase que cierra:
+ frase corta → pausa corta; frase larga → el narrador 'respira' más."""
+ if n_words < short:
+ return os.environ.get("FEA_PAUSE_SHORT", "0.1")
+ if n_words <= long_:
+ return os.environ.get("FEA_PAUSE_MID", "0.2")
+ return os.environ.get("FEA_PAUSE_LONG", "0.3")
+
+
+def ensure_terminal_punctuation(block):
+ """Cierra con punto los bloques sin puntuación final.
+
+ MiniMax deja la entonación abierta cuando un título/párrafo termina "en seco".
+ Si el bloque ya acaba en . ! ? … : ;, se respeta.
+ """
+ block = block.strip()
+ if not block:
+ return ""
+ if block[-1] not in ".!?…:;":
+ return block + "."
+ return block
+
+
+def expand_bible_abbreviations(text):
+ """Expande abreviaturas bíblicas cuando aparecen con forma de cita.
+
+ Ejemplos:
+ - Mt 5, 1-12 -> Mateo 5, 1-12
+ - Lc 2, 10 -> Lucas 2, 10
+ - Jn 3, 16 -> Juan 3, 16
+ - Mc 1, 14 -> Marcos 1, 14
+
+ Se limita a abreviaturas seguidas de capítulo/versículo para no tocar usos
+ no bíblicos de esas siglas dentro del texto.
+ """
+ books = [
+ ("1Cor", "Primera carta a los Corintios"),
+ ("2Cor", "Segunda carta a los Corintios"),
+ ("1Tes", "Primera carta a los Tesalonicenses"),
+ ("2Tes", "Segunda carta a los Tesalonicenses"),
+ ("1Tim", "Primera carta a Timoteo"),
+ ("2Tim", "Segunda carta a Timoteo"),
+ ("1Pe", "Primera carta de Pedro"),
+ ("2Pe", "Segunda carta de Pedro"),
+ ("1Jn", "Primera carta de Juan"),
+ ("2Jn", "Segunda carta de Juan"),
+ ("3Jn", "Tercera carta de Juan"),
+ ("1Mac", "Primer libro de los Macabeos"),
+ ("2Mac", "Segundo libro de los Macabeos"),
+ ("1Sam", "Primer libro de Samuel"),
+ ("2Sam", "Segundo libro de Samuel"),
+ ("1Sm", "Primer libro de Samuel"),
+ ("2Sm", "Segundo libro de Samuel"),
+ ("1Re", "Primer libro de los Reyes"),
+ ("2Re", "Segundo libro de los Reyes"),
+ ("1Cr", "Primer libro de las Crónicas"),
+ ("2Cr", "Segundo libro de las Crónicas"),
+ ("Hch", "Hechos de los Apóstoles"),
+ ("Rom", "Romanos"),
+ ("Rm", "Romanos"),
+ ("Gal", "Gálatas"),
+ ("Gál", "Gálatas"),
+ ("Ef", "Efesios"),
+ ("Flp", "Filipenses"),
+ ("Fil", "Filipenses"),
+ ("Col", "Colosenses"),
+ ("Tit", "Tito"),
+ ("Flm", "Filemón"),
+ ("Heb", "Hebreos"),
+ ("Sant", "Santiago"),
+ ("St", "Santiago"),
+ ("Sto", "Santiago"),
+ ("Jud", "Judas"),
+ ("Ap", "Apocalipsis"),
+ ("Mt", "Mateo"),
+ ("Mc", "Marcos"),
+ ("Lc", "Lucas"),
+ ("Jn", "Juan"),
+ ("Gn", "Génesis"),
+ ("Gen", "Génesis"),
+ ("Ex", "Éxodo"),
+ ("Lv", "Levítico"),
+ ("Lev", "Levítico"),
+ ("Nm", "Números"),
+ ("Num", "Números"),
+ ("Dt", "Deuteronomio"),
+ ("Jos", "Josué"),
+ ("Jue", "Jueces"),
+ ("Rut", "Rut"),
+ ("Esd", "Esdras"),
+ ("Neh", "Nehemías"),
+ ("Tob", "Tobías"),
+ ("Jdt", "Judit"),
+ ("Est", "Ester"),
+ ("Job", "Job"),
+ ("Sal", "Salmos"),
+ ("Prov", "Proverbios"),
+ ("Cant", "Cantar de los Cantares"),
+ ("Sab", "Sabiduría"),
+ ("Eclo", "Eclesiástico"),
+ ("Sir", "Eclesiástico"),
+ ("Ecl", "Eclesiástico"),
+ ("Isa", "Isaías"),
+ ("Is", "Isaías"),
+ ("Jer", "Jeremías"),
+ ("Jr", "Jeremías"),
+ ("Lam", "Lamentaciones"),
+ ("Bar", "Baruc"),
+ ("Eze", "Ezequiel"),
+ ("Ez", "Ezequiel"),
+ ("Dan", "Daniel"),
+ ("Dn", "Daniel"),
+ ("Os", "Oseas"),
+ ("Joel", "Joel"),
+ ("Am", "Amós"),
+ ("Abd", "Abdías"),
+ ("Jon", "Jonás"),
+ ("Miq", "Miqueas"),
+ ("Nah", "Nahúm"),
+ ("Hab", "Habacuc"),
+ ("Sof", "Sofonías"),
+ ("Ag", "Ageo"),
+ ("Zac", "Zacarías"),
+ ("Mal", "Malaquías"),
+ ]
+ for short, full in books:
+ text = re.sub(
+ rf"\b{short}\.?(?=\s+\d)",
+ full,
+ text,
+ )
+ text = re.sub(r"\b1\s+Co\.?(?=\s+\d)", "Primera carta a los Corintios", text)
+ text = re.sub(r"\b2\s+Co\.?(?=\s+\d)", "Segunda carta a los Corintios", text)
+ text = re.sub(r"\b1\s+Ts\.?(?=\s+\d)", "Primera carta a los Tesalonicenses", text)
+ text = re.sub(r"\b2\s+Ts\.?(?=\s+\d)", "Segunda carta a los Tesalonicenses", text)
+ text = re.sub(r"\b1\s+P\.?(?=\s+\d)", "Primera carta de Pedro", text)
+ text = re.sub(r"\b2\s+P\.?(?=\s+\d)", "Segunda carta de Pedro", text)
+ return text
+
+
+def add_pauses(text, para=None):
+ """Pausas MiniMax <#seg#> DINÁMICAS por longitud de frase + cierre de párrafos.
+ - Tras cada fin de frase (.!?…): pausa según nº de palabras de esa frase
+ (long=0.3s; umbrales por palabras).
+ - A los párrafos/títulos sin puntuación final se les añade un punto, para que
+ MiniMax cierre bien la entonación (si no, deja el tono abierto)."""
+ para = para if para is not None else os.environ.get("FEA_PARA_PAUSE", "0.7")
+ short = int(os.environ.get("FEA_SHORT_WORDS", "6"))
+ long_ = int(os.environ.get("FEA_LONG_WORDS", "12"))
+ text = expand_bible_abbreviations(text)
+ out = []
+ for p in text.split("\n\n"):
+ p = ensure_terminal_punctuation(p)
+ if not p:
+ continue
+ # Reconstruir insertando pausa proporcional tras cada signo de fin de frase.
+ parts = re.split(r"([.!?…]+)", p)
+ rebuilt = ""
+ for i in range(0, len(parts), 2):
+ frase = parts[i]
+ sign = parts[i + 1] if i + 1 < len(parts) else ""
+ rebuilt += frase + sign
+ if sign and frase.strip():
+ rebuilt += f" <#{_sent_pause(len(frase.split()), short, long_)}#> "
+ # Quitar la pausa de frase final: el separador de párrafo ya aporta la suya.
+ rebuilt = re.sub(r"\s*<#[\d.]+#>\s*$", "", rebuilt)
+ out.append(rebuilt.strip())
+ return f" <#{para}#> ".join(out)
+
+
+# MiniMax limita a 10.000 car por petición; dejamos margen porque las pausas
+# <#seg#> y el language_boost también cuentan.
+CHAR_LIMIT = 8000
+
+
+def _split_for_tts(text, limit=CHAR_LIMIT):
+ """Trocea respetando las pausas <#..#> (frase/párrafo). Fallback por palabras
+ si una frase suelta supera el límite."""
+ if len(text) <= limit:
+ return [text]
+ parts = re.split(r"(\s*<#[\d.]+#>\s*)", text)
+ chunks, cur = [], ""
+ for seg in parts:
+ if not seg:
+ continue
+ if len(cur) + len(seg) <= limit:
+ cur += seg
+ continue
+ if cur.strip():
+ chunks.append(cur.strip())
+ if len(seg) > limit: # frase gigantesca: parte por palabras
+ cur = ""
+ for w in seg.split(" "):
+ if len(cur) + len(w) + 1 <= limit:
+ cur += (" " if cur else "") + w
+ else:
+ if cur:
+ chunks.append(cur)
+ cur = w
+ else:
+ cur = seg
+ if cur.strip():
+ chunks.append(cur.strip())
+ return chunks
+
+
+def _synth_chunk(text, voice_id, model):
+ """Una petición t2a. Devuelve (audio_bytes|None, rc, usage_chars)."""
+ body = {
+ "model": model,
+ "text": text,
+ "voice_setting": {"voice_id": voice_id, "speed": 1.0, "vol": 1.0, "pitch": 0},
+ "audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "mp3", "channel": 1},
+ "language_boost": "Spanish",
+ }
+ r = requests.post(f"{BASE}/t2a_v2", headers=H_JSON, json=body)
+ j = r.json()
+ audio_hex = (j.get("data") or {}).get("audio")
+ if not audio_hex:
+ rc = (j.get("base_resp") or {}).get("status_code")
+ print(f"t2a falló: {json.dumps(j, ensure_ascii=False)[:300]}")
+ return None, rc, 0
+ usage = (j.get("extra_info") or {}).get("usage_characters", 0)
+ return bytes.fromhex(audio_hex), 0, usage
+
+
+def t2a(text, voice_id, model, name):
+ chunks = _split_for_tts(text)
+ print(f"Sintetizando {len(text)} car con {model} / {voice_id} "
+ f"({len(chunks)} petición/es)…", flush=True)
+ raw = OUT / f"{name}.raw.mp3"
+ if len(chunks) == 1:
+ audio, rc, _ = _synth_chunk(chunks[0], voice_id, model)
+ if audio is None:
+ return rc
+ raw.write_bytes(audio)
+ else:
+ parts = []
+ for k, ch in enumerate(chunks):
+ if k > 0:
+ import os as _os, time as _t
+ _t.sleep(int(_os.environ.get("FEA_CHUNK_PAUSE", "35"))) # respetar TPM de MiniMax
+ print(f" trozo {k + 1}/{len(chunks)} ({len(ch)} car)…", flush=True)
+ audio, rc, _ = _synth_chunk(ch, voice_id, model)
+ if audio is None:
+ for p in parts:
+ p.unlink(missing_ok=True)
+ return rc
+ p = OUT / f"{name}.part{k}.mp3"
+ p.write_bytes(audio)
+ parts.append(p)
+ import subprocess as sp0
+ args = ["ffmpeg", "-y"]
+ for p in parts:
+ args += ["-i", str(p)]
+ n = len(parts)
+ filt = "".join(f"[{k}:a]" for k in range(n)) + f"concat=n={n}:v=0:a=1[a]"
+ args += ["-filter_complex", filt, "-map", "[a]", "-b:a", "128k", str(raw)]
+ sp0.run(args, capture_output=True)
+ for p in parts:
+ p.unlink(missing_ok=True)
+ # Acabado: comfort noise marrón + fade in/out (quita el "bump" final).
+ import subprocess as sp
+ dur = float(sp.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
+ "-of", "default=noprint_wrappers=1:nokey=1", str(raw)],
+ capture_output=True, text=True).stdout.strip() or "0")
+ st = max(0.0, dur - 0.5)
+ mp3 = OUT / f"{name}.mp3"
+ sp.run(["ffmpeg", "-y", "-i", str(raw), "-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];"
+ f"[m]afade=t=in:st=0:d=0.08,afade=t=out:st={st:.2f}:d=0.5[a]",
+ "-map", "[a]", "-b:a", "128k", str(mp3)], capture_output=True)
+ raw.unlink(missing_ok=True)
+ print(f"OK -> {mp3} ({dur:.0f}s)")
+ return 0
+
+
+def main():
+ if len(sys.argv) < 2:
+ sys.exit(__doc__)
+ cmd = sys.argv[1]
+ if not KEY:
+ sys.exit("No encuentro la API key en " + CRED)
+ if cmd == "clone":
+ clone(sys.argv[2], sys.argv[3])
+ elif cmd == "carta":
+ pid, voice_id = sys.argv[2], sys.argv[3]
+ model = sys.argv[4] if len(sys.argv) > 4 else "speech-2.8-turbo"
+ title, text, _author = get_post_text(int(pid))
+ name = sys.argv[5] if len(sys.argv) > 5 else f"carta-minimax-{pid}-{model.split('-')[-1]}"
+ text = add_pauses(text)
+ print(f"Post #{pid}: «{title}» ({len(text)} car con pausas)")
+ t2a(text, voice_id, model, name)
+ elif cmd == "text":
+ model = sys.argv[4] if len(sys.argv) > 4 else "speech-2.8-turbo"
+ name = sys.argv[5] if len(sys.argv) > 5 else "minimax-text"
+ t2a(sys.argv[2], sys.argv[3], model, name)
+ else:
+ sys.exit(__doc__)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/sync_audio_to_prod.py b/scripts/sync_audio_to_prod.py
new file mode 100644
index 0000000..211856f
--- /dev/null
+++ b/scripts/sync_audio_to_prod.py
@@ -0,0 +1,219 @@
+#!/usr/bin/env python3
+"""
+sync_audio_to_prod.py — Sube a PROD los mp3 de TTS ya generados/enlazados en
+local (fea_audio_done=1) y fija el meta fea_audio_url en prod.
+
+Prod (134.0.10.170) tiene glibc rota: scp/sftp NO funcionan (connection closed).
+Workaround: subir el binario por stdin de ssh ("cat > ruta"), igual que el resto
+de scripts que tocan ese servidor (ver feadulta-server-glibc-rota.md).
+
+Uso:
+ python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta
+ python3 sync_audio_to_prod.py --ids 912,919,3001 # ids concretos
+ python3 sync_audio_to_prod.py --carta 54254 --dry-run # solo plan, no toca prod
+
+Rollback (despublica en prod lo que este script publicó):
+ python3 sync_audio_to_prod.py --rollback --carta 54254
+ python3 sync_audio_to_prod.py --rollback --ids 912,919
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import subprocess
+import time
+from pathlib import Path
+
+WP_CONTAINER = os.environ.get("FEA_WP_CONTAINER", "wordpress-web")
+DB_CONTAINER = os.environ.get("FEA_DB_CONTAINER", "wordpress-mysql")
+DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
+DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
+DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
+
+PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
+PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
+PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
+PROD_HELPER = "/tmp/fea_post_io.php"
+PROD_UPLOADS_TTS = "/web/wp-content/uploads/tts"
+
+HELPER_SRC = Path(__file__).resolve().parent / "fea_post_io.php"
+LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
+
+LOG_FILE = Path(os.environ.get(
+ "FEA_AUDIO_SYNC_LOG",
+ str(Path(__file__).resolve().parent.parent / "logs/feadulta-audio-sync.log"),
+))
+STATE_FILE = Path(os.environ.get(
+ "FEA_AUDIO_SYNC_STATE",
+ str(Path(__file__).resolve().parent.parent / "logs/feadulta-audio-sync-state.json"),
+))
+
+
+def log(msg: str) -> None:
+ line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
+ print(line, flush=True)
+ LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
+ with LOG_FILE.open("a", encoding="utf-8") as f:
+ f.write(line + "\n")
+
+
+def sh(cmd: list[str], *, input_bytes: bytes | None = None, timeout: int = 120) -> str:
+ r = subprocess.run(cmd, input=input_bytes, capture_output=True, timeout=timeout)
+ if r.returncode != 0:
+ raise RuntimeError(f"cmd falló ({r.returncode}): {' '.join(cmd[:4])}…\n{r.stderr.decode(errors='replace')[:400]}")
+ return r.stdout.decode(errors="replace")
+
+
+# ── Local ────────────────────────────────────────────────────────────────────
+def local_meta(post_id: int, key: str) -> str:
+ r = subprocess.run(
+ ["docker", "exec", WP_CONTAINER, "php", "/tmp/fea_post_io.php", "getmeta", str(post_id), key],
+ capture_output=True, text=True, timeout=60,
+ )
+ return r.stdout.strip()
+
+
+def carta_article_ids(carta_id: int) -> list[int]:
+ q = ("SELECT post_id FROM wp_postmeta "
+ f"WHERE meta_key='_carta_id' AND meta_value='{carta_id}' ORDER BY post_id;")
+ r = subprocess.run(
+ ["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}",
+ DB_NAME, "-N", "-e", q],
+ capture_output=True, text=True, timeout=60,
+ )
+ return [int(x) for x in r.stdout.split() if x.isdigit()]
+
+
+# ── Prod (glibc rota: nada de scp/sftp, todo por ssh + cat) ────────────────────
+def _ssh_text(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
+ cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
+ "-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
+ r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
+ if r.returncode != 0:
+ raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}…\n{r.stderr.strip()[:400]}")
+ return r.stdout
+
+
+def _ssh_upload_bytes(data: bytes, remote_path: str, *, timeout: int = 180) -> None:
+ cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
+ "-o", "ConnectTimeout=20", PROD_HOST, f"cat > {remote_path}"]
+ sh(cmd, input_bytes=data, timeout=timeout)
+
+
+_prod_helper_ready = False
+
+
+def prod_helper(subcmd: str, *args: str) -> str:
+ global _prod_helper_ready
+ if not _prod_helper_ready:
+ _ssh_upload_bytes(HELPER_SRC.read_bytes(), PROD_HELPER)
+ _prod_helper_ready = True
+ inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
+ return _ssh_text(inner, timeout=60)
+
+
+def prod_upload_mp3(post_id: int) -> None:
+ src = LOCAL_TTS_DIR / f"{post_id}.mp3"
+ data = src.read_bytes()
+ remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3"
+ _ssh_upload_bytes(data, remote_path)
+ # Verificación de tamaño (glibc rota => sin fiarse ciegamente del rc=0 de ssh)
+ remote_size = int(_ssh_text(f"wc -c < {remote_path}").strip())
+ if remote_size != len(data):
+ raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}")
+
+
+def prod_remove_mp3(post_id: int) -> None:
+ _ssh_text(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3")
+
+
+# ── Estado ───────────────────────────────────────────────────────────────────
+def load_state() -> dict:
+ if STATE_FILE.exists():
+ try:
+ return json.loads(STATE_FILE.read_text())
+ except json.JSONDecodeError:
+ pass
+ return {"synced": []}
+
+
+def save_state(state: dict) -> None:
+ STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
+ STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))
+
+
+# ── Sync ─────────────────────────────────────────────────────────────────────
+def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
+ if local_meta(post_id, "fea_audio_done") != "1":
+ return "sin-audio-local"
+ if not (LOCAL_TTS_DIR / f"{post_id}.mp3").exists():
+ return "mp3-local-ausente"
+ if dry_run:
+ return "PLAN: subiría mp3 + setaudio"
+
+ voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
+ prod_upload_mp3(post_id)
+ prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice)
+ if post_id not in state["synced"]:
+ state["synced"].append(post_id)
+ save_state(state)
+ return "ok"
+
+
+def rollback_one(post_id: int, state: dict) -> str:
+ prod_helper("unsetaudio", str(post_id))
+ prod_remove_mp3(post_id)
+ if post_id in state["synced"]:
+ state["synced"].remove(post_id)
+ save_state(state)
+ return "ok"
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="Sincroniza audio TTS local→prod (o lo despublica con --rollback).")
+ ap.add_argument("--carta", type=int, default=0, help="WP post ID de la carta local; sincroniza toda su cola.")
+ ap.add_argument("--ids", default="", help="Lista CSV de post IDs concretos.")
+ ap.add_argument("--rollback", action="store_true", help="Despublica en prod en vez de publicar.")
+ ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no toca prod.")
+ args = ap.parse_args()
+
+ if args.ids:
+ ids = [int(x) for x in args.ids.split(",") if x.strip().isdigit()]
+ elif args.carta:
+ ids = carta_article_ids(args.carta)
+ else:
+ ap.error("hace falta --carta o --ids")
+ return 2
+
+ state = load_state()
+ mode = "ROLLBACK" if args.rollback else "SYNC"
+ log(f"=== INICIO {mode} audio→prod. {len(ids)} posts candidatos: {ids} ===")
+
+ ok = skip = err = 0
+ for pid in ids:
+ try:
+ if args.rollback:
+ if pid not in state["synced"] and not args.ids:
+ res = "no-estaba-sincronizado"
+ skip += 1
+ else:
+ res = rollback_one(pid, state)
+ ok += 1
+ else:
+ res = sync_one(pid, state, dry_run=args.dry_run)
+ if res == "ok" or res.startswith("PLAN"):
+ ok += 1
+ else:
+ skip += 1
+ log(f" #{pid}: {res}")
+ except Exception as exc: # noqa: BLE001
+ err += 1
+ log(f" #{pid}: ERROR {exc}")
+
+ log(f"=== FIN {mode}. ok={ok} skip={skip} error={err}. Estado: {STATE_FILE} ===")
+ return 1 if err else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/tts_produce.py b/scripts/tts_produce.py
new file mode 100644
index 0000000..fb3da2a
--- /dev/null
+++ b/scripts/tts_produce.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+"""Orquestador nocturno: locuta cartas ES del gap con MiniMax, una a una,
+repartido en el tiempo. Voz por defecto Nico; los artículos de autores con voz
+clonada (AUTHOR_VOICES en minimax_tts.py, ej. Fray Marcos) usan la suya propia.
+Reanudable (meta fea_audio_done) y con freno ante la cuota (para tras N fallos
+seguidos). NO toca el front; solo genera el mp3 y asocia la URL al post (meta
+fea_audio_url).
+
+Lanzar: nohup ~/tts-local/xtts-venv/bin/python scripts/tts_produce.py > /tmp/feadulta-tts-prod.out 2>&1 &
+Log: /tmp/feadulta-tts-prod.log
+"""
+import os
+import shutil
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import minimax_tts as mm # get_post_text, add_pauses, t2a, OUT
+import translate_post as tp # carta_article_ids
+
+VOICE = "NicoFeadulta2026"
+MODEL = "speech-2.8-hd"
+CONTAINER = "wordpress-web"
+PROD = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
+LOG = Path("/tmp/feadulta-tts-prod.log")
+INTERVAL = 180 # s entre cartas exitosas (reparte el ritmo)
+BACKOFF = 1800 # s de espera ante fallo de cuota antes de reintentar
+MAX_CONSEC_FAIL = 3 # fallos seguidos → parar (cuota probablemente agotada)
+MIN_CHARS = 200 # por debajo, se considera sin contenido locutable
+
+# Cola de cartas a locutar. Override por entorno (FEA_TTS_CARTAS) para priorizar
+# la carta nueva de la semana; si no, cae al orden del gap histórico.
+_DEFAULT_CARTAS = "45018 44997 44975 44230 44229 44228 44090 44089 44088 44087 44086 44085 44084 44083 42590"
+CARTAS = os.environ.get("FEA_TTS_CARTAS", _DEFAULT_CARTAS).replace(",", " ").split()
+
+
+def log(msg):
+ line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
+ print(line, flush=True)
+ with LOG.open("a") as f:
+ f.write(line + "\n")
+
+
+def php(*args):
+ return subprocess.run(["docker", "exec", CONTAINER, "php", "/tmp/fea_post_io.php", *args],
+ capture_output=True, text=True)
+
+
+def meta(pid, key):
+ return php("getmeta", str(pid), key).stdout.strip()
+
+
+def build_queue():
+ # Cola literal de IDs (ya filtrada/ordenada) para priorizar la carta nueva.
+ ids_override = os.environ.get("FEA_TTS_IDS", "").replace(",", " ").split()
+ if ids_override:
+ return [int(x) for x in ids_override if x.strip().isdigit()]
+ q = []
+ for c in CARTAS:
+ cid = int(c)
+ for pid in tp.carta_article_ids(cid):
+ if pid not in q:
+ q.append(pid)
+ return q
+
+
+def main():
+ PROD.mkdir(parents=True, exist_ok=True)
+ subprocess.run(["docker", "cp", "scripts/fea_post_io.php", f"{CONTAINER}:/tmp/fea_post_io.php"],
+ capture_output=True)
+ queue = build_queue()
+ log(f"=== INICIO orquestador TTS. Cola: {len(queue)} posts ES del gap ===")
+
+ i = consec = ok = 0
+ while i < len(queue):
+ pid = queue[i]
+ if meta(pid, "fea_audio_done") == "1" or meta(pid, "fea_audio_skip") == "1":
+ i += 1
+ continue
+ try:
+ title, text, author = mm.get_post_text(pid)
+ except Exception as e: # noqa: BLE001
+ log(f"#{pid}: error leyendo ({e}); skip")
+ php("setflag", str(pid), "fea_audio_skip", "1")
+ i += 1
+ continue
+ if len(text) < MIN_CHARS:
+ log(f"#{pid}: sin contenido ({len(text)} car); skip")
+ php("setflag", str(pid), "fea_audio_skip", "1")
+ i += 1
+ continue
+
+ voice = mm.voice_for_author(author, VOICE)
+ rc = mm.t2a(mm.add_pauses(text), voice, MODEL, f"prod-{pid}")
+ if rc == 0:
+ src = mm.OUT / f"prod-{pid}.mp3"
+ dst = PROD / f"{pid}.mp3"
+ shutil.move(str(src), str(dst))
+ php("setaudio", str(pid), f"/wp-content/uploads/tts/{pid}.mp3", voice)
+ ok += 1
+ consec = 0
+ voice_tag = f" [{voice}]" if voice != VOICE else ""
+ log(f"#{pid} OK «{title[:45]}»{voice_tag} → tts/{pid}.mp3 (total {ok})")
+ i += 1
+ time.sleep(INTERVAL)
+ else:
+ consec += 1
+ log(f"#{pid} FALLO rc={rc} (fallo seguido {consec}/{MAX_CONSEC_FAIL})")
+ php("setflag", str(pid), "fea_audio_error", str(rc))
+ if consec >= MAX_CONSEC_FAIL:
+ log("Demasiados fallos seguidos → cuota agotada probablemente. PARO. "
+ "Reanudable: relanzar el script más tarde (salta lo ya hecho).")
+ break
+ time.sleep(BACKOFF) # reintenta el mismo post tras esperar
+
+ log(f"=== FIN tanda. {ok} audios generados esta ejecución. ===")
+
+
+if __name__ == "__main__":
+ main()