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()