diff --git a/src/musicxml/types.ts b/src/musicxml/types.ts index cb05ff7fc..01b7f9910 100644 --- a/src/musicxml/types.ts +++ b/src/musicxml/types.ts @@ -1,3 +1,6 @@ +import { Measure } from './measure'; +import { Part } from './part'; + /** * StaveLayout describes how a stave is positioned. * @@ -19,3 +22,9 @@ export type SystemLayout = { topSystemDistance: number | null; systemDistance: number | null; }; + +/** A part and measure. */ +export type PartMeasure = { + part: Part; + measure: Measure; +}; diff --git a/src/rendering/address.ts b/src/rendering/address.ts index 117b073db..2c44e6114 100644 --- a/src/rendering/address.ts +++ b/src/rendering/address.ts @@ -25,7 +25,6 @@ export class Address { private type: T; private id: symbol; private parent: Address | null; - private children: Address[]; private context: AddressContext; private constructor(opts: { type: T; id: symbol; parent: Address | null; context: AddressContext }) { @@ -33,7 +32,6 @@ export class Address { this.id = opts.id; this.parent = opts.parent; this.context = opts.context; - this.children = []; } /** Creates an address for a system. */ @@ -48,7 +46,6 @@ export class Address { ): Address { const id = Symbol(type); const address = new Address({ type, id, parent, context }); - parent?.children.push(address); return address; } @@ -101,15 +98,9 @@ export class Address { return voiceAddress.context?.voiceIndex; } - /** Creates an address for a part. */ - part(context: AddressContext<'part'>): Address<'part'> { - this.assertThisIsA('system'); - return Address.create('part', this, context); - } - /** Creates an address for a measure. */ measure(context: AddressContext<'measure'>): Address<'measure'> { - this.assertThisIsA('part'); + this.assertThisIsA('system'); return Address.create('measure', this, context); } @@ -119,9 +110,15 @@ export class Address { return Address.create('measurefragment', this, context); } + /** Creates an address for a part. */ + part(context: AddressContext<'part'>): Address<'part'> { + this.assertThisIsA('measurefragment'); + return Address.create('part', this, context); + } + /** Creates an address for a stave. */ stave(context: AddressContext<'stave'>): Address<'stave'> { - this.assertThisIsA('measurefragment'); + this.assertThisIsA('part'); return Address.create('stave', this, context); } diff --git a/src/rendering/division.ts b/src/rendering/division.ts index 6ef63c189..b1b40b791 100644 --- a/src/rendering/division.ts +++ b/src/rendering/division.ts @@ -12,6 +12,16 @@ import { Fraction } from '@/util'; export class Division { private constructor(private fraction: Fraction) {} + /** Creates an empty division. */ + static zero() { + return new Division(new Fraction(0)); + } + + /** Creates a division with the maximum safe value. */ + static max() { + return new Division(new Fraction(Number.MAX_SAFE_INTEGER)); + } + /** * Creates a Division. * @@ -28,6 +38,26 @@ export class Division { return this.fraction.isEquivalent(value.fraction); } + /** Returns if the other divisions is less than this. */ + isLessThan(value: Division): boolean { + return this.toBeats() < value.toBeats(); + } + + /** Returns if the other divisions is greater than this. */ + isGreaterThan(value: Division): boolean { + return this.toBeats() > value.toBeats(); + } + + /** Returns if the other divisions is less than or equal to this. */ + isLessThanOrEqualTo(value: Division): boolean { + return this.isLessThan(value) || this.isEqual(value); + } + + /** Returns if the other divisions is greater than or equal to this. */ + isGreaterThanOrEqualTo(value: Division): boolean { + return this.isGreaterThan(value) || this.isEqual(value); + } + /** Returns the sum as a new Division. */ add(value: Division) { const fraction = this.fraction.add(value.fraction); diff --git a/src/rendering/measure.ts b/src/rendering/measure.ts index da0e85ead..cac8d9c2e 100644 --- a/src/rendering/measure.ts +++ b/src/rendering/measure.ts @@ -1,25 +1,26 @@ import * as musicxml from '@/musicxml'; import * as util from '@/util'; import * as drawables from '@/drawables'; -import * as vexflow from 'vexflow'; -import * as conversions from './conversions'; import { Config } from './config'; -import { MeasureFragment, MeasureFragmentRendering } from './measurefragment'; -import { MeasureEntry, StaveSignature } from './stavesignature'; +import { PartScoped } from './types'; import { Address } from './address'; +import { MeasureFragment, MeasureFragmentRendering, MeasureFragmentWidth } from './measurefragment'; +import { MeasureEntry, StaveSignature } from './stavesignature'; +import { Division } from './division'; import { Spanners } from './spanners'; +import { PartName } from './partname'; +import { MeasureEntryIterator } from './measureentryiterator'; const MEASURE_LABEL_OFFSET_X = 0; const MEASURE_LABEL_OFFSET_Y = 24; const MEASURE_LABEL_COLOR = '#aaaaaa'; +const STAVE_CONNECTOR_BRACE_WIDTH = 16; + /** The result of rendering a Measure. */ export type MeasureRendering = { type: 'measure'; address: Address<'measure'>; - vexflow: { - staveConnectors: vexflow.StaveConnector[]; - }; index: number; label: drawables.Text; fragments: MeasureFragmentRendering[]; @@ -34,83 +35,127 @@ export type MeasureRendering = { export class Measure { private config: Config; private index: number; + private partIds: string[]; + private partNames: PartScoped[]; private musicXml: { - measure: musicxml.Measure; + measures: PartScoped[]; staveLayouts: musicxml.StaveLayout[]; }; - private leadingStaveSignature: StaveSignature; - - private staveCount: number; - private measureEntries: MeasureEntry[]; + private leadingStaveSignatures: PartScoped[]; + private entries: PartScoped[]; constructor(opts: { config: Config; index: number; + partIds: string[]; + partNames: PartScoped[]; musicXml: { - measure: musicxml.Measure; + measures: PartScoped[]; staveLayouts: musicxml.StaveLayout[]; }; - leadingStaveSignature: StaveSignature; - staveCount: number; - measureEntries: MeasureEntry[]; + leadingStaveSignatures: PartScoped[]; + entries: PartScoped[]; }) { this.config = opts.config; + this.partIds = opts.partIds; + this.partNames = opts.partNames; this.index = opts.index; this.musicXml = opts.musicXml; - this.leadingStaveSignature = opts.leadingStaveSignature; - this.staveCount = opts.staveCount; - this.measureEntries = opts.measureEntries; + this.leadingStaveSignatures = opts.leadingStaveSignatures; + this.entries = opts.entries; } - /** Returns the index of the measure. */ + /** Returns the absolute index of the measure. */ getIndex(): number { return this.index; } - /** Returns the minimum required width for the Measure. */ - getMinRequiredWidth(opts: { address: Address<'measure'>; previousMeasure: Measure | null }): number { - let sum = 0; + /** Returns the minimum required width for each measure fragment. */ + getMinRequiredFragmentWidths(opts: { + address: Address<'measure'>; + previousMeasure: Measure | null; + }): MeasureFragmentWidth[] { + const widths = new Array(); - util.forEachTriple(this.getFragments(), ([previousMeasureFragment, currentMeasureFragment], { isFirst }) => { + util.forEachTriple(this.getFragments(), ([previousFragment, currentFragment], { isFirst }) => { if (isFirst) { - previousMeasureFragment = util.last(opts.previousMeasure?.getFragments() ?? []); + previousFragment = util.last(opts.previousMeasure?.getFragments() ?? []); } - sum += currentMeasureFragment.getMinRequiredWidth({ - address: opts.address.measureFragment({ measureFragmentIndex: currentMeasureFragment.getIndex() }), - previousMeasureFragment, + widths.push({ + measureIndex: this.index, + measureFragmentIndex: currentFragment.getIndex(), + value: currentFragment.getMinRequiredWidth({ + address: opts.address.measureFragment({ measureFragmentIndex: currentFragment.getIndex() }), + previousMeasureFragment: previousFragment, + }), }); }); - return sum; + return widths; + } + + /** Returns the number of measures the multi rest is active for. 0 means there's no multi rest. */ + getMultiRestCount(): number { + return util.sum(this.getFragments().map((fragment) => fragment.getMultiRestCount())); } - /** Returns the top padding required for the Measure. */ + /** Returns the top padding for the measure. */ getTopPadding(): number { return util.max(this.getFragments().map((fragment) => fragment.getTopPadding())); } - /** Returns the number of measures the multi rest is active for. 0 means there's no multi rest. */ - getMultiRestCount(): number { - return util.sum(this.getFragments().map((fragment) => fragment.getMultiRestCount())); + /** Returns how much to offset the measure by. */ + getStaveOffsetX(opts: { address: Address<'measure'> }): number { + let result = 0; + + const isFirstSystem = opts.address.getSystemIndex() === 0; + const isFirstMeasure = opts.address.getMeasureIndex() === 0; + const isFirstSystemMeasure = opts.address.getSystemMeasureIndex() === 0; + + const hasMultipleStaves = this.leadingStaveSignatures.some( + (staveSignature) => staveSignature.value.getStaveCount() > 1 + ); + if (isFirstSystemMeasure && hasMultipleStaves) { + result += STAVE_CONNECTOR_BRACE_WIDTH; + } + + if (isFirstSystem && isFirstMeasure) { + result += util.max(this.partNames.map((partName) => partName.value.getWidth())); + } + + return result; } - /** Renders the Measure. */ + /** Returns the width of the end barline. */ + getEndBarlineWidth(): number { + return this.getEndBarStyle() === 'none' ? 0 : 1; + } + + /** Renders the measure. */ render(opts: { x: number; y: number; - showLabel: boolean; + fragmentWidths: MeasureFragmentWidth[]; address: Address<'measure'>; spanners: Spanners; - systemCount: number; - targetSystemWidth: number; - minRequiredSystemWidth: number; previousMeasure: Measure | null; nextMeasure: Measure | null; }): MeasureRendering { const fragmentRenderings = new Array(); - let x = opts.x; - let width = 0; + const staveOffsetX = this.getStaveOffsetX({ address: opts.address }); + + let x = opts.x + staveOffsetX; + const y = opts.y; + + const label = new drawables.Text({ + content: this.getLabelTextContent(), + italic: true, + x: x + MEASURE_LABEL_OFFSET_X, + y: y + MEASURE_LABEL_OFFSET_Y, + color: MEASURE_LABEL_COLOR, + size: this.config.MEASURE_NUMBER_FONT_SIZE, + }); util.forEachTriple( this.getFragments(), @@ -122,194 +167,194 @@ export class Measure { nextFragment = util.first(opts.nextMeasure?.getFragments() ?? []); } + const width = opts.fragmentWidths.find( + ({ measureFragmentIndex }) => measureFragmentIndex === currentFragment.getIndex() + ); + if (!width) { + const address = opts.address.toDebugString(); + const widths = JSON.stringify(opts.fragmentWidths); + throw new Error(`Width not found for measure fragment: ${address}, got: ${widths}`); + } + const fragmentRendering = currentFragment.render({ x, - y: opts.y, + y, + width, address: opts.address.measureFragment({ measureFragmentIndex: currentFragment.getIndex() }), - systemCount: opts.systemCount, - minRequiredSystemWidth: opts.minRequiredSystemWidth, - targetSystemWidth: opts.targetSystemWidth, + spanners: opts.spanners, previousMeasureFragment: previousFragment, nextMeasureFragment: nextFragment, - spanners: opts.spanners, }); fragmentRenderings.push(fragmentRendering); x += fragmentRendering.width; - width += fragmentRendering.width; } ); - const vfStaveConnectors = new Array(); - - const staveRenderings = util.first(fragmentRenderings)?.staves ?? []; - if (staveRenderings.length > 1) { - const topStave = util.first(staveRenderings)!; - const bottomStave = util.last(staveRenderings)!; - - const begginingStaveConnectorType = conversions.fromBarlineTypeToBeginningStaveConnectorType( - topStave.vexflow.beginningBarlineType - ); - vfStaveConnectors.push( - new vexflow.StaveConnector(topStave.vexflow.stave, bottomStave.vexflow.stave).setType( - begginingStaveConnectorType - ) - ); - - const endStaveConnectorType = conversions.fromBarlineTypeToEndingStaveConnectorType( - topStave.vexflow.endBarlineType - ); - vfStaveConnectors.push( - new vexflow.StaveConnector(topStave.vexflow.stave, bottomStave.vexflow.stave).setType(endStaveConnectorType) - ); - } - - const label = new drawables.Text({ - content: opts.showLabel ? this.getLabel() : '', - italic: true, - x: opts.x + MEASURE_LABEL_OFFSET_X, - y: opts.y + MEASURE_LABEL_OFFSET_Y, - color: MEASURE_LABEL_COLOR, - size: this.config.MEASURE_NUMBER_FONT_SIZE, - }); - return { type: 'measure', address: opts.address, - vexflow: { - staveConnectors: vfStaveConnectors, - }, + fragments: fragmentRenderings, index: this.index, label, - fragments: fragmentRenderings, - width, + width: staveOffsetX + util.sum(fragmentRenderings.map((fragment) => fragment.width)), }; } @util.memoize() private getFragments(): MeasureFragment[] { - const fragments = new Array(); - - const measureIndex = this.index; - - const beginningBarStyle = - this.musicXml.measure - .getBarlines() - .find((barline) => barline.getLocation() === 'left') - ?.getBarStyle() ?? 'regular'; - - const endBarStyle = - this.musicXml.measure - .getEndingMeasure() - .getBarlines() - .find((barline) => barline.getLocation() === 'right') - ?.getBarStyle() ?? 'regular'; - - let staveSignature = this.leadingStaveSignature; - let currentMeasureEntries = new Array(); - - const config = this.config; - const staveCount = this.staveCount; - const staveLayouts = this.musicXml.staveLayouts; - - let measureFragmentIndex = 0; - - function addFragment( - leadingStaveSignature: StaveSignature, - measureEntries: MeasureEntry[], - beginningBarStyle: musicxml.BarStyle, - endBarStyle: musicxml.BarStyle - ) { - const fragment = new MeasureFragment({ - config, - index: measureFragmentIndex++, - leadingStaveSignature, - beginningBarStyle: beginningBarStyle, - endBarStyle: endBarStyle, - staveCount, - staveLayouts, - measureEntries, - }); - fragments.push(fragment); + const result = new Array(); + + const beginningBarStyle = this.getBeginningBarStyle(); + const endBarStyle = this.getEndBarStyle(); + const boundaries = this.getFragmentBoundaries(); + + const iterators: Record = {}; + for (const partId of this.partIds) { + iterators[partId] = this.getMeasureEntryIterator(partId); + iterators[partId].next(); // initialize iterator } - for (let measureEntryIndex = 0; measureEntryIndex < this.measureEntries.length; measureEntryIndex++) { - const measureEntry = this.measureEntries[measureEntryIndex]; - const isLastMeasureEntry = measureEntryIndex === this.measureEntries.length - 1; - - if (measureEntry instanceof StaveSignature) { - const didStaveModifiersChange = measureEntry.getChangedStaveModifiers().length > 0; - if (didStaveModifiersChange && currentMeasureEntries.length > 0) { - // prettier-ignore - addFragment( - staveSignature, - currentMeasureEntries, - fragments.length === 0 ? beginningBarStyle : 'none', - 'none' - ); - currentMeasureEntries = []; + for (let index = 0; index < boundaries.length; index++) { + const boundary = boundaries[index]; + const isFirstBoundary = index === 0; + const isLastBoundary = index === boundaries.length - 1; + + const beginningBarStyles = new Array>(); + const endBarStyles = new Array>(); + const measureEntries = new Array>(); + const staveSignatures = new Array>(); + + for (const partId of this.partIds) { + const iterator = iterators[partId]; + + const staveSignature = iterator.getStaveSignature(); + + let iteration = iterator.peek(); + + while (!iteration.done && iteration.value.start.isLessThan(boundary)) { + measureEntries.push({ partId, value: iteration.value.entry }); + iteration = iterator.next(); + } + + if (measureEntries.length > 0) { + staveSignatures.push({ partId, value: staveSignature }); + + if (isFirstBoundary) { + beginningBarStyles.push({ partId, value: beginningBarStyle }); + } + if (isLastBoundary) { + endBarStyles.push({ partId, value: endBarStyle }); + } } + } - staveSignature = measureEntry; - } else if ( - measureEntry instanceof musicxml.Direction && - measureEntry.getTypes().some((directionType) => { - const content = directionType.getContent(); - return content.type === 'metronome' && content.metronome.isSupported(); - }) && - currentMeasureEntries.length > 0 - ) { - // prettier-ignore - addFragment( - staveSignature, - currentMeasureEntries, - fragments.length === 0 ? beginningBarStyle : 'none', - 'none' - ) - currentMeasureEntries = []; + // Ignore completely empty fragments. + if (!measureEntries.length && !beginningBarStyles.length && !endBarStyles.length) { + continue; } - currentMeasureEntries.push(measureEntry); - - if (isLastMeasureEntry) { - const nextStaveSignature = staveSignature?.getNext(); - const hasClefChangeAtMeasureBoundary = - nextStaveSignature?.getChangedStaveModifiers().includes('clef') && - nextStaveSignature?.getMeasureIndex() === measureIndex + 1 && - nextStaveSignature?.getMeasureEntryIndex() === 0; - - if (hasClefChangeAtMeasureBoundary) { - // prettier-ignore - addFragment( - staveSignature, - currentMeasureEntries, - fragments.length === 0 ? beginningBarStyle : 'none', - 'none', - ); - - // prettier-ignore - addFragment( - nextStaveSignature, - [nextStaveSignature], - 'none', - endBarStyle - ); - } else { - // prettier-ignore - addFragment( - staveSignature, - currentMeasureEntries, - fragments.length === 0 ? beginningBarStyle : 'none', - endBarStyle - ); + result.push( + new MeasureFragment({ + config: this.config, + index: result.length, + partIds: this.partIds, + partNames: this.partNames, + musicXml: { + staveLayouts: this.musicXml.staveLayouts, + beginningBarStyles, + endBarStyles, + }, + measureEntries, + staveSignatures, + }) + ); + } + + return result; + } + + private getLabelTextContent(): string { + const partId = util.first(this.partIds); + if (!partId) { + return ''; + } + + const measure = this.musicXml.measures.find((measure) => measure.partId === partId)?.value; + if (!measure) { + return ''; + } + + if (measure.isImplicit()) { + return ''; + } + + return measure.getNumber() || (this.index + 1).toString(); + } + + private getFragmentBoundaries(): Division[] { + const boundaries = new Array(); + + for (const partId of this.partIds) { + const iterator = this.getMeasureEntryIterator(partId); + + let iteration = iterator.next(); + + while (!iteration.done) { + if (iteration.value.fragmentation === 'new') { + boundaries.push(iteration.value.end); } + iteration = iterator.next(); } } - return fragments; + boundaries.push(Division.max()); + + const seen = new Set(); + const unique = new Array(); + for (const boundary of boundaries) { + const beats = boundary.toBeats(); + if (!seen.has(beats)) { + unique.push(boundary); + seen.add(beats); + } + } + + return util.sortBy(unique, (boundary) => boundary.toBeats()); + } + + private getBeginningBarStyle(): musicxml.BarStyle { + return ( + util.first( + this.musicXml.measures + .flatMap((measure) => measure.value.getBarlines()) + .filter((barline) => barline.getLocation() === 'left') + .map((barline) => barline.getBarStyle()) + ) ?? 'regular' + ); } - private getLabel(): string { - return this.musicXml.measure.isImplicit() ? '' : this.musicXml.measure.getNumber() || (this.index + 1).toString(); + private getEndBarStyle(): musicxml.BarStyle { + return ( + util.first( + this.musicXml.measures + .flatMap((measure) => measure.value.getBarlines()) + .filter((barline) => barline.getLocation() === 'right') + .map((barline) => barline.getBarStyle()) + ) ?? 'regular' + ); + } + + private getMeasureEntryIterator(partId: string): MeasureEntryIterator { + const entries = this.entries.filter((entry) => entry.partId === partId).map((entry) => entry.value); + + const staveSignature = this.leadingStaveSignatures.find( + (staveSignature) => staveSignature.partId === partId + )?.value; + if (!staveSignature) { + throw new Error(`Stave signature not found for part: ${partId}`); + } + + return new MeasureEntryIterator({ entries, staveSignature }); } } diff --git a/src/rendering/measureentryiterator.ts b/src/rendering/measureentryiterator.ts new file mode 100644 index 000000000..527303b63 --- /dev/null +++ b/src/rendering/measureentryiterator.ts @@ -0,0 +1,99 @@ +import * as musicxml from '@/musicxml'; +import { Division } from './division'; +import { MeasureEntry, StaveSignature } from './stavesignature'; + +export type MeasureEntryFragmentation = 'none' | 'new'; + +/** An iteration value of the iterator. */ +export type MeasureEntryIteration = + | { + done: true; + value: null; + } + | { + done: false; + value: { + entry: MeasureEntry; + start: Division; + end: Division; + fragmentation: MeasureEntryFragmentation; + }; + }; + +/** Iterates over an array of measure entries, accounting for the active stave signature and divisions. */ +export class MeasureEntryIterator { + private entries: MeasureEntry[]; + private index: number; + private staveSignature: StaveSignature; + private iteration?: MeasureEntryIteration; + + constructor(opts: { entries: MeasureEntry[]; staveSignature: StaveSignature }) { + this.entries = opts.entries; + this.index = -1; + this.staveSignature = opts.staveSignature; + } + + /** Returns the current stave signature of the iterator. */ + getStaveSignature(): StaveSignature { + return this.staveSignature; + } + + /** Returns the current iteration of the iterator or throws if there isn't one. */ + peek(): MeasureEntryIteration { + if (typeof this.iteration === 'undefined') { + throw new Error('must initialize before peeking'); + } + return this.iteration; + } + + /** Moves the cursor to the next iteration or throws if there isn't one. */ + next(): MeasureEntryIteration { + if (this.index >= this.entries.length) { + return this.update({ done: true, value: null }); + } + + const entry = this.entries[this.index++]; + + let duration = 0; + + if (entry instanceof StaveSignature) { + this.staveSignature = entry; + } + + if (entry instanceof musicxml.Note) { + duration = entry.getDuration(); + } + + if (entry instanceof musicxml.Backup) { + duration = -entry.getDuration(); + } + + if (entry instanceof musicxml.Forward) { + duration = entry.getDuration(); + } + + const quarterNoteDivisions = this.staveSignature.getQuarterNoteDivisions(); + const start = this.iteration?.value?.end ?? Division.zero(); + const end = start.add(Division.of(duration, quarterNoteDivisions)); + + const fragmentation = entry instanceof StaveSignature || this.isSupportedMetronome(entry) ? 'new' : 'none'; + + return this.update({ done: false, value: { entry, start, end, fragmentation } }); + } + + /** Syntactic sugar for setting iteration and returning in the same expression. */ + private update(iteration: MeasureEntryIteration): MeasureEntryIteration { + this.iteration = iteration; + return iteration; + } + + private isSupportedMetronome(entry: MeasureEntry): boolean { + return ( + entry instanceof musicxml.Direction && + entry + .getTypes() + .map((directionType) => directionType.getContent()) + .some((content) => content.type === 'metronome' && content.metronome.isSupported()) + ); + } +} diff --git a/src/rendering/measurefragment.ts b/src/rendering/measurefragment.ts index 507ea2e0a..19c987caa 100644 --- a/src/rendering/measurefragment.ts +++ b/src/rendering/measurefragment.ts @@ -1,19 +1,32 @@ -import { Config } from './config'; -import { Stave, StaveModifier, StaveRendering } from './stave'; import * as musicxml from '@/musicxml'; import * as util from '@/util'; +import * as vexflow from 'vexflow'; +import * as conversions from './conversions'; +import { Config } from './config'; import { MeasureEntry, StaveSignature } from './stavesignature'; +import { PartScoped } from './types'; import { Address } from './address'; +import { Part, PartRendering } from './part'; +import { Chorus, ChorusRendering } from './chorus'; import { Spanners } from './spanners'; - -const STAVE_SIGNATURE_ONLY_MEASURE_FRAGMENT_PADDING = 8; +import { StaveModifier } from './stave'; +import { PartName } from './partname'; +import { MultiRest } from './multirest'; /** The result of rendering a measure fragment. */ export type MeasureFragmentRendering = { type: 'measurefragment'; address: Address<'measurefragment'>; - staves: StaveRendering[]; + parts: PartRendering[]; width: number; + vexflow: { staveConnectors: vexflow.StaveConnector[] }; +}; + +/** The width of a measure fragment. */ +export type MeasureFragmentWidth = { + measureIndex: number; + measureFragmentIndex: number; + value: number; }; /** @@ -21,38 +34,45 @@ export type MeasureFragmentRendering = { * * A measure fragment is necessary when stave modifiers change. It is not a formal musical concept, and it is moreso an * outcome of vexflow's Stave implementation. + * + * Measure fragments format all measure parts against the first stave. */ export class MeasureFragment { private config: Config; private index: number; - private leadingStaveSignature: StaveSignature; - private measureEntries: MeasureEntry[]; - private staveLayouts: musicxml.StaveLayout[]; - private staveCount: number; - private beginningBarStyle: musicxml.BarStyle; - private endBarStyle: musicxml.BarStyle; + private partIds: string[]; + private partNames: PartScoped[]; + private musicXml: { + staveLayouts: musicxml.StaveLayout[]; + beginningBarStyles: PartScoped[]; + endBarStyles: PartScoped[]; + }; + private measureEntries: PartScoped[]; + private staveSignatures: PartScoped[]; constructor(opts: { config: Config; index: number; - leadingStaveSignature: StaveSignature; - measureEntries: MeasureEntry[]; - staveLayouts: musicxml.StaveLayout[]; - staveCount: number; - beginningBarStyle: musicxml.BarStyle; - endBarStyle: musicxml.BarStyle; + partIds: string[]; + partNames: PartScoped[]; + musicXml: { + staveLayouts: musicxml.StaveLayout[]; + beginningBarStyles: PartScoped[]; + endBarStyles: PartScoped[]; + }; + measureEntries: PartScoped[]; + staveSignatures: PartScoped[]; }) { this.config = opts.config; this.index = opts.index; - this.leadingStaveSignature = opts.leadingStaveSignature; + this.partIds = opts.partIds; + this.partNames = opts.partNames; + this.musicXml = opts.musicXml; this.measureEntries = opts.measureEntries; - this.staveLayouts = opts.staveLayouts; - this.staveCount = opts.staveCount; - this.beginningBarStyle = opts.beginningBarStyle; - this.endBarStyle = opts.endBarStyle; + this.staveSignatures = opts.staveSignatures; } - /** Returns the index of the measure fragment within the measure. */ + /** Returns the index of the measure fragment, which is relative to its parent measure. */ getIndex(): number { return this.index; } @@ -62,160 +82,259 @@ export class MeasureFragment { address: Address<'measurefragment'>; previousMeasureFragment: MeasureFragment | null; }): number { - const staveModifiers = this.getStaveModifiers({ - address: opts.address, - previousMeasureFragment: opts.previousMeasureFragment, - }); - const staveModifiersWidth = this.getStaveModifiersWidth(Array.from(staveModifiers)); + const address = opts.address; + const previousMeasureFragment = opts.previousMeasureFragment; - return this.getMinJustifyWidth(opts.address) + staveModifiersWidth + this.getRightPadding(); + return ( + this.getStaveModifiersWidth({ address, previousMeasureFragment }) + + this.getMinVoiceJustifyWidth({ address }) + + this.getNonVoiceWidth() + ); } - /** Returns the top padding for the measure fragment. */ + /** Returns the top padding of the fragment. */ getTopPadding(): number { - return util.max(this.getStaves().map((stave) => stave.getTopPadding())); + return util.max(this.getParts().map((part) => part.getTopPadding())); } + /** Returns the number of measures the multi rest is active for. 0 means there's no multi rest. */ getMultiRestCount(): number { - // TODO: One stave could be a multi measure rest, while another one could have voices. - return util.max(this.getStaves().map((stave) => stave.getMultiRestCount())); + // TODO: One stave could be a multi measure rest, while another one could have voices. Handle the discrepancy using + // whole measure rests. + return util.max(this.getParts().map((part) => part.getMultiRestCount())); } - /** Renders the MeasureFragment. */ + /** Renders the measure fragment. */ render(opts: { x: number; y: number; address: Address<'measurefragment'>; spanners: Spanners; - systemCount: number; - targetSystemWidth: number; - minRequiredSystemWidth: number; + width: MeasureFragmentWidth; previousMeasureFragment: MeasureFragment | null; nextMeasureFragment: MeasureFragment | null; }): MeasureFragmentRendering { - const staveRenderings = new Array(); - - const isLastSystem = opts.address.getSystemIndex() === opts.systemCount - 1; - const width = isLastSystem - ? this.getMinRequiredWidth({ - address: opts.address, - previousMeasureFragment: opts.previousMeasureFragment, - }) - : this.getSystemFitWidth({ - address: opts.address, - minRequiredSystemWidth: opts.minRequiredSystemWidth, - targetSystemWidth: opts.targetSystemWidth, - previousMeasureFragment: opts.previousMeasureFragment, - }); + const partRenderings = new Array(); + const x = opts.x; let y = opts.y; - const staveModifiers = this.getStaveModifiers({ + const beginningStaveModifiers = this.getBeginningStaveModifiers({ address: opts.address, previousMeasureFragment: opts.previousMeasureFragment, }); + const endStaveModifiers = this.getEndStaveModifiers(); - // Render staves. - util.forEachTriple(this.getStaves(), ([previousStave, currentStave, nextStave], { isFirst, isLast }) => { - if (isFirst) { - previousStave = util.last(opts.previousMeasureFragment?.getStaves() ?? []); - } - if (isLast) { - nextStave = util.first(opts.nextMeasureFragment?.getStaves() ?? []); - } + const vfFormatter = new vexflow.Formatter(); + const vfStaveConnectors = new Array(); + + const previousParts = opts.previousMeasureFragment?.getParts() ?? []; + const currentParts = this.getParts(); + const nextParts = opts.nextMeasureFragment?.getParts() ?? []; - const staveRendering = currentStave.render({ - x: opts.x, + for (let partIndex = 0; partIndex < currentParts.length; partIndex++) { + const previousPart = previousParts[partIndex] ?? null; + const currentPart = currentParts[partIndex]; + const nextPart = nextParts[partIndex] ?? null; + + const partId = currentPart.getId(); + + const partRendering = currentPart.render({ + x, y, - address: opts.address.stave({ staveNumber: currentStave.getNumber() }), + vexflow: { formatter: vfFormatter }, + address: opts.address.part({ partId: currentPart.getId() }), spanners: opts.spanners, - width, - modifiers: staveModifiers, - previousStave, - nextStave, + nextPart, + previousPart, + beginningStaveModifiers, + endStaveModifiers, + width: opts.width.value, }); - staveRenderings.push(staveRendering); - const staveDistance = - this.staveLayouts.find((staveLayout) => staveLayout.staveNumber === staveRendering.staveNumber) - ?.staveDistance ?? this.config.DEFAULT_STAVE_DISTANCE; + partRenderings.push(partRendering); + + const isFirstSystemMeasure = opts.address.getSystemMeasureIndex() === 0; + const isFirstMeasureFragment = this.index === 0; + + if (partRendering.staves.length > 1) { + const topStave = util.first(partRendering.staves)!.vexflow.stave; + const bottomStave = util.last(partRendering.staves)!.vexflow.stave; + + if (isFirstSystemMeasure && isFirstMeasureFragment) { + vfStaveConnectors.push(new vexflow.StaveConnector(topStave, bottomStave).setType('brace')); + } + + const beginningBarStyle = this.musicXml.beginningBarStyles.find( + (barStyle) => barStyle.partId === partId + )?.value; + if (beginningBarStyle) { + const beginningBarlineType = conversions.fromBarStyleToBarlineType(beginningBarStyle); + const beginningStaveConnectorType = + conversions.fromBarlineTypeToBeginningStaveConnectorType(beginningBarlineType); + vfStaveConnectors.push( + new vexflow.StaveConnector(topStave, bottomStave).setType(beginningStaveConnectorType) + ); + } + + const endBarStyle = this.musicXml.endBarStyles.find((barStyle) => barStyle.partId === partId)?.value; + if (endBarStyle) { + const endBarlineType = conversions.fromBarStyleToBarlineType(endBarStyle); + const endStaveConnectorType = conversions.fromBarlineTypeToEndingStaveConnectorType(endBarlineType); + vfStaveConnectors.push(new vexflow.StaveConnector(topStave, bottomStave).setType(endStaveConnectorType)); + } + } - y += staveDistance; - }); + y += partRendering.height + this.config.PART_DISTANCE; + } - return { - type: 'measurefragment', - address: opts.address, - staves: staveRenderings, - width, - }; - } + if (this.index === 0 && partRenderings.length > 0) { + const topPart = util.first(partRenderings)!; + const bottomPart = util.last(partRenderings)!; - @util.memoize() - private getStaves(): Stave[] { - const staves = new Array(this.staveCount); + const vfTopStave = util.first(topPart.staves)?.vexflow.stave; + const vfBottomStave = util.last(bottomPart.staves)?.vexflow.stave; - for (let staveIndex = 0; staveIndex < this.staveCount; staveIndex++) { - const staveNumber = staveIndex + 1; + if (vfTopStave && vfBottomStave) { + vfStaveConnectors.push(new vexflow.StaveConnector(vfTopStave, vfBottomStave).setType('singleLeft')); + } + } - staves[staveIndex] = new Stave({ - config: this.config, - staveSignature: this.leadingStaveSignature, - number: staveNumber, - beginningBarStyle: this.beginningBarStyle, - endBarStyle: this.endBarStyle, - measureEntries: this.measureEntries.filter((entry) => { - if (entry instanceof musicxml.Note) { - return entry.getStaveNumber() === staveNumber; - } - return true; - }), - }); + const vfStave = util.first(partRenderings)?.staves[0]?.vexflow.stave ?? null; + const vfVoices = partRenderings + .flatMap((partRendering) => partRendering.staves) + .map((stave) => stave.entry) + .filter((entry): entry is ChorusRendering => entry.type === 'chorus') + .flatMap((chorusRendering) => chorusRendering.voices) + .map((voice) => voice.vexflow.voice); + if (vfStave && vfVoices.some((vfVoice) => vfVoice.getTickables().length > 0)) { + vfFormatter.formatToStave(vfVoices, vfStave); } - return staves; + return { + type: 'measurefragment', + address: opts.address, + parts: partRenderings, + width: opts.width.value, + vexflow: { staveConnectors: vfStaveConnectors }, + }; } - /** Returns the minimum justify width. */ @util.memoize() - private getMinJustifyWidth(address: Address<'measurefragment'>): number { - return util.max( - this.getStaves().map((stave) => stave.getMinJustifyWidth(address.stave({ staveNumber: stave.getNumber() }))) - ); - } + private getParts(): Part[] { + return this.partIds.map((partId) => { + const measureEntries = this.measureEntries + .filter((measureEntry) => measureEntry.partId === partId) + .map((measureEntry) => measureEntry.value); + + const staveSignature = this.staveSignatures.find((staveSignature) => staveSignature.partId === partId)?.value; + if (!staveSignature) { + throw new Error(`Could not find stave signature for part ${partId}`); + } - /** Returns the right padding of the measure fragment. */ - private getRightPadding(): number { - let padding = 0; + const beginningBarStyle = + this.musicXml.beginningBarStyles.find((barStyle) => barStyle.partId === partId)?.value ?? 'none'; - if (this.measureEntries.length === 1 && this.measureEntries[0] instanceof StaveSignature) { - padding += STAVE_SIGNATURE_ONLY_MEASURE_FRAGMENT_PADDING; - } + const endBarStyle = this.musicXml.endBarStyles.find((barStyle) => barStyle.partId === partId)?.value ?? 'none'; + + const partName = this.partNames.find((partName) => partName.partId === partId)?.value ?? null; + if (!partName) { + throw new Error(`Could not find part name for part ${partId}`); + } - return padding; + return new Part({ + config: this.config, + id: partId, + name: partName, + musicXml: { + staveLayouts: this.musicXml.staveLayouts, + beginningBarStyle, + endBarStyle, + }, + measureEntries, + staveSignature, + }); + }); } - /** Returns the width needed to stretch to fit the target width of the System. */ - private getSystemFitWidth(opts: { + private getStaveModifiersWidth(opts: { address: Address<'measurefragment'>; - targetSystemWidth: number; - minRequiredSystemWidth: number; previousMeasureFragment: MeasureFragment | null; }): number { - const minRequiredWidth = this.getMinRequiredWidth({ + const beginningStaveModifiers = this.getBeginningStaveModifiers({ address: opts.address, previousMeasureFragment: opts.previousMeasureFragment, }); - const widthDeficit = opts.targetSystemWidth - opts.minRequiredSystemWidth; - const widthFraction = minRequiredWidth / opts.minRequiredSystemWidth; - const widthDelta = widthDeficit * widthFraction; + const endStaveModifiers = this.getEndStaveModifiers(); + + return util.max( + this.getParts() + .flatMap((part) => part.getStaves()) + .map((stave) => { + const staveNumber = stave.getNumber(); + const staveSignature = stave.getSignature(); + const nextStaveSignature = staveSignature.getNext(); + return { + current: { + clef: staveSignature.getClef(staveNumber), + keySignature: staveSignature.getKeySignature(staveNumber), + timeSignature: staveSignature.getTimeSignature(staveNumber), + }, + next: { + clef: nextStaveSignature?.getClef(staveNumber) ?? null, + }, + }; + }) + .map(({ current, next }) => { + let width = 0; + + if (beginningStaveModifiers.includes('clef')) { + width += current.clef.getWidth(); + } + if (beginningStaveModifiers.includes('keySignature')) { + width += current.keySignature.getWidth(); + } + if (beginningStaveModifiers.includes('timeSignature')) { + width += current.timeSignature.getWidth(); + } + + if (endStaveModifiers.includes('clef')) { + width += next.clef?.getWidth() ?? 0; + } - return minRequiredWidth + widthDelta; + return width; + }) + ); } - /** Returns what modifiers to render. */ - private getStaveModifiers(opts: { + /** Returns what modifiers to render at the end of the stave. */ + private getEndStaveModifiers(): StaveModifier[] { + const result = new Set(); + + for (const partId of this.partIds) { + const staveSignature = this.staveSignatures.find((staveSignature) => staveSignature.partId === partId)?.value; + if (!staveSignature) { + continue; + } + + const nextStaveSignature = staveSignature.getNext(); + if (!nextStaveSignature) { + continue; + } + + const isAtMeasureBoundary = staveSignature.isAtMeasureBoundary(); + const didClefChange = nextStaveSignature.getChangedStaveModifiers().includes('clef'); + if (isAtMeasureBoundary && didClefChange) { + result.add('clef'); + } + } + + return Array.from(result); + } + + /** Returns what modifiers to render at the beginning of the stave. */ + private getBeginningStaveModifiers(opts: { address: Address<'measurefragment'>; previousMeasureFragment: MeasureFragment | null; }): StaveModifier[] { @@ -225,20 +344,85 @@ export class MeasureFragment { const staveModifiersChanges = new Set(); - for (let staveIndex = 0; staveIndex < this.staveCount; staveIndex++) { - const currentStave = this.getStaves()[staveIndex]; - const previousStave = opts.previousMeasureFragment?.getStaves()[staveIndex] ?? null; + for (const partId of this.partIds) { + const staveSignature = this.staveSignatures.find((staveSignature) => staveSignature.partId === partId)?.value; + if (!staveSignature) { + continue; + } + + const staveCount = staveSignature.getStaveCount(); + + for (let staveIndex = 0; staveIndex < staveCount; staveIndex++) { + const currentStave = + this.getParts() + ?.find((part) => part.getId() === partId) + ?.getStaves()[staveIndex] ?? null; + + const previousStave = + opts.previousMeasureFragment + ?.getParts() + .find((part) => part.getId() === partId) + ?.getStaves()[staveIndex] ?? null; + + const staveModifiers = currentStave?.getModifierChanges({ previousStave }) ?? []; - for (const staveModifier of currentStave.getModifierChanges({ previousStave })) { - staveModifiersChanges.add(staveModifier); + for (const staveModifier of staveModifiers) { + staveModifiersChanges.add(staveModifier); + } } } + // Avoid rendering stave modifiers that changed in the previous one. + const previousEndStaveModifiers = opts.previousMeasureFragment?.getEndStaveModifiers() ?? []; + for (const staveModifier of previousEndStaveModifiers) { + staveModifiersChanges.delete(staveModifier); + } + return Array.from(staveModifiersChanges); } - /** Returns the modifiers width. */ - private getStaveModifiersWidth(staveModifiers: StaveModifier[]): number { - return util.max(this.getStaves().map((stave) => stave.getModifiersWidth(staveModifiers))); + private getMinVoiceJustifyWidth(opts: { address: Address<'measurefragment'> }): number { + const spanners = new Spanners(); + const vfFormatter = new vexflow.Formatter(); + const vfVoices = new Array(); + + for (const part of this.getParts()) { + const partAddress = opts.address.part({ partId: part.getId() }); + + for (const stave of part.getStaves()) { + const entry = stave.getEntry(); + + let vfPartStaveVoices = new Array(); + + if (entry instanceof Chorus) { + const address = partAddress.stave({ staveNumber: stave.getNumber() }).chorus(); + const chorusRendering = entry.render({ address, spanners }); + vfPartStaveVoices = chorusRendering.voices.map((voice) => voice.vexflow.voice); + } + + if (vfPartStaveVoices.length > 0) { + vfFormatter.joinVoices(vfPartStaveVoices); + } + + vfVoices.push(...vfPartStaveVoices); + } + } + + if (vfVoices.length === 0) { + return 0; + } + + return vfFormatter.preCalculateMinTotalWidth(vfVoices) + spanners.getPadding() + this.config.VOICE_PADDING; + } + + private getNonVoiceWidth(): number { + const hasMultiRest = this.getParts() + .flatMap((part) => part.getStaves()) + .some((stave) => stave.getEntry() instanceof MultiRest); + + // This is much easier being configurable. Otherwise, we would have to create a dummy context to render it, then + // get the width via MultiMeasureRest.getBoundingBox. There is no "preCalculateMinTotalWidth" for non-voices at + // the moment. + return hasMultiRest ? this.config.MULTI_MEASURE_REST_WIDTH : 0; } } diff --git a/src/rendering/part.ts b/src/rendering/part.ts index 2c5c2b6d6..f910cafdb 100644 --- a/src/rendering/part.ts +++ b/src/rendering/part.ts @@ -1,210 +1,176 @@ +import * as util from '@/util'; import * as musicxml from '@/musicxml'; import * as vexflow from 'vexflow'; -import * as util from '@/util'; -import { Measure, MeasureRendering } from './measure'; +import { Stave, StaveModifier, StaveRendering } from './stave'; +import { MeasureEntry, StaveSignature } from './stavesignature'; import { Config } from './config'; import { Address } from './address'; import { Spanners } from './spanners'; import { PartName, PartNameRendering } from './partname'; -const STAVE_CONNECTOR_BRACE_WIDTH = 16; - -/** The result of rendering a Part. */ +/** The result of rendering a part. */ export type PartRendering = { + type: 'part'; id: string; - height: number; - address: Address<'part'>; - vexflow: { - staveConnector: vexflow.StaveConnector | null; - }; name: PartNameRendering | null; - measures: MeasureRendering[]; + staves: StaveRendering[]; + height: number; }; -/** - * Represents a Part in a musical score, corresponding to the element in MusicXML. This class encompasses the - * entire musical content for a specific instrument or voice, potentially spanning multiple systems when rendered in the - * viewport. - */ +/** A part in a musical score. */ export class Part { private config: Config; - private name: PartName | null; - private musicXml: { part: musicxml.Part }; - private measures: Measure[]; - private staveCount: number; + private id: string; + private name: PartName; + private musicXml: { + staveLayouts: musicxml.StaveLayout[]; + beginningBarStyle: musicxml.BarStyle; + endBarStyle: musicxml.BarStyle; + }; + private measureEntries: MeasureEntry[]; + private staveSignature: StaveSignature; constructor(opts: { config: Config; - name: PartName | null; - musicXml: { part: musicxml.Part }; - measures: Measure[]; - staveCount: number; + id: string; + name: PartName; + musicXml: { + staveLayouts: musicxml.StaveLayout[]; + beginningBarStyle: musicxml.BarStyle; + endBarStyle: musicxml.BarStyle; + }; + measureEntries: MeasureEntry[]; + staveSignature: StaveSignature; }) { this.config = opts.config; + this.id = opts.id; this.name = opts.name; this.musicXml = opts.musicXml; - this.measures = opts.measures; - this.staveCount = opts.staveCount; + this.measureEntries = opts.measureEntries; + this.staveSignature = opts.staveSignature; } - getId(): string { - return this.musicXml.part.getId(); - } + @util.memoize() + getStaves(): Stave[] { + const result = new Array(); - getMeasures(): Measure[] { - return this.measures; - } + const staveCount = this.staveSignature.getStaveCount(); - getStaveOffset(): number { - let result = 0; + for (let staveIndex = 0; staveIndex < staveCount; staveIndex++) { + const staveNumber = staveIndex + 1; - if (this.staveCount > 1) { - result += STAVE_CONNECTOR_BRACE_WIDTH; - } - if (this.name) { - result += this.name.getWidth(); + const measureEntries = this.measureEntries.filter((entry) => { + if (entry instanceof musicxml.Note) { + return entry.getStaveNumber() === staveNumber; + } + return true; + }); + + result.push( + new Stave({ + config: this.config, + staveSignature: this.staveSignature, + number: staveNumber, + musicXml: { + beginningBarStyle: this.musicXml.beginningBarStyle, + endBarStyle: this.musicXml.endBarStyle, + }, + measureEntries, + }) + ); } return result; } + /** Returns the ID of the part. */ + getId(): string { + return this.id; + } + + /** Returns the top padding of the part. */ + getTopPadding(): number { + return util.max(this.getStaves().map((stave) => stave.getTopPadding())); + } + + /** Returns the number of measures the multi rest is active for. 0 means there's no multi rest. */ + getMultiRestCount(): number { + // TODO: One stave could be a multi measure rest, while another one could have voices. + return util.max(this.getStaves().map((stave) => stave.getMultiRestCount())); + } + + /** Renders the part. */ render(opts: { x: number; y: number; - maxStaveOffset: number; - showMeasureLabels: boolean; + vexflow: { formatter: vexflow.Formatter }; + width: number; address: Address<'part'>; spanners: Spanners; - targetSystemWidth: number; - minRequiredSystemWidth: number; - systemCount: number; + beginningStaveModifiers: StaveModifier[]; + endStaveModifiers: StaveModifier[]; previousPart: Part | null; nextPart: Part | null; }): PartRendering { - const measureRenderings = new Array(); + const staveRenderings = new Array(); - let x = opts.x + opts.maxStaveOffset; - const y = opts.y + this.getTopPadding(); - - let vfStaveConnector: vexflow.StaveConnector | null = null; - - util.forEachTriple(this.measures, ([previousMeasure, currentMeasure, nextMeasure], { isFirst, isLast, index }) => { - // Even though a system has many parts, each part spans the entire system. Therefore the measure index in the - // Part object is the systemMeasureIndex. - const systemMeasureIndex = index; + const x = opts.x; + let y = opts.y; + const width = opts.width; + util.forEachTriple(this.getStaves(), ([previousStave, currentStave, nextStave], { isFirst, isLast }) => { if (isFirst) { - previousMeasure = util.last(opts.previousPart?.measures ?? []); + previousStave = util.last(opts.previousPart?.getStaves() ?? []); } if (isLast) { - nextMeasure = util.first(opts.nextPart?.measures ?? []); - } - - const targetSystemWidth = opts.targetSystemWidth - opts.maxStaveOffset; - - const hasStaveConnectorBrace = isFirst && this.staveCount > 1; - if (hasStaveConnectorBrace) { - x += STAVE_CONNECTOR_BRACE_WIDTH; + nextStave = util.first(opts.nextPart?.getStaves() ?? []); } - if (isFirst && this.name) { - x += this.name.getWidth(); - } - - const measureRendering = currentMeasure.render({ + const staveRendering = currentStave.render({ x, y, - showLabel: opts.showMeasureLabels, - address: opts.address.measure({ - measureIndex: currentMeasure.getIndex(), - systemMeasureIndex, - }), + vexflow: { formatter: opts.vexflow.formatter }, + address: opts.address.stave({ staveNumber: currentStave.getNumber() }), spanners: opts.spanners, - systemCount: opts.systemCount, - minRequiredSystemWidth: opts.minRequiredSystemWidth, - targetSystemWidth, - previousMeasure, - nextMeasure, + width, + beginningModifiers: opts.beginningStaveModifiers, + endModifiers: opts.endStaveModifiers, + previousStave, + nextStave, }); - measureRenderings.push(measureRendering); - const staves = measureRendering.fragments.flatMap((fragment) => fragment.staves); - if (hasStaveConnectorBrace) { - const topStave = util.first(staves)!; - const bottomStave = util.last(staves)!; + staveRenderings.push(staveRendering); - vfStaveConnector = new vexflow.StaveConnector(topStave.vexflow.stave, bottomStave.vexflow.stave).setType( - 'brace' - ); - } + const staveDistance = + this.musicXml.staveLayouts.find((staveLayout) => staveLayout.staveNumber === staveRendering.staveNumber) + ?.staveDistance ?? this.config.DEFAULT_STAVE_DISTANCE; - x += measureRendering.width; + y += staveDistance; }); - const firstMeasureRendering = util.first(measureRenderings); - const topY = this.getTopY(firstMeasureRendering); - const bottomY = this.getBottomY(firstMeasureRendering); - const middleY = topY + (bottomY - topY) / 2; + const topStave = util.first(staveRenderings)?.vexflow.stave; + const bottomStave = util.last(staveRenderings)?.vexflow.stave; - const height = bottomY - topY; + const topY = topStave?.getTopLineTopY() ?? 0; + const bottomY = bottomStave?.getBottomLineBottomY() ?? 0; + const middleY = (topY + bottomY) / 2; + const height = util.max([bottomY - topY, 0]); - let name: PartNameRendering | null = null; const isFirstSystem = opts.address.getSystemIndex() === 0; - if (isFirstSystem && firstMeasureRendering && this.name) { - name = this.name.render({ - x: 0, - y: middleY + this.name.getApproximateHeight() / 2, - }); + const isFirstMeasure = opts.address.getMeasureIndex() === 0; + const isFirstMeasureFragment = opts.address.getMeasureFragmentIndex() === 0; + + let name: PartNameRendering | null = null; + if (isFirstSystem && isFirstMeasure && isFirstMeasureFragment) { + name = this.name.render({ x: 0, y: middleY + this.name.getApproximateHeight() / 2 }); } return { - id: this.musicXml.part.getId(), - height, - address: opts.address, - vexflow: { staveConnector: vfStaveConnector }, + type: 'part', + id: this.id, name, - measures: measureRenderings, + staves: staveRenderings, + height, }; } - - private getTopPadding(): number { - return util.max(this.measures.map((measure) => measure.getTopPadding())); - } - - private getTopY(measureRendering: MeasureRendering | null): number { - if (!measureRendering) { - return 0; - } - - const fragment = util.first(measureRendering.fragments); - if (!fragment) { - return 0; - } - - const topStave = util.first(fragment.staves); - if (!topStave) { - return 0; - } - - return topStave.vexflow.stave.getYForLine(0); - } - - private getBottomY(measureRendering: MeasureRendering | null): number { - if (!measureRendering) { - return 0; - } - - const fragment = util.first(measureRendering.fragments); - if (!fragment) { - return 0; - } - - const bottomStave = util.last(fragment.staves); - if (!bottomStave) { - return 0; - } - - const bottomLine = bottomStave.vexflow.stave.getNumLines() - 1; - return bottomStave.vexflow.stave.getYForLine(bottomLine); - } } diff --git a/src/rendering/partname.ts b/src/rendering/partname.ts index f54b0c602..b0f0fddec 100644 --- a/src/rendering/partname.ts +++ b/src/rendering/partname.ts @@ -7,6 +7,7 @@ const PART_NAME_PADDING_RIGHT = 8; export type PartNameRendering = { type: 'partname'; text: drawables.Text; + width: number; }; /** Represents a part name within a score. */ @@ -40,9 +41,12 @@ export class PartName { family: this.config.PART_NAME_FONT_FAMILY, }); + const width = this.getWidth(); + return { type: 'partname', text, + width, }; } diff --git a/src/rendering/score.ts b/src/rendering/score.ts index afde3231e..97a93d9b9 100644 --- a/src/rendering/score.ts +++ b/src/rendering/score.ts @@ -2,15 +2,14 @@ import { SystemRendering } from './system'; import * as musicxml from '@/musicxml'; import * as vexflow from 'vexflow'; import * as util from '@/util'; +import * as drawables from '@/drawables'; import { Config } from './config'; import { Title, TitleRendering } from './title'; import { MultiRestRendering } from './multirest'; import { ChorusRendering } from './chorus'; import { Seed } from './seed'; import { Spanners } from './spanners'; - -// Space needed to be able to show the end barlines. -const END_BARLINE_OFFSET = 1; +import { Address } from './address'; /** The result of rendering a Score. */ export type ScoreRendering = { @@ -67,23 +66,28 @@ export class Score { // Render the entire hierarchy. util.forEachTriple(systems, ([previousSystem, currentSystem, nextSystem]) => { + const address = Address.system({ + systemIndex: currentSystem.getIndex(), + origin: 'Score.prototype.render', + }); + const systemRendering = currentSystem.render({ x, y, - width: opts.width - END_BARLINE_OFFSET, - systemCount: systems.length, + address, previousSystem, nextSystem, spanners, }); systemRenderings.push(systemRendering); + // TODO: Add height property to SystemRendering instead. const maxY = util.max([ y, - ...systemRendering.parts - .flatMap((part) => part.measures) + ...systemRendering.measures .flatMap((measure) => measure.fragments) - .flatMap((measureFragment) => measureFragment.staves) + .flatMap((measureFragment) => measureFragment.parts) + .flatMap((part) => part.staves) .map((stave) => { const box = stave.vexflow.stave.getBoundingBox(); return box.getY() + box.getH(); @@ -99,36 +103,24 @@ export class Score { const spannersRendering = spanners.render(); // Precalculate different parts of the rendering for readability later. - const parts = systemRenderings.flatMap((system) => system.parts); - const measures = parts.flatMap((part) => part.measures); + const measures = systemRenderings.flatMap((system) => system.measures); const measureFragments = measures.flatMap((measure) => measure.fragments); - const staves = measureFragments.flatMap((measureFragment) => measureFragment.staves); + const parts = measureFragments.flatMap((measureFragment) => measureFragment.parts); + const staves = measureFragments.flatMap((measureFragment) => measureFragment.parts).flatMap((part) => part.staves); // Prepare the vexflow rendering objects. const vfRenderer = new vexflow.Renderer(opts.element, vexflow.Renderer.Backends.SVG).resize(opts.width, y); const vfContext = vfRenderer.getContext(); - // Format vexflow.Voice elements. - staves.forEach((stave) => { - if (stave.entry.type !== 'chorus') { - return; - } - const vfStave = stave.vexflow.stave; - const vfVoices = stave.entry.voices.map((voice) => voice.vexflow.voice); - - if (vfVoices.some((vfVoice) => vfVoice.getTickables().length > 0)) { - new vexflow.Formatter().joinVoices(vfVoices).formatToStave(vfVoices, vfStave); - } - }); - // Draw the title. titleRendering?.text.draw(vfContext); // Draw the part names. parts - .map((part) => part.name) - .forEach((partName) => { - partName?.text.draw(vfContext); + .map((part) => part.name?.text) + .filter((text): text is drawables.Text => text instanceof drawables.Text) + .forEach((text) => { + text.draw(vfContext); }); // Draw vexflow.Stave elements. @@ -138,31 +130,13 @@ export class Score { vfStave.setContext(vfContext).draw(); }); - // Draw vexflow.StaveConnector elements from systems. - systemRenderings - .map((system) => system.vexflow.staveConnector) - .filter( - (vfStaveConnector): vfStaveConnector is vexflow.StaveConnector => - vfStaveConnector instanceof vexflow.StaveConnector - ) - .forEach((vfStaveConnector) => { - vfStaveConnector.setContext(vfContext).draw(); - }); - - // Draw vexflow.StaveConnector elements from measures. - measures - .flatMap((measure) => measure.vexflow.staveConnectors) + // Draw vexflow.StaveConnector elements. + measureFragments + .flatMap((measureFragment) => measureFragment.vexflow.staveConnectors) .forEach((vfStaveConnector) => { vfStaveConnector.setContext(vfContext).draw(); }); - // Draw vexflow.StaveConnector elements from parts. - parts - .map((part) => part.vexflow.staveConnector) - .forEach((vfStaveConnector) => { - vfStaveConnector?.setContext(vfContext).draw(); - }); - // Draw vexflow.MultiMeasureRest elements. staves .map((stave) => stave.entry) diff --git a/src/rendering/seed.ts b/src/rendering/seed.ts index 060a27a88..357972583 100644 --- a/src/rendering/seed.ts +++ b/src/rendering/seed.ts @@ -1,13 +1,17 @@ +import { System } from './system'; import { Config } from './config'; -import { Measure } from './measure'; -import { MeasureEntry, StaveSignature } from './stavesignature'; import * as musicxml from '@/musicxml'; import * as util from '@/util'; -import { Part } from './part'; -import { System } from './system'; +import { PartScoped } from './types'; +import { Measure } from './measure'; import { Address } from './address'; +import { MeasureEntry, StaveSignature } from './stavesignature'; +import { MeasureFragmentWidth } from './measurefragment'; import { PartName } from './partname'; +// Space needed to be able to show the end barlines. +const LAST_SYSTEM_REMAINING_WIDTH_STRETCH_THRESHOLD = 0.25; + /** A reusable data container that houses rendering data to spawn `System` objects. */ export class Seed { private config: Config; @@ -33,220 +37,202 @@ export class Seed { split(width: number): System[] { const systems = new Array(); - let systemMeasureIndex = 0; - let remainingWidth = width; - let measureStartIndex = 0; + let remaining = width; + let totalStaveOffsetX = 0; + let measures = new Array(); + let minRequiredFragmentWidths = new Array(); + let systemAddress = Address.system({ systemIndex: systems.length, origin: 'Seed.prototype.split' }); + let endBarlineWidth = 0; - /** Adds a system to the return value. */ - const commitSystem = (measureEndIndex: number) => { - const parts = this.musicXml.parts.map((part) => { - const partId = part.getId(); - return new Part({ - config: this.config, - staveCount: this.getStaveCount(partId), - name: measureStartIndex === 0 ? this.getPartName(partId) : null, - musicXml: { part }, - measures: this.getMeasures(partId).slice(measureStartIndex, measureEndIndex), - }); - }); + const addSystem = (opts: { stretch: boolean }) => { + const minRequiredSystemWidth = util.sum(minRequiredFragmentWidths.map(({ value }) => value)); - const system = new System({ - config: this.config, - index: systems.length, - parts, - }); + const stretchedWidths = minRequiredFragmentWidths.map( + ({ measureIndex, measureFragmentIndex, value }) => { + const targetWidth = width - endBarlineWidth - totalStaveOffsetX; + const widthDeficit = targetWidth - minRequiredSystemWidth; + const widthFraction = value / minRequiredSystemWidth; + const widthDelta = widthDeficit * widthFraction; - systems.push(system); + return { measureIndex, measureFragmentIndex, value: value + widthDelta }; + } + ); - measureStartIndex = measureEndIndex; - systemMeasureIndex = 0; - remainingWidth = width; + systems.push( + new System({ + config: this.config, + index: systems.length, + measures, + measureFragmentWidths: opts.stretch ? stretchedWidths : minRequiredFragmentWidths, + }) + ); }; - /** Accounts for a measure being added to a system. */ - const continueSystem = (width: number) => { - remainingWidth -= width; - systemMeasureIndex++; - }; + util.forEachTriple(this.getMeasures(), ([previousMeasure, currentMeasure], { isLast }) => { + const measureAddress = systemAddress.measure({ + systemMeasureIndex: measures.length, + measureIndex: currentMeasure.getIndex(), + }); - const measureCount = util.max( - this.musicXml.parts.map((part) => part.getId()).map((partId) => this.getMeasures(partId).length) - ); + let measureMinRequiredFragmentWidths = currentMeasure.getMinRequiredFragmentWidths({ + previousMeasure, + address: measureAddress, + }); - const systemAddress = Address.system({ systemIndex: 0, origin: 'Seed.prototype.split' }); + remaining += endBarlineWidth; // cancel out the previous end barline width + endBarlineWidth = currentMeasure.getEndBarlineWidth(); + remaining -= endBarlineWidth; - for (let measureIndex = 0; measureIndex < measureCount; measureIndex++) { - // Account for the width that the part name will take up for the very first measure. - if (measureIndex === 0) { - remainingWidth -= util.max( - this.musicXml.parts - .map((part) => part.getId()) - .map((partId) => this.getPartName(partId)) - .map((partName) => partName?.getWidth() ?? 0) - ); - } + const staveOffsetX = currentMeasure.getStaveOffsetX({ address: measureAddress }); + remaining -= staveOffsetX; + totalStaveOffsetX += staveOffsetX; - // Represents a column of measures across each part. - const measures = this.musicXml.parts - .map((part) => part.getId()) - .map((partId) => ({ address: systemAddress.part({ partId }), measures: this.getMeasures(partId) })) - .map((data) => ({ - address: data.address.measure({ measureIndex, systemMeasureIndex }), - previous: data.measures[measureIndex - 1] ?? null, - current: data.measures[measureIndex], - })); - - const getMinRequiredWidth = () => - util.max( - measures.map((measure) => - measure.current.getMinRequiredWidth({ - address: measure.address, - previousMeasure: measure.previous, - }) - ) - ); - - let minRequiredWidth = getMinRequiredWidth(); - - const isProcessingLastMeasure = measureIndex === measureCount - 1; - if (isProcessingLastMeasure) { - if (minRequiredWidth <= remainingWidth) { - commitSystem(measureIndex + 1); - } else { - commitSystem(measureIndex); - commitSystem(measureIndex + 1); - } - } else if (minRequiredWidth <= remainingWidth) { - continueSystem(minRequiredWidth); - } else { - commitSystem(measureIndex); - minRequiredWidth = getMinRequiredWidth(); - continueSystem(minRequiredWidth); - } - } + let required = util.sum(measureMinRequiredFragmentWidths.map(({ value }) => value)); - return systems; - } + if (remaining < required) { + addSystem({ stretch: true }); - @util.memoize() - private getMeasuresByPartId(): Record { - const result: Record = {}; + // Reset state. + remaining = width; + measures = []; + minRequiredFragmentWidths = []; - let multiRestMeasureCount = 0; + // Start a new system and re-measure. + systemAddress = Address.system({ systemIndex: systems.length, origin: 'Seed.prototype.split' }); - for (const part of this.musicXml.parts) { - const partId = part.getId(); - result[partId] = []; - - const staveCount = this.getStaveCount(partId); - const measures = part.getMeasures(); + endBarlineWidth = currentMeasure.getEndBarlineWidth(); + remaining -= endBarlineWidth; - for (let measureIndex = 0; measureIndex < measures.length; measureIndex++) { - if (multiRestMeasureCount > 0) { - multiRestMeasureCount--; - continue; - } + totalStaveOffsetX = currentMeasure.getStaveOffsetX({ address: measureAddress }); + remaining -= totalStaveOffsetX; - const measure: Measure = new Measure({ - config: this.config, - index: measureIndex, - musicXml: { - measure: measures[measureIndex], - staveLayouts: this.musicXml.staveLayouts, - }, - staveCount, - leadingStaveSignature: this.getLeadingStaveSignature(partId, measureIndex), - measureEntries: this.getMeasureEntries(partId, measureIndex), + measureMinRequiredFragmentWidths = currentMeasure.getMinRequiredFragmentWidths({ + previousMeasure, + address: systemAddress.measure({ + systemMeasureIndex: 0, + measureIndex: currentMeasure.getIndex(), + }), }); - result[partId].push(measure); + required = util.sum(measureMinRequiredFragmentWidths.map(({ value }) => value)); + } + + remaining -= required; + measures.push(currentMeasure); + minRequiredFragmentWidths.push(...measureMinRequiredFragmentWidths); - // -1 since this measure is part of the multi rest. - multiRestMeasureCount += measure.getMultiRestCount() - 1; + if (isLast) { + addSystem({ stretch: remaining / width <= LAST_SYSTEM_REMAINING_WIDTH_STRETCH_THRESHOLD }); } - } + }); - return result; + return systems; } @util.memoize() - private getMeasureEntryGroupsByPartId(): Record { - const result: Record = {}; + private getMeasureEntryGroups(): PartScoped[] { + const result = []; for (const part of this.musicXml.parts) { const partId = part.getId(); - result[partId] = StaveSignature.toMeasureEntryGroups({ part }); + result.push({ partId, value: StaveSignature.toMeasureEntryGroups({ part }) }); } return result; } @util.memoize() - private getPartNameByPartId(): Record { - const result: Record = {}; + private getPartNames(): PartScoped[] { + const result = new Array>(); for (const partDetail of this.musicXml.partDetails) { - result[partDetail.id] = new PartName({ config: this.config, content: partDetail.name }); + const partId = partDetail.id; + const partName = new PartName({ config: this.config, content: partDetail.name }); + result.push({ partId, value: partName }); } return result; } - private getMeasures(partId: string): Measure[] { - const measuresByPartId = this.getMeasuresByPartId(); - return measuresByPartId[partId]; - } + private getMeasures(): Measure[] { + const measures = new Array(); - private getMeasureEntries(partId: string, measureIndex: number): MeasureEntry[] { - const measureEntryGroups = this.getMeasureEntryGroups(partId); - return measureEntryGroups[measureIndex]; - } + const measureCount = this.getMeasureCount(); + let multiRestMeasureCount = 0; - private getMeasureEntryGroups(partId: string): MeasureEntry[][] { - const measureEntryGroupsByPartId = this.getMeasureEntryGroupsByPartId(); - return measureEntryGroupsByPartId[partId]; - } + for (let measureIndex = 0; measureIndex < measureCount; measureIndex++) { + if (multiRestMeasureCount > 0) { + multiRestMeasureCount--; + continue; + } - private getPartName(partId: string): PartName | null { - const partNameByPartId = this.getPartNameByPartId(); - return partNameByPartId[partId] ?? null; - } + const measure = new Measure({ + config: this.config, + index: measureIndex, + partIds: this.getPartIds(), + partNames: this.getPartNames(), + musicXml: { + measures: this.musicXml.parts.map((part) => ({ + partId: part.getId(), + value: part.getMeasures()[measureIndex], + })), + staveLayouts: this.musicXml.staveLayouts, + }, + leadingStaveSignatures: this.getLeadingStaveSignatures(measureIndex), + entries: this.getMeasureEntries(measureIndex), + }); - /** Returns the stave signature that is active at the beginning of the measure. */ - private getLeadingStaveSignature(partId: string, measureIndex: number): StaveSignature { - const measureEntryGroupsByPartId = this.getMeasureEntryGroupsByPartId(); - const measureEntryGroups = measureEntryGroupsByPartId[partId]; - - const staveSignatures = measureEntryGroups - .flat() - .filter((entry): entry is StaveSignature => entry instanceof StaveSignature) - .filter((staveSignature) => staveSignature.getMeasureIndex() <= measureIndex); - - // Get the first stave signature that matches the measure index or get the last stave signature seen before this - // measure index. - const leadingStaveSignature = - staveSignatures.find((staveSignature) => staveSignature.getMeasureIndex() === measureIndex) ?? - util.last(staveSignatures); - - // We don't expect this to ever happen since we assume that StaveSignatures are created correctly. However, if this - // error ever throws, investigate how StaveSignatures are created. Don't default StaveSignature because it exposes - // getPrevious and getNext, which the caller expects to be a well formed linked list. - if (!leadingStaveSignature) { - throw new Error('expected leading stave signature'); + measures.push(measure); + + // -1 since this measure is part of the multi rest. + multiRestMeasureCount += measure.getMultiRestCount() - 1; } - return leadingStaveSignature; + return measures; } - private getStaveCount(partId: string): number { - const measureEntryGroupsByPartId = this.getMeasureEntryGroupsByPartId(); - const measureEntryGroups = measureEntryGroupsByPartId[partId]; + private getMeasureCount(): number { + return util.max(this.musicXml.parts.map((part) => part.getMeasures().length)); + } - return util.max( - measureEntryGroups + private getPartIds(): string[] { + return this.musicXml.parts.map((part) => part.getId()); + } + + private getLeadingStaveSignatures(measureIndex: number): PartScoped[] { + return this.getPartIds().map((partId) => { + const measureEntryGroups = this.getMeasureEntryGroups() + .filter((measureEntryGroup) => measureEntryGroup.partId === partId) + .flatMap((measureEntryGroup) => measureEntryGroup.value); + + const staveSignatures = measureEntryGroups .flat() .filter((entry): entry is StaveSignature => entry instanceof StaveSignature) - .map((entry) => entry.getStaveCount()) + .filter((staveSignature) => staveSignature.getMeasureIndex() <= measureIndex); + + // Get the first stave signature that matches the measure index or get the last stave signature seen before this + // measure index. + const leadingStaveSignature = + staveSignatures.find((staveSignature) => staveSignature.getMeasureIndex() === measureIndex) ?? + util.last(staveSignatures); + + // We don't expect this to ever happen since we assume that StaveSignatures are created correctly. However, if this + // error ever throws, investigate how StaveSignatures are created. Don't default StaveSignature because it exposes + // getPrevious and getNext, which the caller expects to be a well formed linked list. + if (!leadingStaveSignature) { + throw new Error('expected leading stave signature'); + } + + return { partId, value: leadingStaveSignature }; + }); + } + + private getMeasureEntries(measureIndex: number): PartScoped[] { + return this.getMeasureEntryGroups().flatMap((measureEntryGroup) => + measureEntryGroup.value[measureIndex].map((measureEntry) => ({ + partId: measureEntryGroup.partId, + value: measureEntry, + })) ); } } diff --git a/src/rendering/stave.ts b/src/rendering/stave.ts index 4143162c3..b90bced22 100644 --- a/src/rendering/stave.ts +++ b/src/rendering/stave.ts @@ -49,44 +49,66 @@ export type StaveModifier = 'clef' | 'keySignature' | 'timeSignature'; export class Stave { private config: Config; private number: number; + private musicXml: { + beginningBarStyle: musicxml.BarStyle; + endBarStyle: musicxml.BarStyle; + }; private staveSignature: StaveSignature; - private beginningBarStyle: musicxml.BarStyle; - private endBarStyle: musicxml.BarStyle; private measureEntries: MeasureEntry[]; constructor(opts: { config: Config; number: number; staveSignature: StaveSignature; - beginningBarStyle: musicxml.BarStyle; - endBarStyle: musicxml.BarStyle; + musicXml: { + beginningBarStyle: musicxml.BarStyle; + endBarStyle: musicxml.BarStyle; + }; measureEntries: MeasureEntry[]; }) { this.config = opts.config; this.number = opts.number; this.staveSignature = opts.staveSignature; - this.beginningBarStyle = opts.beginningBarStyle; - this.endBarStyle = opts.endBarStyle; + this.musicXml = opts.musicXml; this.measureEntries = opts.measureEntries; } - /** Returns the minimum justify width for the stave in a measure context. */ @util.memoize() - getMinJustifyWidth(address: Address<'stave'>): number { - const entry = this.getEntry(); - - if (entry instanceof MultiRest) { - // This is much easier being configurable. Otherwise, we would have to create a dummy context to render it, then - // get the width via MultiMeasureRest.getBoundingBox. There is no "preCalculateMinTotalWidth" for non-voices at - // the moment. - return this.config.MULTI_MEASURE_REST_WIDTH; + getEntry(): StaveEntry { + const config = this.config; + const timeSignature = this.getTimeSignature(); + const clef = this.getClef(); + const multiRestCount = this.getMultiRestCount(); + const measureEntries = this.measureEntries; + const quarterNoteDivisions = this.getQuarterNoteDivisions(); + const keySignature = this.getKeySignature(); + + if (multiRestCount === 1) { + return Chorus.wholeRest({ config, clef, timeSignature }); } - if (entry instanceof Chorus) { - return entry.getMinJustifyWidth(address.chorus()); + if (multiRestCount > 1) { + return new MultiRest({ count: multiRestCount }); } - return 0; + if (this.getClef().getType() === 'tab') { + // TODO: Render tablature correctly. + return new Tablature(); + } + + return Chorus.multiVoice({ + config, + measureEntries, + quarterNoteDivisions, + keySignature, + clef, + timeSignature, + }); + } + + /** Returns the stave signature. */ + getSignature(): StaveSignature { + return this.staveSignature; } /** Returns the stave number. */ @@ -94,29 +116,18 @@ export class Stave { return this.number; } - /** Returns the width that the modifiers take up. */ - getModifiersWidth(modifiers: StaveModifier[]): number { - let width = 0; - - if (modifiers.includes('clef')) { - width += this.getClef().getWidth(); - } - if (modifiers.includes('keySignature')) { - width += this.getKeySignature().getWidth(); - } - if (modifiers.includes('timeSignature')) { - width += this.getTimeSignature().getWidth(); - } - - return width; - } - /** Returns the number of measures the multi rest is active for. 0 means there's no multi rest. */ getMultiRestCount(): number { return this.staveSignature?.getMultiRestCount(this.number) ?? 0; } - /** Returns the stave modifiers that changed. */ + /** + * Returns the stave modifiers that changed. + * + * The same StaveSignature can be used across multiple measures/measure fragments/staves/etc. If you use + * `StaveSignature.getChangedStaveModifiers`, it may not be applicable to the current stave. Therefore, we need to + * check the Stave objects directly to see what modifiers changed across them. + */ getModifierChanges(opts: { previousStave: Stave | null }): StaveModifier[] { if (!opts.previousStave) { return ['clef', 'keySignature', 'timeSignature']; @@ -152,38 +163,46 @@ export class Stave { render(opts: { x: number; y: number; + vexflow: { formatter: vexflow.Formatter }; address: Address<'stave'>; spanners: Spanners; width: number; - modifiers: StaveModifier[]; + beginningModifiers: StaveModifier[]; + endModifiers: StaveModifier[]; previousStave: Stave | null; nextStave: Stave | null; }): StaveRendering { - const staveSignature = this.staveSignature.render({ staveNumber: this.number }); + const staveSignatureRendering = this.staveSignature.render({ staveNumber: this.number }); const vfStave = this.getClef().getType() === 'tab' ? new vexflow.TabStave(opts.x, opts.y, opts.width) : new vexflow.Stave(opts.x, opts.y, opts.width); - const vfBeginningBarlineType = conversions.fromBarStyleToBarlineType(this.beginningBarStyle); + const vfBeginningBarlineType = conversions.fromBarStyleToBarlineType(this.musicXml.beginningBarStyle); vfStave.setBegBarType(vfBeginningBarlineType); - const vfEndBarlineType = conversions.fromBarStyleToBarlineType(this.endBarStyle); + const vfEndBarlineType = conversions.fromBarStyleToBarlineType(this.musicXml.endBarStyle); vfStave.setEndBarType(vfEndBarlineType); - if (opts.modifiers.includes('clef')) { - vfStave.addModifier(staveSignature.clef.vexflow.clef); + if (opts.beginningModifiers.includes('clef')) { + vfStave.addModifier(staveSignatureRendering.clef.vexflow.clef); } - if (opts.modifiers.includes('keySignature')) { - vfStave.addModifier(staveSignature.keySignature.vexflow.keySignature); + if (opts.beginningModifiers.includes('keySignature')) { + vfStave.addModifier(staveSignatureRendering.keySignature.vexflow.keySignature); } - if (opts.modifiers.includes('timeSignature')) { - for (const timeSignature of staveSignature.timeSignature.vexflow.timeSignatures) { + if (opts.beginningModifiers.includes('timeSignature')) { + for (const timeSignature of staveSignatureRendering.timeSignature.vexflow.timeSignatures) { vfStave.addModifier(timeSignature); } } + const nextStaveSignature = this.staveSignature.getNext(); + if (opts.endModifiers.includes('clef') && nextStaveSignature) { + const nextStaveSignatureRendering = nextStaveSignature.render({ staveNumber: this.number }); + vfStave.addEndModifier(nextStaveSignatureRendering.clef.vexflow.clef); + } + const metronome = this.getMetronome(); const beatsPerMinute = metronome?.getBeatsPerMinute(); const beatUnitDotCount = metronome?.getBeatUnitDotCount(); @@ -211,6 +230,7 @@ export class Stave { break; case 'chorus': const vfVoices = staveEntryRendering.voices.map((voice) => voice.vexflow.voice); + opts.vexflow.formatter.joinVoices(vfVoices); for (const vfVoice of vfVoices) { vfVoice.setStave(vfStave); } @@ -246,39 +266,6 @@ export class Stave { ); } - @util.memoize() - private getEntry(): StaveEntry { - const config = this.config; - const timeSignature = this.getTimeSignature(); - const clef = this.getClef(); - const multiRestCount = this.getMultiRestCount(); - const measureEntries = this.measureEntries; - const quarterNoteDivisions = this.getQuarterNoteDivisions(); - const keySignature = this.getKeySignature(); - - if (multiRestCount === 1) { - return Chorus.wholeRest({ config, clef, timeSignature }); - } - - if (multiRestCount > 1) { - return new MultiRest({ count: multiRestCount }); - } - - if (this.getClef().getType() === 'tab') { - // TODO: Render tablature correctly. - return new Tablature(); - } - - return Chorus.multiVoice({ - config, - measureEntries, - quarterNoteDivisions, - keySignature, - clef, - timeSignature, - }); - } - private getClef(): Clef { return this.staveSignature.getClef(this.number); } diff --git a/src/rendering/stavesignature.ts b/src/rendering/stavesignature.ts index f6b1d822f..1c4973397 100644 --- a/src/rendering/stavesignature.ts +++ b/src/rendering/stavesignature.ts @@ -281,6 +281,15 @@ export class StaveSignature { return this.attributes; } + /** Whether the stave signature will change at a measure boundary. */ + isAtMeasureBoundary(): boolean { + return ( + !!this.next && + this.next.getMeasureIndex() === this.getMeasureIndex() + 1 && + this.next.getMeasureEntryIndex() === 0 + ); + } + /** Renders the stave signature. */ render(opts: { staveNumber: number }): StaveSignatureRendering { return { diff --git a/src/rendering/system.ts b/src/rendering/system.ts index 0abbd6d7b..84ef11ab4 100644 --- a/src/rendering/system.ts +++ b/src/rendering/system.ts @@ -1,17 +1,15 @@ import * as util from '@/util'; -import * as vexflow from 'vexflow'; -import { Config } from './config'; -import { Part } from './part'; -import { PartRendering } from './part'; import { Address } from './address'; +import { Config } from './config'; +import { Measure, MeasureRendering } from './measure'; import { Spanners } from './spanners'; +import { MeasureFragmentWidth } from './measurefragment'; -/** The result of rendering a System. */ +/** The result of rendering a system. */ export type SystemRendering = { type: 'system'; address: Address<'system'>; - parts: PartRendering[]; - vexflow: { staveConnector: vexflow.StaveConnector | null }; + measures: MeasureRendering[]; }; /** @@ -22,139 +20,79 @@ export type SystemRendering = { export class System { private config: Config; private index: number; - private parts: Part[]; - - constructor(opts: { config: Config; index: number; parts: Part[] }) { + private measures: Measure[]; + private measureFragmentWidths: MeasureFragmentWidth[]; + + constructor(opts: { + config: Config; + index: number; + measures: Measure[]; + measureFragmentWidths: MeasureFragmentWidth[]; + }) { this.config = opts.config; this.index = opts.index; - this.parts = opts.parts; + this.measures = opts.measures; + this.measureFragmentWidths = opts.measureFragmentWidths; } + /** Returns the index of the system. */ + getIndex(): number { + return this.index; + } + + /** Renders the system. */ render(opts: { x: number; y: number; - spanners: Spanners; - width: number; - systemCount: number; + address: Address<'system'>; previousSystem: System | null; nextSystem: System | null; + spanners: Spanners; }): SystemRendering { - const address = Address.system({ systemIndex: this.index, origin: 'System.prototype.render' }); - - const partRenderings = new Array(); - - let y = opts.y; - - const maxStaveOffset = util.max(this.parts.map((part) => part.getStaveOffset())); - - for (let index = 0; index < this.parts.length; index++) { - const currentPart = this.parts[index]; - const previousPart = opts.previousSystem?.parts[index] ?? null; - const nextPart = opts.nextSystem?.parts[index] ?? null; + const measureRenderings = new Array(); + + let x = opts.x; + const y = opts.y + this.getTopPadding(); + + util.forEachTriple(this.measures, ([previousMeasure, currentMeasure, nextMeasure], { isFirst, isLast, index }) => { + if (isFirst) { + previousMeasure = util.last(opts.previousSystem?.measures ?? []); + } + if (isLast) { + nextMeasure = util.first(opts.nextSystem?.measures ?? []); + } + + const address = opts.address.measure({ + systemMeasureIndex: index, + measureIndex: currentMeasure.getIndex(), + }); - const currentPartId = currentPart.getId(); - const minRequiredSystemWidth = this.getMinRequiredWidth(currentPartId); + const fragmentWidths = this.measureFragmentWidths.filter( + ({ measureIndex }) => measureIndex === currentMeasure.getIndex() + ); - const partRendering = currentPart.render({ - x: opts.x - currentPart.getStaveOffset(), + const measureRendering = currentMeasure.render({ + x, y, - maxStaveOffset, - showMeasureLabels: index === 0, - address: address.part({ partId: currentPartId }), + address, + fragmentWidths, + previousMeasure, + nextMeasure, spanners: opts.spanners, - systemCount: opts.systemCount, - minRequiredSystemWidth, - targetSystemWidth: opts.width, - previousPart, - nextPart, }); - partRenderings.push(partRendering); - - y += partRendering.height + this.config.PART_DISTANCE; - } + measureRenderings.push(measureRendering); - const vfStaveConnector = this.getVfStaveConnector(partRenderings); + x += measureRendering.width; + }); return { type: 'system', - address, - parts: partRenderings, - vexflow: { staveConnector: vfStaveConnector }, + address: opts.address, + measures: measureRenderings, }; } - private getMinRequiredWidth(partId: string): number { - // This is a dummy "seed" address and spanners used exclusively for measuring. This should be ok since we're only - // measuring one System, which suggests we're past the seed phase, since that is the phase where systems are - // created. - const systemAddress = Address.system({ systemIndex: this.index, origin: 'System.prototype.getMinRequiredWidth' }); - - let totalWidth = 0; - const measureCount = this.getMeasureCount(); - - const measureGroups = this.parts - .filter((part) => part.getId() === partId) - .map((part) => ({ address: systemAddress.part({ partId: part.getId() }), measures: part.getMeasures() })); - - // Iterate over each measure index, accumulating the max width from each measure "column" (across all parts). We - // can't take the max of the whole part together, because min required width varies for each _measure_ across all - // parts. - for (let systemMeasureIndex = 0; systemMeasureIndex < measureCount; systemMeasureIndex++) { - totalWidth += util.max( - measureGroups - .map((data) => ({ - partAddress: data.address, - previous: data.measures[systemMeasureIndex - 1] ?? null, - current: data.measures[systemMeasureIndex], - })) - .map((measures) => - measures.current.getMinRequiredWidth({ - address: measures.partAddress.measure({ - measureIndex: measures.current.getIndex(), - systemMeasureIndex, - }), - previousMeasure: measures.previous, - }) - ) - ); - } - - return totalWidth; - } - - private getMeasureCount(): number { - return this.parts[0]?.getMeasures().length ?? 0; - } - - private getVfStaveConnector(partRenderings: PartRendering[]): vexflow.StaveConnector | null { - if (partRenderings.length <= 1) { - return null; - } - - const topPart = util.first(partRenderings); - const bottomPart = util.last(partRenderings); - if (!topPart || !bottomPart) { - return null; - } - - const topMeasure = util.first(topPart.measures); - const bottomMeasure = util.first(bottomPart.measures); - if (!topMeasure || !bottomMeasure) { - return null; - } - - const topMeasureFragment = util.first(topMeasure.fragments); - const bottomMeasureFragment = util.first(bottomMeasure.fragments); - if (!topMeasureFragment || !bottomMeasureFragment) { - return null; - } - - const topVfStave = util.first(topMeasureFragment.staves)?.vexflow.stave; - const bottomVfStave = util.first(bottomMeasureFragment.staves)?.vexflow.stave; - if (!topVfStave || !bottomVfStave) { - return null; - } - - return new vexflow.StaveConnector(topVfStave, bottomVfStave).setType('singleLeft'); + private getTopPadding(): number { + return util.max(this.measures.map((measure) => measure.getTopPadding())); } } diff --git a/src/rendering/timesignature.ts b/src/rendering/timesignature.ts index 0f2b644b4..b8bfa8c6e 100644 --- a/src/rendering/timesignature.ts +++ b/src/rendering/timesignature.ts @@ -3,6 +3,8 @@ import * as util from '@/util'; import * as musicxml from '@/musicxml'; import * as vexflow from 'vexflow'; +const COMPLEX_TIME_SIGNATURE_COMPONENT_PADDING = 12; + /** The result of rendering a time signature. */ export type TimeSignatureRendering = { type: 'timesignature'; @@ -122,7 +124,9 @@ export class TimeSignature { /** Returns the width of the time signature.*/ @util.memoize() getWidth(): number { - return util.sum(this.getTimeSpecs().map((timeSpec) => new vexflow.TimeSignature(timeSpec).getWidth())); + const timeSpecs = this.getTimeSpecs(); + const padding = COMPLEX_TIME_SIGNATURE_COMPONENT_PADDING * (timeSpecs.length - 1); + return padding + util.sum(timeSpecs.map((timeSpec) => new vexflow.TimeSignature(timeSpec).getWidth())); } /** Returns whether the time signatures are equal. */ diff --git a/src/rendering/types.ts b/src/rendering/types.ts index c6ff4fd98..fe7047cbb 100644 --- a/src/rendering/types.ts +++ b/src/rendering/types.ts @@ -2,6 +2,7 @@ import * as vexflow from 'vexflow'; import * as musicxml from '@/musicxml'; import { Address } from './address'; +/** Data for a spanner. */ export type SpannerData = { address: Address<'voice'>; keyIndex: number; @@ -14,3 +15,6 @@ export type SpannerData = { staveNote: vexflow.StaveNote; }; }; + +/** A value that is scoped to a specific part. */ +export type PartScoped = { partId: string; value: T }; diff --git a/src/util/array.ts b/src/util/array.ts index 92d9af907..44721b1d7 100644 --- a/src/util/array.ts +++ b/src/util/array.ts @@ -29,6 +29,19 @@ export const sortBy = (array: T[], transform: (item: T) => S): T[] => { }); }; +/** Groups the elements in the array using the transform. */ +export const groupBy = ( + array: T[], + transform: (item: T) => S +): Record => { + return array.reduce((memo, item) => { + const key = transform(item); + memo[key] ??= []; + memo[key].push(item); + return memo; + }, {} as Record); +}; + /** Iterates over each [previous, current, next] triple. */ export const forEachTriple = ( array: T[], diff --git a/tests/integration/__data__/lilypond/12a-Clefs.xml b/tests/integration/__data__/lilypond/12a-Clefs.xml index 4e0307906..90120e43c 100644 --- a/tests/integration/__data__/lilypond/12a-Clefs.xml +++ b/tests/integration/__data__/lilypond/12a-Clefs.xml @@ -301,24 +301,6 @@ - - - TAB - 5 - - - - - C - 4 - - 4 - 1 - whole - - - - none @@ -335,7 +317,7 @@ - + G diff --git a/tests/integration/__data__/vexml/multi_part_formatting.musicxml b/tests/integration/__data__/vexml/multi_part_formatting.musicxml new file mode 100644 index 000000000..56d268033 --- /dev/null +++ b/tests/integration/__data__/vexml/multi_part_formatting.musicxml @@ -0,0 +1,500 @@ + + + Composer / arranger + + MuseScore 4.1.1 + 2023-12-12 + + + + + + + + + + bracket + + + square + + + Part 1 + Fl. 1 + + Flute + wind.flutes.flute + + + + 1 + 74 + 78.7402 + 0 + + + + Part 2 + Fl. 2 + + Flute + wind.flutes.flute + + + + 2 + 74 + 78.7402 + 0 + + + + + + + + + 12 + + 0 + + + + G + 2 + + + + + C + 5 + + 48 + 1 + whole + + + + + + C + 5 + + 24 + 1 + half + down + + + + C + 5 + + 24 + 1 + half + down + + + + + + C + 5 + + 12 + 1 + quarter + down + + + + C + 5 + + 12 + 1 + quarter + down + + + + C + 5 + + 12 + 1 + quarter + down + + + + C + 5 + + 12 + 1 + quarter + down + + + + + + C + 5 + + 6 + 1 + eighth + down + begin + + + + C + 5 + + 6 + 1 + eighth + down + continue + + + + C + 5 + + 6 + 1 + eighth + down + continue + + + + C + 5 + + 6 + 1 + eighth + down + end + + + + C + 5 + + 6 + 1 + eighth + down + begin + + + + C + 5 + + 6 + 1 + eighth + down + continue + + + + C + 5 + + 6 + 1 + eighth + down + continue + + + + C + 5 + + 6 + 1 + eighth + down + end + + + light-heavy + + + + + + + 12 + + 0 + + + + G + 2 + + + + + C + 5 + + 6 + 1 + eighth + down + begin + + + + C + 5 + + 6 + 1 + eighth + down + end + + + + C + 5 + + 12 + 1 + quarter + down + + + + C + 5 + + 24 + 1 + half + down + + + + + + C + 5 + + 4 + 1 + eighth + + 3 + 2 + + down + begin + + + + + + + C + 5 + + 4 + 1 + eighth + + 3 + 2 + + down + continue + + + + C + 5 + + 4 + 1 + eighth + + 3 + 2 + + down + end + + + + + + + C + 5 + + 12 + 1 + quarter + down + + + + C + 5 + + 24 + 1 + half + down + + + + + + C + 5 + + 3 + 1 + 16th + down + begin + begin + + + + C + 5 + + 3 + 1 + 16th + down + continue + continue + + + + C + 5 + + 3 + 1 + 16th + down + continue + continue + + + + C + 5 + + 3 + 1 + 16th + down + end + end + + + + C + 5 + + 24 + 1 + half + down + + + + C + 5 + + 12 + 1 + quarter + down + + + + + + C + 5 + + 18 + 1 + quarter + + down + + + + C + 5 + + 12 + 1 + quarter + down + + + + C + 5 + + 6 + 1 + eighth + down + begin + + + + C + 5 + + 6 + 1 + eighth + down + continue + + + + C + 5 + + 6 + 1 + eighth + down + end + + + light-heavy + + + + \ No newline at end of file diff --git a/tests/integration/__data__/vexml/multi_stave_single_part_formatting.musicxml b/tests/integration/__data__/vexml/multi_stave_single_part_formatting.musicxml new file mode 100644 index 000000000..642990a2c --- /dev/null +++ b/tests/integration/__data__/vexml/multi_stave_single_part_formatting.musicxml @@ -0,0 +1,514 @@ + + + Untitled score + + + Composer / arranger + + MuseScore 4.1.1 + 2023-12-12 + + + + + + + + + + Piano + Pno. + + Piano + keyboard.piano + + + + 1 + 1 + 78.7402 + 0 + + + + + + + 12 + + 0 + + + 2 + + G + 2 + + + F + 4 + + + + + C + 5 + + 24 + 1 + half + down + 1 + + + + C + 5 + + 24 + 1 + half + down + 1 + + + 48 + + + + E + 3 + + 12 + 5 + quarter + down + 2 + + + + E + 3 + + 12 + 5 + quarter + down + 2 + + + + E + 3 + + 12 + 5 + quarter + down + 2 + + + + E + 3 + + 12 + 5 + quarter + down + 2 + + + + + + C + 5 + + 36 + 1 + half + + down + 1 + + + + C + 5 + + 12 + 1 + quarter + down + 1 + + + 48 + + + + E + 3 + + 4 + 5 + eighth + + 3 + 2 + + down + 2 + begin + + + + + + + E + 3 + + 4 + 5 + eighth + + 3 + 2 + + down + 2 + continue + + + + E + 3 + + 4 + 5 + eighth + + 3 + 2 + + down + 2 + end + + + + + + + E + 3 + + 12 + 5 + quarter + down + 2 + + + + E + 3 + + 12 + 5 + quarter + down + 2 + + + + E + 3 + + 12 + 5 + quarter + down + 2 + + + + + + C + 5 + + 12 + 1 + quarter + down + 1 + + + + C + 5 + + 12 + 1 + quarter + down + 1 + + + + C + 5 + + 12 + 1 + quarter + down + 1 + + + + C + 5 + + 12 + 1 + quarter + down + 1 + + + 48 + + + + E + 3 + + 3 + 5 + 16th + down + 2 + begin + begin + + + + E + 3 + + 3 + 5 + 16th + down + 2 + continue + continue + + + + E + 3 + + 3 + 5 + 16th + down + 2 + continue + continue + + + + E + 3 + + 3 + 5 + 16th + down + 2 + end + end + + + + E + 3 + + 24 + 5 + half + down + 2 + + + + E + 3 + + 6 + 5 + eighth + down + 2 + begin + + + + E + 3 + + 6 + 5 + eighth + down + 2 + end + + + + + + C + 5 + + 6 + 1 + eighth + down + 1 + begin + + + + C + 5 + + 6 + 1 + eighth + down + 1 + continue + + + + C + 5 + + 6 + 1 + eighth + down + 1 + continue + + + + C + 5 + + 6 + 1 + eighth + down + 1 + end + + + + C + 5 + + 6 + 1 + eighth + down + 1 + begin + + + + C + 5 + + 6 + 1 + eighth + down + 1 + continue + + + + C + 5 + + 6 + 1 + eighth + down + 1 + continue + + + + C + 5 + + 6 + 1 + eighth + down + 1 + end + + + 48 + + + + E + 3 + + 12 + 5 + quarter + down + 2 + + + + E + 3 + + 24 + 5 + half + down + 2 + + + + E + 3 + + 12 + 5 + quarter + down + 2 + + + light-heavy + + + + \ No newline at end of file diff --git a/tests/integration/__image_snapshots__/01a-Pitches-Pitches_900px.png b/tests/integration/__image_snapshots__/01a-Pitches-Pitches_900px.png index a7e188639..8009b7561 100644 Binary files a/tests/integration/__image_snapshots__/01a-Pitches-Pitches_900px.png and b/tests/integration/__image_snapshots__/01a-Pitches-Pitches_900px.png differ diff --git a/tests/integration/__image_snapshots__/01b-Pitches-Intervals_900px.png b/tests/integration/__image_snapshots__/01b-Pitches-Intervals_900px.png index 9a14e3512..74944aa2e 100644 Binary files a/tests/integration/__image_snapshots__/01b-Pitches-Intervals_900px.png and b/tests/integration/__image_snapshots__/01b-Pitches-Intervals_900px.png differ diff --git a/tests/integration/__image_snapshots__/01e-Pitches-ParenthesizedAccidentals_900px.png b/tests/integration/__image_snapshots__/01e-Pitches-ParenthesizedAccidentals_900px.png index 0cd8b9ca6..f0198e4e5 100644 Binary files a/tests/integration/__image_snapshots__/01e-Pitches-ParenthesizedAccidentals_900px.png and b/tests/integration/__image_snapshots__/01e-Pitches-ParenthesizedAccidentals_900px.png differ diff --git a/tests/integration/__image_snapshots__/01f-Pitches-ParenthesizedMicrotoneAccidentals_900px.png b/tests/integration/__image_snapshots__/01f-Pitches-ParenthesizedMicrotoneAccidentals_900px.png index 8dfa74b57..26a110736 100644 Binary files a/tests/integration/__image_snapshots__/01f-Pitches-ParenthesizedMicrotoneAccidentals_900px.png and b/tests/integration/__image_snapshots__/01f-Pitches-ParenthesizedMicrotoneAccidentals_900px.png differ diff --git a/tests/integration/__image_snapshots__/02a-Rests-Durations_900px.png b/tests/integration/__image_snapshots__/02a-Rests-Durations_900px.png index 33829fe29..51f964a87 100644 Binary files a/tests/integration/__image_snapshots__/02a-Rests-Durations_900px.png and b/tests/integration/__image_snapshots__/02a-Rests-Durations_900px.png differ diff --git a/tests/integration/__image_snapshots__/02c-Rests-MultiMeasureRests_900px.png b/tests/integration/__image_snapshots__/02c-Rests-MultiMeasureRests_900px.png index bdd920f45..9d3f36929 100644 Binary files a/tests/integration/__image_snapshots__/02c-Rests-MultiMeasureRests_900px.png and b/tests/integration/__image_snapshots__/02c-Rests-MultiMeasureRests_900px.png differ diff --git a/tests/integration/__image_snapshots__/02d-Rests-Multimeasure-TimeSignatures_900px.png b/tests/integration/__image_snapshots__/02d-Rests-Multimeasure-TimeSignatures_900px.png index 65e2ec009..29d0dd69f 100644 Binary files a/tests/integration/__image_snapshots__/02d-Rests-Multimeasure-TimeSignatures_900px.png and b/tests/integration/__image_snapshots__/02d-Rests-Multimeasure-TimeSignatures_900px.png differ diff --git a/tests/integration/__image_snapshots__/02e-Rests-NoType_900px.png b/tests/integration/__image_snapshots__/02e-Rests-NoType_900px.png index 1cd97c2ae..87c462ef1 100644 Binary files a/tests/integration/__image_snapshots__/02e-Rests-NoType_900px.png and b/tests/integration/__image_snapshots__/02e-Rests-NoType_900px.png differ diff --git a/tests/integration/__image_snapshots__/03a-Rhythm-Durations_900px.png b/tests/integration/__image_snapshots__/03a-Rhythm-Durations_900px.png index 4b3a27d64..c2cbe7efc 100644 Binary files a/tests/integration/__image_snapshots__/03a-Rhythm-Durations_900px.png and b/tests/integration/__image_snapshots__/03a-Rhythm-Durations_900px.png differ diff --git a/tests/integration/__image_snapshots__/03c-Rhythm-DivisionChange_900px.png b/tests/integration/__image_snapshots__/03c-Rhythm-DivisionChange_900px.png index 772651de6..a9a59ec13 100644 Binary files a/tests/integration/__image_snapshots__/03c-Rhythm-DivisionChange_900px.png and b/tests/integration/__image_snapshots__/03c-Rhythm-DivisionChange_900px.png differ diff --git a/tests/integration/__image_snapshots__/03d-Rhythm-DottedDurations-Factors_900px.png b/tests/integration/__image_snapshots__/03d-Rhythm-DottedDurations-Factors_900px.png index e8b38b657..10131e639 100644 Binary files a/tests/integration/__image_snapshots__/03d-Rhythm-DottedDurations-Factors_900px.png and b/tests/integration/__image_snapshots__/03d-Rhythm-DottedDurations-Factors_900px.png differ diff --git a/tests/integration/__image_snapshots__/11a-TimeSignatures_900px.png b/tests/integration/__image_snapshots__/11a-TimeSignatures_900px.png index 66ad96d63..447310466 100644 Binary files a/tests/integration/__image_snapshots__/11a-TimeSignatures_900px.png and b/tests/integration/__image_snapshots__/11a-TimeSignatures_900px.png differ diff --git a/tests/integration/__image_snapshots__/11d-TimeSignatures-CompoundMultiple_900px.png b/tests/integration/__image_snapshots__/11d-TimeSignatures-CompoundMultiple_900px.png index 6111e1bb5..da1b02b14 100644 Binary files a/tests/integration/__image_snapshots__/11d-TimeSignatures-CompoundMultiple_900px.png and b/tests/integration/__image_snapshots__/11d-TimeSignatures-CompoundMultiple_900px.png differ diff --git a/tests/integration/__image_snapshots__/11e-TimeSignatures-CompoundMixed_900px.png b/tests/integration/__image_snapshots__/11e-TimeSignatures-CompoundMixed_900px.png index 314b5ddc5..2b6ac2392 100644 Binary files a/tests/integration/__image_snapshots__/11e-TimeSignatures-CompoundMixed_900px.png and b/tests/integration/__image_snapshots__/11e-TimeSignatures-CompoundMixed_900px.png differ diff --git a/tests/integration/__image_snapshots__/11h-TimeSignatures-SenzaMisura_900px.png b/tests/integration/__image_snapshots__/11h-TimeSignatures-SenzaMisura_900px.png index fe220b708..fd1e5e84d 100644 Binary files a/tests/integration/__image_snapshots__/11h-TimeSignatures-SenzaMisura_900px.png and b/tests/integration/__image_snapshots__/11h-TimeSignatures-SenzaMisura_900px.png differ diff --git a/tests/integration/__image_snapshots__/12a-Clefs_900px.png b/tests/integration/__image_snapshots__/12a-Clefs_900px.png index 9d3c73df0..2b21f245e 100644 Binary files a/tests/integration/__image_snapshots__/12a-Clefs_900px.png and b/tests/integration/__image_snapshots__/12a-Clefs_900px.png differ diff --git a/tests/integration/__image_snapshots__/12b-Clefs-NoKeyOrClef_900px.png b/tests/integration/__image_snapshots__/12b-Clefs-NoKeyOrClef_900px.png index 0233be130..b874d8a56 100644 Binary files a/tests/integration/__image_snapshots__/12b-Clefs-NoKeyOrClef_900px.png and b/tests/integration/__image_snapshots__/12b-Clefs-NoKeyOrClef_900px.png differ diff --git a/tests/integration/__image_snapshots__/13a-KeySignatures_900px.png b/tests/integration/__image_snapshots__/13a-KeySignatures_900px.png index 61e538109..751129d00 100644 Binary files a/tests/integration/__image_snapshots__/13a-KeySignatures_900px.png and b/tests/integration/__image_snapshots__/13a-KeySignatures_900px.png differ diff --git a/tests/integration/__image_snapshots__/13b-KeySignatures-ChurchModes_900px.png b/tests/integration/__image_snapshots__/13b-KeySignatures-ChurchModes_900px.png index cc595f2b8..96c5526c0 100644 Binary files a/tests/integration/__image_snapshots__/13b-KeySignatures-ChurchModes_900px.png and b/tests/integration/__image_snapshots__/13b-KeySignatures-ChurchModes_900px.png differ diff --git a/tests/integration/__image_snapshots__/31c-MetronomeMarks_900px.png b/tests/integration/__image_snapshots__/31c-MetronomeMarks_900px.png index 89e906c02..4aadc71af 100644 Binary files a/tests/integration/__image_snapshots__/31c-MetronomeMarks_900px.png and b/tests/integration/__image_snapshots__/31c-MetronomeMarks_900px.png differ diff --git a/tests/integration/__image_snapshots__/33a-Spanners_900px.png b/tests/integration/__image_snapshots__/33a-Spanners_900px.png index df0be7b7f..e48fb2290 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__/41a-MultiParts-Partorder_900px.png b/tests/integration/__image_snapshots__/41a-MultiParts-Partorder_900px.png index 7250a182a..bbd187902 100644 Binary files a/tests/integration/__image_snapshots__/41a-MultiParts-Partorder_900px.png and b/tests/integration/__image_snapshots__/41a-MultiParts-Partorder_900px.png differ diff --git a/tests/integration/__image_snapshots__/multi_part_formatting_900px.png b/tests/integration/__image_snapshots__/multi_part_formatting_900px.png new file mode 100644 index 000000000..3ecd8430b Binary files /dev/null and b/tests/integration/__image_snapshots__/multi_part_formatting_900px.png differ diff --git a/tests/integration/__image_snapshots__/multi_stave_single_part_formatting_900px.png b/tests/integration/__image_snapshots__/multi_stave_single_part_formatting_900px.png new file mode 100644 index 000000000..cf0f015c6 Binary files /dev/null and b/tests/integration/__image_snapshots__/multi_stave_single_part_formatting_900px.png differ diff --git a/tests/integration/__image_snapshots__/multi_system_spanners_400px.png b/tests/integration/__image_snapshots__/multi_system_spanners_400px.png index db65daedb..310b79116 100644 Binary files a/tests/integration/__image_snapshots__/multi_system_spanners_400px.png and b/tests/integration/__image_snapshots__/multi_system_spanners_400px.png differ diff --git a/tests/integration/vexml.test.ts b/tests/integration/vexml.test.ts index 66a0d419f..dfd2a630a 100644 --- a/tests/integration/vexml.test.ts +++ b/tests/integration/vexml.test.ts @@ -24,7 +24,8 @@ describe('vexml', () => { it.each([ { filename: 'multi_system_spanners.musicxml', width: 400 }, - // format hint + { filename: 'multi_stave_single_part_formatting.musicxml', width: 900 }, + { filename: 'multi_part_formatting.musicxml', width: 900 }, ])(`$filename ($width px)`, async (t) => { const { document, vexmlDiv, screenshotElementSelector } = setup();