Spaces:
Runtime error
Runtime error
File size: 9,269 Bytes
5c0b162 6a6bd27 5c0b162 6a6bd27 5c0b162 6a6bd27 5c0b162 6a6bd27 5c0b162 6a6bd27 5c0b162 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 | /**
* 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);
|