diff --git a/.gitignore b/.gitignore index f4dda73e..fa79c1de 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,7 @@ racket/prologos/data/cache/ mempalace.yaml entities.json *.pnetx + +# Python bytecode +__pycache__/ +*.pyc diff --git a/racket/prologos/tools/viz/README.md b/racket/prologos/tools/viz/README.md new file mode 100644 index 00000000..d067c353 --- /dev/null +++ b/racket/prologos/tools/viz/README.md @@ -0,0 +1,123 @@ +# Propagator-network visualizer + +`index.html` is a single-file, no-build React + SVG viewer for the propagator +network a Prologos program executes on. Generate a trace and drop it in: + +``` +racket tools/viz-export.rkt FILE.prologos -o out.json [--reduce] +``` + +Open `index.html` (it pulls React / d3 / Babel from CDNs, so it needs network), +then drop the JSON onto the page. + +## Playback + +Two ways to step the execution timeline: + +1. **Timer playback** — the `▶` button advances one round every *speed* ms. +2. **Music-synced playback** — load an audio file **and** a timings file, then + play the audio. Each hit timestamp in the timings file advances the timeline + one step, so the network's reduction plays back *in time with the music*. The + two controls are independent; starting the audio pauses the timer. + +### Timings file format + +A timings file lists hit onsets in seconds. Any of these is accepted: + +- a bare JSON array: `[0.30, 0.62, 0.95, ...]` +- a JSON object keyed by `hits` / `timings` / `beats` / `onsets` / `times` + (numbers, or `{ "time": 0.3 }` objects): + `{ "hits": [0.30, 0.62, ...] }` +- plain text — whitespace/comma/newline-separated numbers. + +## Extracting timings from a song + +`extract-song-timings.py` turns a song into a timings file by detecting +percussive onsets (e.g. xylophone hits) via spectral flux: + +``` +# from a URL (needs yt-dlp + ffmpeg; pass cookies if YouTube demands sign-in): +python3 tools/viz/extract-song-timings.py 'https://youtu.be/VIDEO' \ + -o song.timings.json --keep-audio song.mp3 --cookies-from-browser chrome + +# from a local audio file: +python3 tools/viz/extract-song-timings.py mysong.mp3 -o mysong.timings.json + +# tune: lower --threshold = more hits; --min-gap = min seconds between hits; +# --band-low/--band-high (Hz) restrict onsets to one instrument's band — e.g. a +# low xylophone/marimba sits a few hundred Hz, distinct from arpeggiated strings +# above and the kick/bass below; --min-strength thins the band to just the loud +# mallet hits (drops quiet bass/percussion bleed): +python3 tools/viz/extract-song-timings.py in.wav -o out.json \ + --band-low 110 --band-high 300 --min-strength 0.5 --min-gap 0.12 +``` + +**Beat-grid mode (`--grid`)** — for a quiet, *metronomic* instrument. Per-onset +picking fails when the target plays a steady pulse but is quieter than the +kick/snare on the loud beats: the picker grabs the loud off-beat transients and +misses the quiet ones. `--grid` instead locks a steady beat grid to the band's +pulse — it estimates the tempo from the band onset-envelope autocorrelation and +emits `phase + k·period`. Anchor the phase to the instrument's first audible hit +with `--phase` (blind phase-finding locks onto the louder beat), and bound the +tempo search with `--tempo-min/max`: + +``` +python3 tools/viz/extract-song-timings.py in.wav -o out.json \ + --grid --band-low 110 --band-high 350 --phase 0.10 --tempo-min 55 --tempo-max 90 +``` + +A constant-tempo track stays aligned for its whole length (no drift); the +tradeoff is it pulses through sections where the instrument rests. Pass an +explicit `--bpm` to skip tempo estimation. + +**Tempo map (`--segments`)** — a piecewise grid of `startSec:bpm` segments, each +running at its own tempo until the next. Lets you hold a slow pulse through an +intro then rush at a drop (a tempo switch lands a beat exactly on the boundary): + +``` +python3 tools/viz/extract-song-timings.py in.wav -o out.json \ + --segments '0:67.5,28.5:675' --phase 0.10 +``` + +Dependencies: `numpy`, `ffmpeg` on PATH (`pip install static-ffmpeg` provides a +static binary), and `yt-dlp` for URLs. No librosa required. + +> Note: downloading from YouTube in a datacenter / CI environment is frequently +> blocked by YouTube's bot detection and PO-token / DRM gating. Run the script on +> a machine with a logged-in browser (`--cookies-from-browser`) to fetch a real +> song, or point it at an audio file you already have. + +## Demo + +`examples/` ships a small, copyright-free synthetic xylophone phrase so the +music-sync feature is testable out of the box: + +- `examples/demo-xylophone.mp3` — the audio +- `examples/demo-xylophone.timings.json` — its 13 hit onsets +- `examples/make-demo-song.py` — regenerates both (and doubles as an end-to-end + check that the onset detector recovers the known onsets) + +Load a vizTrace, then load those two files in the **music-synced playback** +panel and press play. + +It also ships real onsets for the original request song: + +- `examples/praise-the-lamb.timings.json` — 407 beats (~1.1/s) of the low + xylophone pulse in *Cult of the Lamb — Praise the Lamb* + (`https://youtu.be/PoH5hC5PzSQ`), `--grid` mode at 67.5 BPM locked to the + xylophone's phase (`--band-low 110 --band-high 350 --phase 0.10`). The audio + itself isn't committed (copyright); pair this timings file with your own copy + of the track in the music panel. +- `examples/praise-the-lamb-tempomap.timings.json` — alternate take: the slow + intro pulse (67.5 BPM) then a 10× burst from 28.5s (`--segments + '0:67.5,28.5:675'`), so the timeline crawls through the build then rushes the + drop. + +## Headless checks + +`check.js` node-verifies the viewer's pure core (graph build, layouts, timings +parsing, step mapping) against real vizTrace envelopes: + +``` +node tools/viz/check.js tools/viz/index.html out.json +``` diff --git a/racket/prologos/tools/viz/check.js b/racket/prologos/tools/viz/check.js index 12433a90..f0effbc1 100644 --- a/racket/prologos/tools/viz/check.js +++ b/racket/prologos/tools/viz/check.js @@ -9,6 +9,24 @@ eval(m[1]); let failures = 0; const check = (c, msg) => { if (!c) { console.error('FAIL: ' + msg); failures++; } }; +// ---- music-synced playback: parseTimings + stepForTime -------------------- +const arrEq = (a, b) => a.length === b.length && a.every((x, i) => Math.abs(x - b[i]) < 1e-9); +check(arrEq(parseTimings('[0.5, 0.9, 1.3]'), [0.5, 0.9, 1.3]), 'parseTimings bare JSON array'); +check(arrEq(parseTimings('{"hits":[1.3,0.5,0.9]}'), [0.5, 0.9, 1.3]), 'parseTimings .hits sorted'); +check(arrEq(parseTimings('{"onsets":[2,1]}'), [1, 2]), 'parseTimings .onsets alias'); +check(arrEq(parseTimings('0.5 0.9\n1.3,2.0'), [0.5, 0.9, 1.3, 2.0]), 'parseTimings plain text'); +check(arrEq(parseTimings('{"hits":[{"time":0.9},{"time":0.1}]}'), [0.1, 0.9]), 'parseTimings object entries'); +check(arrEq(parseTimings(''), []), 'parseTimings empty'); +check(arrEq(parseTimings('garbage'), []), 'parseTimings non-numeric'); +const T = [1.0, 2.0, 3.0]; +check(stepForTime(T, 0.0, 10) === 0, 'stepForTime before first hit = 0'); +check(stepForTime(T, 1.0, 10) === 1, 'stepForTime at first hit = 1'); +check(stepForTime(T, 2.5, 10) === 2, 'stepForTime mid = count elapsed'); +check(stepForTime(T, 99, 10) === 3, 'stepForTime all hits elapsed'); +check(stepForTime(T, 99, 2) === 1, 'stepForTime clamps to nRounds-1'); +check(stepForTime([], 5, 10) === 0, 'stepForTime no timings = 0'); +check(stepForTime(T, 5, 0) === 0, 'stepForTime no rounds = 0'); + for (const file of process.argv.slice(3)) { if (!fs.existsSync(file)) { console.log(file, 'MISSING'); continue; } const env = JSON.parse(fs.readFileSync(file, 'utf8')); diff --git a/racket/prologos/tools/viz/examples/demo-xylophone.mp3 b/racket/prologos/tools/viz/examples/demo-xylophone.mp3 new file mode 100644 index 00000000..007eae15 Binary files /dev/null and b/racket/prologos/tools/viz/examples/demo-xylophone.mp3 differ diff --git a/racket/prologos/tools/viz/examples/demo-xylophone.timings.json b/racket/prologos/tools/viz/examples/demo-xylophone.timings.json new file mode 100644 index 00000000..1624c897 --- /dev/null +++ b/racket/prologos/tools/viz/examples/demo-xylophone.timings.json @@ -0,0 +1,21 @@ +{ +"source": "synthetic demo (make-demo-song.py)", +"sampleRate": 22050, +"durationSec": 4.6, +"count": 13, +"hits": [ +0.3, +0.62, +0.95, +1.1, +1.55, +1.88, +2.2, +2.35, +2.8, +3.15, +3.5, +3.65, +4.1 +] +} \ No newline at end of file diff --git a/racket/prologos/tools/viz/examples/make-demo-song.py b/racket/prologos/tools/viz/examples/make-demo-song.py new file mode 100755 index 00000000..af451684 --- /dev/null +++ b/racket/prologos/tools/viz/examples/make-demo-song.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Generate a small, copyright-free synthetic xylophone melody + its timings file +so the visualizer's music-synced playback can be demoed without downloading +anything. (For a real song, use ../extract-song-timings.py with a URL.) + + python3 make-demo-song.py # writes demo-xylophone.mp3 + demo-xylophone.timings.json + +Each note is a short, fast-decaying tone burst at a known onset time; running the +onset detector over the result recovers those onsets, so the demo doubles as a +self-check that the extraction pipeline works end to end. +""" +import json, os, subprocess, sys + +SR = 22050 +# (onset seconds, frequency Hz) — an irregular little xylophone phrase +SCORE = [(0.30, 880), (0.62, 988), (0.95, 1047), (1.10, 1175), (1.55, 1319), + (1.88, 1047), (2.20, 880), (2.35, 1319), (2.80, 1175), (3.15, 1047), + (3.50, 988), (3.65, 1175), (4.10, 880)] +DUR = 4.6 + + +def main(): + try: + import numpy as np + except ImportError: + sys.exit("numpy required: pip install numpy") + here = os.path.dirname(os.path.abspath(__file__)) + x = np.zeros(int(DUR * SR), dtype=np.float32) + for t, f in SCORE: + i = int(t * SR) + n = int(0.35 * SR) + env = np.exp(-np.linspace(0, 9, n)).astype(np.float32) # fast mallet decay + k = np.arange(n) + tone = np.sin(2 * np.pi * f * k / SR) + 0.4 * np.sin(2 * np.pi * 2 * f * k / SR) + seg = (env * tone * 0.7).astype(np.float32) + m = min(n, len(x) - i) + x[i:i + m] += seg[:m] + x = np.clip(x, -1, 1) + pcm = (x * 32767).astype(" audio --(ffmpeg)--> mono PCM + --(spectral-flux onset detection)--> hit times (seconds) + --> timings JSON { "hits": [...] } + +The visualizer (tools/viz/index.html) advances ONE timeline step per hit, so the +network's reduction is played back in time with the music. + +Usage +----- + # straight from YouTube (needs network + yt-dlp; pass cookies if YouTube + # asks you to "confirm you're not a bot" — see --cookies-from-browser): + python3 extract-song-timings.py 'https://youtu.be/PoH5hC5PzSQ' -o song.timings.json --keep-audio song.mp3 + + # from a local audio file you already have: + python3 extract-song-timings.py mysong.mp3 -o mysong.timings.json + + # tune sensitivity (lower threshold = more hits) and minimum spacing: + python3 extract-song-timings.py in.wav -o out.json --threshold 1.4 --min-gap 0.08 + +Dependencies: numpy, ffmpeg on PATH, and (for URLs) yt-dlp. No librosa needed. + +Timings file format (also accepted: a bare JSON array, or whitespace/newline +separated numbers): + { "source": "...", "sampleRate": 22050, "count": N, "hits": [0.51, 0.93, ...] } +""" +import argparse, json, os, subprocess, sys, tempfile, shutil, datetime + +SR = 22050 # analysis sample rate (mono) + + +def have(cmd): + return shutil.which(cmd) is not None + + +def fetch_audio(url, dest_dir, cookies_from_browser=None): + """Download bestaudio from a URL via yt-dlp; return the audio file path.""" + if not have("yt-dlp"): + sys.exit("yt-dlp not found on PATH. Install with: pip install yt-dlp") + out = os.path.join(dest_dir, "audio.%(ext)s") + cmd = ["yt-dlp", "-f", "bestaudio/best", "-x", "--audio-format", "mp3", + "-o", out, url] + if cookies_from_browser: + cmd[1:1] = ["--cookies-from-browser", cookies_from_browser] + print("[extract] downloading audio:", " ".join(cmd), file=sys.stderr) + subprocess.run(cmd, check=True) + for f in os.listdir(dest_dir): + if f.startswith("audio."): + return os.path.join(dest_dir, f) + sys.exit("yt-dlp produced no audio file") + + +def decode_pcm(path): + """Decode any audio file to mono float32 samples at SR via ffmpeg.""" + if not have("ffmpeg"): + sys.exit("ffmpeg not found on PATH (try: pip install static-ffmpeg).") + import numpy as np + cmd = ["ffmpeg", "-v", "error", "-i", path, "-ac", "1", "-ar", str(SR), + "-f", "s16le", "-"] + raw = subprocess.run(cmd, check=True, stdout=subprocess.PIPE).stdout + x = np.frombuffer(raw, dtype=" 0 or band_high > 0: + n_bins = pos.shape[1] + freqs = np.arange(n_bins) * (SR / 2) / (n_bins - 1) + hi = band_high if band_high > 0 else SR / 2 + mask = ((freqs >= band_low) & (freqs <= hi)).astype("float32") + flux = (pos * mask).sum(axis=1) + else: + flux = pos.sum(axis=1) + flux = np.concatenate([[0.0], flux]) + # absolute strength floor, normalized by a robust (99.5th-pct) loudness scale + scale = np.percentile(flux, 99.5) if flux.size else 0.0 + strength = flux / scale if scale > 0 else flux + if flux.max() > 0: + flux = flux / flux.max() + # adaptive threshold over a local window + w = max(3, int(round(0.10 * SR / hop))) # ~100ms half-window + pad = np.pad(flux, w, mode="edge") + csum = np.cumsum(np.insert(pad, 0, 0.0)) + local_mean = (csum[2 * w + 1:] - csum[:-(2 * w + 1)]) / (2 * w + 1) + local_mean = local_mean[:len(flux)] + sq = np.pad(flux * flux, w, mode="edge") + csq = np.cumsum(np.insert(sq, 0, 0.0)) + local_ms = (csq[2 * w + 1:] - csq[:-(2 * w + 1)]) / (2 * w + 1) + local_ms = local_ms[:len(flux)] + local_std = np.sqrt(np.maximum(local_ms - local_mean * local_mean, 0.0)) + thr = local_mean + threshold * local_std + 1e-4 + min_frames = max(1, int(round(min_gap * SR / hop))) + onsets, last = [], -10 ** 9 + for i in range(1, len(flux) - 1): + if flux[i] >= thr[i] and flux[i] >= flux[i - 1] and flux[i] >= flux[i + 1] \ + and strength[i] >= min_strength: + if i - last >= min_frames: + onsets.append(round(i * hop / SR, 4)) + last = i + return onsets + + +def band_onset_envelope(x, band_low=0.0, band_high=0.0, hop=256, win=1024): + """Positive spectral-flux envelope (optionally band-limited), + frames/sec.""" + import numpy as np + window = np.hanning(win).astype("float32") + n_frames = 1 + (len(x) - win) // hop + mags = np.empty((n_frames, win // 2 + 1), dtype="float32") + for i in range(n_frames): + mags[i] = np.abs(np.fft.rfft(x[i * hop: i * hop + win] * window)) + pos = np.maximum(np.diff(mags, axis=0), 0.0) + if band_low > 0 or band_high > 0: + freqs = np.arange(pos.shape[1]) * (SR / 2) / (pos.shape[1] - 1) + hi = band_high if band_high > 0 else SR / 2 + mask = ((freqs >= band_low) & (freqs <= hi)).astype("float32") + env = (pos * mask).sum(axis=1) + else: + env = pos.sum(axis=1) + return np.concatenate([[0.0], env]), SR / hop + + +def beat_grid(x, dur, bpm=0.0, phase=-1.0, band_low=0.0, band_high=0.0, + tempo_min=50.0, tempo_max=100.0): + """Lock a steady beat grid to ONE instrument's metronomic pulse. + + Some instruments (a low xylophone here) play a dead-steady pulse but are + quieter than the kick/snare on the loud beats — so per-onset picking grabs + the wrong transients and misses the quiet ones. The fix: estimate the tempo + (period) from the band onset envelope's autocorrelation, lock the phase to + where that band's energy actually lands, and emit a grid `phase + k·period`. + Constant-tempo tracks stay aligned for their whole length (no drift). + + `bpm` (0 = auto-estimate within tempo_min..tempo_max); `phase` start seconds + (<0 = auto). Returns the grid times in seconds. + """ + import numpy as np + env, fps = band_onset_envelope(x, band_low, band_high) + e = env - env.mean(); e[e < 0] = 0.0 + tol = int(round(0.05 * fps)) + + def score(P, ph): + idx = np.round(np.arange(ph, dur - 0.1, P) * fps).astype(int) + return sum(env[max(0, i - tol): i + tol + 1].max() + for i in idx if 0 <= i < len(env)) / max(1, len(idx)) + + # candidate period: explicit bpm, else autocorrelation within the tempo range + if bpm > 0: + cand = 60.0 / bpm + else: + ac = np.correlate(e, e, mode="full")[len(e) - 1:] + lags = np.arange(len(ac)) / fps + win = (lags >= 60.0 / tempo_max) & (lags <= 60.0 / tempo_min) + idx = np.where(win)[0] + cand = lags[idx[np.argmax(ac[idx])]] + + # Phase: when a quiet instrument is the target, blind phase-finding locks onto + # the LOUDER beat. So if --phase anchors the pulse (e.g. its first audible + # hit), constrain the search near it; otherwise take the globally best-aligned + # phase. Refine the period jointly (±3%) — a coarse period drifts over a long + # track; the per-beat tolerance-max keeps the grid on the metrical position. + if bpm > 0: + periods = [cand] + else: + periods = np.arange(cand * 0.97, cand * 1.03, 0.0002) + if phase >= 0: + phases = np.arange(max(0.0, phase - 0.12), phase + 0.12, 0.01) + else: + phases = np.arange(0.0, cand, 0.02) + best = (-1.0, cand, phases[0]) + for P in periods: + for ph in phases: + s = score(P, ph) + if s > best[0]: + best = (s, float(P), float(ph)) + _, period, phase = best + return [round(float(t), 4) for t in np.arange(phase, dur - 0.02, period)], period, phase + + +def tempo_map(dur, segments, phase=-1.0): + """Piecewise-constant beat grid: a list of (start_sec, bpm) segments, each + emitting beats at its own tempo from its start until the next segment (or the + end). The first segment may be anchored with `phase` (else it starts at its + own start time); later segments start exactly on their boundary, so a tempo + switch lands a beat right on the drop. Lets you e.g. hold a slow intro pulse + then rush 10x faster at the chorus. + """ + segs = sorted(segments, key=lambda s: s[0]) + times = [] + for i, (start, bpm) in enumerate(segs): + end = segs[i + 1][0] if i + 1 < len(segs) else dur + period = 60.0 / bpm + t = phase if (i == 0 and phase >= 0) else start + while t < start: + t += period + while t < end - 1e-4: + times.append(round(t, 4)) + t += period + return sorted(set(times)) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("source", help="YouTube/other URL, or a local audio file") + ap.add_argument("-o", "--out", default="song.timings.json", help="output timings JSON") + ap.add_argument("--threshold", type=float, default=1.5, help="onset sensitivity (lower = more hits)") + ap.add_argument("--min-gap", type=float, default=0.06, help="min seconds between hits") + ap.add_argument("--band-low", type=float, default=0.0, + help="restrict onsets to freqs >= this (Hz) — isolate one instrument's band") + ap.add_argument("--band-high", type=float, default=0.0, + help="restrict onsets to freqs <= this (Hz); 0 = up to Nyquist") + ap.add_argument("--min-strength", type=float, default=0.0, + help="loudness floor 0..1 (fraction of 99.5pct flux) — thin a band to just the strong hits") + ap.add_argument("--grid", action="store_true", + help="emit a steady beat grid locked to the band's pulse (best for a quiet metronomic " + "instrument like a low xylophone) instead of per-onset picking") + ap.add_argument("--bpm", type=float, default=0.0, help="grid tempo (0 = auto-estimate)") + ap.add_argument("--phase", type=float, default=-1.0, help="grid start offset in seconds (<0 = auto)") + ap.add_argument("--tempo-min", type=float, default=50.0, help="auto-tempo lower bound (BPM)") + ap.add_argument("--tempo-max", type=float, default=100.0, help="auto-tempo upper bound (BPM)") + ap.add_argument("--segments", default="", + help="piecewise tempo map as 'startSec:bpm,startSec:bpm,...' " + "(e.g. '0:67.5,28.5:675' = slow intro then 10x burst at 28.5s); " + "uses --phase to anchor the first segment") + ap.add_argument("--keep-audio", metavar="PATH", help="also save the decoded/downloaded audio here") + ap.add_argument("--cookies-from-browser", help="pass to yt-dlp if YouTube demands sign-in (e.g. chrome, firefox)") + args = ap.parse_args() + + try: + import numpy # noqa: F401 + except ImportError: + sys.exit("numpy is required: pip install numpy") + + tmp = tempfile.mkdtemp(prefix="songtimings-") + try: + is_url = "://" in args.source + audio = fetch_audio(args.source, tmp, args.cookies_from_browser) if is_url else args.source + if not os.path.exists(audio): + sys.exit(f"audio not found: {audio}") + x = decode_pcm(audio) + dur = len(x) / SR + mode = "onset" + grid_period = grid_phase = None + if args.segments: + mode = "segments" + segs = [(float(a), float(b)) for a, b in + (p.split(":") for p in args.segments.split(","))] + hits = tempo_map(dur, segs, phase=args.phase) + print(f"[extract] {dur:.1f}s audio → {len(hits)} beats over " + f"{len(segs)} tempo segments {segs} ({len(hits) / dur:.1f}/s avg)", + file=sys.stderr) + elif args.grid: + mode = "grid" + hits, grid_period, grid_phase = beat_grid( + x, dur, bpm=args.bpm, phase=args.phase, + band_low=args.band_low, band_high=args.band_high, + tempo_min=args.tempo_min, tempo_max=args.tempo_max) + print(f"[extract] {dur:.1f}s audio → {len(hits)} grid beats " + f"@ {60 / grid_period:.1f} BPM (period {grid_period:.4f}s, phase " + f"{grid_phase:.3f}s, {len(hits) / dur:.1f}/s)", file=sys.stderr) + else: + hits = detect_onsets(x, threshold=args.threshold, min_gap=args.min_gap, + band_low=args.band_low, band_high=args.band_high, + min_strength=args.min_strength) + print(f"[extract] {dur:.1f}s audio → {len(hits)} hits " + f"({len(hits) / dur:.1f}/s)", file=sys.stderr) + doc = { + "source": args.source, + "generated": datetime.datetime.utcnow().isoformat() + "Z", + "mode": mode, + "sampleRate": SR, + "durationSec": round(dur, 3), + "threshold": args.threshold, + "minGap": args.min_gap, + "bandLow": args.band_low, + "bandHigh": args.band_high, + "minStrength": args.min_strength, + "count": len(hits), + "hits": hits, + } + if mode == "grid": + doc["bpm"] = round(60 / grid_period, 3) + doc["periodSec"] = round(grid_period, 5) + doc["phaseSec"] = round(grid_phase, 4) + elif mode == "segments": + doc["segments"] = [{"startSec": s, "bpm": b} for s, b in segs] + with open(args.out, "w") as f: + json.dump(doc, f, indent=0) + print(f"[extract] wrote {args.out}", file=sys.stderr) + if args.keep_audio and is_url: + shutil.copy(audio, args.keep_audio) + print(f"[extract] saved audio → {args.keep_audio}", file=sys.stderr) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +if __name__ == "__main__": + main() diff --git a/racket/prologos/tools/viz/index.html b/racket/prologos/tools/viz/index.html index bb4e763e..d4db98b0 100644 --- a/racket/prologos/tools/viz/index.html +++ b/racket/prologos/tools/viz/index.html @@ -13,6 +13,11 @@ hashcons registry). Infrastructure cells hidden by default; e-class cells show their reduced value; propagators label by source construct. + Two ways to play the timeline: the timer ▶ (one round every N ms) or + MUSIC-SYNCED playback — load an audio file + a timings file (hit onsets in + seconds, e.g. a song's xylophone hits) and each hit advances one step, so the + reduction plays in time with the music. See README.md + extract-song-timings.py. + Single file, no build step (deps from CDN — needs network). Generate a trace: racket tools/viz-export.rkt FILE.prologos -o out.json [--reduce] --> @@ -40,8 +45,15 @@ .graphwrap:active { cursor: grabbing; } svg { display: block; width: 100%; height: 100%; } .tooltip { position: absolute; pointer-events: none; background: #2d2d30; border: 1px solid #666; border-radius: 4px; padding: 6px 9px; max-width: 380px; z-index: 5; white-space: pre-wrap; font-family: ui-monospace, monospace; font-size: 12px; } - .side { border-left: 1px solid #333; background: #252526; padding: 10px; overflow-y: auto; } + .side { border-left: 1px solid #333; background: #252526; padding: 10px; overflow-y: auto; display: flex; flex-direction: column; } .side h3 { margin: 12px 0 4px; font-size: 11px; text-transform: uppercase; color: #888; letter-spacing: .5px; } + .music { border: 1px solid #3a3a3a; border-radius: 6px; padding: 4px 8px 8px; margin-top: 6px; background: #2a2a2c; } + .music h3 { margin: 4px 0 6px; } + .musrow { display: flex; gap: 6px; margin: 4px 0; } + .music audio { filter: invert(0.9) hue-rotate(180deg); border-radius: 4px; } + /* legend sticks to the bottom of the scrolling sidebar */ + .legend-wrap { margin-top: auto; position: sticky; bottom: 0; background: #252526; + padding: 6px 0 2px; border-top: 1px solid #333; } .drop { display: flex; align-items: center; justify-content: center; height: 100%; color: #888; font-size: 16px; text-align: center; } .drop .box { border: 2px dashed #444; border-radius: 10px; padding: 40px 60px; } .timeRow { display: flex; gap: 8px; align-items: center; } @@ -236,6 +248,43 @@ function timelineRounds(env) { return (env.rounds || []).slice().sort((a, b) => a.roundNumber - b.roundNumber); } +// ---- music-synced playback ------------------------------------------------ +// A "timings" file is a list of hit timestamps (seconds) — e.g. the xylophone +// onsets of a song. Accepts: a bare JSON array [0.5, 0.9, …]; a JSON object +// keyed by hits/timings/beats/onsets/times (numbers, or {time}/{t}/{sec} +// objects); or plain text (whitespace/comma/newline-separated numbers). +function parseTimings(text) { + text = String(text || "").trim(); + if (!text) return []; + let arr = null; + if (text[0] === "[" || text[0] === "{") { + try { + const o = JSON.parse(text); + if (Array.isArray(o)) arr = o; + else arr = o.hits || o.timings || o.beats || o.onsets || o.times || o.steps || null; + } catch (e) { arr = null; } + } + if (!arr) arr = text.split(/[\s,]+/).filter(s => s.length); + const nums = arr.map(x => { + if (typeof x === "number") return x; + if (typeof x === "string") return parseFloat(x); + if (x && typeof x === "object") { const v = x.time != null ? x.time : x.t != null ? x.t : x.sec != null ? x.sec : x.seconds; return Number(v); } + return NaN; + }).filter(n => Number.isFinite(n) && n >= 0); + nums.sort((a, b) => a - b); + return nums; +} + +// Step index for a given audio time: how many hits have elapsed (each hit +// advances one step), clamped into the available rounds. Step 0 is the initial +// state shown before the first hit fires. Binary search keeps the rAF loop cheap. +function stepForTime(timings, t, nRounds) { + if (!timings || !timings.length || !nRounds) return 0; + let lo = 0, hi = timings.length; + while (lo < hi) { const m = (lo + hi) >> 1; if (timings[m] <= t) lo = m + 1; else hi = m; } + return Math.max(0, Math.min(nRounds - 1, lo)); +} + // Node-id sets present in a given topology (for born/growth detection). function topoNodeKeys(env, topoIdx) { const t = env.topologies[topoIdx]; const s = new Set(); @@ -301,10 +350,41 @@ const [playing, setPlaying] = useState(false); const [speed, setSpeed] = useState(700); // ms per step const [fitSignal, setFitSignal] = useState(0); + // music-synced playback + const [audioUrl, setAudioUrl] = useState(null); + const [audioName, setAudioName] = useState(null); + const [timings, setTimings] = useState(null); + const [timingsName, setTimingsName] = useState(null); + const [musicPlaying, setMusicPlaying] = useState(false); + const audioRef = useRef(null); const rounds = useMemo(() => env ? timelineRounds(env) : [], [env]); const changed = useMemo(() => env ? changedCellIdsGlobal(env) : new Set(), [env]); + const loadAudio = (file) => { + if (audioUrl) URL.revokeObjectURL(audioUrl); + setAudioUrl(URL.createObjectURL(file)); setAudioName(file.name); setMusicPlaying(false); + }; + const loadTimings = (file) => file.text().then(t => { + const hits = parseTimings(t); + setTimings(hits); setTimingsName(file.name + " · " + hits.length + " hits"); + }); + + // While the audio plays, drive the step index from its currentTime via the + // hit timestamps — each onset advances one step. requestAnimationFrame keeps + // it smooth and decoupled from React's render timer. + useEffect(() => { + if (!musicPlaying || !env || !timings) return; + let raf = 0; + const tick = () => { + const a = audioRef.current; + if (a) setIdx(stepForTime(timings, a.currentTime, rounds.length)); + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [musicPlaying, env, timings, rounds.length]); + const loadFile = (file) => file.text().then(t => { let o; try { o = JSON.parse(t); } catch (e) { setMsg("invalid JSON"); return; } if (o.vizTrace !== 2) { setMsg(o.vizTrace ? "need vizTrace 2 (re-export with the current tool)" : "not a vizTrace file"); return; } @@ -312,11 +392,11 @@ }); useEffect(() => { - if (!playing || !env) return; + if (!playing || musicPlaying || !env) return; if (idx + 1 >= rounds.length) { setPlaying(false); return; } const t = setTimeout(() => setIdx(i => i + 1), speed); return () => clearTimeout(t); - }, [playing, idx, rounds.length, env, speed]); + }, [playing, musicPlaying, idx, rounds.length, env, speed]); const round = env ? rounds[idx] : null; const topoIdx = round ? round.topo : 0; @@ -360,7 +440,12 @@ + speed={speed} setSpeed={setSpeed} + audioRef={audioRef} audioUrl={audioUrl} audioName={audioName} + timings={timings} timingsName={timingsName} + loadAudio={loadAudio} loadTimings={loadTimings} + musicPlaying={musicPlaying} setMusicPlaying={setMusicPlaying} + setPlayingTimer={setPlaying} /> ); @@ -527,7 +612,9 @@ return
{text}
; } -function Sidebar({ env, rounds, idx, setIdx, round, topoIdx, playing, setPlaying, graph, speed, setSpeed }) { +function Sidebar({ env, rounds, idx, setIdx, round, topoIdx, playing, setPlaying, graph, speed, setSpeed, + audioRef, audioUrl, audioName, timings, timingsName, loadAudio, loadTimings, + musicPlaying, setMusicPlaying, setPlayingTimer }) { const st = env.topologies[topoIdx].topology.stats; const id = env.topologies[topoIdx].identity || {}; const series = useMemo(() => rounds.map(r => env.topologies[r.topo].topology.stats), [env, rounds]); @@ -553,6 +640,10 @@

execution timeline

onChange={e => setSpeed(1680 - +e.target.value)} /> {speed}ms +

scheduler

@@ -575,12 +666,54 @@

cell writes this round

; })} -

legend

- +
+

legend

+ +
+ + ); +} + +// Music-synced playback: load an audio file + a timings file (hit onsets in +// seconds). Playing the audio drives the timeline — each onset advances one +// step — running alongside (and independent of) the timer-based ▶ control. +function MusicSync({ audioRef, audioUrl, audioName, timings, timingsName, loadAudio, loadTimings, + musicPlaying, setMusicPlaying, setPlayingTimer, idx, nRounds }) { + const ready = audioUrl && timings && timings.length > 0; + return ( +
+

music-synced playback

+
+ +
+
+ +
+ {audioUrl && +
); } +function clip(s, n) { s = String(s); return s.length > n ? s.slice(0, n - 1) + "…" : s; } + function Legend({ graph }) { const groups = new Map(); // distinct cell identity groups actually in view for (const n of graph.nodes)