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:
@@ -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);
|
||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user