yue2-studio / src /lib /abc /parse.ts
RalphThings's picture
Upload folder using huggingface_hub
6a6bd27 verified
Raw History Blame Contribute Delete
14.1 kB
import type { AbcMark, AbcNote, AbcParseResult, AbcScore, AbcVoice } from "@/lib/abc/model";
/**
* Reads the ABC dialect YuE2 and SheetSage2 actually emit.
*
* This is deliberately not a general ABC parser. It covers notes, rests,
* chords, ties, accidentals, octaves, bar lines and repeats, and it *refuses*
* anything else. Refusing matters more than coverage here: the grid writes the
* score back out, so a construct it silently drops is a construct the operator
* loses. When this returns a failure the panel keeps the text editor and says
* which construct stopped it, which is recoverable. Quietly deleting a tuplet
* is not.
*/
/** Semitone offset of each natural within an octave. */
const STEP: Record<string, number> = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
/** Sharps and flats a key signature applies, by letter. */
const KEY_SIGNATURES: Record<string, string[]> = {
C: [], G: ["F"], D: ["F", "C"], A: ["F", "C", "G"], E: ["F", "C", "G", "D"],
B: ["F", "C", "G", "D", "A"], "F#": ["F", "C", "G", "D", "A", "E"],
F: ["B"], Bb: ["B", "E"], Eb: ["B", "E", "A"], Ab: ["B", "E", "A", "D"],
Db: ["B", "E", "A", "D", "G"], Gb: ["B", "E", "A", "D", "G", "C"],
};
const UNSUPPORTED: { pattern: RegExp; name: string }[] = [
{ pattern: /\(\d/, name: "a tuplet" },
{ pattern: /\{/, name: "a grace note" },
{ pattern: /![^!\n]*!/, name: "a decoration" },
{ pattern: /\+[^+\n]*\+/, name: "a decoration" },
];
interface Cursor {
text: string;
at: number;
}
export function parseAbc(source: string): AbcParseResult {
const lines = source.replace(/\r\n?/g, "\n").split("\n");
const header: string[] = [];
const voices: AbcVoice[] = [];
const bodyByVoice = new Map<number, string[]>();
let meter: [number, number] = [4, 4];
let unit: [number, number] | null = null;
let key = "C";
let tempo = 120;
let current = -1;
let sawVoiceLine = false;
for (const raw of lines) {
const line = raw.replace(/%.*$/, "").trimEnd();
if (!line.trim()) continue;
const field = /^([A-Za-z]):\s*(.*)$/.exec(line);
if (field) {
const [, tag, value] = field;
if (tag === "V") {
sawVoiceLine = true;
const [id, ...rest] = value.trim().split(/\s+/);
const existing = voices.findIndex((v) => v.id === id);
if (existing >= 0) {
current = existing;
} else {
voices.push({ id: id || `V${voices.length + 1}`, rest: rest.join(" ") });
current = voices.length - 1;
bodyByVoice.set(current, []);
}
continue;
}
if (tag === "M") {
const m = /^(\d+)\s*\/\s*(\d+)$/.exec(value.trim());
if (m) meter = [Number(m[1]), Number(m[2])];
else if (value.trim() === "C") meter = [4, 4];
else if (value.trim() === "C|") meter = [2, 2];
}
if (tag === "L") {
const m = /^(\d+)\s*\/\s*(\d+)$/.exec(value.trim());
if (m) unit = [Number(m[1]), Number(m[2])];
}
if (tag === "K") key = value.trim() || "C";
if (tag === "Q") tempo = readTempo(value.trim()) ?? tempo;
// Header fields stay verbatim so a rewrite cannot reword the tune.
if (!sawVoiceLine) header.push(line);
continue;
}
if (current < 0) {
// Music before any V: line is a single implicit voice.
voices.push({ id: "1", rest: "" });
current = 0;
bodyByVoice.set(0, []);
}
bodyByVoice.get(current)!.push(line);
}
if (!voices.length) {
return { ok: false, reason: "There is no music in this score yet.", offending: null };
}
// L: is optional in ABC. Its default depends on the meter: short meters get
// a sixteenth, everything else an eighth.
if (!unit) unit = meter[0] / meter[1] < 0.75 ? [1, 16] : [1, 8];
const notes: AbcNote[] = [];
const marks: AbcMark[] = [];
let repeatsExpanded = false;
let seq = 0;
let markSeq = 0;
for (const [voiceIndex, body] of bodyByVoice) {
const text = body.join(" ");
for (const { pattern, name } of UNSUPPORTED) {
const hit = pattern.exec(text);
if (hit) {
return {
ok: false,
reason: `This score contains ${name}, which the grid cannot draw without losing it.`,
offending: hit[0],
};
}
}
const read = readVoice(text, key);
if (!read.ok) return read;
if (read.repeated) repeatsExpanded = true;
for (const event of read.events) {
notes.push({
id: `n${seq++}`,
voice: voiceIndex,
start: event.start,
duration: event.duration,
midi: event.midi,
tied: event.tied,
});
}
for (const mark of read.marks) {
marks.push({ id: `m${markSeq++}`, voice: voiceIndex, ...mark });
}
}
notes.sort((a, b) => a.start - b.start || a.midi - b.midi);
marks.sort((a, b) => a.start - b.start);
const score: AbcScore = { header, voices, notes, marks, meter, unit, key, repeatsExpanded, tempo };
return { ok: true, score };
}
interface Event {
start: number;
duration: number;
midi: number;
tied: boolean;
}
interface RawMark {
start: number;
text: string;
annotation: boolean;
}
interface VoiceOk {
ok: true;
events: Event[];
marks: RawMark[];
repeated: boolean;
}
/** Q:1/4=120, Q:120, or Q:"Allegro" 1/4=120 all mean the same thing here. */
function readTempo(value: string): number | null {
const withUnit = /(\d+)\s*\/\s*(\d+)\s*=\s*(\d+(?:\.\d+)?)/.exec(value);
if (withUnit) {
const beat = Number(withUnit[1]) / Number(withUnit[2]);
const perMinute = Number(withUnit[3]);
// Normalise to quarter notes, which is what the player counts in.
return (beat / 0.25) * perMinute;
}
const plain = /^(\d+(?:\.\d+)?)$/.exec(value);
return plain ? Number(plain[1]) : null;
}
function readVoice(text: string, key: string): VoiceOk | Extract<AbcParseResult, { ok: false }> {
const cursor: Cursor = { text, at: 0 };
const events: Event[] = [];
const marks: RawMark[] = [];
// A repeated section has to be written out, because the grid shows time as
// one straight line and cannot draw "go back and play that again".
let sectionStart = 0;
let sectionEventStart = 0;
let repeated = false;
let time = 0;
// Accidentals last to the end of the bar in ABC, exactly as in notation.
let barAccidentals: Record<string, number> = {};
const signature = accidentalsFor(key);
const pushTiedOrNew = (event: Event) => {
const previous = events[events.length - 1];
if (previous && previous.tied && previous.midi === event.midi) {
previous.duration += event.duration;
previous.tied = event.tied;
return;
}
events.push(event);
};
while (cursor.at < cursor.text.length) {
const ch = cursor.text[cursor.at];
if (ch === " " || ch === "\t") {
cursor.at++;
continue;
}
// Bar lines, in the order that matters: two-character forms first.
const two = cursor.text.slice(cursor.at, cursor.at + 2);
if (two === ":|" || two === "::") {
const from = events.length;
// Replay everything since the section opened, marks included: a chord
// symbol that only appeared on the first pass would leave the repeat
// looking unharmonised.
const span = time - sectionStart;
for (let i = sectionEventStart; i < from; i++) {
const source = events[i];
events.push({ ...source, start: source.start + span });
}
for (const mark of marks.filter((m) => m.start >= sectionStart && m.start < time)) {
marks.push({ ...mark, start: mark.start + span });
}
if (from > sectionEventStart) repeated = true;
time += span;
sectionStart = time;
sectionEventStart = events.length;
cursor.at += 2;
barAccidentals = {};
continue;
}
if (two === "|:" || two === "||" || two === "|]" || two === "[|") {
sectionStart = time;
sectionEventStart = events.length;
cursor.at += 2;
barAccidentals = {};
continue;
}
if (ch === "|") {
cursor.at++;
barAccidentals = {};
continue;
}
if (ch === ":") {
cursor.at++;
continue;
}
// Slur parentheses carry no duration. Tuplets were rejected earlier, so a
// bare "(" here is safe to skip.
if (ch === "(" || ch === ")") {
cursor.at++;
continue;
}
if (ch === "-") {
const previous = events[events.length - 1];
if (previous) previous.tied = true;
cursor.at++;
continue;
}
if (ch === "$" || ch === "\\") {
cursor.at++;
continue;
}
// A quoted string: a chord symbol, or an annotation when it opens with a
// placement character. Both are pinned to the current time so they survive
// a rewrite and can be drawn above the grid.
if (ch === '"') {
const close = cursor.text.indexOf('"', cursor.at + 1);
if (close < 0) {
return { ok: false, reason: "A quoted chord symbol is never closed.", offending: '"' };
}
const raw = cursor.text.slice(cursor.at + 1, close);
cursor.at = close + 1;
if (raw.trim()) {
marks.push({ start: time, text: raw, annotation: /^[\^_<>@]/.test(raw) });
}
continue;
}
// A chord: every note inside shares one onset and one length.
if (ch === "[") {
const close = cursor.text.indexOf("]", cursor.at);
if (close < 0) {
return { ok: false, reason: "A chord bracket is never closed.", offending: "[" };
}
const inner = cursor.text.slice(cursor.at + 1, close);
cursor.at = close + 1;
const trailing = readLength(cursor);
const chordCursor: Cursor = { text: inner, at: 0 };
let chordLength = 1;
const pitches: number[] = [];
while (chordCursor.at < inner.length) {
if (/\s/.test(inner[chordCursor.at])) {
chordCursor.at++;
continue;
}
const note = readNote(chordCursor, barAccidentals, signature);
if (!note) {
return { ok: false, reason: "A chord contains something that is not a note.", offending: inner };
}
if (note.midi !== null) pitches.push(note.midi);
chordLength = note.length;
}
const duration = chordLength * trailing;
for (const midi of pitches) {
pushTiedOrNew({ start: time, duration, midi, tied: false });
}
time += duration;
continue;
}
const note = readNote(cursor, barAccidentals, signature);
if (note) {
if (note.midi === null) {
time += note.length; // a rest
} else {
pushTiedOrNew({ start: time, duration: note.length, midi: note.midi, tied: false });
time += note.length;
}
continue;
}
return {
ok: false,
reason: "This score has notation the grid does not understand.",
offending: cursor.text.slice(cursor.at, cursor.at + 8).trim() || "?",
};
}
return { ok: true, events, marks, repeated };
}
interface ReadNote {
midi: number | null;
length: number;
}
function readNote(
cursor: Cursor,
barAccidentals: Record<string, number>,
signature: Record<string, number>,
): ReadNote | null {
const start = cursor.at;
let accidental: number | null = null;
while (cursor.at < cursor.text.length) {
const ch = cursor.text[cursor.at];
if (ch === "^") {
accidental = (accidental ?? 0) + 1;
cursor.at++;
} else if (ch === "_") {
accidental = (accidental ?? 0) - 1;
cursor.at++;
} else if (ch === "=") {
accidental = 0;
cursor.at++;
} else {
break;
}
}
const letter = cursor.text[cursor.at];
if (!letter || !/[A-Ga-gzZxX]/.test(letter)) {
cursor.at = start;
return null;
}
cursor.at++;
if (/[zZxX]/.test(letter)) {
return { midi: null, length: readLength(cursor) };
}
// Lower case is the octave above upper case, then , and ' shift further.
const upper = letter.toUpperCase();
let octave = letter === upper ? 4 : 5;
while (cursor.at < cursor.text.length) {
const ch = cursor.text[cursor.at];
if (ch === ",") {
octave--;
cursor.at++;
} else if (ch === "'") {
octave++;
cursor.at++;
} else {
break;
}
}
const slot = `${upper}${octave}`;
if (accidental !== null) barAccidentals[slot] = accidental;
const applied = slot in barAccidentals ? barAccidentals[slot] : (signature[upper] ?? 0);
const midi = (octave + 1) * 12 + STEP[upper] + applied;
return { midi, length: readLength(cursor) };
}
/** Reads the multiplier after a note: `2`, `/2`, `/`, `3/2`, or nothing. */
function readLength(cursor: Cursor): number {
let numerator = "";
while (/\d/.test(cursor.text[cursor.at] ?? "")) numerator += cursor.text[cursor.at++];
let denominator = "";
let slashes = 0;
while (cursor.text[cursor.at] === "/") {
slashes++;
cursor.at++;
}
if (slashes) {
while (/\d/.test(cursor.text[cursor.at] ?? "")) denominator += cursor.text[cursor.at++];
}
const top = numerator ? Number(numerator) : 1;
// `/` alone halves, `//` quarters, and so on.
const bottom = denominator ? Number(denominator) : slashes ? 2 ** slashes : 1;
const value = top / bottom;
return Number.isFinite(value) && value > 0 ? value : 1;
}
export function accidentalsFor(key: string): Record<string, number> {
const match = /^([A-G][#b]?)/.exec(key.trim());
const root = match ? match[1] : "C";
const minor = /m(?!aj)/i.test(key) && !/major/i.test(key);
// A minor key carries the signature of its relative major.
const relative = (minor ? RELATIVE_MAJOR[root] : undefined) ?? root;
const letters = KEY_SIGNATURES[relative] ?? [];
const sharp = !relative.includes("b") && relative !== "F";
const out: Record<string, number> = {};
for (const letter of letters) out[letter] = sharp ? 1 : -1;
return out;
}
/** A minor key carries the signature of its relative major. */
const RELATIVE_MAJOR: Record<string, string> = {
A: "C", E: "G", B: "D", "F#": "A", "C#": "E", D: "F", G: "Bb", C: "Eb", F: "Ab", Bb: "Db",
};