jevstrudel.git / website / src / jev / measured.mjs

A song's measured mix (tools/measure): per-part loudness recorded in its SPEC.md frontmatter as measured:, for the art critic. Node only (build time and tools): the browser gets the result through songs.mjs.

A measurement is of one version of song.js, so it records that file's hash, and measuredFor returns it only while song.js still hashes the same: an edited song is not judged by the old song's numbers.

8import { createHash } from 'node:crypto';
10export const songHash = (code) => createHash('sha256').update(code).digest('hex').slice(0, 12);

In the frontmatter it is one line of JSON, which is YAML too, so Astro reads it as a map and the tools need no YAML parser: measured: {"date":"…","song":"<hash>","total":{…},"parts":{"riff":{…},…}, "sections":{"1-4":{"total":{…},"parts":{…}},"5-8":{…},…}} Each level is { loudnessDbfs, peakDbfs }. Sections follow the song's cadence (sectionCycles), keyed by the cycles they span, counted from 1 as a listener counts bars; null where the meter heard too little.

19const LINE = /^measured: (\{.*\})[ \t]*$/m;
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}

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}

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}

A song's section length in cycles: its .every(N) cadence, the longest when it has several (a jev asked less often spans the others), 1 when it 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])));

The sections of a song bars cycles long, every cycles each: the 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}

The measurement without each section's parts: what the art critic reads, and all the browser needs (songs.mjs), since every section's every part 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;