Spaces:
Runtime error
Runtime error
Download scripts/test-abc.ts from RalphThings/yue2-studio: direct link, hf CLI and curl.
- Browser
- Download file 9.27 kB
-
https://huggingface.co/spaces/RalphThings/yue2-studio/resolve/main/scripts/test-abc.ts
- Command line
-
hf download hf://spaces/RalphThings/yue2-studio/scripts/test-abc.ts
-
curl -L -o test-abc.ts https://huggingface.co/spaces/RalphThings/yue2-studio/resolve/main/scripts/test-abc.ts
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); | |