diff --git a/.agents/skills/render-musicxml/SKILL.md b/.agents/skills/render-musicxml/SKILL.md index 834a3c2f1..89d2a6776 100644 --- a/.agents/skills/render-musicxml/SKILL.md +++ b/.agents/skills/render-musicxml/SKILL.md @@ -1,6 +1,6 @@ --- name: render-musicxml -description: Render a MusicXML file in vexml by running `vex render -i `, inspect the generated screenshot when needed, and delete ephemeral render output when finished. +description: Render a MusicXML file in vexml by running `vex render -i `, optionally pass render configuration with `--config `, inspect the generated screenshot when needed, and delete ephemeral render output when finished. --- # Render MusicXML @@ -17,10 +17,18 @@ vex render -i Replace `` with the project-relative or absolute path to the MusicXML file the user wants rendered. +To override render settings, pass a partial render config as JSON with `-c`/`--config`: + +```sh +vex render -i --config '{"noteSpacing":40,"showPartLabels":true}' +``` + +The JSON object corresponds to `Partial` from `src/config.ts`; use that file as the source of truth for available options and defaults. + ## Workflow 1. Identify the MusicXML input path from the user's request or from the repository. -2. Run `vex render -i ` from `vexml`. +2. Run `vex render -i ` from `vexml`. If the user asks for non-default render settings, include `-c`/`--config ` using option names from `src/config.ts`. 3. Read the command output to find the generated screenshot path. 4. If visual inspection is needed, open or inspect the generated screenshot with available tools. 5. When finished, delete the generated screenshot if it was only meant to be ephemeral. diff --git a/site/src/App.tsx b/site/src/App.tsx index c0bc3d5f3..48e1c7d88 100644 --- a/site/src/App.tsx +++ b/site/src/App.tsx @@ -97,7 +97,9 @@ export default function App() { const [config, setConfig] = useState>({}); const noteSpacing = config.noteSpacing ?? 36; const softmaxFactor = config.softmaxFactor ?? 10; - const reset = (key: 'noteSpacing' | 'softmaxFactor') => + const systemSpacing = config.systemSpacing ?? 30; + const notationFont = config.fonts?.notation?.family ?? 'Bravura'; + const reset = (key: 'noteSpacing' | 'softmaxFactor' | 'systemSpacing') => setConfig(({ [key]: _, ...rest }) => rest); // `config` stays live so the sliders/reset respond instantly; `renderConfig` lags @@ -394,6 +396,41 @@ export default function App() { Config +
+ + +

+ The engraving font for noteheads, clefs, accidentals, and + rests. Bravura is the default. +

+
+
+ +
+ + + setConfig((c) => ({ + ...c, + systemSpacing: e.target.valueAsNumber, + })) + } + /> +

+ Vertical gap between stacked systems. Lower packs systems + closer together down the page. +

+
diff --git a/src/config.ts b/src/config.ts index e7b06135d..8556fb9de 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,4 @@ +import { SYSTEM_GAP } from './constants'; import type { FontConfig } from './fonts'; import type { Layout, MeasureNumbering } from './layout'; @@ -23,6 +24,9 @@ export type Config = { * Given the width noteSpacing allots, higher exaggerates the long-vs-short note ratio. A * shape constant, independent of overall density. */ softmaxFactor: number; + /** Vertical gap in px between stacked systems (default: SYSTEM_GAP). Smaller packs + * systems closer together down the page. */ + systemSpacing: number; /** Print each part's instrument name to the left of the first system (default: false). */ showPartLabels: boolean; /** When to print measure numbers above the staff (default: 'system'). 'none' prints @@ -52,6 +56,7 @@ export const DEFAULT_CONFIG: Config = { layout: { type: 'standard', width: 1000 }, noteSpacing: 36, softmaxFactor: 10, + systemSpacing: SYSTEM_GAP, showPartLabels: false, measureNumbering: 'system', showTabHammerPullText: false, diff --git a/src/constants.ts b/src/constants.ts index 98c6803f1..8d78995a5 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -18,8 +18,8 @@ export const PAGE_MARGIN_TOP_WITH_TEMPO = 70; export const PAGE_MARGIN_BOTTOM = 40; /** Vertical gap between stacked systems, plus room for the next system's notes that - * rise above its top staff. */ -export const SYSTEM_GAP = 90; + * rise above its top staff. The default for `Config.systemSpacing`. */ +export const SYSTEM_GAP = 30; /** Vertical gap between staves within one part (a brace-joined group reads as one * instrument because this exceeds INTER_PART_SPACING). */ @@ -95,6 +95,28 @@ export const TAB_TIE_CP2 = 12; * the system's left line with a small gap. */ export const BRACKET_X_SHIFT = 3; +/** Chord-symbol (from ``) text size — a touch smaller than the part label so it + * reads as an annotation above the notes. */ +export const HARMONY_FONT_SIZE = 13; + +/** How far a chord symbol's baseline sits above the top staff line. */ +export const HARMONY_Y_OFFSET = 14; + +/** Clearance kept between a chord symbol's baseline and the top of a high note it + * sits over, so the symbol lifts clear instead of colliding with the notehead. */ +export const HARMONY_NOTE_CLEARANCE = 8; + +/** Words-direction (e.g. "ritardando") text size — matches the chord-symbol size so + * both read as annotations above the notes. */ +export const WORDS_FONT_SIZE = 13; + +/** How far a words-direction baseline sits above the top staff line. */ +export const WORDS_Y_OFFSET = 14; + +/** Clearance kept between a words-direction baseline and the top of a high note it sits + * over, so the directive lifts clear instead of colliding with the notehead. */ +export const WORDS_NOTE_CLEARANCE = 8; + /** Clearance between the bottom of a metronome mark and the top of the first note. */ export const TEMPO_NOTE_CLEARANCE = 6; diff --git a/src/draw.ts b/src/draw.ts index d8675e2b2..e885c3723 100644 --- a/src/draw.ts +++ b/src/draw.ts @@ -23,6 +23,9 @@ import { import type { Config } from './config'; import { BRACKET_X_SHIFT, + HARMONY_FONT_SIZE, + HARMONY_NOTE_CLEARANCE, + HARMONY_Y_OFFSET, LABEL_FONT_SIZE, LABEL_GAP, LEDGER_HEADROOM, @@ -30,11 +33,15 @@ import { PAGE_MARGIN_TOP, TEMPO_NOTE_CLEARANCE, TEMPO_SCALE, + WORDS_FONT_SIZE, + WORDS_NOTE_CLEARANCE, + WORDS_Y_OFFSET, } from './constants'; import type { MeasureNumbering, ScoreLayout } from './layout'; import { endBeatOf, getNoteheadHalfWidth, + harmoniesOf, meterBeats, staffVoices, type TempoMark, @@ -42,6 +49,7 @@ import { vexflowClef, vexflowTabTickables, vexflowVoiceTickables, + wordsOf, } from './notes'; import { buildBeams, @@ -287,7 +295,8 @@ function formatAndDrawPart( // one text line above the staff; if the first note reaches up into that band (a high // note with ledger lines), lift the mark with a negative y-shift so its bottom clears // the notehead — the layout reserves the matching top headroom. Drawn after the notes -// are formatted so firstNote's bounding box is real. +// are formatted so firstNote's extents are real. Uses noteTop, not the bounding box: an +// attached grace-note group makes the box report a bogus near-origin y. function drawTempo( context: RenderContext, stave: Stave, @@ -297,7 +306,7 @@ function drawTempo( const baseY = stave.getYForTopText(1); let shiftY = 0; if (firstNote) { - const clearY = firstNote.getBoundingBox().getY() - TEMPO_NOTE_CLEARANCE; + const clearY = noteTop(firstNote) - TEMPO_NOTE_CLEARANCE; if (clearY < baseY) { shiftY = clearY - baseY; } @@ -330,6 +339,87 @@ function drawTempo( context.restore(); } +// Accidental glyphs in a chord symbol; pulled tight against their root letter. +const HARMONY_ACCIDENTALS = new Set(['♯', '♭', '♮']); +const HARMONY_ACCIDENTAL_KERN = 2.5; +const HARMONY_ACCIDENTAL_FONT_SIZE = HARMONY_FONT_SIZE - 3; + +// Draw a chord symbol (from a ) above its note's stave, left-anchored at +// the note's x — the laid-out position of the note the harmony applies to. Returns +// the y the text reaches up to so the caller can grow the page crop to keep a margin +// above it (drawTempo relies on reserved layout headroom instead; harmony feeds the +// crop directly so no extra top margin is needed). Drawn after the notes are +// formatted so getAbsoluteX is real. +function drawHarmony( + context: RenderContext, + staveNote: StaveNote, + text: string, + font: string, +): number { + const stave = staveNote.getStave(); + if (!stave) { + return Infinity; + } + // Sit a fixed gap above the top staff line, but lift higher when the note rises into + // that band (a high note or its ledger lines) so the symbol clears the notehead. Uses + // noteTop, not the bounding box: an attached grace-note group makes the box report a + // bogus near-origin y, which would fling the symbol to the top of the page and defeat + // the top crop (leaving a huge blank margin above the first system). + const baseY = stave.getYForLine(0) - HARMONY_Y_OFFSET; + const noteClearY = noteTop(staveNote) - HARMONY_NOTE_CLEARANCE; + const y = Math.min(baseY, noteClearY); + context.save(); + context.setFont(font, HARMONY_FONT_SIZE); + context.setFillStyle('#000000'); + // The ♯/♭/♮ glyphs carry wide side-bearings in the text font, so a single fillText + // of "B♭" reads as "B ♭". Draw char by char and pull the accidental in on both sides + // so it sits tight against its root letter. + let x = staveNote.getAbsoluteX(); + for (const ch of text) { + const accidental = HARMONY_ACCIDENTALS.has(ch); + if (accidental) { + x -= HARMONY_ACCIDENTAL_KERN; + context.setFont(font, HARMONY_ACCIDENTAL_FONT_SIZE); + } + context.fillText(ch, x, y); + x += context.measureText(ch).width; + if (accidental) { + x -= HARMONY_ACCIDENTAL_KERN; + context.setFont(font, HARMONY_FONT_SIZE); + } + } + context.restore(); + return y - HARMONY_FONT_SIZE; +} + +// Draw a words direction (e.g. "ritardando") above the stave in italics, left-anchored at +// the first note's x — where the directive applies. Sits a fixed gap above the top staff +// line, but lifts higher when the first note rises into that band (a high note or its ledger +// lines) so the text clears the notehead. Returns the y the text reaches up to so the caller +// can grow the page crop above it (like drawHarmony). Drawn after the notes are formatted so +// getAbsoluteX is real. Uses noteTop, not the bounding box, to clear the note: an attached +// grace-note group makes the box report a bogus near-origin y. +function drawWords( + context: RenderContext, + stave: Stave, + text: string, + firstNote: StaveNote | undefined, + font: string, +): number { + const baseY = stave.getYForLine(0) - WORDS_Y_OFFSET; + const noteClearY = firstNote + ? noteTop(firstNote) - WORDS_NOTE_CLEARANCE + : baseY; + const y = Math.min(baseY, noteClearY); + const x = firstNote ? firstNote.getAbsoluteX() : stave.getNoteStartX(); + context.save(); + context.setFont(font, WORDS_FONT_SIZE, 'normal', 'italic'); + context.setFillStyle('#000000'); + context.fillText(text, x, y); + context.restore(); + return y - WORDS_FONT_SIZE; +} + // Build a tablature staff's notes into vexflow voices of TabNotes (fret numbers on // their strings). Tab notes carry no clef/key, no ghost-note gap filling, and no // beams — the roadmap cases are single-voice fretted lines — so this is a slimmer @@ -550,6 +640,13 @@ export function drawScore( const { x: measureX, width: measureWidth, systemIndex } = box; const { isSystemStart } = box; const isLastMeasure = m === measureCount - 1; + // An explicit right with light-light draws a thin + // double line at this measure's end instead of the default single divider (or, on + // the final measure, the thin-thick end). Read from the first part — a light-light + // boundary applies across the system. + const isLightLight = + parts[0]?.measures[m]?.barlines.find((b) => b.location === 'right') + ?.barStyle === 'light-light'; const showMeasureNumber = showsMeasureNumber( measureNumbering, m, @@ -579,6 +676,16 @@ export function drawScore( tempo: TempoMark; firstNote: StaveNote | undefined; }> = []; + // Chord symbols, drawn after the system is formatted so each sits at its + // note's laid-out x. + const harmonyTasks: Array<{ staveNote: StaveNote; text: string }> = []; + // Words directions (e.g. "ritardando"), each drawn above its part's top stave at + // the first note's laid-out x. + const wordsTasks: Array<{ + stave: Stave; + text: string; + firstNote: StaveNote | undefined; + }> = []; for (const part of parts) { const staveCount = Math.max(part.staveCount, 1); @@ -623,9 +730,11 @@ export function drawScore( stave.setEndBarType( totalStaves > 1 ? Barline.type.NONE - : isLastMeasure - ? Barline.type.END - : Barline.type.SINGLE, + : isLightLight + ? Barline.type.DOUBLE + : isLastMeasure + ? Barline.type.END + : Barline.type.SINGLE, ); // The previous measure's effective signatures (carried forward), used to @@ -742,6 +851,16 @@ export function drawScore( // across parts, not just within this part. systemPending.push(...pendingStaves); + // Chord symbols from this measure's elements, each bound to the + // lead note it sits above. Resolved via byLead (the notation staff's notes); + // a harmony over a tab-only note isn't drawn. + for (const { lead, text } of harmoniesOf(measure)) { + const staveNote = byLead.get(lead); + if (staveNote) { + harmonyTasks.push({ staveNote, text }); + } + } + // A metronome mark (from a ) prints on this part's top // staff wherever it appears — the piece start or a mid-piece tempo change. // Drawn after the system is formatted so it can clear a high first note. @@ -755,6 +874,19 @@ export function drawScore( }); } + // Words directions (e.g. "ritardando") print on this part's top staff, like + // the metronome mark. Drawn after the system is formatted so the first note's + // x is real. + if (topStave) { + for (const text of wordsOf(measure)) { + wordsTasks.push({ + stave: topStave.stave, + text, + firstNote: topStave.staveNotes[0], + }); + } + } + // A part's own staves are joined at each system start by the symbol named in // (brace by default; bracket for guitar notation+tab pairs). // 'none' suppresses the connector entirely. @@ -809,6 +941,18 @@ export function drawScore( for (const t of tempoTasks) { drawTempo(context, t.stave, t.tempo, t.firstNote); } + for (const h of harmonyTasks) { + pageTop = Math.min( + pageTop, + drawHarmony(context, h.staveNote, h.text, labelFont), + ); + } + for (const w of wordsTasks) { + pageTop = Math.min( + pageTop, + drawWords(context, w.stave, w.text, w.firstNote, labelFont), + ); + } // Join the whole system across all parts with a shared left line at the // system start, and a closing line at the system end. @@ -838,7 +982,13 @@ export function drawScore( // The piece's final measure gets a bold thin-thick connector to match its // end barline; all other measure ends get a plain single line. new StaveConnector(systemTop, systemBottom) - .setType(isLastMeasure ? 'boldDoubleRight' : 'singleRight') + .setType( + isLightLight + ? 'thinDouble' + : isLastMeasure + ? 'boldDoubleRight' + : 'singleRight', + ) .setContext(context) .draw(); } diff --git a/src/layout.ts b/src/layout.ts index c2908f3af..4546c3a3d 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -19,7 +19,6 @@ import { PAGE_MARGIN_X, QUARTER_NOTE_TICKS, REFERENCE_WIDTH, - SYSTEM_GAP, TAB_MIN_NOTE_SPACING, } from './constants'; import { @@ -358,7 +357,7 @@ export function computeLayout(parts: Part[], config: Config): ScoreLayout { boxes, staveOffsets, top: y, - systemGap: SYSTEM_GAP, + systemGap: config.systemSpacing, width: naturalWidth, floorHeight, softmaxFactor, diff --git a/src/notes.ts b/src/notes.ts index 6b1254e9d..1992cf21a 100644 --- a/src/notes.ts +++ b/src/notes.ts @@ -1,10 +1,10 @@ import type { Chord, Measure, - Note, Voice as ScoreVoice, Time, } from '@stringsync/mdom'; +import { MElement, Note } from '@stringsync/mdom'; import { Accidental, Annotation, @@ -17,6 +17,7 @@ import { GraceNoteGroup, GraceTabNote, Modifier, + Parenthesis, StaveNote, Stem, type StemmableNote, @@ -89,16 +90,35 @@ function isHarmonic(note: Note): boolean { return !!note.child('notations')?.child('technical')?.child('harmonic'); } +// A x: an X-shaped notehead (a dead/muted note), drawn as a cross on a +// notation stave (see vexflowKey) and as an "X" in place of the fret on tab (see tabPositions). +function isXNotehead(note: Note): boolean { + return note.child('notehead')?.text === 'x'; +} + +// A : a ghost/optional note, drawn with round brackets around the +// notehead on a notation stave (see addParentheses) and the fret wrapped in "()" on tab (see +// tabPositions). +function isParenthesized(note: Note): boolean { + return note.child('notehead')?.getAttribute('parentheses') === 'yes'; +} + // A note's vexflow key, e.g. C#5 -> 'c/5'. A harmonic appends the '/H' notehead code so -// vexflow draws a diamond (open for half+/whole, filled for quarter). Rests have no pitch; -// callers handle them. +// vexflow draws a diamond (open for half+/whole, filled for quarter); an X notehead appends +// '/X2' for a cross. Rests have no pitch; callers handle them. function vexflowKey(note: Note): string { const pitch = note.pitch; if (!pitch) { return 'b/4'; } const key = `${pitch.step.toLowerCase()}/${pitch.octave}`; - return isHarmonic(note) ? `${key}/H` : key; + if (isHarmonic(note)) { + return `${key}/H`; + } + if (isXNotehead(note)) { + return `${key}/X2`; + } + return key; } // Augmentation dots. @@ -134,6 +154,24 @@ function addArticulations(staveNote: StaveNote, note: Note): void { } } +// A : the held-note arc-over-dot. Default placement is above +// (vexflow "a@a"); type="inverted" mirrors it below ("a@u"). Unlike articulations, +// the side is the fermata's type, not the stem direction. +function addFermata(staveNote: StaveNote, note: Note): void { + const fermata = note.child('notations')?.child('fermata'); + if (!fermata) { + return; + } + const inverted = fermata.getAttribute('type') === 'inverted'; + const articulation = new Articulation(inverted ? 'a@u' : 'a@a'); + // Vexflow defaults every Articulation to ABOVE; the below-shaped glyph also needs the + // BELOW position so it sits under the note instead of floating over it. + articulation.setPosition( + inverted ? Modifier.Position.BELOW : Modifier.Position.ABOVE, + ); + staveNote.addModifier(articulation); +} + // Honor an explicit up|down (e.g. to separate two voices on one stave). // Absent, auto-pick from staff position (see vexflowChord's auto_stem). function applyStem(staveNote: StaveNote, note: Note): void { @@ -157,6 +195,17 @@ function addAccidentals(staveNote: StaveNote, chord: Chord): void { }); } +// Wrap each parenthesized chord member's notehead in round brackets. Per-member (like +// accidentals) rather than Parenthesis.buildAndAttach, which brackets every notehead. +function addParentheses(staveNote: StaveNote, chord: Chord): void { + chord.notes.forEach((note, i) => { + if (isParenthesized(note)) { + staveNote.addModifier(new Parenthesis(Modifier.Position.LEFT), i); + staveNote.addModifier(new Parenthesis(Modifier.Position.RIGHT), i); + } + }); +} + // Build a vexflow StaveNote for one chord (a lead note plus any members; // a single note is a one-member chord). Rests render as a centered rest glyph; // grace notes (no ) become small GraceNotes — slashed for an @@ -206,9 +255,11 @@ export function vexflowChord( autoStem: !lead.stem, }); addAccidentals(staveNote, chord); + addParentheses(staveNote, chord); addDots(staveNote, lead); applyStem(staveNote, lead); addArticulations(staveNote, lead); + addFermata(staveNote, lead); return staveNote; } @@ -277,9 +328,23 @@ function addTabModifiers(tabNote: TabNote, lead: Note): void { function tabPositions(chord: Chord) { const toPosition = (note: Chord['notes'][number]) => { const fret = note.fret ?? 0; + // A dead note (x) prints "X" on its string instead of a fret; + // a harmonic angle-brackets its fret. vexflow renders the fret string verbatim. + let fretText: string | number = fret; + if (isXNotehead(note)) { + // A dingbat "✕" (U+2715), not an ASCII "X": the notation font (Bravura) draws an + // ornate glyph for "X" and would win the CSS font fallthrough, but it lacks this + // dingbat, so the fret falls through to the plain text font like the fret digits do. + fretText = '✕'; + } else if (isHarmonic(note)) { + fretText = `<${fret}>`; + } else if (isParenthesized(note)) { + // A ghost/optional fret reads as "(2)". vexflow renders the fret string verbatim. + fretText = `(${fret})`; + } return { str: note.string ?? 1, - fret: isHarmonic(note) ? `<${fret}>` : fret, + fret: fretText, }; }; const struck = chord.notes.filter((note) => !isTieStop(note)); @@ -529,6 +594,62 @@ export function tempoOf(measure: Measure): TempoMark | null { return null; } +// A measure's text directives (e.g. "ritardando", +// "dolce"), in document order. These are free-text expressions printed above the stave. +// ponytail: placement and font-style attributes ignored — every words direction prints +// above the staff in italics; add a placement/style field if a fixture needs below or +// upright words. +export function wordsOf(measure: Measure): string[] { + const out: string[] = []; + for (const direction of measure.directions) { + const words = direction.child('direction-type')?.child('words')?.text; + if (words) { + out.push(words); + } + } + return out; +} + +// MusicXML / semitones -> the printed accidental sign, using the +// real Unicode music symbols (♯ ♭ ♮). 0 prints an explicit natural — rare in a root, but +// MusicXML carries it when the chart wants the sign drawn. An absent maps to +// nothing (no sign), so plain roots stay bare. +const HARMONY_ALTER: Record = { '1': '♯', '-1': '♭', '0': '♮' }; + +// A 's printed chord symbol, e.g. "G7", "C", "F♯m": the plus +// any sign, then the suffix MusicXML carries for +// exactly this (a major triad's text is empty, so it prints the bare root). +// ponytail: a without a text attribute prints just the root — no +// kind-name->suffix table (major-seventh -> "maj7", …); add one if a fixture needs it. +function harmonyText(harmony: MElement): string { + const root = harmony.child('root'); + const step = root?.child('root-step')?.text ?? ''; + const alter = root?.child('root-alter')?.text ?? ''; + const kind = harmony.child('kind')?.getAttribute('text') ?? ''; + return step + (HARMONY_ALTER[alter] ?? '') + kind; +} + +// Each in a measure paired with the lead note it sits above. +// elements are interleaved with s in document order and apply to the note +// that follows, so walk the measure's children tracking the pending harmony and +// bind it to the next chord lead (the next non- note). +export function harmoniesOf(measure: Measure): { lead: Note; text: string }[] { + const harmonies: { lead: Note; text: string }[] = []; + let pending: MElement | null = null; + for (const child of measure.children) { + if (child instanceof MElement && child.tag === 'harmony') { + pending = child; + } else if (pending && child instanceof Note && !child.isChordMember) { + const text = harmonyText(pending); + if (text) { + harmonies.push({ lead: child, text }); + } + pending = null; + } + } + return harmonies; +} + // The beat a measure's voices run out to: the latest onset+duration across them. // Voices that end before this (e.g. one silent on the final beat via ) // are padded out to it so every voice spans the same range — see the trailing diff --git a/src/spanners.ts b/src/spanners.ts index d15eb46e9..0756eb20e 100644 --- a/src/spanners.ts +++ b/src/spanners.ts @@ -170,10 +170,18 @@ export function buildTies( // share number "1", so partner() pairs every start to the chord's first // stop. Re-resolve to the same-pitch member so the tie hits the right // notehead. - const partnerNote = + let partnerNote = (tie.partner && samePitchMember(note, chordOf.get(tie.partner.note))) ?? tie.partner?.note; + // A chain-middle note carries both a tie stop and a tie start. When the + // exporter orders before on that note, mdom's + // document-order pairing matches the start to the note's OWN stop — a + // degenerate self-tie that draws nothing. Re-resolve to the next same-pitch + // note carrying a tie stop, so each link of the chain draws its own arc. + if (tie.tieType === 'start' && (!partnerNote || partnerNote === note)) { + partnerNote = nextTieStopMember(note, chords); + } const to = partnerNote && placement.get(partnerNote); if (tie.tieType !== 'start' || !from || !to) { continue; @@ -231,6 +239,29 @@ export function buildTies( return ties; } +// The next same-pitch note after `note` (in document order) that carries a tie stop. +// Used to recover the partner of a chain-middle tie start when mdom mis-pairs it to +// the note's own stop (see buildTies). null when the chain dangles past the score. +function nextTieStopMember(note: Note, chords: Chord[]): Note | undefined { + const p = note.pitch; + if (!p) { + return undefined; + } + const flat = chords.flatMap((chord) => chord.notes); + for (let i = flat.indexOf(note) + 1; i < flat.length; i++) { + const n = flat[i]; + if ( + n?.pitch?.step === p.step && + n.pitch?.octave === p.octave && + n.pitch?.alter === p.alter && + n.ties.some((t) => t.tieType === 'stop') + ) { + return n; + } + } + return undefined; +} + // The member of `chord` whose pitch matches `note` (a tie's two ends are always the // same pitch), or null when there's no chord or no match. function samePitchMember(note: Note, chord: Chord | undefined): Note | null { diff --git a/tests/integration/__data__/fermata.musicxml b/tests/integration/__data__/fermata.musicxml new file mode 100644 index 000000000..1a7a13643 --- /dev/null +++ b/tests/integration/__data__/fermata.musicxml @@ -0,0 +1,44 @@ + + + + + Music + + + + + + + 8 + + 1 + + G + 2 + + + + C5 + 32 + whole + + normal + + + + + + + C5 + 32 + whole + + normal + + + + + diff --git a/tests/integration/__data__/harmony.musicxml b/tests/integration/__data__/harmony.musicxml new file mode 100644 index 000000000..c506291c9 --- /dev/null +++ b/tests/integration/__data__/harmony.musicxml @@ -0,0 +1,265 @@ + + + + + Music + + + + + + 1 + + 1 + + G + 2 + + + + + C + + major + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + + + G + + dominant + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + + + F + 1 + + minor + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + + + D + + major + + + + C + 6 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + + + B + -1 + + major + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + + + B + 0 + + major + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + diff --git a/tests/integration/__data__/harmony_grace.musicxml b/tests/integration/__data__/harmony_grace.musicxml new file mode 100644 index 000000000..0651f123b --- /dev/null +++ b/tests/integration/__data__/harmony_grace.musicxml @@ -0,0 +1,70 @@ + + + + + Music + + + + + + 1 + + 1 + + G + 2 + + + + + C + + major + + + + + D + 5 + + eighth + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + diff --git a/tests/integration/__data__/measures_light_light.musicxml b/tests/integration/__data__/measures_light_light.musicxml new file mode 100644 index 000000000..52005542e --- /dev/null +++ b/tests/integration/__data__/measures_light_light.musicxml @@ -0,0 +1,39 @@ + + + + + Music + + + + + + 1 + + 1 + + G + 2 + + + + C5 + 4 + whole + + + light-light + + + + + C5 + 4 + whole + + + + diff --git a/tests/integration/__data__/notehead_parentheses.musicxml b/tests/integration/__data__/notehead_parentheses.musicxml new file mode 100644 index 000000000..93632a881 --- /dev/null +++ b/tests/integration/__data__/notehead_parentheses.musicxml @@ -0,0 +1,121 @@ + + + + + Guitar + + + Guitar + + + + + + + 2 + 1 + + G + 2 + + + + B4 + 2 + 1 + quarter + normal + + + B4 + 2 + 1 + quarter + + + G4 + 4 + 1 + half + normal + + + + B4 + 4 + 1 + half + normal + + + + D5 + 4 + 1 + half + normal + + + + + + + 2 + 1 + + TAB + 5 + + + 6 + + + + B4 + 2 + 1 + quarter + normal + 12 + + + B4 + 2 + 1 + quarter + 15 + + + G4 + 4 + 1 + half + normal + 30 + + + + B4 + 4 + 1 + half + normal + 20 + + + + D5 + 4 + 1 + half + normal + 12 + + + + diff --git a/tests/integration/__data__/notehead_x.musicxml b/tests/integration/__data__/notehead_x.musicxml new file mode 100644 index 000000000..ef18dc21a --- /dev/null +++ b/tests/integration/__data__/notehead_x.musicxml @@ -0,0 +1,404 @@ + + + + + Guitar + + + Guitar + + + + + + + 2 + 1 + + G + 2 + + + + B4 + 2 + 1 + quarter + x + + + B4 + 2 + 1 + quarter + x + + + B4 + 2 + 1 + quarter + x + + + B4 + 2 + 1 + quarter + x + + + + + B4 + 1 + 1 + eighth + x + begin + + + B4 + 1 + 1 + eighth + x + continue + + + B4 + 1 + 1 + eighth + x + continue + + + B4 + 1 + 1 + eighth + x + end + + + B4 + 1 + 1 + eighth + x + begin + + + B4 + 1 + 1 + eighth + x + continue + + + B4 + 1 + 1 + eighth + x + continue + + + B4 + 1 + 1 + eighth + x + end + + + + + G4 + 8 + 1 + whole + x + + + + B4 + 8 + 1 + whole + x + + + + D5 + 8 + 1 + whole + x + + + + + G4 + 1 + 1 + eighth + begin + + + A3 + 1 + 1 + eighth + x + continue + + + + D4 + 1 + 1 + eighth + x + + + G4 + 1 + 1 + eighth + continue + + + A3 + 1 + 1 + eighth + x + end + + + + D4 + 1 + 1 + eighth + x + + + G4 + 4 + 1 + half + + + + + + + 2 + 1 + + TAB + 5 + + + 6 + + + + B4 + 2 + 1 + quarter + x + 60 + + + B4 + 2 + 1 + quarter + x + 40 + + + B4 + 2 + 1 + quarter + x + 20 + + + B4 + 2 + 1 + quarter + x + 10 + + + + + B4 + 1 + 1 + eighth + x + 10 + + + B4 + 1 + 1 + eighth + x + 10 + + + B4 + 1 + 1 + eighth + x + 10 + + + B4 + 1 + 1 + eighth + x + 10 + + + B4 + 1 + 1 + eighth + x + 10 + + + B4 + 1 + 1 + eighth + x + 10 + + + B4 + 1 + 1 + eighth + x + 10 + + + B4 + 1 + 1 + eighth + x + 10 + + + + + G3 + 8 + 1 + whole + x + 30 + + + + B3 + 8 + 1 + whole + x + 20 + + + + E4 + 8 + 1 + whole + x + 10 + + + + + G4 + 1 + 1 + eighth + 30 + + + A3 + 1 + 1 + eighth + x + 20 + + + + D4 + 1 + 1 + eighth + x + 10 + + + G4 + 1 + 1 + eighth + 30 + + + A3 + 1 + 1 + eighth + x + 20 + + + + D4 + 1 + 1 + eighth + x + 10 + + + G4 + 4 + 1 + half + 30 + + + + diff --git a/tests/integration/__data__/tie_chain.musicxml b/tests/integration/__data__/tie_chain.musicxml new file mode 100644 index 000000000..5fc1e2d4e --- /dev/null +++ b/tests/integration/__data__/tie_chain.musicxml @@ -0,0 +1,94 @@ + + + + + Music + + + + + + + 96 + + 2 + major + + + 1 + + G + 2 + + + + E4 + 24 + 16th + begin + + + F14 + 72 + + eighth + + end + + + + F14 + 96 + + + quarter + + + + F14 + 96 + + quarter + + + + F14 + 24 + 16th + begin + + + + + + + G4 + 24 + 16th + continue + + + + + E4 + eighth + end + + + + + + + F14 + 48 + eighth + + + + + diff --git a/tests/integration/__data__/words.musicxml b/tests/integration/__data__/words.musicxml new file mode 100644 index 000000000..e046fbaf5 --- /dev/null +++ b/tests/integration/__data__/words.musicxml @@ -0,0 +1,100 @@ + + + + + Music + + + + + + 1 + + 1 + + G + 2 + + + + + *ritardando... + + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + B + 4 + + 1 + quarter + + + + + + *ritardando... + + + + + C + 6 + + 1 + quarter + + + + C + 6 + + 1 + quarter + + + + C + 6 + + 1 + quarter + + + + C + 6 + + 1 + quarter + + + + diff --git a/tests/integration/__screenshots__/beam_variations.png b/tests/integration/__screenshots__/beam_variations.png index c7bd2a3c5..42150b73e 100644 Binary files a/tests/integration/__screenshots__/beam_variations.png and b/tests/integration/__screenshots__/beam_variations.png differ diff --git a/tests/integration/__screenshots__/fermata.png b/tests/integration/__screenshots__/fermata.png new file mode 100644 index 000000000..fc012751f Binary files /dev/null and b/tests/integration/__screenshots__/fermata.png differ diff --git a/tests/integration/__screenshots__/harmony.png b/tests/integration/__screenshots__/harmony.png new file mode 100644 index 000000000..bd568c1f0 Binary files /dev/null and b/tests/integration/__screenshots__/harmony.png differ diff --git a/tests/integration/__screenshots__/harmony_grace.png b/tests/integration/__screenshots__/harmony_grace.png new file mode 100644 index 000000000..ec954e657 Binary files /dev/null and b/tests/integration/__screenshots__/harmony_grace.png differ diff --git a/tests/integration/__screenshots__/measure_numbering_every.png b/tests/integration/__screenshots__/measure_numbering_every.png index 0fb45268c..550cb88f1 100644 Binary files a/tests/integration/__screenshots__/measure_numbering_every.png and b/tests/integration/__screenshots__/measure_numbering_every.png differ diff --git a/tests/integration/__screenshots__/measure_numbering_every_2.png b/tests/integration/__screenshots__/measure_numbering_every_2.png index 4a2aff694..ca45f2508 100644 Binary files a/tests/integration/__screenshots__/measure_numbering_every_2.png and b/tests/integration/__screenshots__/measure_numbering_every_2.png differ diff --git a/tests/integration/__screenshots__/measure_numbering_every_3.png b/tests/integration/__screenshots__/measure_numbering_every_3.png index 939a63f9a..052bf6495 100644 Binary files a/tests/integration/__screenshots__/measure_numbering_every_3.png and b/tests/integration/__screenshots__/measure_numbering_every_3.png differ diff --git a/tests/integration/__screenshots__/measure_numbering_none.png b/tests/integration/__screenshots__/measure_numbering_none.png index ce65b773c..e243c7de7 100644 Binary files a/tests/integration/__screenshots__/measure_numbering_none.png and b/tests/integration/__screenshots__/measure_numbering_none.png differ diff --git a/tests/integration/__screenshots__/measures_light_light.png b/tests/integration/__screenshots__/measures_light_light.png new file mode 100644 index 000000000..fe5cd8cae Binary files /dev/null and b/tests/integration/__screenshots__/measures_light_light.png differ diff --git a/tests/integration/__screenshots__/note_density.png b/tests/integration/__screenshots__/note_density.png index cc30e7198..e34c684a5 100644 Binary files a/tests/integration/__screenshots__/note_density.png and b/tests/integration/__screenshots__/note_density.png differ diff --git a/tests/integration/__screenshots__/notehead_parentheses.png b/tests/integration/__screenshots__/notehead_parentheses.png new file mode 100644 index 000000000..a17d15479 Binary files /dev/null and b/tests/integration/__screenshots__/notehead_parentheses.png differ diff --git a/tests/integration/__screenshots__/notehead_x.png b/tests/integration/__screenshots__/notehead_x.png new file mode 100644 index 000000000..45ba4b66e Binary files /dev/null and b/tests/integration/__screenshots__/notehead_x.png differ diff --git a/tests/integration/__screenshots__/system_break.png b/tests/integration/__screenshots__/system_break.png index f76c857c4..13f57b810 100644 Binary files a/tests/integration/__screenshots__/system_break.png and b/tests/integration/__screenshots__/system_break.png differ diff --git a/tests/integration/__screenshots__/tab_hammer_pull_wrap.png b/tests/integration/__screenshots__/tab_hammer_pull_wrap.png index 9b82fd93f..28ae1af34 100644 Binary files a/tests/integration/__screenshots__/tab_hammer_pull_wrap.png and b/tests/integration/__screenshots__/tab_hammer_pull_wrap.png differ diff --git a/tests/integration/__screenshots__/tempo.png b/tests/integration/__screenshots__/tempo.png index f88c4be37..854f1bd95 100644 Binary files a/tests/integration/__screenshots__/tempo.png and b/tests/integration/__screenshots__/tempo.png differ diff --git a/tests/integration/__screenshots__/tie_chain.png b/tests/integration/__screenshots__/tie_chain.png new file mode 100644 index 000000000..9111ee23a Binary files /dev/null and b/tests/integration/__screenshots__/tie_chain.png differ diff --git a/tests/integration/__screenshots__/words.png b/tests/integration/__screenshots__/words.png new file mode 100644 index 000000000..2dc8a03dc Binary files /dev/null and b/tests/integration/__screenshots__/words.png differ diff --git a/tests/integration/render.test.ts b/tests/integration/render.test.ts index 098089e61..c1c0ef69f 100644 --- a/tests/integration/render.test.ts +++ b/tests/integration/render.test.ts @@ -150,6 +150,15 @@ const TEST_CASES = [ // up into the mark's default band, so the mark is lifted clear of the notehead. testCase('tempo.musicxml', 'tempo.png'), + // Treble stave, 4/4: a words direction from , drawn + // in italics above the staff at the first note's x. Four boring quarters per measure so + // only the directive and the first note's height vary. + // - M1: "*ritardando..." over B4 quarters (mid-staff, no collision) — the text sits one + // fixed gap above the staff. + // - M2: "*ritardando..." over a high first note (C6, two ledger lines above) that reaches + // up into the text's default band, so the text is lifted clear of the notehead. + testCase('words.musicxml', 'words.png'), + // Treble stave, 4/4: two measures split by a barline, each holding one whole note // (C5, same pitch in both). testCase('measures_two.musicxml', 'measures_two.png'), @@ -164,6 +173,12 @@ const TEST_CASES = [ // other measure end. testCase('measures_end_barline.musicxml', 'measures_end_barline.png'), + // Treble stave, 4/4, two whole-note measures (C5 in both). M1 carries an explicit + // right with light-light, so the divider between M1 + // and M2 renders as a thin double line instead of the default single line; M2 closes + // with the usual thin-thick end barline. + testCase('measures_light_light.musicxml', 'measures_light_light.png'), + // Beam variations across seven 4/4 measures. Wraps across systems. // - M1: simple beamed eighths in a small range. // - M2: beamed eighths leaping a wide range (steep beams, ledger lines above on @@ -197,6 +212,15 @@ const TEST_CASES = [ // ("tie from nothing"), rather than one line slanting down across the page. testCase('tie.musicxml', 'tie.png'), + // Treble stave, D major, 4/4: a three-note tie chain on F#4 — dotted-eighth -> quarter -> + // quarter — where the middle note carries both tie start and stop, so two arcs join end to + // end across the same pitch. The exporter orders this note's before its , which mdom's document-order pairing mis-matched to the note's OWN stop (a + // degenerate self-tie drawing nothing); buildTies re-resolves it so both links draw. Beat + // 1 leads in with a 16th E4 beamed to the dotted eighth; beats 3-4 add a below-placed slur + // over a 16th run (F#4-G4) into a slashed grace E4 that slurs into the closing F#4 eighth. + testCase('tie_chain.musicxml', 'tie_chain.png'), + // Treble stave, 4/4, one measure: two stem-up half-note chords (C5/E5/G5) with all three // members tied — the bottom member (C5) bows under (concave up) and the upper two (E5, G5) // bow over (concave down), sandwiching the chord while the over-arcs clear the up-stems. @@ -368,6 +392,35 @@ const TEST_CASES = [ // (strings 3/2/1, single-digit "<5>"). Watch the stacked brackets for vertical clashing. testCase('tab_harmonic.musicxml', 'tab_harmonic.png'), + // Notation stave over a 6-line TAB stave: X noteheads (x) for + // dead/muted notes. The notation stave draws a cross at each pitch (vexflow "/X2"); the tab + // stave prints "✕" in place of the fret on the matching string (src/notes.ts). No