diff options
| -rw-r--r-- | .gitignore | 36 | ||||
| -rwxr-xr-x | analyse_tracks.py | 213 | ||||
| -rwxr-xr-x | announce_tracks.py | 202 | ||||
| -rwxr-xr-x | build_genre_cache.py | 185 | ||||
| -rwxr-xr-x | decode.sh | 2 | ||||
| -rwxr-xr-x | dj_core.py | 352 | ||||
| -rwxr-xr-x | encode.sh | 2 | ||||
| -rwxr-xr-x | ezstream.xml | 71 | ||||
| -rwxr-xr-x | generate_daily_playlist.py | 421 | ||||
| -rwxr-xr-x | generate_jingles.py | 292 | ||||
| -rwxr-xr-x | generate_site.py | 602 | ||||
| -rwxr-xr-x | generate_tonight.py | 33 | ||||
| -rwxr-xr-x | radio.liq | 71 | ||||
| -rwxr-xr-x | radio_dj.py | 297 | ||||
| -rwxr-xr-x | sfx/airhorn.wav | bin | 0 -> 352912 bytes | |||
| -rwxr-xr-x | sfx/cashregister.wav | bin | 0 -> 485970 bytes | |||
| -rwxr-xr-x | sfx/countdown.wav | bin | 0 -> 492020 bytes | |||
| -rwxr-xr-x | sfx/crash.wav | bin | 0 -> 264678 bytes | |||
| -rwxr-xr-x | sfx/demon.wav | bin | 0 -> 411414 bytes | |||
| -rwxr-xr-x | sfx/meow.wav | bin | 0 -> 272530 bytes | |||
| -rwxr-xr-x | sfx/pirate_arr.wav | bin | 0 -> 272462 bytes | |||
| -rwxr-xr-x | sfx/pirate_plank.wav | bin | 0 -> 286794 bytes | |||
| -rwxr-xr-x | sfx/pirate_yaargh.wav | bin | 0 -> 217166 bytes | |||
| -rwxr-xr-x | sfx/reverb_hit.wav | bin | 0 -> 667034 bytes | |||
| -rwxr-xr-x | sfx/scream.wav | bin | 0 -> 97098 bytes | |||
| -rwxr-xr-x | sfx/static.wav | bin | 0 -> 352878 bytes | |||
| -rwxr-xr-x | sfx/sweep.wav | bin | 0 -> 352878 bytes | |||
| -rwxr-xr-x | sfx/synth_horn.wav | bin | 0 -> 352878 bytes | |||
| -rwxr-xr-x | sfx/text_tone.wav | bin | 0 -> 352898 bytes | |||
| -rwxr-xr-x | track_announce.liq | 77 |
30 files changed, 2856 insertions, 0 deletions
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 @@ +<?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> 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""" + <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("&", "&").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 Binary files differnew file mode 100755 index 0000000..2df358f --- /dev/null +++ b/sfx/airhorn.wav diff --git a/sfx/cashregister.wav b/sfx/cashregister.wav Binary files differnew file mode 100755 index 0000000..c1da8cd --- /dev/null +++ b/sfx/cashregister.wav diff --git a/sfx/countdown.wav b/sfx/countdown.wav Binary files differnew file mode 100755 index 0000000..e13eb9d --- /dev/null +++ b/sfx/countdown.wav diff --git a/sfx/crash.wav b/sfx/crash.wav Binary files differnew file mode 100755 index 0000000..e92bb8a --- /dev/null +++ b/sfx/crash.wav diff --git a/sfx/demon.wav b/sfx/demon.wav Binary files differnew file mode 100755 index 0000000..64eb49e --- /dev/null +++ b/sfx/demon.wav diff --git a/sfx/meow.wav b/sfx/meow.wav Binary files differnew file mode 100755 index 0000000..087de62 --- /dev/null +++ b/sfx/meow.wav diff --git a/sfx/pirate_arr.wav b/sfx/pirate_arr.wav Binary files differnew file mode 100755 index 0000000..b4db240 --- /dev/null +++ b/sfx/pirate_arr.wav diff --git a/sfx/pirate_plank.wav b/sfx/pirate_plank.wav Binary files differnew file mode 100755 index 0000000..cd29042 --- /dev/null +++ b/sfx/pirate_plank.wav diff --git a/sfx/pirate_yaargh.wav b/sfx/pirate_yaargh.wav Binary files differnew file mode 100755 index 0000000..14cc987 --- /dev/null +++ b/sfx/pirate_yaargh.wav diff --git a/sfx/reverb_hit.wav b/sfx/reverb_hit.wav Binary files differnew file mode 100755 index 0000000..d689416 --- /dev/null +++ b/sfx/reverb_hit.wav diff --git a/sfx/scream.wav b/sfx/scream.wav Binary files differnew file mode 100755 index 0000000..0dec8f0 --- /dev/null +++ b/sfx/scream.wav diff --git a/sfx/static.wav b/sfx/static.wav Binary files differnew file mode 100755 index 0000000..89aa42b --- /dev/null +++ b/sfx/static.wav diff --git a/sfx/sweep.wav b/sfx/sweep.wav Binary files differnew file mode 100755 index 0000000..008ec46 --- /dev/null +++ b/sfx/sweep.wav diff --git a/sfx/synth_horn.wav b/sfx/synth_horn.wav Binary files differnew file mode 100755 index 0000000..3f114f3 --- /dev/null +++ b/sfx/synth_horn.wav diff --git a/sfx/text_tone.wav b/sfx/text_tone.wav Binary files differnew file mode 100755 index 0000000..a758f8a --- /dev/null +++ b/sfx/text_tone.wav 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 +) |
