Spaces:
Runtime error
Runtime error
Download src/lib/abc/serialize.ts from RalphThings/yue2-studio: direct link, hf CLI and curl.
- Browser
- Download file 7.97 kB
-
https://huggingface.co/spaces/RalphThings/yue2-studio/resolve/main/src/lib/abc/serialize.ts
- Command line
-
hf download hf://spaces/RalphThings/yue2-studio/src/lib/abc/serialize.ts
-
curl -L -o serialize.ts https://huggingface.co/spaces/RalphThings/yue2-studio/resolve/main/src/lib/abc/serialize.ts
7.97 kB
| import type { AbcMark, AbcNote, AbcScore } from "@/lib/abc/model"; | |
| import { unitsPerBar } from "@/lib/abc/model"; | |
| import { accidentalsFor } from "@/lib/abc/parse"; | |
| /** | |
| * Writes the note model back out as ABC. | |
| * | |
| * Headers come through verbatim, so a rewrite can never reword the tune's | |
| * title, meter or key. Only the music is regenerated, and only from what the | |
| * grid actually holds. | |
| * | |
| * Simultaneous notes in one voice are emitted as chords, rests fill the gaps, | |
| * and a note crossing a bar line is tied across it rather than being truncated | |
| * or allowed to run past the barline, either of which would change the music. | |
| */ | |
| export function serializeAbc(score: AbcScore): string { | |
| const bar = unitsPerBar(score); | |
| const lines = [...score.header]; | |
| for (let voice = 0; voice < score.voices.length; voice++) { | |
| const meta = score.voices[voice]; | |
| lines.push(`V:${meta.id}${meta.rest ? ` ${meta.rest}` : ""}`); | |
| const notes = score.notes | |
| .filter((note) => note.voice === voice) | |
| .sort((a, b) => a.start - b.start || a.midi - b.midi); | |
| const marks = (score.marks ?? []) | |
| .filter((mark) => mark.voice === voice) | |
| .sort((a, b) => a.start - b.start); | |
| lines.push(...voiceLines(notes, marks, bar, score.key)); | |
| } | |
| return lines.join("\n"); | |
| } | |
| function voiceLines(notes: AbcNote[], marks: AbcMark[], bar: number, key: string): string[] { | |
| if (!notes.length) return ["z8 |"]; | |
| // Chord symbols sit immediately before the note they belong to, which is | |
| // where ABC expects them and where a reader looks for them. | |
| const marksAt = new Map<number, AbcMark[]>(); | |
| for (const mark of marks) { | |
| const list = marksAt.get(mark.start); | |
| if (list) list.push(mark); | |
| else marksAt.set(mark.start, [mark]); | |
| } | |
| const emitted = new Set<number>(); | |
| const markToken = (at: number) => { | |
| if (emitted.has(at)) return ""; | |
| const list = marksAt.get(at); | |
| if (!list) return ""; | |
| emitted.add(at); | |
| return list.map((mark) => `"${mark.text}"`).join(""); | |
| }; | |
| // Accidentals carry to the end of the bar, so writing one changes how every | |
| // later note on that pitch reads. The serializer has to track exactly what | |
| // the parser will track, or a natural written after a sharp comes back as a | |
| // sharp. The fuzz test found this; the fixed cases never did, because they | |
| // happened not to put a natural after an accidental in one bar. | |
| const signature = accidentalsFor(key); | |
| let barAccidentals: Record<string, number> = {}; | |
| // Group by onset so a chord is written once rather than as stacked notes. | |
| const byStart = new Map<number, AbcNote[]>(); | |
| for (const note of notes) { | |
| const list = byStart.get(note.start); | |
| if (list) list.push(note); | |
| else byStart.set(note.start, [note]); | |
| } | |
| const onsets = [...byStart.keys()].sort((a, b) => a - b); | |
| const end = Math.max(...notes.map((note) => note.start + note.duration)); | |
| const totalBars = Math.max(1, Math.ceil(end / bar)); | |
| const out: string[] = []; | |
| let tokens: string[] = []; | |
| let cursor = 0; | |
| let index = 0; | |
| for (let barIndex = 0; barIndex < totalBars; barIndex++) { | |
| const barEnd = (barIndex + 1) * bar; | |
| const parts: string[] = []; | |
| barAccidentals = {}; | |
| while (index < onsets.length && onsets[index] < barEnd) { | |
| const start = onsets[index]; | |
| if (start > cursor) { | |
| parts.push(`z${lengthToken(start - cursor)}`); | |
| cursor = start; | |
| } | |
| const group = byStart.get(start)!; | |
| // A chord's length is its shortest member, so nothing in it overruns. | |
| const want = Math.min(...group.map((note) => note.duration)); | |
| // Split at the bar line and tie, so the written bar still adds up. | |
| const fits = Math.min(want, barEnd - start); | |
| parts.push( | |
| markToken(start) + chordToken(group, fits, key, barAccidentals, signature) + (fits < want ? "-" : ""), | |
| ); | |
| cursor = start + fits; | |
| if (fits < want) { | |
| // The remainder becomes a fresh onset in the next bar. | |
| const remainder = group.map((note) => ({ ...note, start: cursor, duration: want - fits })); | |
| const existing = byStart.get(cursor); | |
| if (existing) existing.push(...remainder); | |
| else { | |
| byStart.set(cursor, remainder); | |
| onsets.splice(index + 1, 0, cursor); | |
| } | |
| } | |
| index++; | |
| } | |
| if (cursor < barEnd && barIndex < totalBars) { | |
| const rest = barEnd - cursor; | |
| // Trailing silence past the last note is not worth writing. | |
| if (index < onsets.length || barIndex < totalBars - 1) { | |
| parts.push(`z${lengthToken(rest)}`); | |
| cursor = barEnd; | |
| } | |
| } | |
| if (parts.length) tokens.push(parts.join(" "), "|"); | |
| // Four bars to a line keeps the text readable next to the grid. | |
| if ((barIndex + 1) % 4 === 0 && tokens.length) { | |
| out.push(tokens.join(" ")); | |
| tokens = []; | |
| } | |
| } | |
| if (tokens.length) out.push(tokens.join(" ")); | |
| return out.length ? out : ["z8 |"]; | |
| } | |
| function chordToken( | |
| group: AbcNote[], | |
| duration: number, | |
| key: string, | |
| barAccidentals: Record<string, number>, | |
| signature: Record<string, number>, | |
| ): string { | |
| const spell = (note: AbcNote) => pitchToken(note.midi, key, barAccidentals, signature); | |
| if (group.length === 1) return spell(group[0]) + lengthToken(duration); | |
| return `[${group.map(spell).join("")}]${lengthToken(duration)}`; | |
| } | |
| const SHARP_SPELLING = ["C", "^C", "D", "^D", "E", "F", "^F", "G", "^G", "A", "^A", "B"]; | |
| const FLAT_SPELLING = ["C", "_D", "D", "_E", "E", "F", "_G", "G", "_A", "A", "_B", "B"]; | |
| /** | |
| * Spells a MIDI note, writing an accidental whenever one is needed to be read | |
| * back as this exact pitch. | |
| * | |
| * "Needed" covers three cases, and missing any of them silently transposes a | |
| * note on the next parse: the pitch is a black key; the key signature already | |
| * alters this letter and we want the natural; or an accidental earlier in this | |
| * bar altered it and we want something else now. The last two both require an | |
| * explicit natural sign, which is easy to forget because the note itself has | |
| * no accidental in it. | |
| */ | |
| function pitchToken( | |
| midi: number, | |
| key: string, | |
| barAccidentals: Record<string, number>, | |
| signature: Record<string, number>, | |
| ): string { | |
| const flats = /b/.test(key) || /^(F|Dm|Gm|Cm|Fm|Bbm|Ebm)\b/.test(key.trim()); | |
| const spelling = flats ? FLAT_SPELLING : SHARP_SPELLING; | |
| const step = spelling[((midi % 12) + 12) % 12]; | |
| const letter = step.replace(/[\^_]/, ""); | |
| const wanted = step.startsWith("^") ? 1 : step.startsWith("_") ? -1 : 0; | |
| const octave = Math.floor(midi / 12) - 1; | |
| const slot = `${letter}${octave}`; | |
| const inForce = slot in barAccidentals ? barAccidentals[slot] : (signature[letter] ?? 0); | |
| let mark = ""; | |
| if (wanted !== inForce) { | |
| mark = wanted === 1 ? "^" : wanted === -1 ? "_" : "="; | |
| barAccidentals[slot] = wanted; | |
| } | |
| // Lower case is the octave above upper case. | |
| const body = octave >= 5 ? letter.toLowerCase() + "'".repeat(octave - 5) : letter + ",".repeat(Math.max(0, 4 - octave)); | |
| return mark + body; | |
| } | |
| /** Writes a length in units the way ABC spells it. */ | |
| function lengthToken(units: number): string { | |
| if (Math.abs(units - 1) < 1e-9) return ""; | |
| if (units >= 1 && Math.abs(units - Math.round(units)) < 1e-9) return String(Math.round(units)); | |
| // Halves and quarters have their own shorthand; anything else is a fraction. | |
| const rounded = Math.round(units * 16) / 16; | |
| if (Math.abs(rounded - 0.5) < 1e-9) return "/"; | |
| if (Math.abs(rounded - 0.25) < 1e-9) return "//"; | |
| for (let denominator = 2; denominator <= 16; denominator++) { | |
| const numerator = rounded * denominator; | |
| if (Math.abs(numerator - Math.round(numerator)) < 1e-9) { | |
| const top = Math.round(numerator); | |
| return top === 1 ? `/${denominator}` : `${top}/${denominator}`; | |
| } | |
| } | |
| return String(Math.max(1, Math.round(units))); | |
| } | |
| /** Exposed for the round-trip tests only. */ | |
| export const __testables = { lengthToken, pitchToken }; | |