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()