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

Build-time index of the repo's songs (songs/<theme>/<song>/), for the REPL's welcome tab. Runs in the Astro page's frontmatter, never in the browser: Vite resolves the globs at build time, Astro compiles each SPEC.md, and the page embeds the result as JSON (see pages/index.astro).

6import { specBody } from './critic.mjs';
7import { measuredFor, withoutSectionParts } from './measured.mjs';
8import { songCode } from './songCode.mjs';
9import { pitchOf } from './pitches.mjs';
11const specs = import.meta.glob('../../../songs/*/*/SPEC.md', { eager: true });
12const specTexts = import.meta.glob('../../../songs/*/*/SPEC.md', { eager: true, query: '?raw', import: 'default' });
13const codes = import.meta.glob('../../../songs/*/*/song.js', { eager: true, query: '?raw', import: 'default' });
14const readmes = import.meta.glob('../../../songs/*/*/README.md', { eager: true, query: '?raw', import: 'default' });
15const covers = import.meta.glob('../../../songs/*/*/cover.{webp,png,jpg}', {
16  eager: true,
17  query: '?url',
18  import: 'default',
19});

Length, read from the song itself rather than stored beside it: the tempo from setcps(bpm/60/4) and the bar count from the play-once mask that wraps the song, .mask("<1!bars 0!…>"). Section masks have the same shape but span only the song; the play-once mask spans far past it (1!64 0!936), so it is the one with the longest span.

26function timing(code) {
27  // setcps(bpm/60/beats): a cycle is one bar of `beats` (4, or 5 in ALL GREEN's 5/4)
28  const [, tempo, perBar] = code?.match(/setcps\(\s*([\d.]+)\s*\/\s*60\s*\/\s*(\d+)\s*\)/) ?? [];
29  const bpm = Number(tempo);
30  const beats = Number(perBar);
31  const masks = [...(code ?? '').matchAll(/\.mask\("<1!(\d+) 0!(\d+)>"\)/g)].map(([, on, off]) => ({
32    bars: Number(on),
33    span: Number(on) + Number(off),
34  }));
35  const bars = masks.sort((a, b) => b.span - a.span)[0]?.bars ?? null;
36  if (!bpm) return null;
37  // A song whose form Jev walks (`walk(section, …)`) has no fixed length.
38  if (/\bwalk\s*\(/.test(code)) return { bpm, bars, seconds: null, jev: true };
39  if (!bars) return null;
40  return { bpm, bars, seconds: Math.round((bars * beats * 60) / bpm) };
41}

The README's opening paragraph: the human description of the song.

44function description(readme) {
45  const para = readme?.split(/\n\s*\n/).find((p) => p.trim() && !p.trim().startsWith('#'));
46  return para ? para.replace(/\s+/g, ' ').replace(/`/g, '').trim() : '';
47}
49export async function loadSongs() {
50  const songs = await Promise.all(
51    Object.entries(specs).map(async ([path, spec]) => {
52      const dir = path.slice(0, -'/SPEC.md'.length);
53      const fm = spec.frontmatter;
54      const raw = codes[`${dir}/song.js`] ?? null;
55      // with its `// @title` line (songCode.mjs)
56      const code = songCode(raw, fm.title);
57      const cover = Object.entries(covers).find(([p]) => p.startsWith(`${dir}/cover.`))?.[1] ?? null;
58      return {
59        id: dir.split('/songs/')[1], // "<theme>/<song>"
60        title: fm.title,
61        theme: fm.theme,
62        status: fm.status,
63        description: description(readmes[`${dir}/README.md`]),
64        timing: timing(code),
65        // Jev's one-off verdict on whether the song is art (songs/README.md).
66        art: fm.critic
67          ? { p: fm.critic.art, model: fm.critic.model, method: fm.critic.method, criteria: fm.critic.criteria ?? null }
68          : null,
69        cover,
70        // the listener pitch this song answers, if one did: its id
71        // (songs/README.md); the pitches list shows the song under it
72        pitch: pitchOf(fm.pitch, dir),
73        revisions: (fm.revisions ?? []).map(({ rev, date, model, prompt, output, art }) => ({
74          rev,
75          // Frontmatter dates arrive as a Date or a full ISO timestamp; the
76          // page shows the day.
77          date: (date instanceof Date ? date.toISOString() : String(date)).slice(0, 10),
78          model,
79          prompt,
80          output,
81          // Jev's art score for this revision's code (tools/critic), or
82          // null when its code could not be recovered to score.
83          art: typeof art === 'number' ? art : null,
84        })),
85        specHtml: await spec.compiledContent(),
86        // what the art critic reads, for the REPL's own "is it art?"
87        spec: specBody(specTexts[path]),
88        code,
89        // the mix as heard (tools/measure), while song.js is the one measured;
90        // per section only in total, as the critic reads it
91        measured: withoutSectionParts(measuredFor(fm.measured, raw)),
92      };
93    }),
94  );
95  // by theme, then the most art first (Jev's recorded score; unscored last)
96  const art = (song) => song.art?.p ?? -1;
97  return songs.sort((a, b) => a.theme.localeCompare(b.theme) || art(b) - art(a) || a.title.localeCompare(b.title));
98}

JSON that is safe inside <script type="application/json">: no "</script>".

101export function songsJson(songs) {
102  return JSON.stringify(songs).replace(/</g, '\\u003c');
103}