Files
cescobar 2f6b5340f5 Commit inicial: gr-locutor (Locutor IA - GRP)
Locuciones de radio con voz IA local (XTTS-v2) integradas a G Radio
Player: interfaz Rust/GTK4 de configuración, motor de generación y
enriquecimiento de texto en Python, daemon de monitoreo que dispara
locuciones sin pausar playlist-refill. Requiere radio-player >= 0.4.11.
2026-08-23 01:58:56 -05:00

120 lines
3.7 KiB
Python

#!/usr/bin/env python3
"""Genera una locución con XTTS-v2 usando la voz de referencia de GRP (o una
voz de referencia propia de la estación, si reemplazó el archivo).
Uso:
generar.py "texto de la locución" [nombre_salida]
Salida: ruta del MP3 generado, impresa en stdout (para que scripts la capturen).
Variables de entorno:
GR_LOCUTOR_VOZ ruta al WAV de referencia (default: ~/.gradio/data/locutor-ia/referencia/voz_referencia.wav)
GR_LOCUTOR_DIR carpeta de datos (default: ~/.gradio/data/locutor-ia)
"""
import os
import socket
import subprocess
import sys
import time
# Forzar resolución IPv4: en la prueba real en RPi5 una conexión IPv6 mal
# enrutada dejó el proceso colgado ~53 minutos esperando una respuesta que
# nunca llegó. Esto evita ese modo de falla en cualquier red similar.
_orig_getaddrinfo = socket.getaddrinfo
def _ipv4_only_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
return _orig_getaddrinfo(host, port, socket.AF_INET, type, proto, flags)
socket.getaddrinfo = _ipv4_only_getaddrinfo
DATA_DIR = os.environ.get("GR_LOCUTOR_DIR", os.path.expanduser("~/.gradio/data/locutor-ia"))
SALIDA_DIR = os.path.join(DATA_DIR, "salida")
def log(msg):
print(f"[gr-locutor] {msg}", file=sys.stderr, flush=True)
def voz_referencia():
"""Prioridad: variable de entorno GR_LOCUTOR_VOZ > locutor.config
(voz_modo=propia + voz_propia_path, escrito por gr-locutor-config) >
voz genérica de GRP por defecto."""
env = os.environ.get("GR_LOCUTOR_VOZ")
if env:
return env
config_path = os.path.join(DATA_DIR, "locutor.config")
modo = "generica"
ruta_propia = ""
try:
with open(config_path) as f:
for line in f:
if "=" not in line or line.strip().startswith("#"):
continue
clave, valor = line.split("=", 1)
clave, valor = clave.strip(), valor.strip()
if clave == "voz_modo":
modo = valor
elif clave == "voz_propia_path":
ruta_propia = valor
except FileNotFoundError:
pass
if modo == "propia" and ruta_propia:
return ruta_propia
return os.path.join(DATA_DIR, "referencia", "voz_referencia.wav")
REF_VOICE = voz_referencia()
def has_gpu():
try:
import torch
return torch.cuda.is_available()
except Exception:
return False
def main():
if len(sys.argv) < 2:
print(f"Uso: {sys.argv[0]} \"texto de la locución\" [nombre_salida]", file=sys.stderr)
sys.exit(1)
texto = sys.argv[1]
nombre = sys.argv[2] if len(sys.argv) > 2 else time.strftime("locucion_%Y%m%d_%H%M%S")
if not os.path.isfile(REF_VOICE):
log(f"ERROR: no existe la voz de referencia en {REF_VOICE}")
log("Ejecuta el instalador o coloca un WAV de 15-30s en esa ruta.")
sys.exit(1)
os.makedirs(SALIDA_DIR, exist_ok=True)
wav_path = f"/tmp/{nombre}.wav"
mp3_path = os.path.join(SALIDA_DIR, f"{nombre}.mp3")
device = "cuda" if has_gpu() else "cpu"
log(f"Dispositivo: {device} | Voz de referencia: {REF_VOICE}")
t0 = time.time()
from TTS.api import TTS
tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(device)
t_load = time.time()
tts.tts_to_file(text=texto, speaker_wav=REF_VOICE, language="es", file_path=wav_path)
t_gen = time.time()
log(f"Cargar modelo: {t_load - t0:.1f}s | Generar: {t_gen - t_load:.1f}s")
subprocess.run(
["ffmpeg", "-y", "-loglevel", "error", "-i", wav_path, "-ar", "44100", "-b:a", "320k", mp3_path],
check=True,
)
os.remove(wav_path)
print(mp3_path)
if __name__ == "__main__":
main()