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.
120 lines
3.7 KiB
Python
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()
|