ignore any songs.json etc

This commit is contained in:
Ole-Morten Duesund 2026-03-17 17:09:50 +01:00
commit 9a5db25087
3 changed files with 235 additions and 8 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
public/audio/*.mp3
public/audio/*.json

View file

@ -3,14 +3,11 @@ const { useState, useEffect, useRef, useCallback } = React;
// ============================================================ // ============================================================
// SONG DEFINITIONS // SONG DEFINITIONS
// //
// MODE 1 (default): Procedurally generated via Tone.js // Procedural synth songs are the built-in fallback. If
// MODE 2 (production): Self-hosted MP3s in /audio/ // audio/songs.json exists (generated by tools/probe-songs.py),
// // those MP3-backed songs replace the builtins at startup.
// To switch to real audio, set url: "/audio/yourfile.mp3"
// on each song and the player will use HTML5 Audio instead.
// See README.md for details on sourcing CC-BY music.
// ============================================================ // ============================================================
const SONGS = [ const BUILTIN_SONGS = [
{ title: "Bureaucratic Sunrise", artist: "The Paywalls", duration: 90, bpm: 120, { title: "Bureaucratic Sunrise", artist: "The Paywalls", duration: 90, bpm: 120,
gen: (s,t) => { ["C4","E4","G4","B4","C5","B4","G4","E4"].forEach((n,i) => s.triggerAttackRelease(n,"8n",t+i*0.25)); }, gen: (s,t) => { ["C4","E4","G4","B4","C5","B4","G4","E4"].forEach((n,i) => s.triggerAttackRelease(n,"8n",t+i*0.25)); },
wave: "triangle", color: "#e74c3c" }, wave: "triangle", color: "#e74c3c" },
@ -31,6 +28,9 @@ const SONGS = [
wave: "sawtooth", color: "#e67e22" }, wave: "sawtooth", color: "#e67e22" },
]; ];
// Mutable song list — replaced by audio/songs.json if available
let SONGS = BUILTIN_SONGS;
const SUBS = { const SUBS = {
pause: { name:"Pause Plus™", price:"$2.99/mo", micro:0.01, desc:"The freedom to stop. Whenever you want.", color:"#e74c3c", dismiss:"I like surprises" }, pause: { name:"Pause Plus™", price:"$2.99/mo", micro:0.01, desc:"The freedom to stop. Whenever you want.", color:"#e74c3c", dismiss:"I like surprises" },
resume: { name:"Resume Rights™", price:"$2.49/mo", micro:0.02, desc:"You paused. Now un-pause. Two different things.", color:"#e67e22", dismiss:"Silence suits me" }, resume: { name:"Resume Rights™", price:"$2.49/mo", micro:0.02, desc:"You paused. Now un-pause. Two different things.", color:"#e67e22", dismiss:"Silence suits me" },
@ -586,4 +586,9 @@ function PayPlay() {
); );
} }
ReactDOM.createRoot(document.getElementById("root")).render(<PayPlay />); // Load real songs from audio/songs.json if available, then render
fetch("audio/songs.json")
.then(r => { if (r.ok) return r.json(); throw new Error(); })
.then(songs => { if (Array.isArray(songs) && songs.length > 0) SONGS = songs; })
.catch(() => {}) // No songs.json — use procedural builtins
.finally(() => ReactDOM.createRoot(document.getElementById("root")).render(<PayPlay />));

220
tools/probe-songs.py Executable file
View file

@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""Probe audio files with ffprobe and generate songs.json for Pay2Play!
Usage:
./tools/probe-songs.py public/audio/*.mp3
./tools/probe-songs.py -d public/audio
./tools/probe-songs.py -o public/audio/songs.json track1.mp3 track2.mp3
Extracts duration, title, and artist from each file's metadata (ID3 tags).
Falls back to filename for title and "Unknown Artist" when tags are missing.
Outputs JSON compatible with the Pay2Play! SONGS array format.
"""
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
COLORS = [
"#e74c3c",
"#9b59b6",
"#f39c12",
"#3498db",
"#1abc9c",
"#e67e22",
"#2ecc71",
"#d35400",
"#2980b9",
"#8e44ad",
"#16a085",
"#c0392b",
]
AUDIO_EXTENSIONS = {
".mp3",
".ogg",
".wav",
".flac",
".m4a",
".aac",
".opus",
".weba",
".webm",
}
def ffprobe(path: str) -> dict | None:
"""Run ffprobe and return format metadata as a dict."""
try:
result = subprocess.run(
[
"ffprobe",
"-v",
"quiet",
"-print_format",
"json",
"-show_format",
str(path),
],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
print(f" warning: ffprobe failed for {path}", file=sys.stderr)
return None
return json.loads(result.stdout).get("format")
except FileNotFoundError:
print("error: ffprobe not found — install ffmpeg", file=sys.stderr)
sys.exit(1)
except (subprocess.TimeoutExpired, json.JSONDecodeError) as e:
print(f" warning: {e} for {path}", file=sys.stderr)
return None
def title_from_filename(path: str) -> str:
"""Derive a display title from a filename."""
name = Path(path).stem
# Replace common separators with spaces, then title-case
for ch in "-_":
name = name.replace(ch, " ")
return name.strip().title()
def probe_file(path: str, audio_root: str | None, index: int) -> dict | None:
"""Probe a single audio file and return a song entry dict."""
fmt = ffprobe(path)
if fmt is None:
return None
tags = fmt.get("tags", {})
# ffprobe tag keys can be uppercase or lowercase
tag = lambda key: tags.get(key) or tags.get(key.upper()) or tags.get(key.title())
duration = float(fmt.get("duration", 0))
if duration <= 0:
print(f" warning: zero duration for {path}, skipping", file=sys.stderr)
return None
title = tag("title") or title_from_filename(path)
artist = tag("artist") or tag("album_artist") or "Unknown Artist"
# Build URL relative to the served public/ directory
abspath = os.path.abspath(path)
if audio_root:
relpath = os.path.relpath(abspath, os.path.abspath(audio_root))
url = f"/audio/{relpath}"
else:
url = f"/audio/{os.path.basename(path)}"
return {
"title": title,
"artist": artist,
"duration": int(duration),
"url": url,
"color": COLORS[index % len(COLORS)],
}
def collect_files(paths: list[str], scan_dir: str | None) -> list[str]:
"""Resolve input paths and optionally scan a directory for audio files."""
files = []
if scan_dir:
d = Path(scan_dir)
if not d.is_dir():
print(f"error: {scan_dir} is not a directory", file=sys.stderr)
sys.exit(1)
files.extend(
sorted(str(p) for p in d.iterdir() if p.suffix.lower() in AUDIO_EXTENSIONS)
)
for p in paths:
if os.path.isfile(p):
files.append(p)
elif os.path.isdir(p):
files.extend(
sorted(
str(f)
for f in Path(p).iterdir()
if f.suffix.lower() in AUDIO_EXTENSIONS
)
)
else:
print(f" warning: {p} not found, skipping", file=sys.stderr)
return files
def main():
parser = argparse.ArgumentParser(
description="Probe audio files and generate songs.json for Pay2Play!"
)
parser.add_argument("files", nargs="*", help="Audio files or directories to probe")
parser.add_argument("-d", "--dir", help="Directory to scan for audio files")
parser.add_argument(
"-o",
"--output",
help="Output file (default: stdout, or public/audio/songs.json with --dir)",
)
parser.add_argument(
"--audio-root",
help="Path that maps to /audio/ in the served app (default: public/)",
default=None,
)
args = parser.parse_args()
if not args.files and not args.dir:
parser.print_help()
print("\nExamples:", file=sys.stderr)
print(" ./tools/probe-songs.py public/audio/*.mp3", file=sys.stderr)
print(" ./tools/probe-songs.py -d public/audio", file=sys.stderr)
print(
" ./tools/probe-songs.py -o public/audio/songs.json track1.mp3",
file=sys.stderr,
)
sys.exit(1)
# Determine the root that maps to /audio/ in the browser
audio_root = args.audio_root
if not audio_root:
# Default: assume files live in or will be copied to public/audio/
audio_root = "public"
files = collect_files(args.files, args.dir)
if not files:
print("No audio files found.", file=sys.stderr)
sys.exit(1)
print(f"Probing {len(files)} file(s)...", file=sys.stderr)
songs = []
for i, f in enumerate(files):
print(f" {os.path.basename(f)}...", file=sys.stderr, end=" ")
entry = probe_file(f, audio_root, i)
if entry:
songs.append(entry)
print(
f"{entry['duration']}s — {entry['artist']} - {entry['title']}",
file=sys.stderr,
)
else:
print("skipped", file=sys.stderr)
output_path = args.output
if not output_path and args.dir:
output_path = os.path.join(args.dir, "songs.json")
result = json.dumps(songs, indent=2, ensure_ascii=False)
if output_path:
with open(output_path, "w") as f:
f.write(result + "\n")
print(f"\nWrote {len(songs)} song(s) to {output_path}", file=sys.stderr)
else:
print(result)
print(f"\n{len(songs)} song(s) ready for Pay2Play!", file=sys.stderr)
if __name__ == "__main__":
main()