From 01682c62c2c9ea2f7f498544ee3aaa299c0c2423 Mon Sep 17 00:00:00 2001 From: Caine Date: Sat, 7 Mar 2026 12:52:55 +0000 Subject: Initial commit: Radio Susan scripts, configs, and SFX --- .gitignore | 36 +++ analyse_tracks.py | 213 ++++++++++++++++ announce_tracks.py | 202 +++++++++++++++ build_genre_cache.py | 185 ++++++++++++++ decode.sh | 2 + dj_core.py | 352 ++++++++++++++++++++++++++ encode.sh | 2 + ezstream.xml | 71 ++++++ generate_daily_playlist.py | 421 +++++++++++++++++++++++++++++++ generate_jingles.py | 292 ++++++++++++++++++++++ generate_site.py | 602 +++++++++++++++++++++++++++++++++++++++++++++ generate_tonight.py | 33 +++ radio.liq | 71 ++++++ radio_dj.py | 297 ++++++++++++++++++++++ sfx/airhorn.wav | Bin 0 -> 352912 bytes sfx/cashregister.wav | Bin 0 -> 485970 bytes sfx/countdown.wav | Bin 0 -> 492020 bytes sfx/crash.wav | Bin 0 -> 264678 bytes sfx/demon.wav | Bin 0 -> 411414 bytes sfx/meow.wav | Bin 0 -> 272530 bytes sfx/pirate_arr.wav | Bin 0 -> 272462 bytes sfx/pirate_plank.wav | Bin 0 -> 286794 bytes sfx/pirate_yaargh.wav | Bin 0 -> 217166 bytes sfx/reverb_hit.wav | Bin 0 -> 667034 bytes sfx/scream.wav | Bin 0 -> 97098 bytes sfx/static.wav | Bin 0 -> 352878 bytes sfx/sweep.wav | Bin 0 -> 352878 bytes sfx/synth_horn.wav | Bin 0 -> 352878 bytes sfx/text_tone.wav | Bin 0 -> 352898 bytes track_announce.liq | 77 ++++++ 30 files changed, 2856 insertions(+) create mode 100644 .gitignore create mode 100755 analyse_tracks.py create mode 100755 announce_tracks.py create mode 100755 build_genre_cache.py create mode 100755 decode.sh create mode 100755 dj_core.py create mode 100755 encode.sh create mode 100755 ezstream.xml create mode 100755 generate_daily_playlist.py create mode 100755 generate_jingles.py create mode 100755 generate_site.py create mode 100755 generate_tonight.py create mode 100755 radio.liq create mode 100755 radio_dj.py create mode 100755 sfx/airhorn.wav create mode 100755 sfx/cashregister.wav create mode 100755 sfx/countdown.wav create mode 100755 sfx/crash.wav create mode 100755 sfx/demon.wav create mode 100755 sfx/meow.wav create mode 100755 sfx/pirate_arr.wav create mode 100755 sfx/pirate_plank.wav create mode 100755 sfx/pirate_yaargh.wav create mode 100755 sfx/reverb_hit.wav create mode 100755 sfx/scream.wav create mode 100755 sfx/static.wav create mode 100755 sfx/sweep.wav create mode 100755 sfx/synth_horn.wav create mode 100755 sfx/text_tone.wav create mode 100755 track_announce.liq diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fba6d85 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/analyse_tracks.py b/analyse_tracks.py new file mode 100755 index 0000000..120e536 --- /dev/null +++ b/analyse_tracks.py @@ -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() \ No newline at end of file diff --git a/announce_tracks.py b/announce_tracks.py new file mode 100755 index 0000000..c129040 --- /dev/null +++ b/announce_tracks.py @@ -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() diff --git a/build_genre_cache.py b/build_genre_cache.py new file mode 100755 index 0000000..fb9ace3 --- /dev/null +++ b/build_genre_cache.py @@ -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.0–1.0) to 1–10 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 1–10 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() \ No newline at end of file diff --git a/decode.sh b/decode.sh new file mode 100755 index 0000000..0287183 --- /dev/null +++ b/decode.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exec ffmpeg -hide_banner -loglevel error -i "$1" -f wav -ar 44100 -ac 2 - diff --git a/dj_core.py b/dj_core.py new file mode 100755 index 0000000..92667b1 --- /dev/null +++ b/dj_core.py @@ -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} diff --git a/encode.sh b/encode.sh new file mode 100755 index 0000000..bd2f77c --- /dev/null +++ b/encode.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exec ffmpeg -hide_banner -loglevel error -f wav -i pipe:0 -f mp3 -b:a 192k pipe:1 diff --git a/ezstream.xml b/ezstream.xml new file mode 100755 index 0000000..a6bd402 --- /dev/null +++ b/ezstream.xml @@ -0,0 +1,71 @@ + + + + + default + HTTP + 127.0.0.1 + 8910 + source + REDACTED + None + 20 + + + + + + /stream + default + default + No + MP3 + mp3 + Radio Susan + https://radio.jihakuz.xyz + Eclectic + Personal radio station + 192 + 44100 + 2 + + + + + + default + playlist + /var/lib/radio/playlists/all.m3u + Yes + No + + + + + @a@ - @t@ + Yes + No + + + + + ffmpeg-all + /var/lib/radio/decode.sh @T@ + .flac + .mp3 + .ogg + .m4a + .opus + .wav + .wma + + + + + + mp3 + MP3 + /var/lib/radio/encode.sh + + + diff --git a/generate_daily_playlist.py b/generate_daily_playlist.py new file mode 100755 index 0000000..424984e --- /dev/null +++ b/generate_daily_playlist.py @@ -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() diff --git a/generate_jingles.py b/generate_jingles.py new file mode 100755 index 0000000..1a8a7fe --- /dev/null +++ b/generate_jingles.py @@ -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() diff --git a/generate_site.py b/generate_site.py new file mode 100755 index 0000000..4930374 --- /dev/null +++ b/generate_site.py @@ -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""" +
+
+
{show['start']} — {show['end']}
+

{name}

+
{show['description']} · Energy {show['energy']} · {len(trks)} tracks
+
+
+ {''.join(f'
' + f'{t["start_formatted"][:5]}' + f'{_esc(t["artist"])}' + f'' + f'{_esc(t["title"])}' + f'{_esc(t.get("album", ""))}' + f'{t["mode"]}' + f'
' for t in trks)} +
+
+""" + + html = f""" + + + + + Radio Susan — 99.0 + + + +
+
+

RADIO SUSAN

+
99.0 FM
+
Generated {generated} · {total_tracks} tracks · {duration}h
+
+ +
+
NOW PLAYING
+
Loading...
+
+
+ +
+ +
+

Up Next

+
+
+ +
+
{total_tracks}
Tracks
+
{len(shows)}
Shows
+
{mode_counts.get('full_album', 0)}
Album Tracks
+
{mode_counts.get('deep_cuts', 0)}
Deep Cuts
+
{mode_counts.get('new_to_library', 0)}
New
+
+ +

TODAY'S SCHEDULE

+ + {show_cards} + + +
+ + + + + +""" + + return html + + +def _esc(s): + """Escape HTML entities.""" + return (s or "").replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) + + +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() diff --git a/generate_tonight.py b/generate_tonight.py new file mode 100755 index 0000000..b457f90 --- /dev/null +++ b/generate_tonight.py @@ -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") diff --git a/radio.liq b/radio.liq new file mode 100755 index 0000000..0f5b826 --- /dev/null +++ b/radio.liq @@ -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 +) diff --git a/radio_dj.py b/radio_dj.py new file mode 100755 index 0000000..6bdb5a6 --- /dev/null +++ b/radio_dj.py @@ -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() diff --git a/sfx/airhorn.wav b/sfx/airhorn.wav new file mode 100755 index 0000000..2df358f Binary files /dev/null and b/sfx/airhorn.wav differ diff --git a/sfx/cashregister.wav b/sfx/cashregister.wav new file mode 100755 index 0000000..c1da8cd Binary files /dev/null and b/sfx/cashregister.wav differ diff --git a/sfx/countdown.wav b/sfx/countdown.wav new file mode 100755 index 0000000..e13eb9d Binary files /dev/null and b/sfx/countdown.wav differ diff --git a/sfx/crash.wav b/sfx/crash.wav new file mode 100755 index 0000000..e92bb8a Binary files /dev/null and b/sfx/crash.wav differ diff --git a/sfx/demon.wav b/sfx/demon.wav new file mode 100755 index 0000000..64eb49e Binary files /dev/null and b/sfx/demon.wav differ diff --git a/sfx/meow.wav b/sfx/meow.wav new file mode 100755 index 0000000..087de62 Binary files /dev/null and b/sfx/meow.wav differ diff --git a/sfx/pirate_arr.wav b/sfx/pirate_arr.wav new file mode 100755 index 0000000..b4db240 Binary files /dev/null and b/sfx/pirate_arr.wav differ diff --git a/sfx/pirate_plank.wav b/sfx/pirate_plank.wav new file mode 100755 index 0000000..cd29042 Binary files /dev/null and b/sfx/pirate_plank.wav differ diff --git a/sfx/pirate_yaargh.wav b/sfx/pirate_yaargh.wav new file mode 100755 index 0000000..14cc987 Binary files /dev/null and b/sfx/pirate_yaargh.wav differ diff --git a/sfx/reverb_hit.wav b/sfx/reverb_hit.wav new file mode 100755 index 0000000..d689416 Binary files /dev/null and b/sfx/reverb_hit.wav differ diff --git a/sfx/scream.wav b/sfx/scream.wav new file mode 100755 index 0000000..0dec8f0 Binary files /dev/null and b/sfx/scream.wav differ diff --git a/sfx/static.wav b/sfx/static.wav new file mode 100755 index 0000000..89aa42b Binary files /dev/null and b/sfx/static.wav differ diff --git a/sfx/sweep.wav b/sfx/sweep.wav new file mode 100755 index 0000000..008ec46 Binary files /dev/null and b/sfx/sweep.wav differ diff --git a/sfx/synth_horn.wav b/sfx/synth_horn.wav new file mode 100755 index 0000000..3f114f3 Binary files /dev/null and b/sfx/synth_horn.wav differ diff --git a/sfx/text_tone.wav b/sfx/text_tone.wav new file mode 100755 index 0000000..a758f8a Binary files /dev/null and b/sfx/text_tone.wav differ diff --git a/track_announce.liq b/track_announce.liq new file mode 100755 index 0000000..8aac476 --- /dev/null +++ b/track_announce.liq @@ -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 +) -- cgit v1.2.3