#!/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"} 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) 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): out = [] for p in paras: out.append(p) if is_author_signature(p): break return out 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 = 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()