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.
340 lines
12 KiB
Python
340 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Monitor de Locuciones — Ventana corrediza configurable.
|
|
|
|
Cuando una locución+song se consume (desaparece del tope de playlist4),
|
|
genera la siguiente locución para el próximo song sin locución que le
|
|
corresponda narrar (según `cada_cuantos_temas`). Solo genera UNA a la vez
|
|
para no saturar el motor de TTS.
|
|
|
|
Lee `ventana_previas` y `cada_cuantos_temas` de locutor.config en cada
|
|
ciclo (permite reconfigurar sin reiniciar el daemon).
|
|
|
|
No pausa `playlist-refill` (el daemon de GRP que rellena la cola desde la
|
|
parrilla): ese proceso solo agrega canciones al final hasta un largo
|
|
objetivo, sin importar el contenido existente — conviven sin conflicto,
|
|
igual que si se agregaran temas a mano. La única carrera real posible
|
|
(el player consume temas mientras este monitor genera una locución, que
|
|
puede tardar decenas de segundos) se resuelve re-leyendo `playlist4` justo
|
|
antes de escribir, no pausando nada — pausar indefinidamente dejaría de
|
|
rellenar la cola y la estación se quedaría sin música.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
TMP_DIR = os.path.expanduser("~/.gradio/data/tmp")
|
|
DATA_DIR = os.path.expanduser("~/.gradio/data/locutor-ia")
|
|
ESTADO_FILE = os.path.join(TMP_DIR, "estado.json")
|
|
PLAYLIST_FILE = os.path.join(TMP_DIR, "playlist4")
|
|
LOCUTOR_AUTO_FILE = os.path.join(TMP_DIR, "locutor_auto")
|
|
LOCUTOR_CONFIG_FILE = os.path.join(DATA_DIR, "locutor.config")
|
|
CONTADOR_FILE = os.path.join(TMP_DIR, "locutor_song_counter.json")
|
|
SCRIPT_DIR = os.path.join(DATA_DIR, "scripts")
|
|
GENERAR_SCRIPT = os.path.join(SCRIPT_DIR, "generar-intro.sh")
|
|
LOCK_FILE = os.path.join(TMP_DIR, "locuciones_generating")
|
|
|
|
POLL_INTERVAL = 3
|
|
DEFAULT_WINDOW_SIZE = 3
|
|
DEFAULT_CADA_CUANTOS = 3
|
|
|
|
|
|
def log(msg):
|
|
ts = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
print(f"[{ts}] {msg}", flush=True)
|
|
|
|
|
|
def leer_locutor_config():
|
|
"""Lee ventana_previas y cada_cuantos_temas de locutor.config (clave=valor)."""
|
|
ventana = DEFAULT_WINDOW_SIZE
|
|
cada_cuantos = DEFAULT_CADA_CUANTOS
|
|
try:
|
|
with open(LOCUTOR_CONFIG_FILE) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if "=" not in line or line.startswith("#"):
|
|
continue
|
|
clave, valor = line.split("=", 1)
|
|
clave, valor = clave.strip(), valor.strip()
|
|
if clave == "ventana_previas":
|
|
ventana = max(1, int(valor))
|
|
elif clave == "cada_cuantos_temas":
|
|
cada_cuantos = max(1, int(valor))
|
|
except (FileNotFoundError, ValueError):
|
|
pass
|
|
return ventana, cada_cuantos
|
|
|
|
|
|
def read_playlist():
|
|
try:
|
|
with open(PLAYLIST_FILE) as f:
|
|
return [l.strip() for l in f if l.strip()]
|
|
except FileNotFoundError:
|
|
return []
|
|
|
|
|
|
def write_playlist(lines):
|
|
with open(PLAYLIST_FILE, "w") as f:
|
|
for line in lines:
|
|
f.write(line + "\n")
|
|
|
|
|
|
def is_locution(entry):
|
|
ruta = entry.split("\t")[0]
|
|
return "intro_" in os.path.basename(ruta)
|
|
|
|
|
|
def get_song_path(entry):
|
|
return entry.split("\t")[0]
|
|
|
|
|
|
MAX_DECISIONES = 300 # tope para que el archivo no crezca sin límite
|
|
|
|
|
|
def load_contador():
|
|
try:
|
|
with open(CONTADOR_FILE) as f:
|
|
state = json.load(f)
|
|
state.setdefault("count", 0)
|
|
state.setdefault("decisiones", {})
|
|
return state
|
|
except Exception:
|
|
return {"count": 0, "decisiones": {}}
|
|
|
|
|
|
def save_contador(state):
|
|
try:
|
|
with open(CONTADOR_FILE, "w") as f:
|
|
json.dump(state, f)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def corresponde_narrar(song_path, cada_cuantos):
|
|
"""Decide si a este tema le toca locución, contando temas distintos vistos.
|
|
|
|
Recuerda la decisión de CADA tema ya evaluado (no solo el último): el
|
|
escaneo de ensure_window() reinicia desde el principio en cada ciclo de
|
|
poll, así que un tema ya decidido puede volver a aparecer como "primer
|
|
candidato" en ciclos siguientes — si solo se recordara el último tema
|
|
visto, ese tema se recontaría como si fuera nuevo cada vez, corrompiendo
|
|
el conteo de "cada N temas".
|
|
"""
|
|
state = load_contador()
|
|
decisiones = state["decisiones"]
|
|
if song_path not in decisiones:
|
|
state["count"] += 1
|
|
decisiones[song_path] = (state["count"] % cada_cuantos == 0)
|
|
# Podar entradas más viejas si crece demasiado (dict preserva orden de inserción)
|
|
while len(decisiones) > MAX_DECISIONES:
|
|
decisiones.pop(next(iter(decisiones)))
|
|
save_contador(state)
|
|
return decisiones[song_path]
|
|
|
|
|
|
def generate_locution(song_path):
|
|
if not os.path.exists(GENERAR_SCRIPT):
|
|
log(f"ERROR: no existe {GENERAR_SCRIPT}")
|
|
return None, None
|
|
if not os.path.exists(song_path):
|
|
log(f"ERROR: no existe {song_path}")
|
|
return None, None
|
|
try:
|
|
result = subprocess.run(
|
|
["bash", GENERAR_SCRIPT, song_path],
|
|
capture_output=True, text=True, timeout=300,
|
|
cwd=SCRIPT_DIR
|
|
)
|
|
if result.returncode == 0:
|
|
rel_path = result.stdout.strip().split("\n")[-1]
|
|
mp3_path = rel_path if os.path.isabs(rel_path) else os.path.join(SCRIPT_DIR, rel_path)
|
|
if mp3_path and mp3_path.endswith(".mp3") and os.path.exists(mp3_path):
|
|
dur = get_duration(mp3_path)
|
|
return mp3_path, dur
|
|
err = result.stderr.strip() or "(sin stderr)"
|
|
log(f"ERROR generar-intro (rc={result.returncode}): {err[:150]}")
|
|
return None, None
|
|
except subprocess.TimeoutExpired:
|
|
log("ERROR: generar-intro TIMEOUT (>300s)")
|
|
return None, None
|
|
except Exception as e:
|
|
log(f"ERROR: {e}")
|
|
return None, None
|
|
|
|
|
|
def get_duration(mp3_path):
|
|
try:
|
|
r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
|
"-of", "default=noprint_wrappers=1:nokey=1", mp3_path],
|
|
capture_output=True, text=True, timeout=5)
|
|
secs = float(r.stdout.strip())
|
|
mins = int(secs // 60)
|
|
secs = secs % 60
|
|
return f"{mins:02d}:{secs:05.3f}"[:11]
|
|
except Exception:
|
|
pass
|
|
return "00:00:16.000"
|
|
|
|
|
|
LOCK_MAX_AGE = 300 # 5 min — la generación real tarda como mucho ~2 min (CPU)
|
|
|
|
|
|
def ensure_window():
|
|
"""Mantiene `ventana_previas` locuciones al inicio de la playlist, solo
|
|
para los temas que correspondan según `cada_cuantos_temas`. Genera UNA
|
|
a la vez para evitar saturar el motor de TTS.
|
|
"""
|
|
if os.path.exists(LOCK_FILE):
|
|
# Si el lock es viejo, quedó de una generación que murió sin pasar
|
|
# por el `finally` (kill -9, crash) — lo consideramos obsoleto y lo
|
|
# limpiamos, sino el monitor queda trabado para siempre sin avisar.
|
|
edad = time.time() - os.path.getmtime(LOCK_FILE)
|
|
if edad > LOCK_MAX_AGE:
|
|
log(f"Lock de generación obsoleto ({edad:.0f}s) — se limpia y se continúa")
|
|
os.unlink(LOCK_FILE)
|
|
else:
|
|
return # ya hay una generación en curso
|
|
|
|
ventana, cada_cuantos = leer_locutor_config()
|
|
|
|
pl = read_playlist()
|
|
if len(pl) < 1:
|
|
return
|
|
|
|
pairs = 0
|
|
start_i = 1 if (len(pl) > 0 and not is_locution(pl[0])) else 0
|
|
i = start_i
|
|
while i < len(pl) - 1:
|
|
if is_locution(pl[i]) and not is_locution(pl[i + 1]):
|
|
pairs += 1
|
|
i += 2
|
|
else:
|
|
break
|
|
|
|
if pairs >= ventana:
|
|
return # ventana completa
|
|
|
|
# El primer item de la playlist es el que está sonando (o por sonar).
|
|
# Si es un song sin locución, su intro ya fue consumida → omitir (evita loop).
|
|
start = 1 if (len(pl) > 0 and not is_locution(pl[0])) else 0
|
|
for i in range(start, len(pl)):
|
|
entry = pl[i]
|
|
if is_locution(entry):
|
|
continue
|
|
if i > 0 and is_locution(pl[i - 1]):
|
|
continue
|
|
|
|
song_path = get_song_path(entry)
|
|
|
|
if not corresponde_narrar(song_path, cada_cuantos):
|
|
continue # a este tema no le toca, seguir buscando el próximo candidato
|
|
|
|
song_name = os.path.basename(song_path)
|
|
log(f"Generando locución para: {song_name} ({pairs+1}/{ventana})")
|
|
|
|
open(LOCK_FILE, "w").close()
|
|
try:
|
|
mp3, dur = generate_locution(song_path)
|
|
finally:
|
|
os.unlink(LOCK_FILE)
|
|
|
|
if not mp3:
|
|
log(" ✗ falló generación, reintentando después")
|
|
return
|
|
|
|
loc_entry = f"{mp3}\t{dur}"
|
|
|
|
# Re-leer playlist actual para evitar race condition: el player pudo
|
|
# haber consumido items durante la generación (decenas de segundos).
|
|
current_pl = read_playlist()
|
|
if not current_pl:
|
|
current_pl = [loc_entry]
|
|
log(" ✓ playlist vacía, insertando única línea")
|
|
else:
|
|
target_path = get_song_path(entry)
|
|
inserted = False
|
|
for j, line in enumerate(current_pl):
|
|
if get_song_path(line) == target_path:
|
|
if j > 0 and is_locution(current_pl[j - 1]):
|
|
log(" ✗ la canción ya tiene locución, omitiendo")
|
|
return
|
|
current_pl.insert(j, loc_entry)
|
|
inserted = True
|
|
break
|
|
if not inserted:
|
|
current_pl.append(loc_entry)
|
|
log(" ✓ canción ya consumida, insertada al final")
|
|
else:
|
|
log(f" ✓ insertada en playlist: {len(current_pl)} líneas")
|
|
|
|
write_playlist(current_pl)
|
|
return # solo una por ciclo
|
|
|
|
log("(no se encontraron temas pendientes de narrar)")
|
|
|
|
|
|
def locutor_habilitado():
|
|
try:
|
|
with open(LOCUTOR_AUTO_FILE) as f:
|
|
return f.read().strip() == "1"
|
|
except FileNotFoundError:
|
|
return False
|
|
|
|
|
|
def main():
|
|
log("=== Monitor de Locuciones (gr-locutor) iniciado ===")
|
|
|
|
# Un lock encontrado al arrancar es, por definición, de una instancia
|
|
# anterior (este proceso recién empieza, no pudo haber creado ninguno
|
|
# todavía) — por ejemplo si el daemon anterior murió por SIGTERM en
|
|
# medio de una generación, sin pasar por el `finally` que lo libera.
|
|
if os.path.exists(LOCK_FILE):
|
|
log("Lock de generación de una instancia anterior encontrado al iniciar — se limpia")
|
|
os.unlink(LOCK_FILE)
|
|
|
|
if not locutor_habilitado():
|
|
log("tmp/locutor_auto no está en '1' — el monitor no generará nada.")
|
|
log("Activalo en GRP → Configuración → Locutor Automático.")
|
|
|
|
ventana, cada_cuantos = leer_locutor_config()
|
|
log(f"Ventana de {ventana} locuciones · narra 1 de cada {cada_cuantos} temas")
|
|
log("playlist-refill sigue activo — no se pausa (conviven sin conflicto)")
|
|
|
|
last_track = ""
|
|
|
|
while True:
|
|
try:
|
|
if locutor_habilitado():
|
|
ensure_window()
|
|
except Exception as e:
|
|
log(f"Error: {e}")
|
|
|
|
try:
|
|
with open(ESTADO_FILE) as f:
|
|
estado = json.load(f)
|
|
except (FileNotFoundError, json.JSONDecodeError):
|
|
estado = None
|
|
|
|
if estado:
|
|
track = estado.get("track_actual", "")
|
|
reproduciendo = estado.get("reproduciendo", False)
|
|
if track and track != last_track and reproduciendo:
|
|
name = os.path.basename(track)
|
|
if "intro_" in name:
|
|
log(f"▶ Locución: {name}")
|
|
else:
|
|
log(f"▶ Tema: {name}")
|
|
last_track = track
|
|
|
|
time.sleep(POLL_INTERVAL)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except KeyboardInterrupt:
|
|
log("Monitor detenido por el usuario")
|
|
sys.exit(0)
|