Files
radio-susan/build_genre_cache.py

185 lines
6.6 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()