1// Build-time index of the repo's songs (songs/<theme>/<song>/), for the REPL's 2// welcome tab. Runs in the Astro page's frontmatter, never in the browser: Vite 3// resolves the globs at build time, Astro compiles each SPEC.md, and the page 4// embeds the result as JSON (see pages/index.astro). 5 6import { specBody } from './critic.mjs'; 7import { measuredFor, withoutSectionParts } from './measured.mjs'; 8import { songCode } from './songCode.mjs'; 9import { pitchOf } from './pitches.mjs'; 10 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}); 20 21// Length, read from the song itself rather than stored beside it: the tempo 22// from setcps(bpm/60/4) and the bar count from the play-once mask that wraps 23// the song, `.mask("<1!bars 0!…>")`. Section masks have the same shape but 24// span only the song; the play-once mask spans far past it (1!64 0!936), so 25// 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} 42 43// 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} 48 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} 99 100// JSON that is safe inside <script type="application/json">: no "</script>". 101export function songsJson(songs) { 102 return JSON.stringify(songs).replace(/</g, '\\u003c'); 103}