Initial commit: Radio Susan scripts, configs, and SFX

This commit is contained in:
Caine
2026-03-07 12:52:55 +00:00
commit 01682c62c2
30 changed files with 2856 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
# Virtual environment
venv/
# Databases (generated, contain runtime state)
*.db
*.db-wal
*.db-shm
# Generated caches & playlists
genre_cache.json
playlist.json
playlist.m3u
track_state.json
track_history.json
playlists/
# Generated audio (TTS announcements, jingles are regenerated)
announcements/
jingles/
# Logs
*.log
*.log.*
# Python
__pycache__/
*.pyc
# Runtime/cache
.cache/
# Legacy/backup files
*.bak
# Secrets — Icecast password is in radio.liq and track_announce.liq
# These are tracked but the password should be moved to env var eventually
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""Analyse audio tracks with Essentia. Run daily via cron.
Use venv: /var/lib/radio/venv/bin/python3 /var/lib/radio/analyse_tracks.py
"""
import math
import os
import sqlite3
import subprocess
import sys
import tempfile
import time
from pathlib import Path
FEATURES_DB = "/var/lib/radio/audio_features.db"
MUSIC_ROOT = "/disks/Plex/Music/"
SUPPORTED = {".flac", ".mp3", ".ogg", ".opus", ".m4a", ".wav"}
# Energy percentile breakpoints from bulk analysis of ~6500 tracks.
# Maps raw log-energy values to even 0-1 distribution.
# Index 0 = 0th percentile, index 20 = 100th percentile (every 5%).
ENERGY_BREAKPOINTS = [
0.0, 0.7148, 0.7581, 0.7801, 0.7999, 0.8163, 0.8289, 0.8401,
0.8493, 0.8587, 0.8667, 0.8744, 0.8811, 0.8877, 0.8949, 0.9018,
0.9089, 0.9169, 0.9252, 0.9377, 1.0,
]
def normalize_energy(raw_log_energy: float) -> float:
"""Map a raw log-energy value to 0-1 using the library's percentile curve."""
bp = ENERGY_BREAKPOINTS
if raw_log_energy <= bp[0]:
return 0.0
if raw_log_energy >= bp[-1]:
return 1.0
# Find which bucket it falls in and interpolate
for i in range(1, len(bp)):
if raw_log_energy <= bp[i]:
frac = (raw_log_energy - bp[i - 1]) / (bp[i] - bp[i - 1]) if bp[i] != bp[i - 1] else 0.5
return round((i - 1 + frac) / (len(bp) - 1), 4)
return 1.0
def init_db():
"""Create features DB and table if needed."""
conn = sqlite3.connect(FEATURES_DB)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("""
CREATE TABLE IF NOT EXISTS features (
path TEXT PRIMARY KEY,
energy REAL,
bpm REAL,
loudness REAL,
danceability REAL,
analysed_at REAL
)
""")
conn.commit()
return conn
def get_analysed_paths(conn):
"""Return set of already-analysed file paths."""
cur = conn.execute("SELECT path FROM features")
return {row[0] for row in cur}
def find_audio_files():
"""Walk music root for all supported audio files."""
root = Path(MUSIC_ROOT)
files = []
for p in root.rglob("*"):
if p.suffix.lower() in SUPPORTED and p.is_file():
files.append(str(p))
return files
def analyse_track(filepath):
"""Extract audio features using Essentia. Returns dict or None on error."""
try:
import essentia
import essentia.standard as es
except ImportError:
print("Error: essentia not installed. Install with: pip install essentia", file=sys.stderr)
sys.exit(1)
# Essentia's bundled decoder segfaults on opus and struggles with some
# m4a/ogg files. Pre-decode these to a temp WAV via ffmpeg.
NEEDS_PREDECODE = {".opus", ".ogg", ".m4a"}
tmp_wav = None
load_path = filepath
if Path(filepath).suffix.lower() in NEEDS_PREDECODE:
try:
tmp_wav = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
tmp_wav.close()
subprocess.run(
["ffmpeg", "-y", "-i", filepath, "-ac", "1", "-ar", "22050", "-sample_fmt", "s16", "-f", "wav", tmp_wav.name],
capture_output=True, timeout=15,
)
load_path = tmp_wav.name
except Exception as e:
print(f" Skipping (ffmpeg decode error): {e}", file=sys.stderr)
if tmp_wav and os.path.exists(tmp_wav.name):
os.unlink(tmp_wav.name)
return None
try:
sr = 22050 if tmp_wav else 44100
audio = es.MonoLoader(filename=load_path, sampleRate=sr)()
except Exception as e:
print(f" Skipping (load error): {e}", file=sys.stderr)
if tmp_wav and os.path.exists(tmp_wav.name):
os.unlink(tmp_wav.name)
return None
finally:
if tmp_wav and os.path.exists(tmp_wav.name):
os.unlink(tmp_wav.name)
# Energy — raw log-energy mapped through percentile curve
try:
raw_energy = es.Energy()(audio)
raw_log = min(1.0, math.log1p(raw_energy) / 15.0)
energy_norm = normalize_energy(raw_log)
energy_raw = raw_log
except Exception:
energy_norm = 0.5
energy_raw = 0.5
# BPM
try:
bpm, _, _, _, _ = es.RhythmExtractor2013()(audio)
if bpm < 30 or bpm > 300:
bpm = 0.0
except Exception:
bpm = 0.0
# Loudness
try:
loudness = es.Loudness()(audio)
except Exception:
loudness = 0.0
# Danceability
try:
danceability, _ = es.Danceability()(audio)
except Exception:
danceability = 0.0
return {
"energy": round(energy_norm, 4),
"energy_raw": round(energy_raw, 4),
"bpm": round(bpm, 2),
"loudness": round(loudness, 4),
"danceability": round(danceability, 4),
}
def main():
print(f"Essentia track analyser — {time.strftime('%Y-%m-%d %H:%M:%S')}")
conn = init_db()
analysed = get_analysed_paths(conn)
print(f"Already analysed: {len(analysed)} tracks")
all_files = find_audio_files()
print(f"Found on disk: {len(all_files)} audio files")
pending = [f for f in all_files if f not in analysed]
print(f"To analyse: {len(pending)} new tracks")
if not pending:
print("Nothing to do.")
conn.close()
return
done = 0
errors = 0
start = time.time()
for i, filepath in enumerate(pending):
if (i + 1) % 100 == 0 or i == 0:
elapsed = time.time() - start
rate = (done / elapsed) if elapsed > 0 and done > 0 else 0
eta = ((len(pending) - i) / rate / 60) if rate > 0 else 0
print(f" [{i+1}/{len(pending)}] {rate:.1f} tracks/sec, ETA {eta:.0f} min")
features = analyse_track(filepath)
if features is None:
errors += 1
continue
try:
conn.execute(
"INSERT OR REPLACE INTO features (path, energy, energy_raw, bpm, loudness, danceability, analysed_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(filepath, features["energy"], features["energy_raw"], features["bpm"], features["loudness"], features["danceability"], time.time()),
)
if done % 50 == 0:
conn.commit()
done += 1
except Exception as e:
print(f" DB error for {filepath}: {e}", file=sys.stderr)
errors += 1
conn.commit()
conn.close()
elapsed = time.time() - start
print(f"Done: {done} analysed, {errors} errors, {elapsed:.1f}s total")
if __name__ == "__main__":
main()
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""
Generate a track announcement TTS clip for Radio Susan.
Called by liquidsoap via request.dynamic between tracks.
Reads track_state.json (written by liquidsoap on_track) and
track_history.json (maintained here) to announce what just played.
Outputs an MP3 file path to stdout, or nothing if no announcement needed.
"""
import hashlib
import json
import os
import random
import subprocess
import tempfile
from pathlib import Path
VENV_BIN = Path("/var/lib/radio/venv/bin")
EDGE_TTS = VENV_BIN / "edge-tts"
ANNOUNCE_DIR = Path("/var/lib/radio/announcements")
ANNOUNCE_DIR.mkdir(parents=True, exist_ok=True)
STATE_FILE = Path("/var/lib/radio/track_state.json")
HISTORY_FILE = Path("/var/lib/radio/track_history.json")
VOICES = [
"en-US-GuyNeural",
"en-US-ChristopherNeural",
"en-GB-RyanNeural",
"en-US-AriaNeural",
"en-US-JennyNeural",
]
# Station ID variations
STATION_IDS = [
"Radio Susan, ninety nine point oh.",
"You're listening to Radio Susan.",
"Radio Susan.",
"Susan Radio, always on.",
"This is Radio Susan.",
]
def pick_voice(text):
h = int(hashlib.md5(text.encode()).hexdigest(), 16)
return VOICES[h % len(VOICES)]
def generate_tts(text, outfile, voice=None):
if voice is None:
voice = pick_voice(text)
cmd = [
str(EDGE_TTS),
"--voice", voice,
"--rate", "+10%",
"--text", text,
"--write-media", outfile,
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
return result.returncode == 0
except subprocess.TimeoutExpired:
return False
def normalize_audio(infile, outfile):
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-i", infile,
"-af", "loudnorm=I=-14:TP=-1:LRA=11,aformat=sample_rates=44100:channel_layouts=stereo",
"-b:a", "192k",
outfile,
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
return result.returncode == 0
except subprocess.TimeoutExpired:
return False
def clean_title(s):
if not s or s == "Unknown":
return None
for ext in ('.flac', '.mp3', '.ogg', '.m4a', '.opus', '.wav'):
s = s.replace(ext, '')
s = s.strip()
# Strip leading track numbers like "01 -", "03.", "001 "
if len(s) > 3 and s[:2].isdigit() and s[2] in '. -':
s = s[3:].strip()
if len(s) > 4 and s[:3].isdigit() and s[3] in '. -':
s = s[4:].strip()
# Strip "Artist - " prefix if title already contains it
return s.strip() if s.strip() else None
def fmt_track(entry):
"""Format a history entry as 'Artist - Title'."""
artist = entry.get("artist", "")
title = entry.get("title", "")
if artist and title:
return f"{artist} - {title}"
return clean_title(title or artist) or None
def load_history():
if HISTORY_FILE.exists():
try:
return json.loads(HISTORY_FILE.read_text())
except Exception:
pass
return []
def save_history(history):
HISTORY_FILE.write_text(json.dumps(history[-20:]))
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--state", action="store_true")
args = parser.parse_args()
if not args.state:
return
# Read current track from liquidsoap's state file
current = None
if STATE_FILE.exists():
try:
current = json.loads(STATE_FILE.read_text())
except Exception:
return
if not current:
return
history = load_history()
# Check if this is the same track we already announced (avoid repeats)
current_key = f"{current.get('artist', '')}|{current.get('title', '')}"
if history and f"{history[-1].get('artist', '')}|{history[-1].get('title', '')}" == current_key:
return # Already announced this one
# Add current to history
history.append(current)
save_history(history)
# Need at least 2 tracks in history to announce (we announce what JUST played)
if len(history) < 2:
return
# Announce the two most recent tracks before this one.
# With 2:1 rotation these roughly match what the listener just heard.
last = fmt_track(history[-2]) if len(history) >= 2 else None
prev = fmt_track(history[-3]) if len(history) >= 3 else None
if not last:
return
# Build announcement — keep it vague ("recently" not "just played")
station_id = random.choice(STATION_IDS)
if prev:
text = f"You've been listening to {last}, and {prev}. {station_id}"
else:
text = f"You've been listening to {last}. {station_id}"
# Cache based on content hash
content_hash = hashlib.md5(text.encode()).hexdigest()[:12]
cached = ANNOUNCE_DIR / f"announce_{content_hash}.mp3"
if cached.exists():
print(str(cached))
return
# Generate TTS
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp:
tmp_path = tmp.name
try:
if not generate_tts(text, tmp_path):
return
if normalize_audio(tmp_path, str(cached)):
print(str(cached))
else:
os.rename(tmp_path, str(cached))
print(str(cached))
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
# Cleanup old announcements (keep last 200)
announcements = sorted(ANNOUNCE_DIR.glob("announce_*.mp3"), key=lambda f: f.stat().st_mtime)
for old in announcements[:-200]:
old.unlink()
if __name__ == "__main__":
main()
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Build genre cache from beets database + Essentia audio features. Run daily via cron.
Genre-level energy scores are computed from the mean Essentia energy of all
analysed tracks in that genre. Unanalysed tracks fall back to their genre
average, or 5 (neutral) if the genre has no analysed tracks yet.
"""
import json
import os
import sqlite3
import sys
import time
BEETS_DB = "/home/susan/.config/beets/library.db"
FEATURES_DB = "/var/lib/radio/audio_features.db"
CACHE_PATH = "/var/lib/radio/genre_cache.json"
NEUTRAL_ENERGY = 5
# Genre → broad group (for DJ filtering / display)
GROUP_KEYWORDS = {
"chill": ["ambient", "downtempo", "chill", "drone", "new age", "lounge", "easy listening", "sleep", "meditation", "minimal"],
"jazz": ["jazz", "bossa nova", "bebop", "swing", "fusion"],
"classical": ["classical", "chamber", "orchestral", "piano", "baroque", "romantic"],
"electronic": ["techno", "house", "trance", "electro", "electronic", "idm", "ebm", "synth", "synthwave"],
"bass": ["dubstep", "drum and bass", "dnb", "jungle", "breakbeat", "garage", "uk garage", "dub", "bass"],
"rock": ["rock", "punk", "metal", "shoegaze", "post rock", "post-rock", "grunge", "alternative", "indie rock", "noise rock"],
"hiphop": ["hip hop", "hip-hop", "rap", "trap", "boom bap", "grime"],
"soul": ["soul", "r&b", "rnb", "funk", "disco", "motown", "gospel", "neo soul"],
"folk": ["folk", "acoustic", "singer-songwriter", "country", "bluegrass", "americana"],
"pop": ["pop", "synth pop", "synthpop", "dream pop", "new wave", "art pop", "indie pop"],
"world": ["afrobeat", "reggae", "latin", "world", "flamenco", "samba", "cumbia"],
"heavy": ["industrial", "noise", "hardcore", "metal", "thrash", "grindcore", "power electronics"],
"experimental": ["experimental", "avant-garde", "musique concrete", "free improvisation"],
}
def normalize_genre(g):
return g.lower().replace("-", " ").replace("_", " ").strip()
def essentia_to_scale(e):
"""Convert Essentia normalized energy (0.01.0) to 110 integer scale."""
return max(1, min(10, round(e * 9 + 1)))
def find_group(genre):
g = normalize_genre(genre)
for group, keywords in GROUP_KEYWORDS.items():
for kw in keywords:
if kw in g or g in kw:
return group
return "other"
def load_essentia_features():
"""Load path → normalized energy from audio_features.db."""
if not os.path.exists(FEATURES_DB):
print(f"Warning: features DB not found at {FEATURES_DB}, all tracks will get energy {NEUTRAL_ENERGY}", file=sys.stderr)
return {}
try:
conn = sqlite3.connect(f"file:{FEATURES_DB}?mode=ro", uri=True)
rows = conn.execute("SELECT path, energy FROM features").fetchall()
conn.close()
return {row[0]: row[1] for row in rows}
except Exception as e:
print(f"Warning: failed to read features DB: {e}", file=sys.stderr)
return {}
def main():
if not os.path.exists(BEETS_DB):
print(f"Error: beets DB not found at {BEETS_DB}", file=sys.stderr)
sys.exit(1)
essentia_energies = load_essentia_features()
print(f"Loaded Essentia features for {len(essentia_energies)} tracks")
conn = sqlite3.connect(f"file:{BEETS_DB}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("SELECT path, title, artist, album, albumartist, genre FROM items")
# First pass: collect per-track data, accumulate Essentia energies per genre
track_data = {}
genre_energies = {} # genre → [list of 110 scores from analysed tracks]
analysed_count = 0
for row in cur:
path = row["path"]
if isinstance(path, bytes):
try:
path = path.decode("utf-8")
except UnicodeDecodeError:
continue
genre_raw = row["genre"] or ""
genre = normalize_genre(genre_raw) if genre_raw else "misc"
if path in essentia_energies:
energy = essentia_to_scale(essentia_energies[path])
analysed_count += 1
genre_energies.setdefault(genre, []).append(energy)
else:
energy = None # resolve in second pass
track_data[path] = {
"genre": genre,
"genre_raw": genre_raw,
"energy": energy,
"artist": row["artist"] or "",
"album": row["album"] or "",
"title": row["title"] or "",
}
conn.close()
# Compute genre averages from Essentia data
genre_avg_energy = {}
for genre, scores in genre_energies.items():
genre_avg_energy[genre] = round(sum(scores) / len(scores))
print(f"Genre energies computed from Essentia for {len(genre_avg_energy)} genres")
# Second pass: fill unanalysed tracks with genre average or neutral
fallback_genre_avg = 0
fallback_neutral = 0
for info in track_data.values():
if info["energy"] is None:
if info["genre"] in genre_avg_energy:
info["energy"] = genre_avg_energy[info["genre"]]
fallback_genre_avg += 1
else:
info["energy"] = NEUTRAL_ENERGY
fallback_neutral += 1
# Build genre summary
all_genres = set(info["genre"] for info in track_data.values())
genres_out = {}
for genre in all_genres:
genres_out[genre] = {
"energy": genre_avg_energy.get(genre, NEUTRAL_ENERGY),
"group": find_group(genre),
"analysed_tracks": len(genre_energies.get(genre, [])),
}
# Build output tracks
tracks_out = {
path: {
"genre": info["genre"],
"energy": info["energy"],
"artist": info["artist"],
"album": info["album"],
"title": info["title"],
}
for path, info in track_data.items()
}
cache = {
"genres": genres_out,
"tracks": tracks_out,
"built_at": time.time(),
"stats": {
"total_tracks": len(tracks_out),
"essentia_analysed": analysed_count,
"fallback_genre_avg": fallback_genre_avg,
"fallback_neutral": fallback_neutral,
"genres_with_essentia": len(genre_energies),
},
}
tmp = CACHE_PATH + ".tmp"
with open(tmp, "w") as f:
json.dump(cache, f)
os.replace(tmp, CACHE_PATH)
print(f"Built genre cache: {len(tracks_out)} tracks, {len(genres_out)} genres")
print(f" Essentia energy: {analysed_count} tracks")
print(f" Genre avg fallback: {fallback_genre_avg} tracks")
print(f" Neutral ({NEUTRAL_ENERGY}) fallback: {fallback_neutral} tracks")
if __name__ == "__main__":
main()
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
exec ffmpeg -hide_banner -loglevel error -i "$1" -f wav -ar 44100 -ac 2 -
Executable
+352
View File
@@ -0,0 +1,352 @@
#!/usr/bin/env python3
"""Shared core logic for Radio Susan DJ — used by radio_dj.py and generate_daily_playlist.py."""
import json
import math
import os
import random
import sqlite3
import time
from pathlib import Path
# ── Paths ──────────────────────────────────────────────────────────────────────
BEETS_DB = "/home/susan/.config/beets/library.db"
NAVIDROME_DB = "/var/lib/navidrome/navidrome.db"
GENRE_CACHE = "/var/lib/radio/genre_cache.json"
FEATURES_DB = "/var/lib/radio/audio_features.db"
STATE_DB = "/var/lib/radio/dj_state.db"
MUSIC_ROOT = "/disks/Plex/Music/"
SUPPORTED = {".flac", ".mp3", ".ogg", ".opus", ".m4a", ".wav"}
# ── Energy curve (hour → target energy 1-10) ──────────────────────────────────
ENERGY_CURVE = {
0: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1,
7: 2, 8: 3,
9: 4, 10: 5, 11: 5,
12: 4, 13: 4,
14: 5, 15: 6, 16: 6,
17: 5, 18: 6,
19: 7, 20: 8, 21: 7,
22: 4, 23: 2,
}
# ── Show definitions ───────────────────────────────────────────────────────────
SHOWS = [
{"name": "Night Drift", "start_hour": 0, "end_hour": 7, "energy": "1", "description": "Ambient, drone, sleep"},
{"name": "Morning Calm", "start_hour": 7, "end_hour": 9, "energy": "2-3", "description": "Downtempo, chill, acoustic"},
{"name": "Late Morning", "start_hour": 9, "end_hour": 12, "energy": "4-5", "description": "Indie, jazz, trip-hop"},
{"name": "Lunch Break", "start_hour": 12, "end_hour": 14, "energy": "4", "description": "Relaxed, soul, dub"},
{"name": "Afternoon Session", "start_hour": 14, "end_hour": 17, "energy": "5-6", "description": "Electronic, funk, hip-hop"},
{"name": "Drive Time", "start_hour": 17, "end_hour": 19, "energy": "5-6", "description": "Mixed energy"},
{"name": "Evening Heat", "start_hour": 19, "end_hour": 22, "energy": "7-8", "description": "Techno, house, DnB, dubstep"},
{"name": "Wind Down", "start_hour": 22, "end_hour": 24, "energy": "2-4", "description": "Comedown, ambient"},
]
# ── Mode weights ───────────────────────────────────────────────────────────────
MODE_WEIGHTS = {
"shuffle": 40,
"deep_cuts": 20,
"new_to_library": 15,
"old_favourites": 15,
"full_album": 10,
}
# ── Cooldowns ──────────────────────────────────────────────────────────────────
TRACK_COOLDOWN = 7 * 86400 # 7 days
ARTIST_COOLDOWN = 3 * 3600 # 3 hours
ALBUM_COOLDOWN = 24 * 3600 # 24 hours
# ── Genre energy keywords (fallback) ──────────────────────────────────────────
ENERGY_KEYWORDS = {
1: ["ambient", "drone", "new age", "sleep", "meditation"],
2: ["downtempo", "chill", "lounge", "easy listening", "bossa nova", "minimal"],
3: ["classical", "chamber", "piano", "acoustic"],
4: ["jazz", "soul", "trip hop", "lo-fi", "lofi", "dub", "blues"],
5: ["indie", "pop", "rock", "shoegaze", "post rock", "alternative", "folk", "dream pop"],
6: ["funk", "disco", "hip hop", "rap", "reggae", "afrobeat"],
7: ["house", "electronic", "electro", "garage", "dubstep", "idm"],
8: ["techno", "trance", "drum and bass", "dnb", "jungle", "breakbeat"],
9: ["industrial", "noise", "hardcore", "metal", "punk"],
10: ["speedcore", "gabber", "power electronics", "harsh noise"],
}
# ── Station ID variations ─────────────────────────────────────────────────────
STATION_IDS = [
"Radio Susan, ninety nine point oh.",
"You're listening to Radio Susan.",
"Radio Susan.",
"Susan Radio, always on.",
"This is Radio Susan.",
]
TTS_VOICES = [
"en-US-GuyNeural",
"en-US-ChristopherNeural",
"en-GB-RyanNeural",
"en-US-AriaNeural",
"en-US-JennyNeural",
]
# ── Helpers ────────────────────────────────────────────────────────────────────
def normalize_genre(g):
return g.lower().replace("-", " ").replace("_", " ").strip() if g else ""
def keyword_energy(genre):
g = normalize_genre(genre)
if not g:
return 5
for score in range(10, 0, -1):
for kw in ENERGY_KEYWORDS.get(score, []):
if kw in g or g in kw:
return score
return 5
def essentia_to_scale(e):
return max(1, min(10, round(e * 9 + 1)))
def open_ro(path):
if not os.path.exists(path):
return None
try:
c = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
c.row_factory = sqlite3.Row
return c
except Exception:
return None
def random_file_fallback():
root = Path(MUSIC_ROOT)
candidates = [str(p) for p in root.rglob("*") if p.suffix.lower() in SUPPORTED and p.is_file()]
return random.choice(candidates) if candidates else None
def get_show_for_hour(hour):
for show in SHOWS:
if show["start_hour"] <= hour < show["end_hour"]:
return show
return SHOWS[0]
# ── Track loading ──────────────────────────────────────────────────────────────
def load_tracks():
"""Load all track metadata. Returns list of dicts with path, artist, album, genre, energy, added, title, duration."""
tracks = {}
# Genre cache
cache = None
cache_age = float("inf")
if os.path.exists(GENRE_CACHE):
try:
with open(GENRE_CACHE, "r") as f:
cache = json.load(f)
cache_age = time.time() - cache.get("built_at", 0)
except Exception:
pass
use_cache = cache is not None and cache_age < 48 * 3600
# Beets DB
beets = open_ro(BEETS_DB)
beets_data = {}
if beets:
try:
for row in beets.execute("SELECT path, title, artist, album, albumartist, genre, added, length FROM items"):
p = row["path"]
if isinstance(p, bytes):
try:
p = p.decode("utf-8")
except UnicodeDecodeError:
continue
beets_data[p] = {
"title": row["title"] or "",
"artist": row["artist"] or row["albumartist"] or "",
"album": row["album"] or "",
"genre": row["genre"] or "",
"added": row["added"] or 0,
"duration": row["length"] or 0,
}
beets.close()
except Exception:
pass
# Essentia features
features = {}
feat_conn = open_ro(FEATURES_DB)
if feat_conn:
try:
for row in feat_conn.execute("SELECT path, energy, bpm FROM features"):
features[row["path"]] = {"energy": row["energy"], "bpm": row["bpm"]}
feat_conn.close()
except Exception:
pass
# Merge
if use_cache:
for path, info in cache["tracks"].items():
t = {
"path": path,
"artist": info.get("artist", ""),
"album": info.get("album", ""),
"title": info.get("title", ""),
"genre": info.get("genre", "misc"),
"energy": info.get("energy", 5),
"added": 0,
"duration": 240,
}
if path in beets_data:
bd = beets_data[path]
t["added"] = bd["added"]
t["duration"] = bd["duration"] if bd["duration"] > 0 else 240
if not t["artist"]:
t["artist"] = bd["artist"]
if not t["title"]:
t["title"] = bd["title"]
if path in features:
t["energy"] = essentia_to_scale(features[path]["energy"])
tracks[path] = t
elif beets_data:
for path, bd in beets_data.items():
energy = keyword_energy(bd["genre"])
if path in features:
energy = essentia_to_scale(features[path]["energy"])
tracks[path] = {
"path": path,
"artist": bd["artist"],
"album": bd["album"],
"title": bd["title"],
"genre": normalize_genre(bd["genre"]) or "misc",
"energy": energy,
"added": bd["added"],
"duration": bd["duration"] if bd["duration"] > 0 else 240,
}
else:
root = Path(MUSIC_ROOT)
for p in root.rglob("*"):
if p.suffix.lower() in SUPPORTED and p.is_file():
sp = str(p)
parts = p.relative_to(MUSIC_ROOT).parts
artist = parts[0] if len(parts) > 0 else ""
album = parts[1] if len(parts) > 1 else ""
energy = 5
if sp in features:
energy = essentia_to_scale(features[sp]["energy"])
tracks[sp] = {
"path": sp, "artist": artist, "album": album,
"title": p.stem, "genre": "misc", "energy": energy,
"added": 0, "duration": 240,
}
return list(tracks.values())
def load_navidrome():
nav = open_ro(NAVIDROME_DB)
if not nav:
return {}
try:
data = {}
rows = nav.execute("""
SELECT mf.path, a.play_count, a.play_date, a.starred
FROM media_file mf
LEFT JOIN annotation a ON a.item_id = mf.id AND a.item_type = 'media_file'
""").fetchall()
for r in rows:
p = r["path"]
if isinstance(p, bytes):
try:
p = p.decode("utf-8")
except UnicodeDecodeError:
continue
if not p.startswith("/"):
p = os.path.join(MUSIC_ROOT, p)
data[p] = {
"play_count": r["play_count"] or 0,
"play_date": r["play_date"] or "",
"starred": bool(r["starred"]),
}
nav.close()
return data
except Exception:
return {}
# ── Selection logic ────────────────────────────────────────────────────────────
def filter_by_energy(tracks, target, tolerance=2):
pool = [t for t in tracks if abs(t["energy"] - target) <= tolerance]
while len(pool) < 20 and tolerance < 9:
tolerance += 1
pool = [t for t in tracks if abs(t["energy"] - target) <= tolerance]
return pool
def apply_cooldowns(pool, cooled_paths, cooled_artists, cooled_albums, last_genre):
result = []
for t in pool:
if t["path"] in cooled_paths:
continue
if t["artist"] and t["artist"].lower() in cooled_artists:
continue
if t["album"] and t["album"].lower() in cooled_albums:
continue
if last_genre and normalize_genre(t["genre"]) == last_genre:
continue
result.append(t)
return result
def pick_mode():
modes = list(MODE_WEIGHTS.keys())
weights = list(MODE_WEIGHTS.values())
return random.choices(modes, weights=weights, k=1)[0]
def apply_mode(pool, mode, nav_data, now):
if mode == "deep_cuts" and nav_data:
deep = [t for t in pool if nav_data.get(t["path"], {}).get("play_count", 0) == 0]
if deep:
return deep, "deep_cuts"
elif mode == "new_to_library":
cutoff = now - 2 * 86400
new = [t for t in pool if t["added"] > cutoff]
if new:
return new, "new_to_library"
elif mode == "old_favourites" and nav_data:
scored = [(t, nav_data.get(t["path"], {}).get("play_count", 0)) for t in pool]
scored.sort(key=lambda x: x[1], reverse=True)
top = [s[0] for s in scored[:max(10, len(scored) // 10)] if s[1] > 0]
if top:
return top, "old_favourites"
return pool, "shuffle"
def contrast_pick(candidates, recent_energies):
if not recent_energies or len(candidates) <= 1:
return random.choice(candidates)
avg_recent = sum(recent_energies) / len(recent_energies)
scored = [(t, abs(t["energy"] - avg_recent)) for t in candidates]
scored.sort(key=lambda x: x[1], reverse=True)
top = scored[:min(3, len(scored))]
weights = [s[1] + 0.5 for s in top]
return random.choices([s[0] for s in top], weights=weights, k=1)[0]
def find_album_tracks(pool, min_tracks=3):
"""Group pool by album and return albums with min_tracks+ tracks."""
albums = {}
for t in pool:
if t["album"]:
key = (t["artist"].lower(), t["album"].lower())
albums.setdefault(key, []).append(t)
return {k: sorted(v, key=lambda t: t["path"]) for k, v in albums.items() if len(v) >= min_tracks}
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
exec ffmpeg -hide_banner -loglevel error -f wav -i pipe:0 -f mp3 -b:a 192k pipe:1
Executable
+71
View File
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<ezstream>
<servers>
<server>
<name>default</name>
<protocol>HTTP</protocol>
<hostname>127.0.0.1</hostname>
<port>8910</port>
<user>source</user>
<password>REDACTED</password>
<tls>None</tls>
<reconnect_attempts>20</reconnect_attempts>
</server>
</servers>
<streams>
<stream>
<mountpoint>/stream</mountpoint>
<intake>default</intake>
<server>default</server>
<public>No</public>
<format>MP3</format>
<encoder>mp3</encoder>
<stream_name>Radio Susan</stream_name>
<stream_url>https://radio.jihakuz.xyz</stream_url>
<stream_genre>Eclectic</stream_genre>
<stream_description>Personal radio station</stream_description>
<stream_bitrate>192</stream_bitrate>
<stream_samplerate>44100</stream_samplerate>
<stream_channels>2</stream_channels>
</stream>
</streams>
<intakes>
<intake>
<name>default</name>
<type>playlist</type>
<filename>/var/lib/radio/playlists/all.m3u</filename>
<shuffle>Yes</shuffle>
<stream_once>No</stream_once>
</intake>
</intakes>
<metadata>
<format_str>@a@ - @t@</format_str>
<normalize_strings>Yes</normalize_strings>
<no_updates>No</no_updates>
</metadata>
<decoders>
<decoder>
<name>ffmpeg-all</name>
<program>/var/lib/radio/decode.sh @T@</program>
<file_ext>.flac</file_ext>
<file_ext>.mp3</file_ext>
<file_ext>.ogg</file_ext>
<file_ext>.m4a</file_ext>
<file_ext>.opus</file_ext>
<file_ext>.wav</file_ext>
<file_ext>.wma</file_ext>
</decoder>
</decoders>
<encoders>
<encoder>
<name>mp3</name>
<format>MP3</format>
<program>/var/lib/radio/encode.sh</program>
</encoder>
</encoders>
</ezstream>
+421
View File
@@ -0,0 +1,421 @@
#!/usr/bin/env python3
"""Generate a full 24-hour playlist for Radio Susan.
Run at midnight via cron. Outputs playlist.json and playlist.m3u.
"""
import asyncio
import grp
import hashlib
import json
import os
import random
import subprocess
import sys
import tempfile
import time
from datetime import datetime, timedelta
from pathlib import Path
sys.path.insert(0, os.path.dirname(__file__))
import dj_core
# ── Paths ──────────────────────────────────────────────────────────────────────
RADIO_DIR = Path("/var/lib/radio")
PLAYLIST_JSON = RADIO_DIR / "playlist.json"
PLAYLIST_M3U = RADIO_DIR / "playlist.m3u"
JINGLES_DIR = RADIO_DIR / "jingles"
ANNOUNCE_DIR = RADIO_DIR / "announcements"
VENV_BIN = RADIO_DIR / "venv" / "bin"
EDGE_TTS = VENV_BIN / "edge-tts"
ANNOUNCE_DIR.mkdir(parents=True, exist_ok=True)
DAY_SECONDS = 86400
DEFAULT_ANNOUNCE_DURATION = 8
DEFAULT_JINGLE_DURATION = 10
ANNOUNCE_INTERVAL = 3
JINGLE_INTERVAL = 5
def set_mediaserver_perms(path):
"""Set file group to mediaserver and perms to 664."""
try:
gid = grp.getgrnam("mediaserver").gr_gid
os.chown(str(path), -1, gid)
os.chmod(str(path), 0o664)
except Exception:
pass
def get_jingle():
"""Pick a random jingle, return (path, duration)."""
jingles = list(JINGLES_DIR.glob("*.mp3"))
if not jingles:
return None, 0
j = random.choice(jingles)
dur = get_audio_duration(str(j))
return str(j), dur if dur > 0 else DEFAULT_JINGLE_DURATION
def get_audio_duration(path):
"""Get audio duration in seconds via ffprobe."""
try:
result = subprocess.run(
["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", path],
capture_output=True, text=True, timeout=5
)
return float(result.stdout.strip())
except Exception:
return 0
def generate_announcement_tts(text, voice=None):
"""Generate TTS announcement, return cached path and duration."""
if voice is None:
h = int(hashlib.md5(text.encode()).hexdigest(), 16)
voice = dj_core.TTS_VOICES[h % len(dj_core.TTS_VOICES)]
content_hash = hashlib.md5(text.encode()).hexdigest()[:12]
cached = ANNOUNCE_DIR / f"announce_{content_hash}.mp3"
if cached.exists():
dur = get_audio_duration(str(cached))
return str(cached), dur if dur > 0 else DEFAULT_ANNOUNCE_DURATION
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp:
tmp_path = tmp.name
try:
result = subprocess.run(
[str(EDGE_TTS), "--voice", voice, "--rate", "+10%",
"--text", text, "--write-media", tmp_path],
capture_output=True, text=True, timeout=15
)
if result.returncode != 0:
return None, 0
# Normalize
norm_result = subprocess.run(
["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-i", tmp_path,
"-af", "loudnorm=I=-14:TP=-1:LRA=11,aformat=sample_rates=44100:channel_layouts=stereo",
"-b:a", "192k", str(cached)],
capture_output=True, text=True, timeout=15
)
if norm_result.returncode != 0:
os.rename(tmp_path, str(cached))
set_mediaserver_perms(cached)
dur = get_audio_duration(str(cached))
return str(cached), dur if dur > 0 else DEFAULT_ANNOUNCE_DURATION
except Exception:
return None, 0
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
def format_time(seconds):
"""Format seconds into HH:MM:SS."""
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
return f"{h:02d}:{m:02d}:{s:02d}"
def build_playlist():
"""Generate the full 24-hour playlist."""
print("Loading tracks...")
all_tracks = dj_core.load_tracks()
if not all_tracks:
print("ERROR: No tracks found!")
return None
nav_data = dj_core.load_navidrome()
now = time.time()
print(f"Loaded {len(all_tracks)} tracks, {len(nav_data)} with play data")
# In-memory cooldown tracking
cooled_paths = set()
cooled_artists = {} # artist_lower → timestamp of last play
cooled_albums = {} # album_lower → timestamp of last play
last_genre = None
recent_energies = []
entries = []
current_time = 0.0 # seconds from midnight
track_count = 0
tracks_since_announce = 0
tracks_since_jingle = 0
jingle_files = list(JINGLES_DIR.glob("*.mp3"))
# Recent track info for announcements
recent_tracks = []
print("Building playlist...")
while current_time < DAY_SECONDS:
hour = int(current_time // 3600) % 24
target_energy = dj_core.ENERGY_CURVE.get(hour, 5)
show = dj_core.get_show_for_hour(hour)
# ── Energy filter ─────────────────────────────────────────────
pool = dj_core.filter_by_energy(all_tracks, target_energy)
# ── Cooldown filter (in-memory) ───────────────────────────────
sim_now = now + current_time
active_paths = cooled_paths # 7-day cooldown, but within a day they're all active
active_artists = {a for a, ts in cooled_artists.items() if sim_now - ts < dj_core.ARTIST_COOLDOWN}
active_albums = {a for a, ts in cooled_albums.items() if sim_now - ts < dj_core.ALBUM_COOLDOWN}
filtered = dj_core.apply_cooldowns(pool, active_paths, active_artists, active_albums, last_genre)
if len(filtered) < 5:
# Relax cooldowns
filtered = dj_core.apply_cooldowns(pool, active_paths, set(), set(), None)
if len(filtered) < 5:
filtered = pool
if not filtered:
filtered = all_tracks
# ── Mode selection ────────────────────────────────────────────
mode = dj_core.pick_mode()
# Handle full_album
if mode == "full_album":
multi = dj_core.find_album_tracks(filtered)
if multi:
chosen_key = random.choice(list(multi.keys()))
album_tracks = multi[chosen_key]
for i, t in enumerate(album_tracks):
if current_time >= DAY_SECONDS:
break
dur = t["duration"] if t["duration"] > 0 else 240
entries.append({
"type": "track",
"path": t["path"],
"artist": t["artist"],
"title": t["title"],
"album": t["album"],
"genre": t["genre"],
"energy": t["energy"],
"duration": round(dur, 1),
"start_time": round(current_time, 1),
"start_formatted": format_time(current_time),
"mode": "full_album",
"show": show["name"],
})
# Update cooldowns
cooled_paths.add(t["path"])
if t["artist"]:
cooled_artists[t["artist"].lower()] = sim_now
if t["album"]:
cooled_albums[t["album"].lower()] = sim_now
last_genre = dj_core.normalize_genre(t["genre"])
recent_energies = (recent_energies + [t["energy"]])[-3:]
recent_tracks.append(t)
current_time += dur
track_count += 1
tracks_since_announce += 1
tracks_since_jingle += 1
# After album, maybe insert announcement
if tracks_since_announce >= ANNOUNCE_INTERVAL:
ann_entry = _make_announcement(recent_tracks, current_time)
if ann_entry:
entries.append(ann_entry)
current_time += ann_entry["duration"]
tracks_since_announce = 0
continue # Skip normal pick, already added album
else:
mode = "shuffle"
# ── Normal pick ───────────────────────────────────────────────
mode_pool, actual_mode = dj_core.apply_mode(filtered, mode, nav_data, now)
pick = dj_core.contrast_pick(mode_pool, recent_energies)
if not os.path.isfile(pick["path"]):
mode_pool = [t for t in mode_pool if os.path.isfile(t["path"])]
if mode_pool:
pick = random.choice(mode_pool)
else:
continue
dur = pick["duration"] if pick["duration"] > 0 else 240
entries.append({
"type": "track",
"path": pick["path"],
"artist": pick["artist"],
"title": pick["title"],
"album": pick["album"],
"genre": pick["genre"],
"energy": pick["energy"],
"duration": round(dur, 1),
"start_time": round(current_time, 1),
"start_formatted": format_time(current_time),
"mode": actual_mode,
"show": show["name"],
})
# Update cooldowns
cooled_paths.add(pick["path"])
if pick["artist"]:
cooled_artists[pick["artist"].lower()] = sim_now
if pick["album"]:
cooled_albums[pick["album"].lower()] = sim_now
last_genre = dj_core.normalize_genre(pick["genre"])
recent_energies = (recent_energies + [pick["energy"]])[-3:]
recent_tracks.append(pick)
current_time += dur
track_count += 1
tracks_since_announce += 1
tracks_since_jingle += 1
# ── Insert announcement ───────────────────────────────────────
if tracks_since_announce >= ANNOUNCE_INTERVAL and current_time < DAY_SECONDS:
ann_entry = _make_announcement(recent_tracks, current_time)
if ann_entry:
entries.append(ann_entry)
current_time += ann_entry["duration"]
tracks_since_announce = 0
# ── Insert jingle ─────────────────────────────────────────────
if tracks_since_jingle >= JINGLE_INTERVAL and jingle_files and current_time < DAY_SECONDS:
j_path, j_dur = get_jingle()
if j_path:
entries.append({
"type": "jingle",
"path": j_path,
"duration": round(j_dur, 1),
"start_time": round(current_time, 1),
"start_formatted": format_time(current_time),
})
current_time += j_dur
tracks_since_jingle = 0
# Build show schedule with formatted times
shows = []
for s in dj_core.SHOWS:
shows.append({
"name": s["name"],
"start": f"{s['start_hour']:02d}:00",
"end": f"{s['end_hour']:02d}:00" if s["end_hour"] < 24 else "00:00",
"energy": s["energy"],
"description": s["description"],
})
playlist = {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"total_tracks": track_count,
"total_entries": len(entries),
"total_duration_hours": round(current_time / 3600, 1),
"shows": shows,
"entries": entries,
}
return playlist
def _make_announcement(recent_tracks, current_time):
"""Build an announcement entry from recent tracks."""
if len(recent_tracks) < 2:
return None
last = recent_tracks[-1]
prev = recent_tracks[-2]
last_str = f"{last['artist']} - {last['title']}" if last["artist"] and last["title"] else None
prev_str = f"{prev['artist']} - {prev['title']}" if prev["artist"] and prev["title"] else None
if not last_str:
return None
station_id = random.choice(dj_core.STATION_IDS)
if prev_str:
text = f"You've been listening to {last_str}, and {prev_str}. {station_id}"
else:
text = f"You've been listening to {last_str}. {station_id}"
path, dur = generate_announcement_tts(text)
if not path:
return None
return {
"type": "announcement",
"path": path,
"text": text,
"duration": round(dur, 1),
"start_time": round(current_time, 1),
"start_formatted": format_time(current_time),
}
def write_outputs(playlist):
"""Write playlist.json and playlist.m3u."""
# JSON
with open(PLAYLIST_JSON, "w") as f:
json.dump(playlist, f, indent=2)
set_mediaserver_perms(PLAYLIST_JSON)
# M3U
with open(PLAYLIST_M3U, "w") as f:
f.write("#EXTM3U\n")
for entry in playlist["entries"]:
path = entry["path"]
dur = int(entry.get("duration", 0))
if entry["type"] == "track":
f.write(f"#EXTINF:{dur},{entry.get('artist', '')} - {entry.get('title', '')}\n")
elif entry["type"] == "announcement":
f.write(f"#EXTINF:{dur},Announcement\n")
elif entry["type"] == "jingle":
f.write(f"#EXTINF:{dur},Jingle\n")
f.write(f"{path}\n")
set_mediaserver_perms(PLAYLIST_M3U)
def main():
start = time.time()
print(f"Radio Susan — Daily Playlist Generator")
print(f"Started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print()
playlist = build_playlist()
if not playlist:
sys.exit(1)
print(f"\nGenerating announcements...")
# Count announcements
ann_count = sum(1 for e in playlist["entries"] if e["type"] == "announcement")
jingle_count = sum(1 for e in playlist["entries"] if e["type"] == "jingle")
print(f"\nWriting outputs...")
write_outputs(playlist)
elapsed = time.time() - start
print(f"\n{'='*60}")
print(f" Playlist generated successfully!")
print(f" Tracks: {playlist['total_tracks']}")
print(f" Announcements: {ann_count}")
print(f" Jingles: {jingle_count}")
print(f" Total entries: {playlist['total_entries']}")
print(f" Duration: {playlist['total_duration_hours']}h")
print(f" Time taken: {elapsed:.1f}s")
print(f" Output: {PLAYLIST_JSON}")
print(f" Output: {PLAYLIST_M3U}")
print(f"{'='*60}")
if __name__ == "__main__":
main()
+292
View File
@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""
Jingle Factory for Radio Susan
Phrases use {sfx_name} markers to insert sound effects at breakpoints.
Each phrase gets TTS'd with a random voice, then stitched with SFX.
Background ambience (static/noise) is layered underneath.
Usage: python3 generate_jingles.py [--list-sfx] [--preview PHRASE]
SFX are stored in /var/lib/radio/sfx/
Jingles output to /var/lib/radio/jingles/
"""
import asyncio
import json
import os
import random
import re
import subprocess
import sys
import tempfile
from pathlib import Path
RADIO_DIR = Path("/var/lib/radio")
SFX_DIR = RADIO_DIR / "sfx"
JINGLES_DIR = RADIO_DIR / "jingles"
VENV_BIN = RADIO_DIR / "venv" / "bin"
EDGE_TTS = VENV_BIN / "edge-tts"
# Voices to randomly pick from — mix of dramatic, cheesy, authoritative
VOICES = [
"en-US-GuyNeural", # Passion — classic radio voice
"en-US-ChristopherNeural", # Authority — news anchor
"en-US-EricNeural", # Rational — deadpan
"en-US-BrianNeural", # Casual bro
"en-GB-RyanNeural", # British bloke
"en-GB-ThomasNeural", # Posh British
"en-US-AriaNeural", # Confident woman
"en-US-JennyNeural", # Friendly woman
"en-GB-SoniaNeural", # British woman
]
# Phrase definitions — {sfx_name} marks where sound effects go
# Add as many as you want!
PHRASES = [
# Classic station IDs
"You're listening to {crash} ninety nine point oh {synth_horn} Susan Radio!",
"This is {sweep} Susan Radio {crash} ninety nine point oh {reverb_hit} all day, all night",
"{synth_horn} You're tuned in to {crash} Susan Radio! {airhorn}",
"Susan Radio {reverb_hit} ninety nine point oh {crash} on your dial",
"{sweep} Don't touch that dial! {crash} Susan Radio {airhorn} ninety nine point oh!",
"Coming at you live {crash} this is {reverb_hit} Susan Radio! {synth_horn}",
"{sweep} Lock it in {crash} ninety nine point oh {reverb_hit} Susan Radio!",
# Chaotic ones
"{pirate_arr} Ahoy! {crash} You're aboard Susan Radio! {pirate_yaargh} Ninety nine point oh!",
"{scream} Susan Radio! {airhorn} Ninety nine point oh! {meow}",
"{countdown} Susan Radio is GO! {crash} Ninety nine point oh! {airhorn}",
"{demon} Susan Radio {crash} ninety nine point oh {scream} we never stop!",
"{text_tone} New message! {crash} You're listening to Susan Radio! {synth_horn}",
"{meow} Susan Radio {reverb_hit} ninety nine point oh {cashregister}",
"{pirate_plank} Walk the plank! {crash} Or just listen to Susan Radio! {pirate_arr} Ninety nine point oh!",
"{cashregister} Cha-ching! {sweep} Susan Radio! {airhorn} Ninety nine point oh!",
]
def generate_sfx():
"""Generate built-in sound effects using ffmpeg."""
SFX_DIR.mkdir(parents=True, exist_ok=True)
sfx_specs = {
"crash": {
# White noise burst with sharp attack, longer decay
"filter": "anoisesrc=d=1.5:c=white:a=0.8,afade=t=in:d=0.02,afade=t=out:st=0.3:d=1.2,lowpass=f=3000",
},
"airhorn": {
# Stacked sine waves with vibrato — longer blast
"filter": (
"sine=f=750:d=1.2,aformat=sample_rates=44100"
),
"filter_complex": (
"[0:a]volume=0.5[a];"
"anoisesrc=d=1.2:c=pink:a=0.15,aformat=sample_rates=44100[n];"
"sine=f=950:d=1.2,aformat=sample_rates=44100,volume=0.4[b];"
"[a][n]amix=inputs=2[m1];[m1][b]amix=inputs=2,afade=t=in:d=0.02,afade=t=out:st=0.7:d=0.5[out]"
),
},
"static": {
# Pink noise crackle — longer
"filter": "anoisesrc=d=1.2:c=pink:a=0.4,afade=t=in:d=0.1,afade=t=out:st=0.7:d=0.5,highpass=f=500",
},
"reverb_hit": {
# Impact with longer reverb tail
"filter": "anoisesrc=d=0.05:c=white:a=1.0,apad=whole_dur=2,aecho=0.8:0.88:80:0.5,afade=t=out:st=0.5:d=1.5",
},
"sweep": {
# Frequency sweep — longer with more movement
"filter": "sine=f=200:d=1.0,aformat=sample_rates=44100,afade=t=in:d=0.05,afade=t=out:st=0.5:d=0.5,vibrato=f=15:d=0.8",
},
}
for name, spec in sfx_specs.items():
outfile = SFX_DIR / f"{name}.wav"
if outfile.exists():
continue
if "filter_complex" in spec:
cmd = [
"ffmpeg", "-y",
"-f", "lavfi", "-i", spec["filter"],
"-filter_complex", spec["filter_complex"],
"-map", "[out]",
"-ar", "44100", "-ac", "2",
str(outfile)
]
else:
cmd = [
"ffmpeg", "-y",
"-f", "lavfi", "-i", spec["filter"],
"-ar", "44100", "-ac", "2",
str(outfile)
]
subprocess.run(cmd, capture_output=True)
print(f" Generated SFX: {name}")
return {f.stem: str(f) for f in SFX_DIR.glob("*.wav")}
def tts_segment(text, voice, outfile):
"""Generate TTS for a text segment using edge-tts."""
cmd = [
str(EDGE_TTS),
"--voice", voice,
"--rate", "+15%", # Faster for radio energy
"--text", text,
"--write-media", outfile,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f" TTS error: {result.stderr}")
return False
return True
def stitch_jingle(segments, sfx_files, output_path):
"""
Stitch TTS segments and SFX together with background ambience.
segments: list of (type, path) where type is 'voice' or 'sfx'
"""
if not segments:
return False
# Build ffmpeg concat filter
inputs = []
filter_parts = []
for i, (stype, path) in enumerate(segments):
inputs.extend(["-i", path])
# Normalize all inputs to same format
filter_parts.append(f"[{i}:a]aformat=sample_rates=44100:channel_layouts=stereo,aresample=44100[s{i}]")
# Concat all segments
concat_inputs = "".join(f"[s{i}]" for i in range(len(segments)))
filter_parts.append(f"{concat_inputs}concat=n={len(segments)}:v=0:a=1[main]")
# Generate background static layer
filter_parts.append(
"anoisesrc=d=30:c=pink:a=0.04,aformat=sample_rates=44100:channel_layouts=stereo,"
"highpass=f=800,lowpass=f=4000[bg]"
)
# Mix main audio with background, trim to main length
filter_parts.append(
"[bg][main]amix=inputs=2:duration=shortest:weights=1 3,"
"afade=t=in:d=0.1,loudnorm=I=-14:TP=-1:LRA=11[out]"
)
filter_complex = ";\n".join(filter_parts)
cmd = [
"ffmpeg", "-y",
*inputs,
"-filter_complex", filter_complex,
"-map", "[out]",
"-ar", "44100", "-ac", "2", "-b:a", "192k",
str(output_path)
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f" FFmpeg error: {result.stderr[-500:]}")
return False
return True
def parse_phrase(phrase):
"""Parse a phrase into segments: text parts and {sfx} markers."""
parts = re.split(r'\{(\w+)\}', phrase)
segments = []
for i, part in enumerate(parts):
part = part.strip()
if not part:
continue
if i % 2 == 0:
segments.append(("text", part))
else:
segments.append(("sfx", part))
return segments
def generate_one_jingle(phrase, voice, sfx_files, output_path):
"""Generate a single jingle from a phrase template."""
parsed = parse_phrase(phrase)
audio_segments = [] # (type, filepath)
tmpfiles = []
try:
for stype, content in parsed:
if stype == "text":
tmpfile = tempfile.mktemp(suffix=".mp3", dir="/tmp")
tmpfiles.append(tmpfile)
if tts_segment(content, voice, tmpfile):
audio_segments.append(("voice", tmpfile))
else:
print(f" Failed TTS for: {content}")
return False
elif stype == "sfx":
if content in sfx_files:
audio_segments.append(("sfx", sfx_files[content]))
else:
print(f" Unknown SFX: {content}")
if not audio_segments:
return False
return stitch_jingle(audio_segments, sfx_files, output_path)
finally:
for f in tmpfiles:
try:
os.unlink(f)
except:
pass
def main():
JINGLES_DIR.mkdir(parents=True, exist_ok=True)
if "--list-sfx" in sys.argv:
sfx_files = generate_sfx()
print("Available SFX:", ", ".join(sorted(sfx_files.keys())))
return
# Clear old jingles before regenerating
for old in JINGLES_DIR.glob("jingle_*.mp3"):
old.unlink()
print("Generating SFX...")
sfx_files = generate_sfx()
print(f" {len(sfx_files)} SFX ready: {', '.join(sorted(sfx_files.keys()))}")
# Generate multiple jingles with different voice/phrase combos
print("\nGenerating jingles...")
generated = 0
for i, phrase in enumerate(PHRASES):
voice = random.choice(VOICES)
output = JINGLES_DIR / f"jingle_{i+1:02d}.mp3"
print(f"\n [{i+1}/{len(PHRASES)}] Voice: {voice}")
print(f" Phrase: {phrase}")
if generate_one_jingle(phrase, voice, sfx_files, output):
# Get duration
probe = subprocess.run(
["ffprobe", "-hide_banner", "-show_entries", "format=duration",
"-of", "csv=p=0", str(output)],
capture_output=True, text=True
)
dur = float(probe.stdout.strip()) if probe.stdout.strip() else 0
print(f"{output.name} ({dur:.1f}s)")
generated += 1
else:
print(f" ✗ Failed")
print(f"\nDone! {generated}/{len(PHRASES)} jingles generated in {JINGLES_DIR}")
print("Reload liquidsoap to pick them up: sudo systemctl restart liquidsoap")
if __name__ == "__main__":
main()
+602
View File
@@ -0,0 +1,602 @@
#!/usr/bin/env python3
"""Generate the static site for Radio Susan from playlist.json.
Outputs to /var/www/radio/. Run after generate_daily_playlist.py.
"""
import grp
import json
import os
import sys
from datetime import datetime
from pathlib import Path
PLAYLIST_JSON = Path("/var/lib/radio/playlist.json")
OUTPUT_DIR = Path("/var/www/radio")
STREAM_URL = "https://radio.jihakuz.xyz/stream"
def set_mediaserver_perms(path):
try:
gid = grp.getgrnam("mediaserver").gr_gid
os.chown(str(path), -1, gid)
os.chmod(str(path), 0o664)
except Exception:
pass
def generate_html(playlist):
generated = playlist["generated_at"][:10]
total_tracks = playlist["total_tracks"]
duration = playlist["total_duration_hours"]
shows = playlist["shows"]
entries = playlist["entries"]
# Build tracks-only list for the schedule
tracks = [e for e in entries if e["type"] == "track"]
# Group tracks by show
show_tracks = {}
for t in tracks:
show_name = t.get("show", "Unknown")
show_tracks.setdefault(show_name, []).append(t)
# Mode stats
mode_counts = {}
for t in tracks:
m = t.get("mode", "unknown")
mode_counts[m] = mode_counts.get(m, 0) + 1
# Build show cards HTML
show_cards = ""
for show in shows:
name = show["name"]
trks = show_tracks.get(name, [])
show_cards += f"""
<div class="show-card" id="show-{name.lower().replace(' ', '-')}">
<div class="show-header">
<div class="show-time">{show['start']}{show['end']}</div>
<h2 class="show-name">{name}</h2>
<div class="show-desc">{show['description']} · Energy {show['energy']} · {len(trks)} tracks</div>
</div>
<div class="show-tracklist">
{''.join(f'<div class="track" data-start="{t["start_time"]}">'
f'<span class="track-time">{t["start_formatted"][:5]}</span>'
f'<span class="track-artist">{_esc(t["artist"])}</span>'
f'<span class="track-sep">—</span>'
f'<span class="track-title">{_esc(t["title"])}</span>'
f'<span class="track-meta">{_esc(t.get("album", ""))}</span>'
f'<span class="track-mode">{t["mode"]}</span>'
f'</div>' for t in trks)}
</div>
</div>
"""
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Radio Susan — 99.0</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
:root {{
--bg: #0a0a0f;
--surface: #12121a;
--surface2: #1a1a26;
--border: #2a2a3a;
--text: #e0e0e8;
--text-dim: #7a7a8e;
--accent: #ff6b35;
--accent2: #ffd700;
--green: #4ade80;
--red: #f87171;
}}
body {{
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
}}
.container {{
max-width: 900px;
margin: 0 auto;
padding: 2rem 1.5rem;
}}
/* Header */
.header {{
text-align: center;
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--border);
}}
.header h1 {{
font-size: 2.5rem;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.05em;
}}
.header .freq {{
font-size: 1.2rem;
color: var(--accent2);
margin-top: 0.25rem;
}}
.header .meta {{
font-size: 0.8rem;
color: var(--text-dim);
margin-top: 1rem;
}}
/* Now Playing */
.now-playing {{
background: var(--surface);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
text-align: center;
}}
.now-playing .label {{
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--accent);
margin-bottom: 0.5rem;
}}
.now-playing .live-dot {{
display: inline-block;
width: 8px;
height: 8px;
background: var(--red);
border-radius: 50%;
margin-right: 0.5rem;
animation: pulse 2s infinite;
}}
@keyframes pulse {{
0%, 100% {{ opacity: 1; }}
50% {{ opacity: 0.3; }}
}}
.now-playing .track-name {{
font-size: 1.3rem;
font-weight: 600;
color: var(--text);
}}
.now-playing .track-album {{
font-size: 0.85rem;
color: var(--text-dim);
margin-top: 0.25rem;
}}
.now-playing .show-label {{
font-size: 0.75rem;
color: var(--accent2);
margin-top: 0.75rem;
}}
.listen-btn {{
display: inline-block;
margin-top: 1rem;
padding: 0.6rem 2rem;
background: var(--accent);
color: var(--bg);
text-decoration: none;
border-radius: 4px;
font-weight: 700;
font-size: 0.85rem;
letter-spacing: 0.05em;
border: none;
cursor: pointer;
}}
.listen-btn:hover {{
background: #ff8555;
}}
/* Up Next */
.up-next {{
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.25rem;
margin-bottom: 2.5rem;
}}
.up-next h3 {{
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--text-dim);
margin-bottom: 0.75rem;
}}
.up-next .next-track {{
display: flex;
justify-content: space-between;
padding: 0.3rem 0;
font-size: 0.85rem;
}}
.up-next .next-time {{
color: var(--text-dim);
min-width: 50px;
}}
/* Stats bar */
.stats {{
display: flex;
justify-content: space-around;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
margin-bottom: 2.5rem;
font-size: 0.8rem;
}}
.stat {{
text-align: center;
}}
.stat .stat-val {{
font-size: 1.3rem;
font-weight: 700;
color: var(--accent);
}}
.stat .stat-label {{
color: var(--text-dim);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.1em;
}}
/* Show cards */
.show-card {{
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 1.5rem;
overflow: hidden;
}}
.show-header {{
padding: 1.25rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
}}
.show-time {{
font-size: 0.7rem;
color: var(--accent2);
letter-spacing: 0.1em;
}}
.show-name {{
font-size: 1.2rem;
font-weight: 600;
margin-top: 0.2rem;
}}
.show-desc {{
font-size: 0.75rem;
color: var(--text-dim);
margin-top: 0.25rem;
}}
.show-tracklist {{
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}}
.show-card.open .show-tracklist {{
max-height: 5000px;
}}
.track {{
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 1.25rem;
font-size: 0.8rem;
border-bottom: 1px solid var(--bg);
}}
.track:hover {{
background: var(--surface2);
}}
.track.active {{
background: var(--surface2);
border-left: 3px solid var(--accent);
}}
.track-time {{
color: var(--text-dim);
min-width: 40px;
font-size: 0.75rem;
}}
.track-artist {{
font-weight: 600;
color: var(--text);
}}
.track-sep {{
color: var(--text-dim);
}}
.track-title {{
color: var(--text);
flex: 1;
}}
.track-meta {{
color: var(--text-dim);
font-size: 0.7rem;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}}
.track-mode {{
font-size: 0.6rem;
padding: 0.15rem 0.4rem;
border-radius: 3px;
background: var(--surface2);
color: var(--text-dim);
white-space: nowrap;
}}
/* Footer */
.footer {{
text-align: center;
padding: 2rem 0;
margin-top: 2rem;
border-top: 1px solid var(--border);
color: var(--text-dim);
font-size: 0.7rem;
}}
@media (max-width: 600px) {{
.container {{ padding: 1rem; }}
.header h1 {{ font-size: 1.8rem; }}
.track-meta {{ display: none; }}
.stats {{ flex-wrap: wrap; gap: 0.5rem; }}
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>RADIO SUSAN</h1>
<div class="freq">99.0 FM</div>
<div class="meta">Generated {generated} · {total_tracks} tracks · {duration}h</div>
</div>
<div class="now-playing" id="now-playing">
<div class="label"><span class="live-dot"></span>NOW PLAYING</div>
<div class="track-name" id="np-track">Loading...</div>
<div class="track-album" id="np-album"></div>
<div class="show-label" id="np-show"></div>
<button class="listen-btn" onclick="togglePlay()">▶ LISTEN</button>
</div>
<div class="up-next" id="up-next">
<h3>Up Next</h3>
<div id="next-tracks"></div>
</div>
<div class="stats">
<div class="stat"><div class="stat-val">{total_tracks}</div><div class="stat-label">Tracks</div></div>
<div class="stat"><div class="stat-val">{len(shows)}</div><div class="stat-label">Shows</div></div>
<div class="stat"><div class="stat-val">{mode_counts.get('full_album', 0)}</div><div class="stat-label">Album Tracks</div></div>
<div class="stat"><div class="stat-val">{mode_counts.get('deep_cuts', 0)}</div><div class="stat-label">Deep Cuts</div></div>
<div class="stat"><div class="stat-val">{mode_counts.get('new_to_library', 0)}</div><div class="stat-label">New</div></div>
</div>
<h2 style="font-size: 1rem; margin-bottom: 1rem; color: var(--text-dim); letter-spacing: 0.1em;">TODAY'S SCHEDULE</h2>
{show_cards}
<div class="footer">
RADIO SUSAN · Powered by Liquidsoap + Icecast · Self-hosted on Susan
</div>
</div>
<audio id="audio-player" preload="none">
<source src="{STREAM_URL}" type="audio/mpeg">
</audio>
<script>
const schedule = {json.dumps([e for e in entries if e['type'] == 'track'], separators=(',', ':'))};
function getScheduleSeconds() {{
const now = new Date();
return now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds();
}}
// Find track in schedule by artist+title match
function findInSchedule(artist, title) {{
for (let i = 0; i < schedule.length; i++) {{
if (schedule[i].artist === artist && schedule[i].title === title) {{
return {{ track: schedule[i], index: i }};
}}
}}
return null;
}}
// Fallback: find by time
function findByTime() {{
const secs = getScheduleSeconds();
let current = null;
let idx = 0;
for (let i = 0; i < schedule.length; i++) {{
if (schedule[i].start_time <= secs) {{
current = schedule[i];
idx = i;
}}
}}
return current ? {{ track: current, index: idx }} : null;
}}
let lastNp = '';
async function updateNowPlaying() {{
// Poll live state from liquidsoap
let liveArtist = '', liveTitle = '';
try {{
const resp = await fetch('/now_playing.json?' + Date.now());
if (resp.ok) {{
const data = await resp.json();
liveArtist = data.artist || '';
liveTitle = data.title || '';
}}
}} catch(e) {{}}
const npKey = liveArtist + '|' + liveTitle;
// Update now playing display
if (liveArtist || liveTitle) {{
document.getElementById('np-track').textContent =
(liveArtist && liveTitle) ? liveArtist + ' \u2014 ' + liveTitle :
liveArtist || liveTitle;
}}
// Try to find this track in the schedule for metadata + up next
let match = null;
if (liveArtist && liveTitle) {{
match = findInSchedule(liveArtist, liveTitle);
}}
if (!match) {{
match = findByTime();
}}
if (match) {{
const {{ track, index }} = match;
document.getElementById('np-album').textContent = track.album || '';
document.getElementById('np-show').textContent = track.show + ' \u00b7 ' + track.mode;
// Up next (from schedule position)
const nextDiv = document.getElementById('next-tracks');
nextDiv.innerHTML = '';
for (let i = index + 1; i < Math.min(index + 4, schedule.length); i++) {{
const t = schedule[i];
const div = document.createElement('div');
div.className = 'next-track';
div.innerHTML = '<span class="next-time">' + t.start_formatted.slice(0, 5) + '</span> '
+ '<span>' + t.artist + ' \u2014 ' + t.title + '</span>';
nextDiv.appendChild(div);
}}
// Highlight active track
if (npKey !== lastNp) {{
document.querySelectorAll('.track.active').forEach(el => el.classList.remove('active'));
const allTracks = document.querySelectorAll('.track');
allTracks.forEach(el => {{
if (parseFloat(el.dataset.start) === track.start_time) {{
el.classList.add('active');
const card = el.closest('.show-card');
if (card && !card.classList.contains('open')) {{
card.classList.add('open');
}}
el.scrollIntoView({{ behavior: 'smooth', block: 'center' }});
}}
}});
lastNp = npKey;
}}
}} else {{
document.getElementById('np-album').textContent = '';
document.getElementById('np-show').textContent = '';
}}
}}
// Toggle show tracklists
document.querySelectorAll('.show-header').forEach(header => {{
header.addEventListener('click', () => {{
header.parentElement.classList.toggle('open');
}});
}});
// Audio player
let playing = false;
function togglePlay() {{
const audio = document.getElementById('audio-player');
const btn = document.querySelector('.listen-btn');
if (playing) {{
audio.pause();
audio.src = '';
btn.textContent = '\u25b6 LISTEN';
playing = false;
}} else {{
audio.src = '{STREAM_URL}';
audio.play();
btn.textContent = '\u23f8 STOP';
playing = true;
}}
}}
// Update every 10 seconds
updateNowPlaying();
setInterval(updateNowPlaying, 10000);
// Open current show by default
const timeMatch = findByTime();
if (timeMatch) {{
const showId = 'show-' + timeMatch.track.show.toLowerCase().replace(/ /g, '-');
const el = document.getElementById(showId);
if (el) el.classList.add('open');
}}
</script>
</body>
</html>"""
return html
def _esc(s):
"""Escape HTML entities."""
return (s or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def main():
if not PLAYLIST_JSON.exists():
print(f"ERROR: {PLAYLIST_JSON} not found. Run generate_daily_playlist.py first.")
sys.exit(1)
playlist = json.load(open(PLAYLIST_JSON))
print(f"Generating site from playlist ({playlist['total_tracks']} tracks)...")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
# Generate index.html
html = generate_html(playlist)
index_path = OUTPUT_DIR / "index.html"
index_path.write_text(html)
set_mediaserver_perms(index_path)
# Copy playlist.json for API access
api_path = OUTPUT_DIR / "playlist.json"
api_path.write_text(json.dumps(playlist, separators=(",", ":")))
set_mediaserver_perms(api_path)
print(f"Site generated at {OUTPUT_DIR}")
print(f" index.html: {index_path.stat().st_size / 1024:.1f}KB")
print(f" playlist.json: {api_path.stat().st_size / 1024:.1f}KB")
if __name__ == "__main__":
main()
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""One-off: generate playlist for remaining hours tonight.
Unlike the full generator, this resets start_time to 0 so Liquidsoap
plays from the top immediately instead of seeking to a time offset."""
import sys, time, json, os
sys.path.insert(0, os.path.dirname(__file__))
from generate_daily_playlist import build_playlist, write_outputs
playlist = build_playlist()
if not playlist:
sys.exit(1)
now_secs = time.localtime().tm_hour * 3600 + time.localtime().tm_min * 60
entries = [e for e in playlist["entries"] if e["start_time"] >= now_secs]
# Rebase start times to 0 so the playlist plays sequentially from the top
offset = entries[0]["start_time"] if entries else 0
for e in entries:
e["start_time"] = round(e["start_time"] - offset, 1)
h = int(e["start_time"] // 3600)
m = int((e["start_time"] % 3600) // 60)
s = int(e["start_time"] % 60)
e["start_formatted"] = f"{h:02d}:{m:02d}:{s:02d}"
playlist["entries"] = entries
playlist["total_tracks"] = sum(1 for e in entries if e["type"] == "track")
playlist["total_entries"] = len(entries)
write_outputs(playlist)
ann_count = sum(1 for e in entries if e["type"] == "announcement")
jingle_count = sum(1 for e in entries if e["type"] == "jingle")
print(f"Tonight: {playlist['total_tracks']} tracks, {ann_count} announcements, {jingle_count} jingles from {now_secs/3600:.1f}h onwards")
Executable
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/liquidsoap
# Radio Susan — pre-generated daily playlist
# Tracks, announcements, and jingles are all baked into playlist.m3u
# Liquidsoap just plays it sequentially.
set("log.file.path", "/var/lib/radio/radio.log")
set("log.level", 3)
set("server.telnet", true)
set("server.telnet.port", 1234)
state_file = "/var/lib/radio/track_state.json"
playlist_file = "/var/lib/radio/playlist.m3u"
# Primary source: pre-generated daily playlist
music = playlist(
id="daily_playlist",
mode="normal",
reload=3600,
reload_mode="watch",
playlist_file
)
# Fallback 1: live DJ (per-track)
def get_next_track() =
result = list.hd(default="", process.read.lines("/usr/bin/python3 /var/lib/radio/radio_dj.py"))
if result != "" then
log("DJ fallback picked: #{result}")
request.create(result)
else
log("DJ returned nothing")
null()
end
end
dj_fallback = request.dynamic(id="dj_fallback", get_next_track)
# Fallback 2: static random playlist (last resort)
random_fallback = playlist(
id="random_fallback",
mode="randomize",
reload=3600,
"/var/lib/radio/playlists/all.m3u"
)
# Chain: playlist → DJ → random
radio = fallback(id="Radio_Susan", track_sensitive=true, [music, dj_fallback, random_fallback])
# Write track metadata to state file on each new track
radio = source.on_track(radio, fun(m) -> begin
artist = m["artist"]
title = m["title"]
data = '{"artist":"#{artist}","title":"#{title}"}'
ignore(file.write(data=data, state_file))
log("Now playing: #{artist} - #{title}")
end)
radio = mksafe(radio)
output.icecast(
%mp3(bitrate=192),
host="localhost",
port=8910,
password="REDACTED",
mount="/stream",
name="Radio Susan",
description="Personal radio station",
genre="Eclectic",
url="https://radio.jihakuz.xyz",
radio
)
Executable
+297
View File
@@ -0,0 +1,297 @@
#!/usr/bin/env python3
"""Radio DJ — smart track picker for Liquidsoap.
Prints one absolute file path to stdout and exits.
All logging goes to /var/lib/radio/dj.log.
This is the per-track fallback. The preferred approach is generate_daily_playlist.py
which pre-generates a full day's playlist.
"""
import json
import logging
import logging.handlers
import os
import random
import sqlite3
import sys
import time
from pathlib import Path
sys.path.insert(0, os.path.dirname(__file__))
import dj_core
# ── Logging ────────────────────────────────────────────────────────────────────
LOG_PATH = "/var/lib/radio/dj.log"
os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
log = logging.getLogger("radio_dj")
log.setLevel(logging.DEBUG)
handler = logging.handlers.RotatingFileHandler(LOG_PATH, maxBytes=1_000_000, backupCount=3)
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
log.addHandler(handler)
HISTORY_PRUNE = 7 * 86400
# ── State DB ───────────────────────────────────────────────────────────────────
def init_state_db():
try:
conn = sqlite3.connect(dj_core.STATE_DB, timeout=5)
conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row
conn.executescript("""
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL, artist TEXT, album TEXT,
genre TEXT, energy INTEGER, mode TEXT, timestamp REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS album_queue (
id INTEGER PRIMARY KEY CHECK (id = 1),
album TEXT, tracks TEXT, current_index INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp);
CREATE INDEX IF NOT EXISTS idx_history_artist ON history(artist);
CREATE INDEX IF NOT EXISTS idx_history_path ON history(path);
""")
conn.commit()
return conn
except Exception as e:
log.error("State DB corrupt, recreating: %s", e)
try:
os.remove(dj_core.STATE_DB)
except OSError:
pass
conn = sqlite3.connect(dj_core.STATE_DB, timeout=5)
conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row
conn.executescript("""
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL, artist TEXT, album TEXT,
genre TEXT, energy INTEGER, mode TEXT, timestamp REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS album_queue (
id INTEGER PRIMARY KEY CHECK (id = 1),
album TEXT, tracks TEXT, current_index INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp);
CREATE INDEX IF NOT EXISTS idx_history_artist ON history(artist);
CREATE INDEX IF NOT EXISTS idx_history_path ON history(path);
""")
conn.commit()
return conn
def prune_history(state):
cutoff = time.time() - HISTORY_PRUNE
state.execute("DELETE FROM history WHERE timestamp < ?", (cutoff,))
state.commit()
def get_cooldowns(state):
now = time.time()
rows = state.execute(
"SELECT path, artist, album, genre, timestamp FROM history ORDER BY timestamp DESC"
).fetchall()
cooled_paths = set()
cooled_artists = set()
cooled_albums = set()
last_genre = None
for r in rows:
age = now - r["timestamp"]
if age < dj_core.TRACK_COOLDOWN:
cooled_paths.add(r["path"])
if age < dj_core.ARTIST_COOLDOWN and r["artist"]:
cooled_artists.add(r["artist"].lower())
if age < dj_core.ALBUM_COOLDOWN and r["album"]:
cooled_albums.add(r["album"].lower())
if rows:
last_genre = dj_core.normalize_genre(rows[0]["genre"]) if rows[0]["genre"] else None
return cooled_paths, cooled_artists, cooled_albums, last_genre
def get_recent_energies(state, n=3):
rows = state.execute(
"SELECT energy FROM history ORDER BY timestamp DESC LIMIT ?", (n,)
).fetchall()
return [r["energy"] for r in rows if r["energy"] is not None]
def record_pick(state, path, artist, album, genre, energy, mode):
state.execute(
"INSERT INTO history (path, artist, album, genre, energy, mode, timestamp) VALUES (?,?,?,?,?,?,?)",
(path, artist, album, genre, energy, mode, time.time()),
)
state.commit()
# ── Album queue ────────────────────────────────────────────────────────────────
def get_album_queue(state):
row = state.execute("SELECT album, tracks, current_index FROM album_queue WHERE id = 1").fetchone()
if row and row["tracks"]:
try:
tracks = json.loads(row["tracks"])
return row["album"], tracks, row["current_index"] or 0
except (json.JSONDecodeError, TypeError):
pass
return None, [], 0
def set_album_queue(state, album, tracks, idx):
state.execute(
"INSERT OR REPLACE INTO album_queue (id, album, tracks, current_index) VALUES (1, ?, ?, ?)",
(album, json.dumps(tracks), idx),
)
state.commit()
def clear_album_queue(state):
state.execute("DELETE FROM album_queue WHERE id = 1")
state.commit()
# ── Main ───────────────────────────────────────────────────────────────────────
def main():
try:
now = time.time()
hour = time.localtime().tm_hour
target_energy = dj_core.ENERGY_CURVE.get(hour, 5)
state = init_state_db()
prune_history(state)
# ── Check album queue first ───────────────────────────────────
album_name, album_tracks, album_idx = get_album_queue(state)
if album_tracks and album_idx < len(album_tracks):
next_path = album_tracks[album_idx]
if os.path.isfile(next_path):
set_album_queue(state, album_name, album_tracks, album_idx + 1)
all_tracks = dj_core.load_tracks()
meta = next((t for t in all_tracks if t["path"] == next_path), None)
if meta:
record_pick(state, next_path, meta["artist"], meta["album"],
meta["genre"], meta["energy"], "full_album")
log.info("ALBUM [%d/%d] %s%s%s (energy=%d)",
album_idx + 1, len(album_tracks),
meta["artist"], meta["title"], meta["album"], meta["energy"])
else:
record_pick(state, next_path, "", album_name, "", 5, "full_album")
log.info("ALBUM [%d/%d] %s", album_idx + 1, len(album_tracks), next_path)
if album_idx + 1 >= len(album_tracks):
clear_album_queue(state)
log.info("Album complete, resuming normal mode")
state.close()
print(next_path)
return
else:
log.warning("Album track missing: %s, clearing queue", next_path)
clear_album_queue(state)
# ── Load data ─────────────────────────────────────────────────
all_tracks = dj_core.load_tracks()
if not all_tracks:
log.error("No tracks found anywhere!")
fallback = dj_core.random_file_fallback()
if fallback:
print(fallback)
state.close()
return
nav_data = dj_core.load_navidrome()
cooled_paths, cooled_artists, cooled_albums, last_genre = get_cooldowns(state)
recent_energies = get_recent_energies(state)
log.info("Loaded %d tracks, %d with play data, target energy=%d (hour=%d)",
len(all_tracks), len(nav_data), target_energy, hour)
# ── Energy filter ─────────────────────────────────────────────
pool = dj_core.filter_by_energy(all_tracks, target_energy)
log.info("Energy pool: %d tracks (target=%d ±2)", len(pool), target_energy)
# ── Cooldown filter ───────────────────────────────────────────
pool = dj_core.apply_cooldowns(pool, cooled_paths, cooled_artists, cooled_albums, last_genre)
log.info("After cooldowns: %d tracks", len(pool))
if not pool:
log.warning("Pool empty after cooldowns, relaxing artist/album cooldowns")
pool = dj_core.filter_by_energy(all_tracks, target_energy)
pool = [t for t in pool if t["path"] not in cooled_paths]
if not pool:
log.warning("Still empty, using all tracks minus path cooldown")
pool = [t for t in all_tracks if t["path"] not in cooled_paths]
if not pool:
pool = all_tracks
# ── Mode selection ────────────────────────────────────────────
mode = dj_core.pick_mode()
log.info("Mode roll: %s", mode)
# Handle full_album
if mode == "full_album":
multi = dj_core.find_album_tracks(pool)
if multi:
chosen_key = random.choice(list(multi.keys()))
album_tks = multi[chosen_key]
paths = [t["path"] for t in album_tks]
first = album_tks[0]
set_album_queue(state, first["album"], paths, 1)
record_pick(state, first["path"], first["artist"], first["album"],
first["genre"], first["energy"], "full_album")
log.info("FULL ALBUM: %s%s (%d tracks, energy=%d)",
first["artist"], first["album"], len(paths), first["energy"])
state.close()
print(first["path"])
return
else:
log.info("full_album: no multi-track albums in pool, falling back to shuffle")
mode = "shuffle"
# ── Apply mode filter ─────────────────────────────────────────
mode_pool, actual_mode = dj_core.apply_mode(pool, mode, nav_data, now)
log.info("Mode %s%d candidates", actual_mode, len(mode_pool))
# ── Contrast pick ─────────────────────────────────────────────
pick = dj_core.contrast_pick(mode_pool, recent_energies)
# ── Verify file exists ────────────────────────────────────────
if not os.path.isfile(pick["path"]):
log.warning("Selected file missing: %s, trying another", pick["path"])
mode_pool = [t for t in mode_pool if os.path.isfile(t["path"])]
if mode_pool:
pick = random.choice(mode_pool)
else:
fallback = dj_core.random_file_fallback()
if fallback:
print(fallback)
state.close()
return
# ── Record and output ─────────────────────────────────────────
record_pick(state, pick["path"], pick["artist"], pick["album"],
pick["genre"], pick["energy"], actual_mode)
log.info("PICK: [%s] %s%s%s (genre=%s, energy=%d, pool=%d)",
actual_mode, pick["artist"], pick["title"], pick["album"],
pick["genre"], pick["energy"], len(mode_pool))
state.close()
print(pick["path"])
except Exception as e:
log.exception("Fatal error: %s", e)
fallback = dj_core.random_file_fallback()
if fallback:
print(fallback)
else:
print("/dev/null")
if __name__ == "__main__":
main()
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Executable
BIN
View File
Binary file not shown.
Executable
BIN
View File
Binary file not shown.
Executable
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Executable
BIN
View File
Binary file not shown.
Executable
BIN
View File
Binary file not shown.
Executable
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/liquidsoap
# Radio Susan — with track announcements
set("log.file.path", "/var/lib/radio/radio.log")
set("log.level", 3)
set("server.telnet", true)
set("server.telnet.port", 1234)
set("ffmpeg.content_type.parse_metadata", false)
# State file for track metadata exchange
state_file = "/var/lib/radio/track_state.json"
# Track counter
track_count = ref(0)
music = playlist(
mode="randomize",
reload=3600,
reload_mode="watch",
"/var/lib/radio/playlists/all.m3u"
)
music = drop_video(music)
# Write track metadata to state file on each new track
music = on_track(fun(m) -> begin
track_count := !track_count + 1
artist = m["artist"]
title = m["title"]
count = !track_count
# Write JSON state file
data = '{"artist":"#{artist}","title":"#{title}","count":#{string_of(count)}}'
ignore(file.write(data=data, state_file))
log("Track #{string_of(count)}: #{artist} - #{title}")
end, music)
# Jingles
jingles = playlist(
mode="randomize",
"/var/lib/radio/jingles"
)
jingles = drop_video(jingles)
jingles = amplify(3.0, jingles)
# Dynamic announcements
def get_announcement() =
result = list.hd(default="", process.read.lines("/usr/bin/python3 /var/lib/radio/announce_tracks.py --state"))
if result != "" then
log("Announcement: #{result}")
request.create(result)
else
# Fallback to a random jingle
null()
end
end
announcements = request.dynamic(get_announcement)
announcements = drop_video(announcements)
announcements = amplify(3.0, announcements)
# Rotate: 2 music tracks, then 1 announcement or jingle
radio = rotate(weights=[2, 1], [music, random(weights=[1, 1], [jingles, announcements])])
radio = mksafe(radio)
output.icecast(
%mp3(bitrate=192),
host="localhost",
port=8910,
password="REDACTED",
mount="/stream",
name="Radio Susan",
description="Personal radio station",
genre="Eclectic",
url="https://radio.jihakuz.xyz",
radio
)