1#!/usr/bin/env node
buck2 run //:check-songs [-- [--speed N] [--url URL] [song …]]: plays every song (or
the ones named, as theme/song or song) fast in a silent headless tab
on the dev server, and prints what Jev did with each: the path it took,
its warnings, late answers and fallbacks. Exits nonzero when a song fails
to evaluate or the page throws.
What it needs: buck2 run //:dev running (the site on its port, the Jev
relay beside it), and the devshell. Playwright and its browsers come
from the flake (.#playwright, .#playwright-browsers), one nixpkgs for
both so they always match; nothing is installed from npm.
Run from a worktree, songs hear the worktree's samples/, not those of the checkout the dev server serves (tools/worktree-samples/).
The tab is ?session=check-<pid>, which never plays out loud (see the repo
CLAUDE.md), and each song's tempo is multiplied by --speed, since what
Jev decides and what warns depends on the patterns, not the speed. At
high speed the parts request, asked after the section's, can land after
its section starts; those show as late here and are not late at 1×.
21import { execFileSync } from 'node:child_process'; 22import { readdirSync, readFileSync, statSync } from 'node:fs'; 23import { join } from 'node:path'; 24import { sectionCycles } from '../../website/src/jev/measured.mjs'; 25import { routingNote, worktreeSamples } from '../worktree-samples/worktree-samples.mjs';
27process.chdir(execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim());
── arguments ───────────────────────────────────────
30const args = process.argv.slice(2); 31let speed = 4; 32let url = null; // --url: another dev server (a worktree's, on its own ports) 33const wanted = []; 34for (let i = 0; i < args.length; i++) { 35 if (args[i] === '--speed') speed = Number(args[++i]); 36 else if (args[i] === '--url') url = args[++i].replace(/\/$/, ''); 37 else wanted.push(args[i]); 38} 39if (!(speed > 0)) { 40 console.error('check-songs: --speed takes a positive number'); 41 process.exit(2); 42}
── the songs: directories only ─────────────────────
45const dirs = (d) => readdirSync(d).filter((x) => statSync(join(d, x)).isDirectory()); 46const songs = dirs('songs').flatMap((theme) => 47 dirs(join('songs', theme)).map((song) => ({ id: `${theme}/${song}`, file: join('songs', theme, song, 'song.js') })), 48); 49const chosen = wanted.length 50 ? wanted.map((w) => { 51 const s = songs.find((x) => x.id === w || x.id.endsWith(`/${w}`)); 52 if (!s) { 53 console.error(`check-songs: no song "${w}"; songs are ${songs.map((x) => x.id).join(', ')}`); 54 process.exit(2); 55 } 56 return s; 57 }) 58 : songs;
── the dev server ────────────────────────────────── The site's port is set once, in website/astro.config.mjs (tools/dev/CLAUDE.md).
62if (!url) { 63 const port = readFileSync('website/astro.config.mjs', 'utf8').match(/\n server: \{[^}]*?\bport: (\d+)/)?.[1]; 64 if (!port) { 65 console.error("check-songs: could not read the site's port from website/astro.config.mjs"); 66 process.exit(2); 67 } 68 url = `http://localhost:${port}`; 69} 70const URL_ = `${url}/?session=check-${process.pid}`; 71try { 72 await fetch(`${url}/`); 73} catch { 74 console.error(`check-songs: nothing answers on ${url}; start the dev server first (buck2 run //:dev)`); 75 process.exit(2); 76}
From a worktree, songs hear its own samples, not the dev server's checkout's
── playwright, from the flake ──────────────────────
82const [playwright, browsers] = execFileSync( 83 'nix', 84 ['build', '--no-link', '--print-out-paths', '.#playwright', '.#playwright-browsers'], 85 { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }, 86) 87 .trim() 88 .split('\n'); 89process.env.PLAYWRIGHT_BROWSERS_PATH = browsers; 90process.env.PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = '1'; 91const { chromium } = await import(`${playwright}/lib/node_modules/playwright/index.mjs`);
── the tab ─────────────────────────────────────────
Counted so a song can tell it was cut short: after a reload the tab just goes quiet, which would otherwise read as the song having finished. A new document, not framenavigated: the REPL rewrites the URL on every evaluate.
The featured song plays on a tab's first click (client.mjs takeAutoplay); here it would land after the song being checked and replace it.
The dev server reloads the tab when a song, sample or site file changes (astro.config.mjs's jevSongsReload); an evaluate in flight then loses its page. That is not the song's fault: open the tab again and retry.
120const reloaded = (e) => /Execution context was destroyed|navigat/i.test(e.message);
122const JEV = /^\[jev\] cycles /; 123const LATE = /answered after the section began/; 124const FALLBACK = /playing the fallbacks|played the fallback/; 125const NOISE = /sampler|load sound|piano\.json|Welcome|\[jev\]/; 126 127async function play(song) { 128 const code = readFileSync(song.file, 'utf8'); 129 const cps = code.match(/setcps\(([^)]*)\)/); 130 if (!cps) return { error: 'no setcps(…) to speed up' }; 131 const fast = code.replace(cps[0], `setcps((${cps[1]})*${speed})`); 132 // seconds a section lasts at this speed, for how long a quiet Jev means done 133 const bpm = Number(cps[1].split('/')[0]) || 120; 134 const every = sectionCycles(code); 135 const sectionMs = ((every * 4 * 60) / bpm / speed) * 1000; 136 // long enough for the longest form the song allows (walk's max, 16 by 137 // default), plus the opening and a margin 138 const maxSections = Math.max(16, ...[...code.matchAll(/\bmax:\s*(\d+)/g)].map((m) => Number(m[1]))); 139 const limitMs = (maxSections + 3) * sectionMs + 10_000; 140 141 logs = []; 142 pageErrors = []; 143 const tab = navigations; 144 const cutShort = () => { 145 if (navigations !== tab) throw new Error('the page navigated: reloaded under the song'); 146 }; 147 const evalError = await page.evaluate(async (c) => { 148 const ed = window.strudelMirror; 149 await ed.stop(); 150 ed.setCode(c); 151 await ed.evaluate(); 152 return ed.repl.state.evalError?.message ?? null; 153 }, fast); 154 if (evalError) return { error: evalError };
Done when Jev has said nothing new for three sections, or a song without Jev has played long enough to show its warnings.
158 const start = Date.now(); 159 let seen = 0; 160 let lastJev = Date.now(); 161 const quiet = 3 * sectionMs + 2000; 162 let timedOut = true; 163 while (Date.now() - start < limitMs) { 164 await page.waitForTimeout(500); 165 cutShort(); 166 const n = logs.filter((l) => JEV.test(l)).length; 167 if (n !== seen) [seen, lastJev] = [n, Date.now()]; 168 else if (Date.now() - lastJev > quiet) { 169 timedOut = false; 170 break; 171 } 172 } 173 cutShort(); 174 await page.evaluate(() => window.strudelMirror.stop());
Jev's lines are "<cycles>: <what>": a decision ("riff 1.5 (full…), …"), or a problem named for its question ("section: not confident…").
178 const kinds = { path: [], late: [], fallbacks: [], problems: [] }; 179 for (const l of logs.filter((x) => JEV.test(x)).map((x) => x.replace(JEV, ''))) { 180 const what = l.slice(l.indexOf(': ') + 2); 181 if (LATE.test(what)) kinds.late.push(l); 182 else if (FALLBACK.test(what)) kinds.fallbacks.push(l); 183 else if (/^\w+: /.test(what)) kinds.problems.push(l); 184 else kinds.path.push(l); 185 } 186 return { 187 seconds: Math.round((Date.now() - start) / 1000), 188 ...kinds, 189 warnings: [...new Set(logs.filter((l) => /warn|error|is not|unreachable/i.test(l) && !NOISE.test(l)))], 190 pageErrors: [...pageErrors, ...(timedOut ? [`cut short: still playing after ${Math.round(limitMs / 1000)} s`] : [])], 191 // the relay's Retry-After, as the page reports it 192 retryAfter: Math.max(0, ...logs.map((l) => Number(l.match(/asked again in (\d+) s/)?.[1] ?? 0))), 193 }; 194}
196await open(); 197let failed = 0; 198for (const song of chosen) { 199 let result; 200 for (let attempt = 1; ; attempt++) { 201 try { 202 result = await play(song); 203 break; 204 } catch (e) { 205 if (!reloaded(e) || attempt === 3) throw e; 206 console.log(` (the page reloaded under ${song.id}; opening it again)`); 207 await open(); 208 } 209 } 210 211 const bad = result.error || result.pageErrors?.length; 212 if (bad) failed++; 213 console.log(`== ${song.id}${result.error ? ` EVALUATION ERROR: ${result.error}` : ` (${result.seconds} s)`}`); 214 if (result.error) continue; 215 for (const l of result.path) console.log(` ${l}`); 216 if (!result.path.length) console.log(' (no Jev decisions)'); 217 for (const l of result.late) console.log(` late: ${l}`); 218 for (const l of result.fallbacks) console.log(` fallback: ${l}`); 219 for (const l of result.problems) console.log(` ! ${l}`); 220 for (const w of result.warnings) console.log(` ! ${w.slice(0, 400)}`); 221 for (const e of result.pageErrors) console.log(` PAGE ERROR: ${e}`); 222 if (result.retryAfter) { 223 console.log(` (rate limited: waiting ${result.retryAfter} s, as the relay asked)`); 224 await page.waitForTimeout(result.retryAfter * 1000 + 1000); 225 } 226} 227await browser.close(); 228console.log(`${chosen.length} songs, ${failed} failed`); 229process.exit(failed ? 1 : 0);