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