yue2-studio / src /lib /abc /serialize.ts
RalphThings's picture
Upload folder using huggingface_hub
6a6bd27 verified
Raw History Blame Contribute Delete
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 };