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 = { 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 = { 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(); 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 { 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 = {}; 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, signature: Record, ): 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 { 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 = {}; 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 = { A: "C", E: "G", B: "D", "F#": "A", "C#": "E", D: "F", G: "Bb", C: "Eb", F: "Ab", Bb: "Db", };