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;