1#!/usr/bin/env node
Measures each song's mix as written, per part, and records it in the
song's SPEC.md frontmatter as measured: for the art critic
(website/src/jev/measured.mjs, critic.mjs).
node tools/measure/measure.mjs # every song, print only node tools/measure/measure.mjs --write # and record … --song jev/lightning-in-a-bottle only this song (repeatable) … --url http://localhost:4322 the dev server to play in … --tabs 4 songs measured at once
Each song plays in its own silent headless tab (?session=measure-<pid>-N, unique per run,
never a listener's), at 1× for its length as written, with Jev
unreachable: the page's calls to the relay fail, so every jev() plays its
fallbacks and the song plays as written. Then it reads the page's meter
(meter.mjs, window.jevMeter): loudness and peak in total and per part,
each part on the orbit named for its question, for the whole song and for
each section on its .every(N) cadence.
Run from a worktree, songs hear the worktree's samples/, not those of the checkout the dev server serves (tools/worktree-samples/).
Needs the dev server (buck2 run //:dev) and Playwright's browsers from
nix; run it through nix run .#measure, or set PLAYWRIGHT_BROWSERS_PATH
yourself (see README.md).
26import { execFileSync } from 'node:child_process'; 27import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; 28import { join } from 'node:path'; 29import { songFacts } from '../../website/src/jev/critic.mjs'; 30import { sectionCycles, sectionSpans, setMeasured, songHash } from '../../website/src/jev/measured.mjs'; 31import { routingNote, worktreeSamples } from '../worktree-samples/worktree-samples.mjs';
33const args = process.argv.slice(2); 34const opt = (name, fallback) => { 35 const i = args.indexOf(name); 36 return i < 0 ? fallback : args[i + 1]; 37}; 38const write = args.includes('--write'); 39const only = args.flatMap((a, i) => (args[i - 1] === '--song' ? [a] : [])); 40const url = opt('--url', 'http://localhost:4322'); 41const tabs = Number(opt('--tabs', 4)); 42const TAIL_S = 4; // the ending rings on past the last bar 43const LIMIT_S = 400; // longer than any song plus loading 44 45const { chromium } = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright'); 46 47const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); 48const songs = readdirSync(join(root, 'songs'), { withFileTypes: true }) 49 .filter((d) => d.isDirectory()) 50 .flatMap((theme) => 51 readdirSync(join(root, 'songs', theme.name), { withFileTypes: true }) 52 .filter((d) => d.isDirectory() && existsSync(join(root, 'songs', theme.name, d.name, 'song.js'))) 53 .map((d) => `${theme.name}/${d.name}`), 54 ) 55 .filter((id) => !only.length || only.includes(id)) 56 .sort();
From a worktree, songs hear its own samples, not the dev server's checkout's
62const round = (x) => (Number.isFinite(x) ? x : null); 63const clean = (m) => ({ loudnessDbfs: round(m.loudnessDbfs), peakDbfs: round(m.peakDbfs) }); 64const cleanParts = (parts = {}) => Object.fromEntries(Object.entries(parts).map(([k, v]) => [k, clean(v)])); 65 66async function measure(browser, id, n) { 67 const code = readFileSync(join(root, 'songs', id, 'song.js'), 'utf8'); 68 const { tempo, barsAsWritten } = songFacts(code); 69 const bpm = parseFloat(tempo); 70 if (!bpm || !barsAsWritten) return { id, error: 'no tempo or written length in song.js' }; 71 const seconds = (barsAsWritten * 4 * 60) / bpm + TAIL_S; 72 73 const page = await browser.newPage(); 74 const errors = []; 75 page.on('pageerror', (e) => errors.push(e.message)); 76 // Jev unreachable: every jev() plays its fallbacks, the song as written 77 await page.route('**/jev/v1/systemone', (route) => route.abort()); 78 await worktree?.route(page); 79 // The featured song plays on a tab's first click (client.mjs takeAutoplay); 80 // under load it replaced the song being measured, so mark it as played. 81 await page.addInitScript(() => sessionStorage.setItem('jevstrudel.autoplayed', '1')); 82 await page.goto(`${url}/?session=measure-${process.pid}-${n}`); 83 await page.waitForFunction(() => window.strudelMirror && window.jevMeter); 84 // browsers keep audio off until a gesture on the page; this is one 85 // (10, 10) is the header's padding: 700, 450 was a song card, and the 86 // click played that song, whose orbits leaked into the measurement 87 await page.mouse.click(10, 10); 88 await page.waitForTimeout(2000); 89 const evalError = await page.evaluate(async (c) => { 90 const ed = window.strudelMirror; 91 await ed.stop(); 92 ed.setCode(c); 93 await ed.evaluate(); 94 return ed.repl.state.evalError?.message ?? null; 95 }, code); 96 if (evalError) { 97 await page.close(); 98 return { id, error: `evaluation: ${evalError}` }; 99 } 100 // The dev server reloads every open tab when a file under songs/ changes, 101 // which restarts the page and empties its meter mid-play. 102 let reloaded = false; 103 page.on('framenavigated', (frame) => { 104 if (frame === page.mainFrame()) reloaded = true; 105 }); 106 await page.waitForTimeout(seconds * 1000); 107 if (reloaded) { 108 await page.close(); 109 return { id, error: 'the page reloaded mid-play (a file under songs/ changed); measure it again' }; 110 } 111 const heard = await page.evaluate(() => window.jevMeter.all()); 112 // per section, on the song's cadence: what a spec's "each drop over the 113 // section before it" is checked against 114 const spans = sectionSpans(sectionCycles(code), barsAsWritten); 115 const sections = await page.evaluate( 116 (spans) => spans.map(({ from, to }) => window.jevMeter.hear(from, to)), 117 spans, 118 ); 119 await page.evaluate(() => window.strudelMirror.stop()); 120 await page.close(); 121 if (!heard) return { id, error: 'the meter heard nothing (no audio?)' }; 122 return { 123 id, 124 seconds: Math.round(seconds), 125 errors, 126 measured: { 127 date: new Date().toLocaleDateString('en-CA'), 128 song: songHash(code), 129 total: clean(heard.total), 130 parts: cleanParts(heard.parts), 131 sections: Object.fromEntries( 132 spans.map(({ key }, i) => [key, sections[i] && { total: clean(sections[i]), parts: cleanParts(sections[i].parts) }]), 133 ), 134 }, 135 }; 136} 137 138const started = Date.now(); 139const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required'] }); 140const queue = [...songs]; 141const results = []; 142await Promise.all( 143 Array.from({ length: Math.min(tabs, queue.length) }, async (_, n) => { 144 while (queue.length) { 145 const id = queue.shift(); 146 // a hung tab fails its song, never the run; a finished one clears its 147 // timer, or it would hold the process open for LIMIT_S after the run 148 let timer; 149 const limit = new Promise((resolve) => { 150 timer = setTimeout(() => resolve({ id, error: `no result in ${LIMIT_S} s` }), LIMIT_S * 1000); 151 }); 152 const r = await Promise.race([measure(browser, id, n).catch((e) => ({ id, error: e.message })), limit]); 153 clearTimeout(timer); 154 results.push(r); 155 if (r.error) { 156 console.log(`${id}: ${r.error}`); 157 continue; 158 } 159 const parts = Object.entries(r.measured.parts) 160 .map(([k, v]) => `${k} ${v.loudnessDbfs ?? 'silent'}`) 161 .join(', '); 162 console.log(`${id} (${r.seconds} s): total ${r.measured.total.loudnessDbfs} dBFS, peak ${r.measured.total.peakDbfs}; ${parts}`); 163 const sections = Object.entries(r.measured.sections) 164 .map(([k, v]) => `${k} ${v?.total.loudnessDbfs ?? 'unheard'}`) 165 .join(', '); 166 console.log(` sections: ${sections}`); 167 for (const e of new Set(r.errors)) console.log(` ! ${e}`); 168 } 169 }), 170); 171await browser.close();
Only once every song is measured: the dev server reloads every open tab when a song's files change, which would restart the songs still playing.
174if (write) { 175 for (const r of results.filter((r) => !r.error)) { 176 const path = join(root, 'songs', r.id, 'SPEC.md'); 177 writeFileSync(path, setMeasured(readFileSync(path, 'utf8'), r.measured)); 178 } 179} 180console.log(`measure: ${results.length} songs in ${Math.round((Date.now() - started) / 1000)} s`); 181if (results.some((r) => r.error)) process.exit(1);