Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,7 @@ racket/prologos/data/cache/
mempalace.yaml
entities.json
*.pnetx

# Python bytecode
__pycache__/
*.pyc
123 changes: 123 additions & 0 deletions racket/prologos/tools/viz/README.md
Original file line number Diff line number Diff line change
@@ -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
```
18 changes: 18 additions & 0 deletions racket/prologos/tools/viz/check.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
Binary file not shown.
21 changes: 21 additions & 0 deletions racket/prologos/tools/viz/examples/demo-xylophone.timings.json
Original file line number Diff line number Diff line change
@@ -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
]
}
55 changes: 55 additions & 0 deletions racket/prologos/tools/viz/examples/make-demo-song.py
Original file line number Diff line number Diff line change
@@ -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("<i2").tobytes()

mp3 = os.path.join(here, "demo-xylophone.mp3")
subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "s16le", "-ar", str(SR),
"-ac", "1", "-i", "-", mp3], input=pcm, check=True)

# the timings ARE the score onsets (ground truth for the demo)
hits = [round(t, 4) for t, _ in SCORE]
doc = {"source": "synthetic demo (make-demo-song.py)", "sampleRate": SR,
"durationSec": DUR, "count": len(hits), "hits": hits}
with open(os.path.join(here, "demo-xylophone.timings.json"), "w") as fp:
json.dump(doc, fp, indent=0)
print(f"wrote demo-xylophone.mp3 + demo-xylophone.timings.json ({len(hits)} hits)")


if __name__ == "__main__":
main()
Loading
Loading