summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCaine <caine@jihakuz.xyz>2026-03-07 12:52:55 +0000
committerCaine <caine@jihakuz.xyz>2026-03-07 12:52:55 +0000
commit01682c62c2c9ea2f7f498544ee3aaa299c0c2423 (patch)
tree82a86298eeed90bfba253bf03a4b3393b182b491
Initial commit: Radio Susan scripts, configs, and SFX
-rw-r--r--.gitignore36
-rwxr-xr-xanalyse_tracks.py213
-rwxr-xr-xannounce_tracks.py202
-rwxr-xr-xbuild_genre_cache.py185
-rwxr-xr-xdecode.sh2
-rwxr-xr-xdj_core.py352
-rwxr-xr-xencode.sh2
-rwxr-xr-xezstream.xml71
-rwxr-xr-xgenerate_daily_playlist.py421
-rwxr-xr-xgenerate_jingles.py292
-rwxr-xr-xgenerate_site.py602
-rwxr-xr-xgenerate_tonight.py33
-rwxr-xr-xradio.liq71
-rwxr-xr-xradio_dj.py297
-rwxr-xr-xsfx/airhorn.wavbin0 -> 352912 bytes
-rwxr-xr-xsfx/cashregister.wavbin0 -> 485970 bytes
-rwxr-xr-xsfx/countdown.wavbin0 -> 492020 bytes
-rwxr-xr-xsfx/crash.wavbin0 -> 264678 bytes
-rwxr-xr-xsfx/demon.wavbin0 -> 411414 bytes
-rwxr-xr-xsfx/meow.wavbin0 -> 272530 bytes
-rwxr-xr-xsfx/pirate_arr.wavbin0 -> 272462 bytes
-rwxr-xr-xsfx/pirate_plank.wavbin0 -> 286794 bytes
-rwxr-xr-xsfx/pirate_yaargh.wavbin0 -> 217166 bytes
-rwxr-xr-xsfx/reverb_hit.wavbin0 -> 667034 bytes
-rwxr-xr-xsfx/scream.wavbin0 -> 97098 bytes
-rwxr-xr-xsfx/static.wavbin0 -> 352878 bytes
-rwxr-xr-xsfx/sweep.wavbin0 -> 352878 bytes
-rwxr-xr-xsfx/synth_horn.wavbin0 -> 352878 bytes
-rwxr-xr-xsfx/text_tone.wavbin0 -> 352898 bytes
-rwxr-xr-xtrack_announce.liq77
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
+
+
+def main():
+ if not PLAYLIST_JSON.exists():
+ print(f"ERROR: {PLAYLIST_JSON} not found. Run generate_daily_playlist.py first.")
+ sys.exit(1)
+
+ playlist = json.load(open(PLAYLIST_JSON))
+ print(f"Generating site from playlist ({playlist['total_tracks']} tracks)...")
+
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+
+ # Generate index.html
+ html = generate_html(playlist)
+ index_path = OUTPUT_DIR / "index.html"
+ index_path.write_text(html)
+ set_mediaserver_perms(index_path)
+
+ # Copy playlist.json for API access
+ api_path = OUTPUT_DIR / "playlist.json"
+ api_path.write_text(json.dumps(playlist, separators=(",", ":")))
+ set_mediaserver_perms(api_path)
+
+ print(f"Site generated at {OUTPUT_DIR}")
+ print(f" index.html: {index_path.stat().st_size / 1024:.1f}KB")
+ print(f" playlist.json: {api_path.stat().st_size / 1024:.1f}KB")
+
+
+if __name__ == "__main__":
+ main()
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
--- /dev/null
+++ b/sfx/airhorn.wav
Binary files differ
diff --git a/sfx/cashregister.wav b/sfx/cashregister.wav
new file mode 100755
index 0000000..c1da8cd
--- /dev/null
+++ b/sfx/cashregister.wav
Binary files differ
diff --git a/sfx/countdown.wav b/sfx/countdown.wav
new file mode 100755
index 0000000..e13eb9d
--- /dev/null
+++ b/sfx/countdown.wav
Binary files differ
diff --git a/sfx/crash.wav b/sfx/crash.wav
new file mode 100755
index 0000000..e92bb8a
--- /dev/null
+++ b/sfx/crash.wav
Binary files differ
diff --git a/sfx/demon.wav b/sfx/demon.wav
new file mode 100755
index 0000000..64eb49e
--- /dev/null
+++ b/sfx/demon.wav
Binary files differ
diff --git a/sfx/meow.wav b/sfx/meow.wav
new file mode 100755
index 0000000..087de62
--- /dev/null
+++ b/sfx/meow.wav
Binary files differ
diff --git a/sfx/pirate_arr.wav b/sfx/pirate_arr.wav
new file mode 100755
index 0000000..b4db240
--- /dev/null
+++ b/sfx/pirate_arr.wav
Binary files differ
diff --git a/sfx/pirate_plank.wav b/sfx/pirate_plank.wav
new file mode 100755
index 0000000..cd29042
--- /dev/null
+++ b/sfx/pirate_plank.wav
Binary files differ
diff --git a/sfx/pirate_yaargh.wav b/sfx/pirate_yaargh.wav
new file mode 100755
index 0000000..14cc987
--- /dev/null
+++ b/sfx/pirate_yaargh.wav
Binary files differ
diff --git a/sfx/reverb_hit.wav b/sfx/reverb_hit.wav
new file mode 100755
index 0000000..d689416
--- /dev/null
+++ b/sfx/reverb_hit.wav
Binary files differ
diff --git a/sfx/scream.wav b/sfx/scream.wav
new file mode 100755
index 0000000..0dec8f0
--- /dev/null
+++ b/sfx/scream.wav
Binary files differ
diff --git a/sfx/static.wav b/sfx/static.wav
new file mode 100755
index 0000000..89aa42b
--- /dev/null
+++ b/sfx/static.wav
Binary files differ
diff --git a/sfx/sweep.wav b/sfx/sweep.wav
new file mode 100755
index 0000000..008ec46
--- /dev/null
+++ b/sfx/sweep.wav
Binary files differ
diff --git a/sfx/synth_horn.wav b/sfx/synth_horn.wav
new file mode 100755
index 0000000..3f114f3
--- /dev/null
+++ b/sfx/synth_horn.wav
Binary files differ
diff --git a/sfx/text_tone.wav b/sfx/text_tone.wav
new file mode 100755
index 0000000..a758f8a
--- /dev/null
+++ b/sfx/text_tone.wav
Binary files 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
+)