yue2-studio / scripts /test-abc.ts
RalphThings's picture
Upload folder using huggingface_hub
6a6bd27 verified
Raw History Blame Contribute Delete
9.27 kB
/**
* Round-trip tests for the ABC parser and serializer.
*
* The grid rewrites the score, so a parse or spelling bug here silently
* transposes the operator's music. The randomised block at the bottom is the
* important half: the fixed cases only cover what I thought to write down, and
* it was the random ones that found the accidental bugs.
*
* npm run test:abc
*/
import { unitsPerBar } from "@/lib/abc/model";
import type { AbcNote, AbcScore } from "@/lib/abc/model";
import { parseAbc } from "@/lib/abc/parse";
import { serializeAbc } from "@/lib/abc/serialize";
let fails = 0;
const check = (label: string, ok: boolean, extra?: string) => {
console.log((ok ? " ok " : " FAIL ") + label + (ok || !extra ? "" : "\n " + extra));
if (!ok) fails++;
};
const SKELETON = `X:1
T:Your title
M:4/4
L:1/8
K:G
V:Vocal
|: G2 A2 B2 d2 | e4 d4 :|
V:Ins
|: G,4 B,4 | C4 D4 :|`;
const first = parseAbc(SKELETON);
check("the skeleton parses", first.ok, first.ok ? "" : first.reason);
if (!first.ok) process.exit(1);
const s = first.score;
check("two voices found", s.voices.length === 2 && s.voices[0].id === "Vocal" && s.voices[1].id === "Ins");
check("meter and unit read", s.meter[0] === 4 && s.meter[1] === 4 && s.unit[1] === 8);
check("eight units to a bar", unitsPerBar(s) === 8);
check("repeats were written out", s.repeatsExpanded);
const vocal = s.notes.filter((n) => n.voice === 0).sort((a, b) => a.start - b.start);
check("repeat doubled the vocal line", vocal.length === 12, `got ${vocal.length}`);
check("the first pass spans two bars", vocal[5].start + vocal[5].duration === 16);
check("G in the key of G is G4 = midi 67", vocal[0].midi === 67, `got ${vocal[0].midi}`);
check("lower case d is an octave up from D", vocal[3].midi === 74, `got ${vocal[3].midi}`);
check("the repeat starts where the first pass ended", vocal[6].start === 16, `got ${vocal[6].start}`);
check(
"the repeat replays the same pitches",
vocal.slice(0, 6).map((n) => n.midi).join() === vocal.slice(6).map((n) => n.midi).join(),
);
const ins = s.notes.filter((n) => n.voice === 1).sort((a, b) => a.start - b.start);
check("G, is an octave below G", ins[0].midi === 55, `got ${ins[0].midi}`);
// --- key signature and accidentals ------------------------------------------
const inG = parseAbc("X:1\nM:4/4\nL:1/8\nK:G\nV:1\nF4 f4 |");
check("the key signature sharpens F", inG.ok && inG.score.notes[0].midi === 66);
const natural = parseAbc("X:1\nM:4/4\nL:1/8\nK:G\nV:1\n=F4 F4 |");
check(
"a natural cancels it for the rest of the bar",
natural.ok && natural.score.notes[0].midi === 65 && natural.score.notes[1].midi === 65,
);
const nextBar = parseAbc("X:1\nM:4/4\nL:1/8\nK:G\nV:1\n=F4 z4 | F4 z4 |");
check("and the signature returns in the next bar", nextBar.ok && nextBar.score.notes[1].midi === 66);
// --- lengths, ties, chords, rests -------------------------------------------
const lengths = parseAbc("X:1\nM:4/4\nL:1/8\nK:C\nV:1\nC C2 C/ C// C3/2 |");
check(
"plain, doubled, halved, quartered and dotted lengths",
lengths.ok && lengths.score.notes.map((n) => n.duration).join() === "1,2,0.5,0.25,1.5",
);
const tied = parseAbc("X:1\nM:4/4\nL:1/8\nK:C\nV:1\nC4-C4 |");
check("a tie merges into one long note", tied.ok && tied.score.notes.length === 1 && tied.score.notes[0].duration === 8);
const chord = parseAbc("X:1\nM:4/4\nL:1/8\nK:C\nV:1\n[CEG]4 |");
check(
"a chord is three notes at one onset",
chord.ok && chord.score.notes.length === 3 && chord.score.notes.every((n) => n.start === 0 && n.duration === 4),
);
const rest = parseAbc("X:1\nM:4/4\nL:1/8\nK:C\nV:1\nz4 C4 |");
check("a rest advances time without a note", rest.ok && rest.score.notes.length === 1 && rest.score.notes[0].start === 4);
// --- refusals ---------------------------------------------------------------
// Refusing beats dropping: the grid writes the score back out, so anything it
// cannot draw is something the operator would lose without being told.
for (const [label, src] of [
["a tuplet", "X:1\nK:C\nV:1\n(3CDE |"],
["a grace note", "X:1\nK:C\nV:1\n{g}C4 |"],
] as const) {
const r = parseAbc(src);
check(`${label} is refused rather than dropped`, !r.ok && Boolean(r.offending));
}
// --- chord symbols ----------------------------------------------------------
// These are not exotic. YuE2 writes them on ordinary output, and refusing a
// score for containing one made the grid useless on real material.
const harmonised = parseAbc('X:1\nM:4/4\nL:1/8\nQ:1/4=96\nK:E\nV:Lead\n"G#m" B2 c2 "E" e4 | "A" a8 |');
check("a score with chord symbols parses", harmonised.ok, harmonised.ok ? "" : harmonised.reason);
if (harmonised.ok) {
check("every chord symbol is kept", harmonised.score.marks.length === 3, `got ${harmonised.score.marks.length}`);
check("the sharp minor survives verbatim", harmonised.score.marks[0].text === "G#m");
check("each one is pinned to its onset", harmonised.score.marks.map((m) => m.start).join() === "0,4,8");
check("they are chords, not annotations", harmonised.score.marks.every((m) => !m.annotation));
check("the tempo is read", harmonised.score.tempo === 96, `got ${harmonised.score.tempo}`);
}
const annotated = parseAbc('X:1\nM:4/4\nL:1/8\nK:C\nV:1\n"^softly" C4 "Dm" D4 |');
check(
"a placement prefix marks an annotation, not a chord",
annotated.ok && annotated.score.marks[0].annotation === true && annotated.score.marks[1].annotation === false,
);
// --- round trip -------------------------------------------------------------
const fingerprint = (score: AbcScore) =>
[
...score.notes.map((n) => `${n.voice}:${n.start}:${n.duration}:${n.midi}`),
...score.marks.map((m) => `mark ${m.voice}:${m.start}:${m.text}`),
]
.sort()
.join("|");
function roundTrip(label: string, src: string) {
const a = parseAbc(src);
if (!a.ok) return check(`${label} parses`, false, a.reason);
const text = serializeAbc(a.score);
const b = parseAbc(text);
if (!b.ok) return check(`${label} re-parses`, false, `${b.reason}\n${text}`);
const same = fingerprint(a.score) === fingerprint(b.score);
check(`${label} survives a round trip`, same, same ? "" : `\n${text}\nwas ${fingerprint(a.score)}\nnow ${fingerprint(b.score)}`);
}
roundTrip("the skeleton", SKELETON);
roundTrip("chords and rests", "X:1\nM:4/4\nL:1/8\nK:C\nV:1\n[CEG]4 z2 D2 | C8 |");
roundTrip("accidentals", "X:1\nM:4/4\nL:1/8\nK:C\nV:1\n^C2 _D2 =E2 ^^F2 |");
roundTrip("a natural after an accidental", "X:1\nM:4/4\nL:1/8\nK:C\nV:1\n^F2 F2 =F2 F2 |");
roundTrip("naturals under a key signature", "X:1\nM:4/4\nL:1/8\nK:G\nV:1\n=F2 F2 =C2 C2 |");
roundTrip("wide octaves", "X:1\nM:4/4\nL:1/8\nK:C\nV:1\nC,,2 C,2 C2 c2 | c'2 c''2 z4 |");
roundTrip("short notes", "X:1\nM:4/4\nL:1/8\nK:C\nV:1\nC/ D/ E// F// G3/2 A/2 z2 |");
roundTrip("three four time", "X:1\nM:3/4\nL:1/8\nK:D\nV:1\nD2 F2 A2 | d6 |");
roundTrip("chord symbols", 'X:1\nM:4/4\nL:1/8\nK:E\nV:Lead\n"G#m" B2 c2 "E" e4 | "A" a8 |');
roundTrip("chord symbols on both voices", 'X:1\nM:4/4\nL:1/8\nK:C\nV:Vocal\n"C" C4 "F" F4 |\nV:Ins\n"C" C,8 |');
// The shape the real take had: chord symbols, repeats and two voices together.
roundTrip(
"a YuE2 shaped score",
'X:1\nT:Take\nM:4/4\nL:1/8\nQ:1/4=84\nK:E\nV:Vocal\n|: "G#m" B2 c2 B2 A2 | "E" e6 z2 :|\nV:Ins\n|: "G#m" E,4 G,4 | "E" E,8 :|',
);
// --- randomised round trip ---------------------------------------------------
function mulberry(seed: number) {
return () => {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
let fuzzFails = 0;
let firstReport = "";
for (let trial = 0; trial < 500; trial++) {
const rand = mulberry(trial * 7919 + 13);
const lengths = [0.5, 1, 1.5, 2, 3, 4, 6, 8];
const keys = ["C", "G", "D", "F", "Bb", "Am"];
const key = keys[Math.floor(rand() * keys.length)];
const notes: AbcNote[] = [];
const voiceCount = 1 + Math.floor(rand() * 2);
let id = 0;
for (let v = 0; v < voiceCount; v++) {
let t = 0;
const count = 1 + Math.floor(rand() * 10);
for (let i = 0; i < count; i++) {
t += Math.floor(rand() * 3) * 0.5;
const duration = lengths[Math.floor(rand() * lengths.length)];
notes.push({ id: `f${id++}`, voice: v, start: t, duration, midi: 36 + Math.floor(rand() * 48), tied: false });
t += duration;
}
}
const score: AbcScore = {
header: ["X:1", "M:4/4", "L:1/8", `K:${key}`],
voices: Array.from({ length: voiceCount }, (_, i) => ({ id: String(i + 1), rest: "" })),
notes,
marks: [],
meter: [4, 4],
unit: [1, 8],
key,
repeatsExpanded: false,
tempo: 120,
};
const text = serializeAbc(score);
const back = parseAbc(text);
const want = fingerprint(score);
const got = back.ok ? fingerprint(back.score) : "PARSE FAILED";
if (want !== got) {
if (!fuzzFails) firstReport = `\n first mismatch, trial ${trial}, key ${key}\n${text}\nwant ${want}\ngot ${got}`;
fuzzFails++;
}
}
check("500 random scores round trip", fuzzFails === 0, `${fuzzFails} mismatched${firstReport}`);
console.log(fails ? `\n${fails} FAILURES` : "\nall assertions passed");
process.exit(fails ? 1 : 0);