jevstrudel.git / website / src / jev / measured.mjs
1// A song's measured mix (tools/measure): per-part loudness recorded in its
2// SPEC.md frontmatter as `measured:`, for the art critic. Node only (build
3// time and tools): the browser gets the result through songs.mjs.
4//
5// A measurement is of one version of song.js, so it records that file's
6// hash, and `measuredFor` returns it only while song.js still hashes the
7// same: an edited song is not judged by the old song's numbers.
8import { createHash } from 'node:crypto';
9
10export const songHash = (code) => createHash('sha256').update(code).digest('hex').slice(0, 12);
11
12// In the frontmatter it is one line of JSON, which is YAML too, so Astro
13// reads it as a map and the tools need no YAML parser:
14//   measured: {"date":"…","song":"<hash>","total":{…},"parts":{"riff":{…},…},
15//              "sections":{"1-4":{"total":{…},"parts":{…}},"5-8":{…},…}}
16// Each level is { loudnessDbfs, peakDbfs }. Sections follow the song's
17// cadence (sectionCycles), keyed by the cycles they span, counted from 1
18// as a listener counts bars; `null` where the meter heard too little.
19const LINE = /^measured: (\{.*\})[ \t]*$/m;
20
21export function readMeasured(specText) {
22  const json = specText.split('---')[1]?.match(LINE)?.[1];
23  if (!json) return null;
24  try {
25    return JSON.parse(json);
26  } catch {
27    return null;
28  }
29}
30
31// Sets `measured:` in a SPEC.md, replacing any, after `song:`.
32export function setMeasured(specText, measured) {
33  const line = `measured: ${JSON.stringify(measured)}`;
34  if (LINE.test(specText)) return specText.replace(LINE, line);
35  return specText.replace(/^(song:.*\n)/m, `$1${line}\n`);
36}
37
38// The measurement for this song.js, or null when there is none or it is stale.
39export function measuredFor(measured, code) {
40  if (!measured || typeof measured !== 'object' || !code) return null;
41  return measured.song === songHash(code) ? measured : null;
42}
43
44// A song's section length in cycles: its `.every(N)` cadence, the longest
45// when it has several (a jev asked less often spans the others), 1 when it
46// has none. tools/check-songs times sections by it too.
47export const sectionCycles = (code) =>
48  Math.max(1, ...[...code.matchAll(/\.every\(\s*(\d+)/g)].map((m) => Number(m[1])));
49
50// The sections of a song `bars` cycles long, `every` cycles each: the
51// last is cut short when the length is not a multiple.
52export function sectionSpans(every, bars) {
53  const spans = [];
54  for (let from = 0; from < bars; from += every) {
55    const to = Math.min(from + every, bars);
56    spans.push({ key: `${from + 1}-${to}`, from, to });
57  }
58  return spans;
59}
60
61// The measurement without each section's parts: what the art critic reads,
62// and all the browser needs (songs.mjs), since every section's every part
63// would add kilobytes per song to every page.
64export const withoutSectionParts = (m) =>
65  m?.sections
66    ? { ...m, sections: Object.fromEntries(Object.entries(m.sections).map(([k, v]) => [k, v && { total: v.total }])) }
67    : m;