#!/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). Dos modos de cola: - cartas (por defecto): FEA_TTS_CARTAS / --cartas / --ids. Es el flujo de la carta semanal, que tiene prioridad y no cambia. - backlog por autor: --autor 382 [--desde 2025] [--hasta 2026] [--max 15]. La cola sale de `listpending` en fea_post_io.php (posts ES publicados sin audio, más recientes primero). Esa consulta ES la idempotencia: no hay fichero de estado, lo ya locutado deja de salir solo. 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 argparse 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 = os.environ.get("FEA_TTS_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 errores transitorios no clasificados MAX_CONSEC_FAIL = 3 # fallos seguidos no clasificados → parar QUOTA_OR_RATE_ERRORS = {2056, 1039} # MiniMax: no reintentar en este proceso 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 backlog_ids(autor, desde, hasta, limite): """Cola del backlog de un autor, delegada a la BD (ver listpending).""" # Voz clonada del autor, si la tiene: los locutados con otra voz también # cuentan como pendientes. Sin clon (""), pendiente = simplemente sin audio. voz = mm.voice_for_author(autor, "") r = php("listpending", str(autor), str(desde), str(hasta), str(limite), voz) if r.returncode != 0: log(f"listpending falló (rc={r.returncode}): {r.stderr.strip()[:200]}") return [] return [int(x) for x in r.stdout.split() if x.strip().isdigit()] 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(): global CARTAS parser = argparse.ArgumentParser( description="Locuta posts ES de Fe Adulta con MiniMax; sin --ids conserva la cola programada." ) parser.add_argument("--ids", help="CSV de IDs ES concretos, en el orden de locución deseado") parser.add_argument("--cartas", help="CSV de cartas para construir la cola; sustituye FEA_TTS_CARTAS") parser.add_argument("--autor", type=int, help="WP user_id: cola del backlog de ese autor en vez de cartas") parser.add_argument("--desde", type=int, default=0, help="año inicial del backlog (con --autor)") parser.add_argument("--hasta", type=int, default=9999, help="año final del backlog (con --autor)") parser.add_argument("--max", type=int, default=0, help="para tras N audios OK en esta ejecución (0 = sin tope)") parser.add_argument("--dry-run", action="store_true", help="imprime la cola y sale, sin sintetizar ni gastar cuota") parser.add_argument("--allow-default-voice", action="store_true", help="permite Nico para autores sin voz digitalizada (desactivado por defecto)") args = parser.parse_args() if args.ids: os.environ["FEA_TTS_IDS"] = args.ids if args.cartas: os.environ["FEA_TTS_CARTAS"] = args.cartas CARTAS = args.cartas.replace(",", " ").split() 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) if args.autor: # Pide holgura sobre --max: parte de la cola puede caerse por contenido corto. limite = args.max * 3 if args.max else 0 queue = backlog_ids(args.autor, args.desde, args.hasta, limite) origen = (f"backlog autor {args.autor} ({args.desde}-{args.hasta}), " f"voz {mm.voice_for_author(args.autor, VOICE)}") else: queue = build_queue() origen = "cartas" tope = f", tope {args.max} esta tanda" if args.max else "" log(f"=== INICIO orquestador TTS. Cola: {len(queue)} posts ES [{origen}]{tope} ===") if args.dry_run: log("--dry-run: no sintetizo. Cola = " + (",".join(str(x) for x in queue) or "(vacía)")) return 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 cloned_voice = mm.voice_for_author(author, "") if cloned_voice: voice = cloned_voice elif args.allow_default_voice: voice = VOICE else: log(f"#{pid}: autor sin voz digitalizada (author_id={author}); omitido") i += 1 continue 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 if args.max and ok >= args.max: log(f"Tope de la tanda alcanzado ({args.max}). PARO. " "Reanudable: la próxima ventana recalcula la cola y sigue.") break 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 rc in QUOTA_OR_RATE_ERRORS: log(f"MiniMax rc={rc}: cuota/rate limit explícito. PARO sin reintentar. " "Reanudable: relanzar el script más tarde (salta lo ya hecho).") break if consec >= MAX_CONSEC_FAIL: log("Demasiados fallos seguidos no clasificados. PARO. " "Reanudable: relanzar el script más tarde (salta lo ya hecho).") break time.sleep(BACKOFF) # solo errores transitorios no clasificados log(f"=== FIN tanda. {ok} audios generados esta ejecución. ===") if __name__ == "__main__": main()