diff --git a/src/rendering/score.ts b/src/rendering/score.ts index 3d6187ccb..bd2728482 100644 --- a/src/rendering/score.ts +++ b/src/rendering/score.ts @@ -165,11 +165,11 @@ export class Score { beam.vexflow.beam?.setContext(vfContext).draw(); }); - // Draw vexflow.StaveTie elements for slurs. + // Draw vexflow.Curve elements for slurs. spannersRendering.slurs - .flatMap((slur) => slur.vexflow.tie) - .forEach((vfStaveTie) => { - vfStaveTie.setContext(vfContext).draw(); + .flatMap((slur) => slur.vexflow.curve) + .forEach((vfCurve) => { + vfCurve?.setContext(vfContext).draw(); }); // Draw vexflow.StaveTie elements for ties. diff --git a/src/rendering/slur.ts b/src/rendering/slur.ts index 3ff8a39aa..6c1653da5 100644 --- a/src/rendering/slur.ts +++ b/src/rendering/slur.ts @@ -6,11 +6,13 @@ import { SpannerMap } from './spannermap'; import { SpannerData } from './types'; import { Address } from './address'; +const SLUR_PADDING_PER_NOTE = 20; + /** The result of rendering a slur. */ export type SlurRendering = { type: 'slur'; vexflow: { - tie: vexflow.StaveTie; + curve: vexflow.Curve | null; }; }; @@ -22,6 +24,9 @@ export type SlurFragment = { type: SlurFragmentType; number: number; address: Address; + musicXML: { + slur: musicxml.Slur; + }; vexflow: { note: vexflow.Note; keyIndex: number; @@ -31,6 +36,8 @@ export type SlurFragment = { /** The container for slurs. */ type SlurContainer = SpannerMap; +type CurveOpeningDirection = 'up' | 'down' | 'unknown'; + /** Represents a curved line that connects two or more different notes of varying pitch to indicate that they should be * played legato. */ @@ -61,6 +68,7 @@ export class Slur { type: slurType, number: slur.getNumber(), address: data.address, + musicXML: { slur }, vexflow: { note: data.vexflow.staveNote, keyIndex: data.keyIndex, @@ -95,33 +103,35 @@ export class Slur { } } + getExtraMeasureFragmentWidth(address: Address<'measurefragment'>): number { + return ( + this.fragments.filter((fragment) => fragment.address.isMemberOf('measurefragment', address)).length * + SLUR_PADDING_PER_NOTE + ); + } + /** Renders the slur. */ render(): SlurRendering { - const vfTieNotes: vexflow.TieNotes = {}; + const vfStartNote = this.fragments.find((fragment) => fragment.type === 'start')?.vexflow.note; + const vfStopNote = this.fragments.find((fragment) => fragment.type === 'stop')?.vexflow.note; - for (let index = 0; index < this.fragments.length; index++) { - const fragment = this.fragments[index]; - const isFirst = index === 0; - const isLast = index === this.fragments.length - 1; - - // Iterating is not necessary, but it is cleaner than dealing with nulls. - if (isFirst) { - vfTieNotes.firstNote = fragment.vexflow.note; - vfTieNotes.firstIndexes = [fragment.vexflow.keyIndex]; - } - if (isLast) { - vfTieNotes.lastNote = fragment.vexflow.note; - vfTieNotes.lastIndexes = [fragment.vexflow.keyIndex]; - } + if (!vfStartNote && !vfStopNote) { + return { + type: 'slur', + vexflow: { curve: null }, + }; } - const vfSlurDirection = this.getVfSlurDirection(); - const vfTie = new vexflow.StaveTie(vfTieNotes).setDirection(vfSlurDirection); + const vfCurveOptions = this.getVfCurveOptions({ vexflow: { startNote: vfStartNote, stopNote: vfStopNote } }); + + // Partial curves are allowed, but the types disallow it: + // https://github.com/0xfe/vexflow/blob/8ddc8fa1a6d304a879e73830919fa17f3a9bdef4/src/curve.ts#L87 + const vfCurve = new vexflow.Curve(vfStartNote as any, vfStopNote as any, vfCurveOptions); return { type: 'slur', vexflow: { - tie: vfTie, + curve: vfCurve, }, }; } @@ -130,12 +140,79 @@ export class Slur { return util.last(this.fragments)!; } - private getVfSlurDirection(): number { - const slurPlacement = this.getSlurPlacement(); - return conversions.fromAboveBelowToVexflowSlurDirection(slurPlacement); + private getVfCurveOptions(opts: { + vexflow: { startNote: vexflow.Note | undefined; stopNote: vexflow.Note | undefined }; + }): vexflow.CurveOptions { + const placement = this.getSlurPlacement(); + + const startStem = this.getStem(opts.vexflow.startNote); + const stopStem = this.getStem(opts.vexflow.stopNote); + + const startPosition = this.getVfCurvePosition(placement, startStem); + const stopPosition = this.getVfCurvePosition(placement, stopStem); + + const openingDirection = this.getCurveOpeningDirection(opts.vexflow.startNote, opts.vexflow.stopNote); + const invert = + (openingDirection === 'up' && placement === 'above') || (openingDirection === 'down' && placement === 'below'); + + return { + position: startPosition, + positionEnd: stopPosition, + invert, + }; + } + + private getVfCurvePosition( + placement: musicxml.AboveBelow, + stem: musicxml.Stem | undefined + ): vexflow.CurvePosition | undefined { + if (placement === 'above' && stem === 'up') { + return vexflow.CurvePosition.NEAR_TOP; + } + if (placement === 'above' && stem === 'down') { + return vexflow.CurvePosition.NEAR_HEAD; + } + if (placement === 'below' && stem === 'up') { + return vexflow.CurvePosition.NEAR_HEAD; + } + if (placement === 'below' && stem === 'down') { + return vexflow.CurvePosition.NEAR_TOP; + } + return undefined; + } + + private getCurveOpeningDirection( + startNote: vexflow.Note | undefined, + stopNote: vexflow.Note | undefined + ): CurveOpeningDirection { + let note: vexflow.Note; + + if (startNote && stopNote) { + note = stopNote; + } else if (startNote) { + note = startNote; + } else if (stopNote) { + note = stopNote; + } else { + return 'unknown'; + } + + switch (this.getStem(note)) { + case 'up': + return 'up'; + case 'down': + return 'down'; + default: + return 'unknown'; + } } private getSlurPlacement(): musicxml.AboveBelow { + const placement = this.fragments[0].musicXML.slur.getPlacement(); + if (placement) { + return placement; + } + const vfNote = util.first(this.fragments)?.vexflow.note; if (!vfNote) { return 'above'; @@ -166,7 +243,11 @@ export class Slur { } } - private getStem(vfNote: vexflow.Note): musicxml.Stem { + private getStem(vfNote: vexflow.Note | undefined): musicxml.Stem | undefined { + if (!vfNote) { + return undefined; + } + // Calling getStemDirection will throw if there is no stem. // https://github.com/0xfe/vexflow/blob/7e7eb97bf1580a31171302b3bd8165f057b692ba/src/stemmablenote.ts#L118 try { diff --git a/src/rendering/spanners.ts b/src/rendering/spanners.ts index fa7e92cc6..4e8a99922 100644 --- a/src/rendering/spanners.ts +++ b/src/rendering/spanners.ts @@ -37,7 +37,13 @@ export class Spanners { /** Returns the additional padding needed to accommodate some spanners. */ getExtraMeasureFragmentWidth(address: Address<'measurefragment'>): number { - return util.sum(this.tuplets.values().map((tuplet) => tuplet.getExtraMeasureFragmentWidth(address))); + const tupletExtraWidth = util.sum( + this.tuplets.values().map((tuplet) => tuplet.getExtraMeasureFragmentWidth(address)) + ); + + const slurExtraWidth = util.sum(this.slurs.values().map((slur) => slur.getExtraMeasureFragmentWidth(address))); + + return tupletExtraWidth + slurExtraWidth; } /** Extracts and processes all the spanners within the given data. */ diff --git a/tests/integration/__image_snapshots__/33a-Spanners_900px.png b/tests/integration/__image_snapshots__/33a-Spanners_900px.png index 35dd0597e..105d14833 100644 Binary files a/tests/integration/__image_snapshots__/33a-Spanners_900px.png and b/tests/integration/__image_snapshots__/33a-Spanners_900px.png differ diff --git a/tests/integration/__image_snapshots__/33c-Spanners-Slurs_900px.png b/tests/integration/__image_snapshots__/33c-Spanners-Slurs_900px.png new file mode 100644 index 000000000..6fe250758 Binary files /dev/null and b/tests/integration/__image_snapshots__/33c-Spanners-Slurs_900px.png differ diff --git a/tests/integration/lilypond.test.ts b/tests/integration/lilypond.test.ts index 5ba0589b0..b4f928dea 100644 --- a/tests/integration/lilypond.test.ts +++ b/tests/integration/lilypond.test.ts @@ -84,7 +84,7 @@ describe('lilypond', () => { // { filename: '32d-Arpeggio.musicxml', width: 900 }, { filename: '33a-Spanners.musicxml', width: 900 }, { filename: '33b-Spanners-Tie.musicxml', width: 900 }, - // { filename: '33c-Spanners-Slurs.musicxml', width: 900 }, + { filename: '33c-Spanners-Slurs.musicxml', width: 900 }, // { filename: '33d-Spanners-OctaveShifts.musicxml', width: 900 }, // { filename: '33e-Spanners-OctaveShifts-InvalidSize.musicxml', width: 900 }, // { filename: '33f-Trill-EndingOnGraceNote.musicxml', width: 900 },