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.
369 lines
12 KiB
Python
369 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Toma la ruta de un tema, extrae metadata y datos online, genera texto enriquecido.
|
|
|
|
Uso:
|
|
./enriquecer-locucion.py /ruta/al/tema.mp3
|
|
|
|
Salida (stdout): texto enriquecido listo para locución
|
|
|
|
Estructura de la locución:
|
|
1. Opener: una línea aleatoria, alternando entre curiosidades.txt y
|
|
frases-motivacionales.txt (nunca se repite el mismo banco dos veces seguidas).
|
|
2. Tema: título/artista + info enriquecida (año/género/país/dato Wikipedia).
|
|
3. Cierre opcional: una línea de identificaciones.txt (con {radio} sustituido
|
|
por el nombre real de la emisora, leído de gradio.config) — aparece según
|
|
el porcentaje configurado en locutor.config (50% por defecto).
|
|
"""
|
|
|
|
import json, os, random, re, requests, sys
|
|
|
|
try:
|
|
import mutagen
|
|
except ImportError:
|
|
mutagen = None
|
|
|
|
DATA_DIR = os.environ.get("GR_LOCUTOR_DIR", os.path.expanduser("~/.gradio/data/locutor-ia"))
|
|
CACHE_FILE = os.path.expanduser("~/.gradio/data/tmp/locuciones_cache.json")
|
|
GRADIO_CONFIG_FILE = os.path.expanduser("~/.gradio/data/tmp/gradio.config")
|
|
LOCUTOR_CONFIG_FILE = os.path.join(DATA_DIR, "locutor.config")
|
|
ALTERNANCIA_FILE = os.path.expanduser("~/.gradio/data/tmp/locutor_ultimo_banco")
|
|
MB_USER_AGENT = "GRadio-LocutorIA/1.0"
|
|
TIMEOUT = 8
|
|
|
|
|
|
def log(msg):
|
|
print(f"[enriquecer] {msg}", file=sys.stderr, flush=True)
|
|
|
|
|
|
def station_name():
|
|
"""Lee el nombre de la radio desde gradio.config (línea 3), igual que GRP.
|
|
Default 'G Radio' si no existe el archivo o la línea está vacía."""
|
|
try:
|
|
with open(GRADIO_CONFIG_FILE) as f:
|
|
lines = f.read().splitlines()
|
|
if len(lines) >= 3 and lines[2].strip():
|
|
return lines[2].strip()
|
|
except FileNotFoundError:
|
|
pass
|
|
return "G Radio"
|
|
|
|
|
|
def porcentaje_identificacion():
|
|
"""Lee porcentaje_identificacion de locutor.config (clave=valor). Default 50."""
|
|
try:
|
|
with open(LOCUTOR_CONFIG_FILE) as f:
|
|
for line in f:
|
|
if line.strip().startswith("porcentaje_identificacion"):
|
|
return int(line.split("=", 1)[1].strip())
|
|
except (FileNotFoundError, ValueError, IndexError):
|
|
pass
|
|
return 50
|
|
|
|
|
|
def leer_banco(nombre_archivo):
|
|
path = os.path.join(DATA_DIR, nombre_archivo)
|
|
try:
|
|
with open(path, encoding="utf-8") as f:
|
|
return [l.strip() for l in f if l.strip()]
|
|
except FileNotFoundError:
|
|
return []
|
|
|
|
|
|
def elegir_opener():
|
|
"""Alterna entre curiosidades.txt y frases-motivacionales.txt: nunca
|
|
repite el mismo banco dos veces seguidas (se persiste cuál tocó última vez)."""
|
|
bancos = {"curiosidades": "curiosidades.txt", "frases": "frases-motivacionales.txt"}
|
|
ultimo = ""
|
|
try:
|
|
with open(ALTERNANCIA_FILE) as f:
|
|
ultimo = f.read().strip()
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
opciones = [b for b in bancos if b != ultimo] or list(bancos)
|
|
elegido = random.choice(opciones)
|
|
lineas = leer_banco(bancos[elegido])
|
|
if not lineas:
|
|
# Fallback: probar el otro banco si el elegido está vacío/falta
|
|
otro = [b for b in bancos if b != elegido]
|
|
if otro:
|
|
lineas = leer_banco(bancos[otro[0]])
|
|
elegido = otro[0]
|
|
if not lineas:
|
|
return ""
|
|
|
|
try:
|
|
with open(ALTERNANCIA_FILE, "w") as f:
|
|
f.write(elegido)
|
|
except OSError:
|
|
pass
|
|
|
|
return random.choice(lineas)
|
|
|
|
|
|
def elegir_identificacion():
|
|
lineas = leer_banco("identificaciones.txt")
|
|
if not lineas:
|
|
return f"Estás en sintonía con {station_name()}."
|
|
return random.choice(lineas).replace("{radio}", station_name())
|
|
|
|
|
|
def extract_metadata(song_path):
|
|
title = artist = album = year = genre = None
|
|
if mutagen:
|
|
try:
|
|
tags = mutagen.File(song_path, easy=True)
|
|
if tags:
|
|
title = tags.get("title", [None])[0]
|
|
artist = tags.get("artist", [None])[0]
|
|
album = tags.get("album", [None])[0]
|
|
year = tags.get("date", [None])[0]
|
|
genre = tags.get("genre", [None])[0]
|
|
if title and artist:
|
|
return title.strip(), artist.strip(), album, year, genre
|
|
except Exception as e:
|
|
log(f"mutagen error: {e}")
|
|
|
|
name = os.path.splitext(os.path.basename(song_path))[0]
|
|
name = re.sub(r"\(.*?\)", "", name)
|
|
name = re.sub(r"www\..*", "", name)
|
|
name = name.strip()
|
|
artist = title = None
|
|
if " - " in name:
|
|
parts = name.split(" - ", 1)
|
|
artist = parts[0].strip()
|
|
title = parts[1].strip()
|
|
else:
|
|
title = name
|
|
return title, artist, album, year, genre
|
|
|
|
|
|
def load_cache():
|
|
try:
|
|
with open(CACHE_FILE) as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def save_cache(cache):
|
|
try:
|
|
with open(CACHE_FILE, "w") as f:
|
|
json.dump(cache, f)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def lookup_wikipedia(artist, title, cache):
|
|
if not title:
|
|
return None
|
|
key = f"wiki:{artist}:{title}"
|
|
if key in cache:
|
|
return cache[key]
|
|
|
|
queries = []
|
|
if artist:
|
|
queries.append(f'"{artist}" "{title}" canción')
|
|
queries.append(f'"{artist}" "{title}" música')
|
|
queries.append(f'"{artist}" músico')
|
|
else:
|
|
queries.append(f'"{title}" canción')
|
|
|
|
WIKI = "https://es.wikipedia.org/w/api.php"
|
|
for q in queries:
|
|
try:
|
|
r = requests.get(WIKI,
|
|
params={
|
|
"action": "query",
|
|
"list": "search",
|
|
"srsearch": q,
|
|
"format": "json",
|
|
"srlimit": 3,
|
|
},
|
|
timeout=TIMEOUT,
|
|
headers={"User-Agent": "GRadio-LocutorIA/1.0 (radio-automation)"},
|
|
)
|
|
data = r.json()
|
|
pages = data.get("query", {}).get("search", [])
|
|
if not pages:
|
|
continue
|
|
|
|
for page in pages:
|
|
page_title = page["title"]
|
|
r2 = requests.get(
|
|
WIKI,
|
|
params={
|
|
"action": "query",
|
|
"prop": "extracts",
|
|
"exintro": True,
|
|
"explaintext": True,
|
|
"titles": page_title,
|
|
"format": "json",
|
|
"exsentences": 3,
|
|
},
|
|
timeout=TIMEOUT,
|
|
headers={"User-Agent": "GRadio-LocutorIA/1.0 (radio-automation)"},
|
|
)
|
|
pg_data = r2.json().get("query", {}).get("pages", {})
|
|
for pid, pg in pg_data.items():
|
|
if pid == "-1" or "extract" not in pg:
|
|
continue
|
|
extract = pg["extract"].strip()
|
|
sentences = re.split(r"(?<=[.!?])\s+", extract)
|
|
for s in sentences:
|
|
s = s.strip()
|
|
if 40 <= len(s) <= 200:
|
|
cache[key] = s
|
|
save_cache(cache)
|
|
return s
|
|
except Exception as e:
|
|
log(f"wiki error ({q[:30]}...): {e}")
|
|
|
|
cache[key] = None
|
|
save_cache(cache)
|
|
return None
|
|
|
|
|
|
def lookup_musicbrainz(artist, title, cache):
|
|
if not artist or not title:
|
|
return None
|
|
key = f"mb:{artist}:{title}"
|
|
if key in cache:
|
|
return cache[key]
|
|
|
|
try:
|
|
r = requests.get(
|
|
"https://musicbrainz.org/ws/2/recording/",
|
|
params={
|
|
"query": f'artist:"{artist}" AND recording:"{title}"',
|
|
"fmt": "json",
|
|
"limit": 1,
|
|
},
|
|
timeout=TIMEOUT + 2,
|
|
headers={"User-Agent": MB_USER_AGENT},
|
|
)
|
|
data = r.json()
|
|
recs = data.get("recordings", [])
|
|
if not recs:
|
|
cache[key] = None
|
|
save_cache(cache)
|
|
return None
|
|
|
|
rec = recs[0]
|
|
info = {}
|
|
if "releases" in rec and rec["releases"]:
|
|
rel = rec["releases"][0]
|
|
date = rel.get("date", "")
|
|
info["year"] = date[:4] if date and len(date) >= 4 else ""
|
|
info["country"] = rel.get("country", "")
|
|
if "artist-credit" in rec:
|
|
ac = rec["artist-credit"][0]
|
|
if isinstance(ac, dict) and "artist" in ac:
|
|
info["artist_type"] = ac["artist"].get("type", "")
|
|
|
|
cache[key] = info if info else None
|
|
save_cache(cache)
|
|
return info
|
|
except Exception as e:
|
|
log(f"musicbrainz error: {e}")
|
|
cache[key] = None
|
|
save_cache(cache)
|
|
return None
|
|
|
|
|
|
_PAISES = {
|
|
"US": "Estados Unidos", "GB": "Reino Unido", "UK": "Reino Unido",
|
|
"ES": "España", "MX": "México", "AR": "Argentina", "CO": "Colombia",
|
|
"CL": "Chile", "PE": "Perú", "CU": "Cuba", "PR": "Puerto Rico",
|
|
"DE": "Alemania", "FR": "Francia", "IT": "Italia", "BR": "Brasil",
|
|
"CA": "Canadá", "AU": "Australia", "JP": "Japón", "MY": "Malasia",
|
|
"SE": "Suecia", "NL": "Países Bajos", "IE": "Irlanda", "NO": "Noruega",
|
|
"DK": "Dinamarca", "PT": "Portugal", "RU": "Rusia",
|
|
}
|
|
|
|
|
|
def generate_text(artist, title, album, year, genre, wiki_fact, mb_info):
|
|
anyo = mb_info.get("year", "") if mb_info and isinstance(mb_info, dict) else ""
|
|
|
|
nombre_pais = ""
|
|
pa_mb = mb_info.get("country", "") if mb_info and isinstance(mb_info, dict) else ""
|
|
if pa_mb:
|
|
nombre_pais = _PAISES.get(pa_mb, pa_mb)
|
|
|
|
# --- 1. Opener (alternando curiosidades.txt / frases-motivacionales.txt) ---
|
|
opener = elegir_opener()
|
|
|
|
# --- 2. Tema ---
|
|
if artist and title:
|
|
tema = f"Escuchas {title} de {artist}"
|
|
elif title:
|
|
tema = f"Escuchas {title}"
|
|
else:
|
|
tema = ""
|
|
|
|
# --- 3. Info enriquecida ---
|
|
infos = []
|
|
if anyo and anyo not in ("?", ""):
|
|
infos.append(f"grabada en {anyo}")
|
|
if genre and genre not in ("?", ""):
|
|
infos.append(f"un tema de {genre}")
|
|
if nombre_pais:
|
|
infos.append(f"lanzada en {nombre_pais}")
|
|
|
|
info_str = ", ".join(infos) if infos else ""
|
|
|
|
wiki_str = ""
|
|
if wiki_fact:
|
|
fact = wiki_fact.strip()
|
|
if len(fact) > 160:
|
|
fact = fact[:157] + "..."
|
|
wiki_str = fact
|
|
|
|
if tema and info_str:
|
|
cuerpo = f"{tema}, {info_str}."
|
|
elif tema:
|
|
cuerpo = f"{tema}."
|
|
elif info_str:
|
|
cuerpo = info_str
|
|
else:
|
|
cuerpo = ""
|
|
|
|
if wiki_str:
|
|
cuerpo = f"{cuerpo} {wiki_str}" if cuerpo else wiki_str
|
|
|
|
# --- 4. Cierre opcional (según porcentaje_identificacion de locutor.config) ---
|
|
cierre = ""
|
|
if random.randint(1, 100) <= porcentaje_identificacion():
|
|
cierre = elegir_identificacion()
|
|
|
|
oraciones = [o for o in (opener, cuerpo, cierre) if o]
|
|
return " ".join(oraciones)
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) < 2:
|
|
log("Uso: enriquecer-locucion.py /ruta/al/tema.mp3")
|
|
sys.exit(1)
|
|
|
|
song_path = os.path.abspath(sys.argv[1])
|
|
if not os.path.exists(song_path):
|
|
log(f"no existe: {song_path}")
|
|
sys.exit(1)
|
|
|
|
title, artist, album, year, genre = extract_metadata(song_path)
|
|
log(f"metadata: artist={artist} title={title} album={album} year={year} genre={genre}")
|
|
|
|
cache = load_cache()
|
|
wiki_fact = lookup_wikipedia(artist or title, title, cache)
|
|
mb_info = lookup_musicbrainz(artist or title, title, cache)
|
|
|
|
text = generate_text(artist, title, album, year, genre, wiki_fact, mb_info)
|
|
log(f"wiki={'sí' if wiki_fact else 'no'} mb={'sí' if mb_info else 'no'}")
|
|
log(f"texto ({len(text)} chars): {text[:100]}...")
|
|
|
|
print(text)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|