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