#!/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)