abc-folk / ui /viewer.js
m87admin's picture
abc-folk:3, exported by slmkit from run-9b890db6dced
561f581 verified
Raw History Blame Contribute Delete
15.7 kB
// abc_music's viewer for `slm serve`'s playground (ADR 0008). `slm export` copies this directory
// into the model as ui/; the page imports it and calls setup() and render().
//
// It draws a generated tune as sheet music and plays it in the browser with abcjs
// (https://www.abcjs.net/, MIT licence), loaded from jsDelivr with a pinned version and a
// subresource-integrity hash: the browser refuses the file if the CDN serves anything else.
// Playback samples come from abcjs's default soundfont (also fetched by the browser).
const ABCJS = {
version: "6.7.1",
script: "https://cdn.jsdelivr.net/npm/abcjs@6.7.1/dist/abcjs-basic-min.js",
scriptHash: "sha384-gO9mym1Z3WJwxNm4ZpC6ZQbMyiu+72akLTHzztpwTs6KYVd3NnfkQigzPk+Oqzqy",
css: "https://cdn.jsdelivr.net/npm/abcjs@6.7.1/abcjs-audio.css",
cssHash: "sha384-eK1u60r2vqfWom2CLxO79qAvQKkjn5j1PjFslU5/+6gScaYuFJ92tkZ1N4z0hXbF",
};
// The forms the training corpus actually has most of (experiments.md Β§5: ask for what exists).
// meter and L: (unit note length) are the most common pairing for each rhythm.
const RHYTHMS = [
{ rhythm: "reel", meter: "2/2", unit: "1/8" },
{ rhythm: "jig", meter: "6/8", unit: "1/8" },
{ rhythm: "hornpipe", meter: "2/4", unit: "1/16" },
{ rhythm: "slip jig", meter: "9/8", unit: "1/8" },
{ rhythm: "strathspey", meter: "4/4", unit: "1/8" },
{ rhythm: null, meter: "3/4", unit: "1/8", label: "a tune in 3/4 (e.g. an air)" },
{ rhythm: null, meter: "6/8", unit: "1/8", label: "a tune in 6/8" },
{ rhythm: null, meter: "4/4", unit: "1/8", label: "a tune in 4/4" },
];
const KEYS = ["G", "D", "A", "C", "F", "Bb", "Em", "Am", "Dm", "Bm", "Gm", "F#m", "Amix", "Dmix", "Ador", "Edor"];
const MODES = { "": "major", m: "minor", mix: "mixolydian", dor: "dorian" };
const INSTRUMENTS = [["fiddle", 40], ["piano", 0], ["flute", 73], ["accordion", 21], ["harp", 46]];
let loading = null;
let playing = null; // the current SynthController, so a new result can stop the old one
function loadAbcjs() {
if (window.ABCJS) return Promise.resolve();
loading ??= new Promise((resolve, reject) => {
const css = Object.assign(document.createElement("link"), { rel: "stylesheet", href: ABCJS.css,
integrity: ABCJS.cssHash, crossOrigin: "anonymous" });
const js = Object.assign(document.createElement("script"), { src: ABCJS.script,
integrity: ABCJS.scriptHash, crossOrigin: "anonymous" });
js.onload = resolve;
js.onerror = () => reject(new Error(`could not load abcjs ${ABCJS.version} from jsDelivr (offline?)`));
const style = document.createElement("style");
style.textContent = `
.abc-paper { background: #fff; color: #000; border-radius: 6px; padding: 8px; margin-top: 10px; }
.abc-paper .abcjs-highlight { fill: #2a78d6; }
.abc-tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 4px 0 8px; }
.abc-tools label { margin: 0; display: flex; gap: 6px; align-items: center; }
.abc-tools select { font: inherit; padding: 4px 6px; border-radius: 6px; }
.abc-tools a { font-size: 13px; }
.abc-warnings { font-size: 12px; margin-top: 8px; }
.abc-builder { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; }
.abc-read { margin-top: 14px; border-top: 1px solid var(--line, #ddd); padding-top: 10px; font-size: 14px; }
.abc-read h3 { margin: 0 0 8px; font-size: 15px; }
.abc-read table { border-collapse: collapse; margin-bottom: 8px; }
.abc-read th, .abc-read td { text-align: left; padding: 3px 12px 3px 0; }
.abc-read th { color: var(--muted, #888); font-weight: 600; }
.abc-read ul { margin: 4px 0; padding-left: 20px; }
.abc-read details { margin-top: 6px; }`;
document.head.append(css, style, js);
});
return loading;
}
function keyWords(key) {
const m = key.match(/^([A-G][b#]?)(m|mix|dor)?$/);
return `${m[1]} ${MODES[m[2] ?? ""]}`;
}
// The request, in the format the model was trained on. For a fine-tuned model these are the same
// templates as the project's SFT eval prompts (project.py: EVAL_WITH_RHYTHM, EVAL_WITHOUT_RHYTHM).
function buildPrompt(stage, form, key) {
if (stage === "sft") {
const request = form.rhythm
? `${/^[aeiou]/.test(form.rhythm) ? "an" : "a"} ${form.rhythm} in ${keyWords(key)}, ${form.meter} time`
: `a tune in ${form.meter} time in ${keyWords(key)}`;
return `% ${request}\n`;
}
return (form.rhythm ? `R:${form.rhythm}\n` : "") + `M:${form.meter}\nL:${form.unit}\nK:${key}\n`;
}
function h(tag, attrs, ...children) {
const el = Object.assign(document.createElement(tag), attrs);
for (const c of children.flat()) if (c != null) el.append(c instanceof Node ? c : document.createTextNode(String(c)));
return el;
}
function select(label, options) {
const wrap = document.createElement("div");
const l = Object.assign(document.createElement("label"), { textContent: label });
const s = document.createElement("select");
for (const [text, value] of options) s.append(new Option(text, value));
l.append(s);
wrap.append(l);
return [wrap, s];
}
export async function setup({ info, container, setPrompt }) {
loadAbcjs().catch(() => {}); // start fetching early; render() reports failures
const [formBox, formSel] = select("Ask for", RHYTHMS.map((f, i) => [f.label ?? `a ${f.rhythm} (${f.meter})`, i]));
const [keyBox, keySel] = select("in the key of", KEYS.map((k) => [keyWords(k), k]));
const grid = Object.assign(document.createElement("div"), { className: "abc-builder" });
grid.append(formBox, keyBox);
const note = Object.assign(document.createElement("div"), {
className: "hint",
textContent: info.stage === "sft"
? "Builds a request in words, the way this model was fine-tuned. Or edit the prompt freely."
: "This is a base model: the request is written as ABC headers. Or edit the prompt freely.",
});
keySel.value = "D"; // matches the model card's example prompt (a reel in D), which the page shows first
const update = () => setPrompt(buildPrompt(info.stage, RHYTHMS[Number(formSel.value)], keySel.value));
formSel.addEventListener("change", update);
keySel.addEventListener("change", update);
container.append(grid, note);
}
// ---------------------------------------------------------------- reading a tune, in plain words
// For someone who doesn't read music: what was asked, what was written, and what the headers mean.
// The precise checks are the project's graders (graders.py); this is an explanation, not a grade.
const RHYTHM_NOTES = {
reel: "a fast dance tune, two strong beats in every bar",
jig: "a lively dance with a lilting one-two-three, one-two-three feel",
"slip jig": "like a jig, but three groups of three in every bar",
hornpipe: "a steady dance tune, usually played with a swing",
strathspey: "a slow Scottish dance with short, sharp 'snapped' rhythms",
polka: "a quick two-beat dance", waltz: "a three-beat dance", march: "a tune to walk in step to",
air: "a slow song melody, not a dance", clog: "a dance tune for clog dancing, close to a hornpipe",
};
const NOTE_LENGTHS = { 1: "whole", 2: "half", 4: "quarter", 8: "eighth", 16: "sixteenth" };
const MODE_NOTES = { major: "bright, the usual 'happy' scale", minor: "darker, often sad-sounding",
mixolydian: "major with a flattened 7th, common in Irish tunes", dorian: "minor with a raised 6th, very common in Irish tunes" };
function header(text, letter) { return text.match(new RegExp(`^${letter}:\\s*(.+)$`, "m"))?.[1]?.trim() ?? null; }
function normMeter(m) { return m === "C" ? "4/4" : m === "C|" ? "2/2" : m; }
function meterWords(m) {
const [n, d] = normMeter(m).split("/").map(Number);
if (!n || !d) return m;
const len = NOTE_LENGTHS[d] ?? `1/${d}`;
return d === 8 && n % 3 === 0 && n > 3
? `each bar holds ${n} ${len} notes, felt as ${n / 3} groups of three`
: `each bar holds ${n} ${len} notes (${n} beats)`;
}
function keyName(k) {
const m = (k ?? "").match(/^([A-G][b#]?)\s*(m(?:in)?|maj|mix|dor|lyd|phr|loc)?/i);
if (!m) return null;
const mode = { m: "minor", min: "minor", maj: "major", mix: "mixolydian", dor: "dorian",
lyd: "lydian", phr: "phrygian", loc: "locrian" }[(m[2] ?? "").toLowerCase()] ?? "major";
return { tonic: m[1], mode, words: `${m[1]} ${mode}` };
}
// The tune's notes after the headers: chord names, decorations, grace notes and comments removed.
function body(text) {
return text.split("\n").filter((l) => !/^[A-Za-z]:/.test(l) && !l.startsWith("%")).join("\n")
.replace(/"[^"]*"|![^!]*!|\+[^+]*\+|\{[^}]*\}|\[[A-Za-z]:[^\]]*\]/g, " ");
}
function lastNote(b) {
const notes = [...b.matchAll(/([_=^]*)([A-Ga-g])[,']*/g)];
return notes.length ? notes[notes.length - 1][2].toUpperCase() : null;
}
function barCount(b) {
return b.split(/\|+|:\||\|:|::|\[\d/).filter((seg) => /[A-Ga-g]/.test(seg)).length;
}
function asked(prompt) {
const line = prompt.split("\n")[0];
if (line.startsWith("%")) {
const words = line.replace(/^%\s*/, "");
const tune = words.match(/^a tune in (\d+\/\d+) time in (.+)$/);
if (tune) return { rhythm: null, meter: tune[1], key: tune[2] };
const m = words.match(/^(?:an? |compose an? |write an? )?(.+?) in (?:the key of )?([A-G][b#]? \w+)(?:, (\d+\/\d+) time)?$/);
return m ? { rhythm: m[1], key: m[2], meter: m[3] ?? null } : null;
}
const k = keyName(header(prompt, "K"));
return { rhythm: header(prompt, "R"), meter: header(prompt, "M"), key: k?.words ?? null };
}
function explain(result) {
const t = result.text;
const wrote = { rhythm: header(t, "R"), meter: header(t, "M"), key: keyName(header(t, "K")) };
const want = asked(t.slice(0, t.length - result.completion.length));
const row = (label, a, w, same) => h("tr", {}, h("th", {}, label), h("td", {}, a ?? "–"), h("td", {}, w ?? "–"),
h("td", {}, a == null ? "" : same ? "βœ“" : "βœ—"));
const rows = want ? [
row("rhythm", want.rhythm, wrote.rhythm, want.rhythm?.toLowerCase() === wrote.rhythm?.toLowerCase()),
row("time signature", want.meter, wrote.meter, want.meter && wrote.meter && normMeter(want.meter) === normMeter(wrote.meter)),
row("key", want.key, wrote.key?.words, want.key?.toLowerCase() === wrote.key?.words.toLowerCase()),
] : [];
const b = body(t);
const end = lastNote(b);
const bars = barCount(b);
const parts = (b.match(/:\|/g) ?? []).length;
const facts = [
wrote.rhythm && `R:${wrote.rhythm} β€” ${RHYTHM_NOTES[wrote.rhythm.toLowerCase()] ?? "the type of tune"}.`,
wrote.meter && `M:${wrote.meter} β€” the time signature: ${meterWords(wrote.meter)}.`,
header(t, "L") && `L:${header(t, "L")} β€” a plain letter lasts a 1/${header(t, "L").split("/")[1]} note; "A2" lasts twice as long, "A/" half as long.`,
wrote.key && `K:${header(t, "K")} β€” the key, ${wrote.key.words}: ${MODE_NOTES[wrote.key.mode] ?? "a scale"}. Its home note is ${wrote.key.tonic}.`,
`${bars} bars written out${parts ? `, with repeat signs (|: … :|) marking parts that are played twice` : ""}. `
+ "A typical folk tune has two 8-bar parts, each played twice.",
end && wrote.key && (end === wrote.key.tonic[0]
? `It ends on ${end}, the home note βœ“ β€” about 80% of real tunes do.`
: `It ends on ${end}, not the home note ${wrote.key.tonic} βœ— β€” real tunes end at home about 80% of the time; this model, about a third.`),
].filter(Boolean);
return h("section", { class: "abc-read" }, h("h3", {}, "Reading this tune"),
rows.length ? h("table", {}, h("tr", {}, h("th", {}, ""), h("th", {}, "you asked for"), h("th", {}, "it wrote"), h("th", {}, "")), rows) : null,
h("ul", {}, facts.map((f) => h("li", {}, f))),
h("details", {}, h("summary", {}, "How to read ABC notation"), h("ul", {},
h("li", {}, "The lines with a letter and a colon are headers: R rhythm, M time signature, L note length, K key."),
h("li", {}, "After them, every letter is a note: C D E F G A B, the white keys of a piano. Lowercase (c d e…) is an octave higher; an apostrophe (c') higher still, a comma (C,) lower."),
h("li", {}, "A number after a note makes it longer (A2 = two units), a slash shorter (A/ = half). ^ before a note is a sharp, _ a flat."),
h("li", {}, "| marks the end of a bar; |: … :| means \"play this part twice\". Letters in quotes are chord names for an accompanist."))));
}
// A title for the sheet music: the request itself for a fine-tuned model ("% a jig in G ..."),
// or what the headers asked for, for a base model.
function titleFor(text) {
const first = text.split("\n")[0];
if (first.startsWith("%")) return first.replace(/^%\s*/, "").slice(0, 60);
const rhythm = text.match(/^R:\s*(.+)$/m)?.[1]?.trim();
const key = text.match(/^K:\s*(\S+)/m)?.[1];
return `${rhythm ?? "a tune"}${key ? ` in ${key}` : ""}`;
}
export async function render({ result, container }) {
await loadAbcjs();
const ABC = window.ABCJS;
if (playing) { playing.pause(); playing = null; }
const title = titleFor(result.text);
const abc = `X:1\nT:${title} (seed ${result.seed})\n${result.text}${result.text.endsWith("\n") ? "" : "\n"}`;
const tools = Object.assign(document.createElement("div"), { className: "abc-tools" });
const audio = document.createElement("div");
const paper = Object.assign(document.createElement("div"), { className: "abc-paper" });
const warnings = Object.assign(document.createElement("div"), { className: "abc-warnings" });
container.append(tools, audio, paper, warnings, explain(result));
const tune = ABC.renderAbc(paper, abc, { responsive: "resize", add_classes: true })[0];
// What abcjs complained about while reading the tune: the browser's view of `plays`.
const found = (tune.warnings ?? []).map((w) => w.replace(/<[^>]*>/g, ""));
warnings.append(Object.assign(document.createElement("div"), {
className: found.length ? "error" : "hint",
textContent: found.length
? `abcjs found ${found.length} problem(s) reading this tune:\n` + found.slice(0, 5).join("\n")
: "abcjs read the tune without complaint.",
}));
const [instBox, instSel] = select("Instrument", INSTRUMENTS);
const fileName = `${title.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "")}-seed${result.seed}`;
const abcLink = Object.assign(document.createElement("a"), { textContent: "Download .abc", download: `${fileName}.abc`,
href: URL.createObjectURL(new Blob([abc], { type: "text/vnd.abc" })) });
const midiLink = Object.assign(document.createElement("a"), { textContent: "Download .mid", download: `${fileName}.mid` });
const setMidi = () => { midiLink.href = ABC.synth.getMidiFile(tune, { midiOutputType: "encoded", program: Number(instSel.value) }); };
setMidi();
tools.append(instBox, abcLink, midiLink);
if (!ABC.synth.supportsAudio()) {
audio.textContent = "This browser has no Web Audio support: use the .mid download.";
return;
}
let lit = [];
const cursor = {
onEvent(ev) {
lit.forEach((el) => el.classList.remove("abcjs-highlight"));
lit = (ev.elements ?? []).flat();
lit.forEach((el) => el.classList.add("abcjs-highlight"));
},
onFinished() { lit.forEach((el) => el.classList.remove("abcjs-highlight")); lit = []; },
};
const synth = new ABC.synth.SynthController();
synth.load(audio, cursor, { displayLoop: true, displayRestart: true, displayPlay: true,
displayProgress: true, displayWarp: true });
await synth.setTune(tune, false, { program: Number(instSel.value) });
playing = synth;
instSel.addEventListener("change", async () => {
synth.pause();
await synth.setTune(tune, false, { program: Number(instSel.value) });
setMidi();
});
}