diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a5e00fd
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+public/audio/*.mp3
+public/audio/*.json
diff --git a/public/app.js b/public/app.js
index 0b6b532..413bfa0 100644
--- a/public/app.js
+++ b/public/app.js
@@ -3,14 +3,11 @@ const { useState, useEffect, useRef, useCallback } = React;
// ============================================================
// SONG DEFINITIONS
//
-// MODE 1 (default): Procedurally generated via Tone.js
-// MODE 2 (production): Self-hosted MP3s in /audio/
-//
-// 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.
+// Procedural synth songs are the built-in fallback. If
+// audio/songs.json exists (generated by tools/probe-songs.py),
+// those MP3-backed songs replace the builtins at startup.
// ============================================================
-const SONGS = [
+const BUILTIN_SONGS = [
{ 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)); },
wave: "triangle", color: "#e74c3c" },
@@ -31,6 +28,9 @@ const SONGS = [
wave: "sawtooth", color: "#e67e22" },
];
+// Mutable song list — replaced by audio/songs.json if available
+let SONGS = BUILTIN_SONGS;
+
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" },
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();
+// 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());
diff --git a/tools/probe-songs.py b/tools/probe-songs.py
new file mode 100755
index 0000000..d02fe3f
--- /dev/null
+++ b/tools/probe-songs.py
@@ -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()