ignore any songs.json etc
This commit is contained in:
parent
96b1ecf7e6
commit
9a5db25087
3 changed files with 235 additions and 8 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
public/audio/*.mp3
|
||||
public/audio/*.json
|
||||
|
|
@ -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(<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
220
tools/probe-songs.py
Executable 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue