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(); 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(); 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 = {}; // Group by onset so a chord is written once rather than as stacked notes. const byStart = new Map(); 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, signature: Record, ): 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, signature: Record, ): 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 };