Initial commit: Radio Susan scripts, configs, and SFX

This commit is contained in:
Caine
2026-03-07 12:52:55 +00:00
commit 01682c62c2
30 changed files with 2856 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Build genre cache from beets database + Essentia audio features. Run daily via cron.
Genre-level energy scores are computed from the mean Essentia energy of all
analysed tracks in that genre. Unanalysed tracks fall back to their genre
average, or 5 (neutral) if the genre has no analysed tracks yet.
"""
import json
import os
import sqlite3
import sys
import time
BEETS_DB = "/home/susan/.config/beets/library.db"
FEATURES_DB = "/var/lib/radio/audio_features.db"
CACHE_PATH = "/var/lib/radio/genre_cache.json"
NEUTRAL_ENERGY = 5
# Genre → broad group (for DJ filtering / display)
GROUP_KEYWORDS = {
"chill": ["ambient", "downtempo", "chill", "drone", "new age", "lounge", "easy listening", "sleep", "meditation", "minimal"],
"jazz": ["jazz", "bossa nova", "bebop", "swing", "fusion"],
"classical": ["classical", "chamber", "orchestral", "piano", "baroque", "romantic"],
"electronic": ["techno", "house", "trance", "electro", "electronic", "idm", "ebm", "synth", "synthwave"],
"bass": ["dubstep", "drum and bass", "dnb", "jungle", "breakbeat", "garage", "uk garage", "dub", "bass"],
"rock": ["rock", "punk", "metal", "shoegaze", "post rock", "post-rock", "grunge", "alternative", "indie rock", "noise rock"],
"hiphop": ["hip hop", "hip-hop", "rap", "trap", "boom bap", "grime"],
"soul": ["soul", "r&b", "rnb", "funk", "disco", "motown", "gospel", "neo soul"],
"folk": ["folk", "acoustic", "singer-songwriter", "country", "bluegrass", "americana"],
"pop": ["pop", "synth pop", "synthpop", "dream pop", "new wave", "art pop", "indie pop"],
"world": ["afrobeat", "reggae", "latin", "world", "flamenco", "samba", "cumbia"],
"heavy": ["industrial", "noise", "hardcore", "metal", "thrash", "grindcore", "power electronics"],
"experimental": ["experimental", "avant-garde", "musique concrete", "free improvisation"],
}
def normalize_genre(g):
return g.lower().replace("-", " ").replace("_", " ").strip()
def essentia_to_scale(e):
"""Convert Essentia normalized energy (0.01.0) to 110 integer scale."""
return max(1, min(10, round(e * 9 + 1)))
def find_group(genre):
g = normalize_genre(genre)
for group, keywords in GROUP_KEYWORDS.items():
for kw in keywords:
if kw in g or g in kw:
return group
return "other"
def load_essentia_features():
"""Load path → normalized energy from audio_features.db."""
if not os.path.exists(FEATURES_DB):
print(f"Warning: features DB not found at {FEATURES_DB}, all tracks will get energy {NEUTRAL_ENERGY}", file=sys.stderr)
return {}
try:
conn = sqlite3.connect(f"file:{FEATURES_DB}?mode=ro", uri=True)
rows = conn.execute("SELECT path, energy FROM features").fetchall()
conn.close()
return {row[0]: row[1] for row in rows}
except Exception as e:
print(f"Warning: failed to read features DB: {e}", file=sys.stderr)
return {}
def main():
if not os.path.exists(BEETS_DB):
print(f"Error: beets DB not found at {BEETS_DB}", file=sys.stderr)
sys.exit(1)
essentia_energies = load_essentia_features()
print(f"Loaded Essentia features for {len(essentia_energies)} tracks")
conn = sqlite3.connect(f"file:{BEETS_DB}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("SELECT path, title, artist, album, albumartist, genre FROM items")
# First pass: collect per-track data, accumulate Essentia energies per genre
track_data = {}
genre_energies = {} # genre → [list of 110 scores from analysed tracks]
analysed_count = 0
for row in cur:
path = row["path"]
if isinstance(path, bytes):
try:
path = path.decode("utf-8")
except UnicodeDecodeError:
continue
genre_raw = row["genre"] or ""
genre = normalize_genre(genre_raw) if genre_raw else "misc"
if path in essentia_energies:
energy = essentia_to_scale(essentia_energies[path])
analysed_count += 1
genre_energies.setdefault(genre, []).append(energy)
else:
energy = None # resolve in second pass
track_data[path] = {
"genre": genre,
"genre_raw": genre_raw,
"energy": energy,
"artist": row["artist"] or "",
"album": row["album"] or "",
"title": row["title"] or "",
}
conn.close()
# Compute genre averages from Essentia data
genre_avg_energy = {}
for genre, scores in genre_energies.items():
genre_avg_energy[genre] = round(sum(scores) / len(scores))
print(f"Genre energies computed from Essentia for {len(genre_avg_energy)} genres")
# Second pass: fill unanalysed tracks with genre average or neutral
fallback_genre_avg = 0
fallback_neutral = 0
for info in track_data.values():
if info["energy"] is None:
if info["genre"] in genre_avg_energy:
info["energy"] = genre_avg_energy[info["genre"]]
fallback_genre_avg += 1
else:
info["energy"] = NEUTRAL_ENERGY
fallback_neutral += 1
# Build genre summary
all_genres = set(info["genre"] for info in track_data.values())
genres_out = {}
for genre in all_genres:
genres_out[genre] = {
"energy": genre_avg_energy.get(genre, NEUTRAL_ENERGY),
"group": find_group(genre),
"analysed_tracks": len(genre_energies.get(genre, [])),
}
# Build output tracks
tracks_out = {
path: {
"genre": info["genre"],
"energy": info["energy"],
"artist": info["artist"],
"album": info["album"],
"title": info["title"],
}
for path, info in track_data.items()
}
cache = {
"genres": genres_out,
"tracks": tracks_out,
"built_at": time.time(),
"stats": {
"total_tracks": len(tracks_out),
"essentia_analysed": analysed_count,
"fallback_genre_avg": fallback_genre_avg,
"fallback_neutral": fallback_neutral,
"genres_with_essentia": len(genre_energies),
},
}
tmp = CACHE_PATH + ".tmp"
with open(tmp, "w") as f:
json.dump(cache, f)
os.replace(tmp, CACHE_PATH)
print(f"Built genre cache: {len(tracks_out)} tracks, {len(genres_out)} genres")
print(f" Essentia energy: {analysed_count} tracks")
print(f" Genre avg fallback: {fallback_genre_avg} tracks")
print(f" Neutral ({NEUTRAL_ENERGY}) fallback: {fallback_neutral} tracks")
if __name__ == "__main__":
main()