feat: TTS multi-voz por autor (MiniMax voice cloning, gitea.feadulta.com #152)

Voz clonada por autor (Fray Marcos, Pagola, Sicre, Arregi) en vez de una
única voz genérica para todo el TTS. minimax_tts.py resuelve la voz por
post_author (AUTHOR_VOICES) y cae a Nico si no hay clon; fea_post_io.php
propaga autor/voz; tts_produce.py y sync_audio_to_prod.py (local→prod,
workaround glibc rota del servidor) usan la voz correcta en vez de forzar
Nico.

Incluye fix de trim_after_author_signature: la heurística de "firma final
del autor" confundía encabezados litúrgicos iniciales (p.ej. "CORPUS (A)")
con la firma, truncando el texto a sintetizar a un par de palabras. Ahora
solo cuenta como firma un párrafo con >200 caracteres de contenido real
por delante.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 21:34:20 -04:00
parent 82ca122a4e
commit db0d0a03f8
4 changed files with 869 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
<?php
/**
* IO mínimo de posts WP para el reprocesador EN.
* get <id> -> escribe /tmp/fea_es.json {title, content, status}
* update <id> <titlef> <bodyf> -> 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 <id> <relpath> [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 <id> <key> <value>
update_post_meta((int)$argv[2], $argv[3], $argv[4]);
exit(0);
}
if ($action === 'unsetaudio') { // unsetaudio <id> (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);
+454
View File
@@ -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 <audio.wav> <voice_id> sube y clona (voice_id: >=8 chars, letras+números)
carta <post_id> <voice_id> [model] [nombre] locuta una carta entera
text "<texto>" <voice_id> [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)</p>|<br\s*/?>|</h[1-6]>", "\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
(<short=0.1s, <=long=0.2s, >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()
+219
View File
@@ -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())
+122
View File
@@ -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()