jevstrudel.git / tools / measure / measure.mjs
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

59const worktree = await worktreeSamples({ url, root });
60if (worktree) console.log(routingNote('measure', worktree));
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);