1#!/usr/bin/env node 2// Measures each song's mix as written, per part, and records it in the 3// song's SPEC.md frontmatter as `measured:` for the art critic 4// (website/src/jev/measured.mjs, critic.mjs). 5// 6// node tools/measure/measure.mjs # every song, print only 7// node tools/measure/measure.mjs --write # and record 8// … --song jev/lightning-in-a-bottle only this song (repeatable) 9// … --url http://localhost:4322 the dev server to play in 10// … --tabs 4 songs measured at once 11// 12// Each song plays in its own silent headless tab (`?session=measure-<pid>-N`, unique per run, 13// never a listener's), at 1× for its length as written, with Jev 14// unreachable: the page's calls to the relay fail, so every jev() plays its 15// fallbacks and the song plays as written. Then it reads the page's meter 16// (meter.mjs, window.jevMeter): loudness and peak in total and per part, 17// each part on the orbit named for its question, for the whole song and for 18// each section on its `.every(N)` cadence. 19// 20// Run from a worktree, songs hear the worktree's samples/, not those of the 21// checkout the dev server serves (tools/worktree-samples/). 22// 23// Needs the dev server (`buck2 run //:dev`) and Playwright's browsers from 24// nix; run it through `nix run .#measure`, or set PLAYWRIGHT_BROWSERS_PATH 25// 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'; 32 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(); 57 58// From a worktree, songs hear its own samples, not the dev server's checkout's 59const worktree = await worktreeSamples({ url, root }); 60if (worktree) console.log(routingNote('measure', worktree)); 61 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(); 172// Only once every song is measured: the dev server reloads every open tab 173// 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);