diff --git a/jest.config.js b/jest.config.js index c6c6aaea4..021a05765 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,6 +7,10 @@ export default { testPathIgnorePatterns: ['/node_modules/', '/cli/'], moduleNameMapper: { '@/(.*)': '/src/$1', + // @stringsync/mdom only exposes an ESM "import" export condition, which jest's CJS resolver can't follow. Point the + // bare specifier at its dist entry and let babel transform it (see transformIgnorePatterns). + '^@stringsync/mdom$': '/node_modules/@stringsync/mdom/dist/index.js', }, + transformIgnorePatterns: ['/node_modules/(?!(@stringsync/mdom)/)'], reporters: ['default', 'jest-image-snapshot/src/outdated-snapshot-reporter.js'], }; diff --git a/package-lock.json b/package-lock.json index 27402bffc..a07b66c53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.2.3", "license": "MIT", "dependencies": { + "@stringsync/mdom": "^0.1.0", "jszip": "3.10.1", "vexflow": "5.0.0" }, @@ -3616,6 +3617,18 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@stringsync/mdom": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@stringsync/mdom/-/mdom-0.1.0.tgz", + "integrity": "sha512-dcJ5ofP6EEF+LBFIj8fFh0nUFE0XXDkeIp6BMcmPikF4yPTeWYMviVztk9VDm6gFpr1p7pfqPHhz9VPlTzfQQA==", + "dependencies": { + "jszip": "^3.10.1", + "xml-js": "^1.6.11" + }, + "bin": { + "mdom": "cli/index.ts" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", @@ -12067,6 +12080,15 @@ "dev": true, "license": "MIT" }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -13974,6 +13996,18 @@ } } }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", diff --git a/package.json b/package.json index 5268b3f6b..516ea7919 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "license": "MIT", "scripts": {}, "dependencies": { + "@stringsync/mdom": "^0.1.0", "jszip": "3.10.1", "vexflow": "5.0.0" }, diff --git a/src/parsing/index.ts b/src/parsing/index.ts index 70f7e6a4d..b071b5cf2 100644 --- a/src/parsing/index.ts +++ b/src/parsing/index.ts @@ -1,2 +1,5 @@ -export * from './mxl'; -export * from './musicxml'; +// Production parsing is powered by the mdom adapter (@stringsync/mdom). The class names are kept for API compatibility: +// `MusicXMLParser`/`MXLParser` still parse MusicXML/MXL, now by reading mdom instead of the @/musicxml DOM wrappers. +// The legacy readers remain importable from '@/parsing/musicxml' and '@/parsing/mxl' for the IR-equivalence test. +export { MdomParser as MusicXMLParser, type MdomParserOptions as MusicXMLParserOptions } from './mdom'; +export { MdomMXLParser as MXLParser, type MdomMXLParserOptions as MXLParserOptions } from './mdom'; diff --git a/src/parsing/mdom/eventcalculator.ts b/src/parsing/mdom/eventcalculator.ts new file mode 100644 index 000000000..32e2e6a41 --- /dev/null +++ b/src/parsing/mdom/eventcalculator.ts @@ -0,0 +1,430 @@ +import * as mdom from '@stringsync/mdom'; +import { Fraction } from '@/util'; +import { DYNAMIC_TYPES } from '@/musicxml'; +import { Clef } from '@/parsing/musicxml/clef'; +import { Key } from '@/parsing/musicxml/key'; +import { Time } from '@/parsing/musicxml/time'; +import { Note } from '@/parsing/musicxml/note'; +import { Rest } from '@/parsing/musicxml/rest'; +import { Chord } from '@/parsing/musicxml/chord'; +import { StaveCount } from '@/parsing/musicxml/stavecount'; +import { StaveLineCount } from '@/parsing/musicxml/stavelinecount'; +import { Metronome } from '@/parsing/musicxml/metronome'; +import { Dynamics } from '@/parsing/musicxml/dynamics'; +import { Wedge } from '@/parsing/musicxml/wedge'; +import { Pedal } from '@/parsing/musicxml/pedal'; +import { OctaveShift } from '@/parsing/musicxml/octaveshift'; +import { ChordSymbol } from '@/parsing/musicxml/chordsymbol'; +import { DynamicType } from '@/parsing/musicxml/enums'; +import { MeasureEvent } from '@/parsing/musicxml/types'; +import { Config } from '@/config'; +import { Logger } from '@/debug'; + +/** + * Produces the same {@link MeasureEvent} stream that the legacy `EventCalculator` emits, but sourced from an mdom + * document instead of the `@/musicxml` wrappers. It walks each measure's raw children in document order and replicates + * the legacy Fraction accumulation (including /) so onset/duration representations match exactly. + */ +export class MdomEventCalculator { + private measureBeat = Fraction.zero(); + private events = new Array(); + private quarterNoteDivisions = 1; + + // partId -> voiceId + private previousExplicitVoiceId: Record = {}; + // partId -> staveNumber + private previousExplicitStaveNumber: Record = {}; + // partId -> staveCount + private previousExplicitStaveCount: Record = {}; + + // staveNumber -> Key + private previousKeys = new Map(); + + // Grace notes seen since the last principal note, awaiting attachment to the next principal note/chord. + private pendingGraces = new Array(); + + // The most recent chord symbol, awaiting attachment to the next eligible note/rest/chord. + private pendingChordSymbol: ChordSymbol | null = null; + + constructor(private config: Config, private log: Logger, private score: mdom.Score) {} + + calculate(): MeasureEvent[] { + this.events = []; + + for (const part of this.score.parts) { + this.quarterNoteDivisions = 1; + this.previousKeys = new Map(); + + const partId = part.getAttribute('id') ?? ''; + const measures = part.measures; + + this.previousExplicitStaveNumber[partId] = 1; + this.previousExplicitVoiceId[partId] = '1'; + this.previousExplicitStaveCount[partId] = 1; + + for (let measureIndex = 0; measureIndex < measures.length; measureIndex++) { + this.measureBeat = Fraction.zero(); + this.pendingGraces = []; + this.pendingChordSymbol = null; + const measure = measures[measureIndex]; + const chordLeads = this.getChordLeads(measure); + + for (const node of measure.children) { + if (node instanceof mdom.MElement) { + this.process(node, partId, measureIndex, chordLeads); + } + } + } + } + + return this.events; + } + + /** Maps each chord lead note (the non-`` head) to the full group of notes that sound at its onset. */ + private getChordLeads(measure: mdom.Measure): Map { + const leads = new Map(); + for (const chord of measure.chords) { + leads.set(chord.notes[0], chord.notes); + } + return leads; + } + + private process( + node: mdom.MElement, + partId: string, + measureIndex: number, + chordLeads: Map + ): void { + if (node instanceof mdom.Note) { + this.processNote(node, partId, measureIndex, chordLeads); + } else if (node.tag === 'backup') { + this.processBackup(node); + } else if (node.tag === 'forward') { + this.processForward(node); + } else if (node.tag === 'attributes') { + this.processAttributes(node, partId, measureIndex); + } else if (node instanceof mdom.Direction) { + this.processDirection(node, partId, measureIndex); + } else if (node.tag === 'harmony') { + // Keep the previous pending symbol when this has no (mirrors `?? pending`). + this.pendingChordSymbol = + ChordSymbol.fromMdom(this.config, this.log, { harmony: node }) ?? this.pendingChordSymbol; + } + } + + private processNote( + note: mdom.Note, + partId: string, + measureIndex: number, + chordLeads: Map + ): void { + const staveNumber = this.resolveStaveNumber(partId, this.rawStaveNumber(note)); + const voiceId = this.resolveVoiceId(partId, this.rawVoice(note)); + + // Grace notes (including grace chord members) carry no duration; accumulate them for the next principal note. + if (note.isGrace) { + this.pendingGraces.push(note); + return; + } + + const quarterNotes = Number(note.child('duration')?.text ?? 0); + const duration = new Fraction(quarterNotes, this.quarterNoteDivisions); + + if (note.isChordMember) { + return; + } + + const graceNotes = this.pendingGraces; + this.pendingGraces = []; + + const chordSymbol = this.pendingChordSymbol; + this.pendingChordSymbol = null; + + const group = chordLeads.get(note); + if (group && group.length > 1) { + this.events.push({ + type: 'chord', + partId, + measureIndex, + staveNumber, + voiceId, + measureBeat: this.measureBeat, + duration, + chord: Chord.fromMdom( + this.config, + this.log, + this.measureBeat, + duration, + { notes: group }, + chordSymbol, + graceNotes + ), + }); + } else if (note.isRest) { + this.events.push({ + type: 'rest', + partId, + measureIndex, + staveNumber, + voiceId, + measureBeat: this.measureBeat, + duration, + rest: Rest.fromMdom(this.config, this.log, this.measureBeat, duration, { note }, chordSymbol), + }); + } else { + this.events.push({ + type: 'note', + partId, + measureIndex, + staveNumber, + voiceId, + measureBeat: this.measureBeat, + duration, + note: Note.fromMdom(this.config, this.log, this.measureBeat, duration, { note }, chordSymbol, graceNotes), + }); + } + + this.measureBeat = this.measureBeat.add(duration); + } + + private processBackup(node: mdom.MElement): void { + const quarterNotes = Number(node.child('duration')?.text ?? 0); + const duration = new Fraction(quarterNotes, this.quarterNoteDivisions); + this.measureBeat = this.measureBeat.subtract(duration); + if (this.measureBeat.isLessThan(Fraction.zero())) { + this.measureBeat = Fraction.zero(); + } + } + + private processForward(node: mdom.MElement): void { + const quarterNotes = Number(node.child('duration')?.text ?? 0); + const duration = new Fraction(quarterNotes, this.quarterNoteDivisions); + this.measureBeat = this.measureBeat.add(duration); + } + + private processAttributes(attributes: mdom.MElement, partId: string, measureIndex: number): void { + const rawDivisions = attributes.child('divisions')?.text; + if (typeof rawDivisions === 'string') { + this.quarterNoteDivisions = parseInt(rawDivisions, 10) || this.quarterNoteDivisions; + } + + const rawStaves = attributes.child('staves')?.text; + const explicitStaveCount = typeof rawStaves === 'string' ? parseInt(rawStaves, 10) : null; + const staveCount = explicitStaveCount ?? this.previousExplicitStaveCount[partId]; + if (explicitStaveCount) { + this.events.push({ + type: 'stavecount', + partId, + measureIndex, + measureBeat: this.measureBeat, + staveCount: new StaveCount(this.config, this.log, partId, staveCount), + }); + } + + for (const staveDetails of attributes.childrenNamed('staff-details')) { + const staveLineCount = StaveLineCount.fromMdom(this.config, this.log, partId, { staveDetails }); + this.events.push({ + type: 'stavelinecount', + partId, + measureIndex, + measureBeat: this.measureBeat, + staveNumber: staveLineCount.getStaveNumber(), + staveLineCount, + }); + } + + for (const clefNode of attributes.childrenOfType(mdom.Clef)) { + const clef = Clef.fromMdom(this.config, this.log, partId, { clef: clefNode }); + this.events.push({ + type: 'clef', + partId, + measureIndex, + measureBeat: this.measureBeat, + staveNumber: clef.getStaveNumber(), + clef, + }); + } + + // Keys can apply to a specific stave or to all staves. We track the previous key to know if cancel accidentals are + // needed. + for (const keyNode of attributes.childrenOfType(mdom.Key)) { + const rawNumber = keyNode.getAttribute('number'); + const staveNumber = rawNumber ? parseInt(rawNumber, 10) : null; + + if (typeof staveNumber === 'number') { + this.resolveStaveNumber(partId, staveNumber); + const previousKey = this.previousKeys.get(staveNumber) ?? null; + const key = Key.fromMdom(this.config, this.log, partId, staveNumber, previousKey, { key: keyNode }); + this.previousKeys.set(staveNumber, key); + this.events.push({ type: 'key', partId, measureIndex, measureBeat: this.measureBeat, staveNumber, key }); + } else { + for (let index = 0; index < staveCount; index++) { + const previousKey = this.previousKeys.get(index + 1) ?? null; + const key = Key.fromMdom(this.config, this.log, partId, index + 1, previousKey, { key: keyNode }); + this.previousKeys.set(index + 1, key); + this.events.push({ + type: 'key', + partId, + measureIndex, + measureBeat: this.measureBeat, + staveNumber: index + 1, + key, + }); + } + } + } + + const times = attributes + .childrenOfType(mdom.Time) + .flatMap((timeNode) => { + const rawNumber = timeNode.getAttribute('number'); + const staveNumber = rawNumber ? parseInt(rawNumber, 10) : null; + if (typeof staveNumber === 'number') { + return [ + Time.fromMdom(this.config, this.log, partId, this.resolveStaveNumber(partId, staveNumber), { + time: timeNode, + }), + ]; + } else { + return Time.fromMdomMulti(this.config, this.log, partId, staveCount, { time: timeNode }); + } + }) + .filter((time): time is Time => time !== null); + for (const time of times) { + this.events.push({ + type: 'time', + partId, + measureIndex, + measureBeat: this.measureBeat, + staveNumber: time.getStaveNumber(), + time, + }); + } + + const measureStyle = attributes + .childrenNamed('measure-style') + .find((measureStyle) => Number(measureStyle.child('multiple-rest')?.text ?? 0) > 0); + if (measureStyle) { + const measureCount = Number(measureStyle.child('multiple-rest')?.text ?? 0); + const rawNumber = measureStyle.getAttribute('number'); + const staveNumber = rawNumber ? parseInt(rawNumber, 10) : null; + this.events.push({ + type: 'multirest', + partId, + measureIndex, + measureBeat: this.measureBeat, + measureCount, + staveNumber, + }); + } + } + + private processDirection(direction: mdom.Direction, partId: string, measureIndex: number): void { + const directionTypes = direction.childrenNamed('direction-type'); + + const metronomeElement = directionTypes.flatMap((dt) => dt.childrenNamed('metronome')).at(0); + if (metronomeElement) { + const metronome = Metronome.fromMdom(this.config, this.log, { metronome: metronomeElement }); + if (metronome) { + this.events.push({ type: 'metronome', partId, measureIndex, measureBeat: this.measureBeat, metronome }); + } + } + + if (directionTypes.some((dt) => dt.childrenNamed('segno').length > 0)) { + this.events.push({ type: 'segno', partId, measureIndex, measureBeat: this.measureBeat }); + } + + if (directionTypes.some((dt) => dt.childrenNamed('coda').length > 0)) { + this.events.push({ type: 'coda', partId, measureIndex, measureBeat: this.measureBeat }); + } + + const dynamicType = directionTypes + .flatMap((dt) => dt.childrenNamed('dynamics')) + .flatMap((dynamics) => dynamics.children.map((child) => (child as mdom.MElement).tag)) + .find((tag) => DYNAMIC_TYPES.includes(tag)) as DynamicType | undefined; + if (dynamicType) { + const staveNumber = this.resolveStaveNumber(partId, this.directionStaveNumber(direction)); + const voiceId = this.resolveVoiceId(partId, this.directionVoice(direction)); + this.events.push({ + type: 'dynamics', + partId, + measureIndex, + staveNumber, + voiceId, + measureBeat: this.measureBeat, + dynamics: new Dynamics(this.config, this.log, this.measureBeat, dynamicType), + }); + } + + const wedge = direction.wedges.at(0); + if (wedge) { + const staveNumber = this.resolveStaveNumber(partId, this.directionStaveNumber(direction)); + const voiceId = this.resolveVoiceId(partId, this.directionVoice(direction)); + this.events.push({ + type: 'wedge', + partId, + measureIndex, + measureBeat: this.measureBeat, + staveNumber, + voiceId, + wedge: Wedge.fromMdom({ direction, wedge }), + }); + } + + const pedal = direction.pedals.at(0); + if (pedal) { + const staveNumber = this.resolveStaveNumber(partId, this.directionStaveNumber(direction)); + const voiceId = this.resolveVoiceId(partId, this.directionVoice(direction)); + this.events.push({ + type: 'pedal', + partId, + measureIndex, + measureBeat: this.measureBeat, + staveNumber, + voiceId, + pedal: Pedal.fromMdom(this.config, this.log, { pedal }), + }); + } + + const octaveShift = direction.octaveShifts.at(0); + if (octaveShift) { + const staveNumber = this.resolveStaveNumber(partId, this.directionStaveNumber(direction)); + const voiceId = this.resolveVoiceId(partId, this.directionVoice(direction)); + this.events.push({ + type: 'octaveshift', + partId, + measureIndex, + measureBeat: this.measureBeat, + staveNumber, + voiceId, + octaveShift: OctaveShift.fromMdom(this.config, this.log, { octaveShift }), + }); + } + } + + private directionStaveNumber(direction: mdom.Direction): number | null { + const raw = direction.child('staff')?.text; + return typeof raw === 'string' ? parseInt(raw, 10) : null; + } + + private directionVoice(direction: mdom.Direction): string | null { + return direction.child('voice')?.text ?? null; + } + + private rawStaveNumber(note: mdom.Note): number | null { + const raw = note.child('staff')?.text; + return typeof raw === 'string' ? parseInt(raw, 10) : null; + } + + private rawVoice(note: mdom.Note): string | null { + return note.child('voice')?.text ?? null; + } + + private resolveVoiceId(partId: string, voiceId: string | null): string { + return (this.previousExplicitVoiceId[partId] = voiceId ?? this.previousExplicitVoiceId[partId]); + } + + private resolveStaveNumber(partId: string, staveNumber: number | null): number { + return (this.previousExplicitStaveNumber[partId] = staveNumber ?? this.previousExplicitStaveNumber[partId]); + } +} diff --git a/src/parsing/mdom/index.ts b/src/parsing/mdom/index.ts new file mode 100644 index 000000000..1d5d4cbba --- /dev/null +++ b/src/parsing/mdom/index.ts @@ -0,0 +1,2 @@ +export * from './mdomparser'; +export * from './mdommxlparser'; diff --git a/src/parsing/mdom/mdommxlparser.ts b/src/parsing/mdom/mdommxlparser.ts new file mode 100644 index 000000000..ab7725395 --- /dev/null +++ b/src/parsing/mdom/mdommxlparser.ts @@ -0,0 +1,31 @@ +import * as mxl from '@/mxl'; +import { MdomParser } from './mdomparser'; +import { Document } from '@/data'; +import { Config, DEFAULT_CONFIG } from '@/config'; +import { Logger, NoopLogger } from '@/debug'; + +export type MdomMXLParserOptions = { + config?: Partial; + logger?: Logger; +}; + +/** Parses an MXL (compressed MusicXML) blob via the mdom adapter. */ +export class MdomMXLParser { + private config: Config; + private log: Logger; + + constructor(opts?: MdomMXLParserOptions) { + this.config = { ...DEFAULT_CONFIG, ...opts?.config }; + this.log = opts?.logger ?? new NoopLogger(); + } + + async parse(blob: Blob): Promise { + const musicXML = await this.raw(blob); + return new MdomParser({ config: this.config, logger: this.log }).parse(musicXML); + } + + /** Returns the MusicXML document as a string. */ + async raw(blob: Blob): Promise { + return new mxl.MXL(blob).getMusicXML(); + } +} diff --git a/src/parsing/mdom/mdomparser.ts b/src/parsing/mdom/mdomparser.ts new file mode 100644 index 000000000..98a3d2c67 --- /dev/null +++ b/src/parsing/mdom/mdomparser.ts @@ -0,0 +1,38 @@ +import * as data from '@/data'; +import * as mdom from '@stringsync/mdom'; +import * as errors from '@/errors'; +import { MdomScore } from './score'; +import { Config, DEFAULT_CONFIG } from '@/config'; +import { Logger, NoopLogger } from '@/debug'; + +export type MdomParserOptions = { + config?: Partial; + logger?: Logger; +}; + +/** Parses MusicXML into a vexml data document, sourcing the data from the `@stringsync/mdom` model. */ +export class MdomParser { + private config: Config; + private log: Logger; + + constructor(opts?: MdomParserOptions) { + this.config = { ...DEFAULT_CONFIG, ...opts?.config }; + this.log = opts?.logger ?? new NoopLogger(); + } + + parse(musicXMLSrc: string | Document): data.Document { + let xml: string; + if (typeof musicXMLSrc === 'string') { + xml = musicXMLSrc; + } else if (musicXMLSrc instanceof Document) { + xml = new XMLSerializer().serializeToString(musicXMLSrc); + } else { + throw new errors.VexmlError(`Invalid source type: ${musicXMLSrc}`); + } + + const document = new mdom.MDOMParser().parseFromString(xml); + const score = MdomScore.create(this.config, this.log, document.score); + + return new data.Document(score.parse()); + } +} diff --git a/src/parsing/mdom/score.ts b/src/parsing/mdom/score.ts new file mode 100644 index 000000000..19e72b3e1 --- /dev/null +++ b/src/parsing/mdom/score.ts @@ -0,0 +1,50 @@ +import * as data from '@/data'; +import * as mdom from '@stringsync/mdom'; +import { MdomSystem } from './system'; +import { IdProvider } from '@/parsing/musicxml/idprovider'; +import { ScoreContext } from '@/parsing/musicxml/contexts'; +import { Config } from '@/config'; +import { Logger } from '@/debug'; + +export class MdomScore { + private constructor( + private config: Config, + private log: Logger, + private idProvider: IdProvider, + private title: string, + private partLabels: string[], + private systems: MdomSystem[] + ) {} + + static create(config: Config, log: Logger, score: mdom.Score): MdomScore { + const idProvider = new IdProvider(); + // Legacy reads only (trimmed); it does not fall back to like mdom's .title. + const title = score.child('movement-title')?.text?.trim() ?? ''; + // Mirror legacy getPartDetails(): labels come from , not the actual elements. + const partLabels = (score.child('part-list')?.childrenNamed('score-part') ?? []).map( + (scorePart) => scorePart.child('part-name')?.text ?? '' + ); + + // When parsing, we assume a single system. Pre-rendering determines the minimum widths used to split into multiple + // systems if a constrained width requires it. + const systems = [MdomSystem.create(config, log, score)]; + + return new MdomScore(config, log, idProvider, title, partLabels, systems); + } + + parse(): data.Score { + const scoreCtx = new ScoreContext(this.idProvider); + + return { + type: 'score', + title: this.title, + partLabels: this.partLabels, + systems: this.systems.map((s) => s.parse(scoreCtx)), + curves: scoreCtx.getCurves(), + wedges: scoreCtx.getWedges(), + pedals: scoreCtx.getPedals(), + octaveShifts: scoreCtx.getOctaveShifts(), + vibratos: scoreCtx.getVibratos(), + }; + } +} diff --git a/src/parsing/mdom/system.ts b/src/parsing/mdom/system.ts new file mode 100644 index 000000000..947d15861 --- /dev/null +++ b/src/parsing/mdom/system.ts @@ -0,0 +1,207 @@ +import * as data from '@/data'; +import * as mdom from '@stringsync/mdom'; +import * as util from '@/util'; +import * as conversions from '@/parsing/musicxml/conversions'; +import { Measure } from '@/parsing/musicxml/measure'; +import { Signature } from '@/parsing/musicxml/signature'; +import { ScoreContext, SystemContext } from '@/parsing/musicxml/contexts'; +import { JumpGroup } from '@/parsing/musicxml/jumpgroup'; +import { MdomEventCalculator } from './eventcalculator'; +import { Config } from '@/config'; +import { Logger } from '@/debug'; + +type BarlineLocation = 'left' | 'right'; + +export class MdomSystem { + private constructor(private config: Config, private log: Logger, private measures: Measure[]) {} + + static create(config: Config, log: Logger, score: mdom.Score): MdomSystem { + const partIds = score.parts.map((part) => part.getAttribute('id') ?? ''); + + const measureCount = util.max(score.parts.map((part) => part.measures.length)); + const measureLabels = MdomSystem.getMeasureLabels(measureCount, score); + const measureEvents = new MdomEventCalculator(config, log, score).calculate(); + + const jumpGroups = MdomSystem.getJumpGroups(config, log, measureCount, score); + + const startBarlineStyles = MdomSystem.getBarlineStyles(measureCount, 'left', score, jumpGroups); + const endBarlineStyles = MdomSystem.getBarlineStyles(measureCount, 'right', score, jumpGroups); + + const measures = new Array(measureCount); + + let signature = Signature.default(config, log); + + for (let measureIndex = 0; measureIndex < measureCount; measureIndex++) { + const measure = Measure.create( + config, + log, + signature, + measureIndex, + measureLabels[measureIndex], + measureEvents.filter((event) => event.measureIndex === measureIndex), + partIds, + jumpGroups[measureIndex], + startBarlineStyles[measureIndex], + endBarlineStyles[measureIndex] + ); + measures[measureIndex] = measure; + signature = measure.getLastSignature(); + } + + return new MdomSystem(config, log, measures); + } + + private static getMeasureLabels(measureCount: number, score: mdom.Score): Array { + const measureLabels = new Array(measureCount).fill(null); + + const part = util.first(score.parts); + if (!part) { + return measureLabels; + } + + const measures = part.measures; + + for (let measureIndex = 0; measureIndex < measureCount; measureIndex++) { + const measure = measures[measureIndex]; + if (measure.getAttribute('implicit') === 'yes') { + measureLabels[measureIndex] = null; + } + + const number = parseInt(measure.number, 10); + if (Number.isInteger(number) && !Number.isNaN(number)) { + measureLabels[measureIndex] = number; + } else { + measureLabels[measureIndex] = measureIndex + 1; + } + } + + return measureLabels; + } + + private static getJumpGroups(config: Config, log: Logger, measureCount: number, score: mdom.Score): Array { + const jumpGroups = new Array(); + for (let measureIndex = 0; measureIndex < measureCount; measureIndex++) { + jumpGroups.push(MdomSystem.getJumpGroup(config, log, measureIndex, score)); + } + return jumpGroups; + } + + private static getJumpGroup(config: Config, log: Logger, measureIndex: number, score: mdom.Score): JumpGroup { + const barlines = score.parts + .map((part) => part.measures[measureIndex]) + .filter((measure): measure is mdom.Measure => !!measure) + .flatMap((measure) => measure.barlines); + + const jumps = new Array(); + + const leftBarlines = barlines.filter((barline) => barline.repeat === 'forward' || barline.location === 'left'); + const rightBarlines = barlines.filter((barline) => barline.repeat === 'backward' || barline.location === 'right'); + + if (leftBarlines.some((barline) => barline.repeat !== null)) { + jumps.push({ type: 'repeatstart' }); + } + + const leftEnding = leftBarlines.find((barline) => barline.child('ending') !== null); + const rightEnding = rightBarlines.find((barline) => barline.child('ending') !== null); + const repeatEnd = rightBarlines.find((barline) => barline.repeat !== null); + if (leftEnding || rightEnding) { + const hasStart = leftEnding?.child('ending')?.getAttribute('type') === 'start'; + const hasStop = rightEnding?.child('ending')?.getAttribute('type') === 'stop'; + + let endingBracketType: data.EndingBracketType = 'mid'; + if (hasStart && hasStop) { + endingBracketType = 'both'; + } else if (hasStart) { + endingBracketType = 'begin'; + } else if (hasStop) { + endingBracketType = 'end'; + } + + const label = + leftEnding?.child('ending')?.text || + rightEnding?.child('ending')?.text || + leftEnding?.child('ending')?.getAttribute('number') || + rightEnding?.child('ending')?.getAttribute('number') || + ''; + + let times = 0; + if (repeatEnd) { + times = MdomSystem.getRepeatTimes(repeatEnd) ?? 1; + } + + jumps.push({ type: 'repeatending', times, label, endingBracketType }); + } else if (repeatEnd) { + jumps.push({ type: 'repeatend', times: MdomSystem.getRepeatTimes(repeatEnd) ?? 1 }); + } + + return new JumpGroup(config, log, jumps); + } + + private static getRepeatTimes(barline: mdom.Barline): number | null { + const raw = barline.child('repeat')?.getAttribute('times'); + return typeof raw === 'string' ? parseInt(raw, 10) : null; + } + + private static getBarlineStyles( + measureCount: number, + location: BarlineLocation, + score: mdom.Score, + jumpGroups: JumpGroup[] + ): Array { + const barlineStyles = new Array(measureCount).fill(null); + + for (let measureIndex = 0; measureIndex < measureCount; measureIndex++) { + const jumpGroup = jumpGroups[measureIndex]; + + let jumpGroupBarlineStyle: data.BarlineStyle | null = null; + if (location === 'left') { + jumpGroupBarlineStyle = jumpGroup.getStartBarlineStyle(); + } + if (location === 'right') { + jumpGroupBarlineStyle = jumpGroup.getEndBarlineStyle(); + } + + const barlineStyle = + jumpGroupBarlineStyle ?? + conversions.fromMusicXMLBarStyleToBarlineStyle( + score.parts + .flatMap((part) => part.measures[measureIndex]?.barlines ?? []) + .filter((barline) => barline.location === location) + // A with no defaults to 'regular' (matching the legacy wrapper). + .map((barline) => barline.barStyle ?? 'regular') + .at(0) as Parameters[0] + ); + + barlineStyles[measureIndex] = barlineStyle; + } + + return barlineStyles; + } + + parse(scoreCtx: ScoreContext): data.System { + const systemCtx = new SystemContext(scoreCtx); + + const parsedMeasures = new Array(); + + for (const measure of this.measures) { + const multiRestEvents = measure.getEvents().filter((event) => event.type === 'multirest'); + for (const multiRestEvent of multiRestEvents) { + systemCtx.incrementMultiRestCount( + multiRestEvent.partId, + multiRestEvent.staveNumber, + multiRestEvent.measureCount + ); + } + + const parsedMeasure = measure.parse(systemCtx); + parsedMeasures.push(parsedMeasure); + + systemCtx.decrementMultiRestCounts(); + } + + return { + type: 'system', + measures: parsedMeasures, + }; + } +} diff --git a/src/parsing/musicxml/annotation.ts b/src/parsing/musicxml/annotation.ts index b6c1de7d9..1e440bfac 100644 --- a/src/parsing/musicxml/annotation.ts +++ b/src/parsing/musicxml/annotation.ts @@ -1,5 +1,6 @@ import * as data from '@/data'; import * as musicxml from '@/musicxml'; +import type * as mdom from '@stringsync/mdom'; import { TextStateMachine } from './textstatemachine'; import { Config } from '@/config'; import { Logger } from '@/debug'; @@ -29,6 +30,30 @@ export class Annotation { return null; } + static fromMdomLyric(config: Config, log: Logger, mdom: { lyric: mdom.MElement }): Annotation { + const machine = new TextStateMachine(); + for (const child of mdom.lyric.children) { + const element = child as mdom.MElement; + if (element.tag === 'syllabic') { + machine.process({ type: 'syllabic', value: element.text ?? 'single' } as musicxml.LyricComponent); + } else if (element.tag === 'text') { + machine.process({ type: 'text', value: element.text ?? '' }); + } else if (element.tag === 'elision') { + machine.process({ type: 'elision', value: element.text ?? '' }); + } + } + return new Annotation(config, log, machine.getText(), 'center', 'bottom'); + } + + static fromMdomFingering(config: Config, log: Logger, mdom: { fingering: mdom.MElement }): Annotation | null { + const raw = mdom.fingering.text; + const number = typeof raw === 'string' ? parseInt(raw, 10) : NaN; + if (!Number.isNaN(number)) { + return new Annotation(config, log, `${number}`, 'center', 'top'); + } + return null; + } + parse(): data.Annotation { return { type: 'annotation', diff --git a/src/parsing/musicxml/articulation.ts b/src/parsing/musicxml/articulation.ts index 2dc3484a6..9bade5337 100644 --- a/src/parsing/musicxml/articulation.ts +++ b/src/parsing/musicxml/articulation.ts @@ -1,5 +1,6 @@ import * as data from '@/data'; import * as musicxml from '@/musicxml'; +import type * as mdom from '@stringsync/mdom'; import { Config } from '@/config'; import { Logger } from '@/debug'; @@ -104,6 +105,101 @@ export class Articulation { return articulations; } + static fromMdom(config: Config, log: Logger, mdom: { note: mdom.Note }): Articulation[] { + const articulations = new Array(); + const notations = mdom.note.childrenNamed('notations'); + + function add(articulationType: data.ArticulationType, placement: data.ArticulationPlacement) { + articulations.push(new Articulation(config, log, articulationType, placement)); + } + + function placementOf(element: mdom.MElement): data.ArticulationPlacement { + const placement = element.getAttribute('placement'); + return placement === 'above' || placement === 'below' ? placement : 'above'; + } + + notations + .flatMap((n) => n.childrenNamed('fermata')) + .forEach((fermata) => { + const type = fermata.getAttribute('type') ?? 'upright'; + const shape = fermata.text ?? 'normal'; + if (type === 'upright' && shape === 'normal') { + add('upright-normal-fermata', 'above'); + } else if (type === 'upright' && shape === 'angled') { + add('upright-angled-fermata', 'above'); + } else if (type === 'upright' && shape === 'square') { + add('upright-square-fermata', 'above'); + } else if (type === 'inverted' && shape === 'normal') { + add('inverted-normal-fermata', 'below'); + } else if (type === 'inverted' && shape === 'angled') { + add('inverted-angled-fermata', 'below'); + } else if (type === 'inverted' && shape === 'square') { + add('inverted-square-fermata', 'below'); + } + }); + + notations + .flatMap((n) => n.childrenNamed('technical')) + .forEach((technical) => { + technical.childrenNamed('harmonic').forEach(() => add('harmonic', 'above')); + technical.childrenNamed('open-string').forEach(() => add('open-string', 'above')); + technical.childrenNamed('double-tongue').forEach(() => add('double-tongue', 'above')); + technical.childrenNamed('triple-tongue').forEach(() => add('triple-tongue', 'above')); + technical.childrenNamed('stopped').forEach(() => add('stopped', 'above')); + technical.childrenNamed('snap-pizzicato').forEach(() => add('snap-pizzicato', 'above')); + technical.childrenNamed('tap').forEach(() => add('tap', 'above')); + technical.childrenNamed('heel').forEach(() => add('heel', 'above')); + technical.childrenNamed('toe').forEach(() => add('toe', 'above')); + technical.childrenNamed('up-bow').forEach(() => add('upstroke', 'above')); + technical.childrenNamed('down-bow').forEach(() => add('downstroke', 'above')); + }); + + notations + .flatMap((n) => n.childrenNamed('articulations')) + .forEach((articulation) => { + articulation.childrenNamed('accent').forEach((e) => add('accent', placementOf(e))); + articulation.childrenNamed('strong-accent').forEach((e) => add('strong-accent', placementOf(e))); + articulation.childrenNamed('staccato').forEach((e) => add('staccato', placementOf(e))); + articulation.childrenNamed('tenuto').forEach((e) => add('tenuto', placementOf(e))); + articulation.childrenNamed('detached-legato').forEach((e) => add('detached-legato', placementOf(e))); + articulation.childrenNamed('staccatissimo').forEach((e) => add('staccatissimo', placementOf(e))); + articulation.childrenNamed('scoop').forEach((e) => add('scoop', placementOf(e))); + articulation.childrenNamed('doit').forEach((e) => add('doit', placementOf(e))); + articulation.childrenNamed('falloff').forEach((e) => add('falloff', placementOf(e))); + articulation.childrenNamed('breath-mark').forEach((e) => add('breath-mark', placementOf(e))); + }); + + notations + .flatMap((n) => n.childrenNamed('ornaments')) + .forEach((ornament) => { + ornament.childrenNamed('trill-mark').forEach(() => add('trill-mark', 'above')); + ornament.childrenNamed('mordent').forEach(() => add('mordent', 'above')); + ornament.childrenNamed('inverted-mordent').forEach(() => add('inverted-mordent', 'above')); + }); + + for (const n of notations) { + const arpeggiate = n.childrenNamed('arpeggiate'); + if (arpeggiate.length === 0) { + continue; + } + switch (arpeggiate[0].getAttribute('direction')) { + case 'up': + // Yes, ROLL_DOWN is correct. + add('arpeggio-roll-down', 'above'); + break; + case 'down': + // Yes, ROLL_UP is correct. + add('arpeggio-roll-up', 'above'); + break; + default: + add('arpeggio-directionless', 'above'); + break; + } + } + + return articulations; + } + parse(): data.Articulation { return { type: 'articulation', diff --git a/src/parsing/musicxml/beam.ts b/src/parsing/musicxml/beam.ts index 2819bbb0c..c96410342 100644 --- a/src/parsing/musicxml/beam.ts +++ b/src/parsing/musicxml/beam.ts @@ -1,4 +1,5 @@ import * as musicxml from '@/musicxml'; +import type * as mdom from '@stringsync/mdom'; import { VoiceEntryContext } from './contexts'; import { Config } from '@/config'; import { Logger } from '@/debug'; @@ -22,6 +23,11 @@ export class Beam { return new Beam(config, log, phase); } + static fromMdom(config: Config, log: Logger, mdom: { beam: mdom.Beam }): Beam { + const phase: BeamPhase = mdom.beam.beamValue === 'begin' ? 'start' : 'continue'; + return new Beam(config, log, phase); + } + parse(voiceEntryCtx: VoiceEntryContext): string { if (this.phase === 'start') { return voiceEntryCtx.beginBeam(); diff --git a/src/parsing/musicxml/bend.ts b/src/parsing/musicxml/bend.ts index 2f0613933..21b2420bb 100644 --- a/src/parsing/musicxml/bend.ts +++ b/src/parsing/musicxml/bend.ts @@ -1,5 +1,6 @@ import * as data from '@/data'; import * as musicxml from '@/musicxml'; +import type * as mdom from '@stringsync/mdom'; import { Config } from '@/config'; import { Logger } from '@/debug'; @@ -30,6 +31,23 @@ export class Bend { return new Bend(config, log, bendType, semitones); } + static fromMdom(config: Config, log: Logger, mdom: { bend: mdom.MElement }): Bend { + const bend = mdom.bend; + const alterText = bend.child('bend-alter')?.text; + const semitones = typeof alterText === 'string' ? parseFloat(alterText) : 1; + + let bendType: data.BendType; + if (bend.child('pre-bend')) { + bendType = 'prebend'; + } else if (bend.child('release')) { + bendType = 'release'; + } else { + bendType = 'normal'; + } + + return new Bend(config, log, bendType, semitones); + } + parse(): data.Bend { return { type: 'bend', diff --git a/src/parsing/musicxml/chord.ts b/src/parsing/musicxml/chord.ts index 8dc5bb73c..789d267f0 100644 --- a/src/parsing/musicxml/chord.ts +++ b/src/parsing/musicxml/chord.ts @@ -1,5 +1,6 @@ import * as data from '@/data'; import * as musicxml from '@/musicxml'; +import type * as mdom from '@stringsync/mdom'; import * as util from '@/util'; import { Note } from './note'; import { ChordSymbol } from './chordsymbol'; @@ -30,6 +31,22 @@ export class Chord { return new Chord(config, log, notes, chordSymbol); } + static fromMdom( + config: Config, + log: Logger, + measureBeat: util.Fraction, + duration: util.Fraction, + mdom: { notes: mdom.Note[] }, + chordSymbol: ChordSymbol | null = null, + graceNotes: mdom.Note[] = [] + ): Chord { + // Grace notes attach to the chord's lead note (the head carries the chord's grace entries). + const notes = mdom.notes.map((note, index) => + Note.fromMdom(config, log, measureBeat, duration, { note }, null, index === 0 ? graceNotes : []) + ); + return new Chord(config, log, notes, chordSymbol); + } + parse(voiceCtx: VoiceContext): data.Chord { const parsed = this.notes.map((note) => note.parse(voiceCtx)); diff --git a/src/parsing/musicxml/chordsymbol.ts b/src/parsing/musicxml/chordsymbol.ts index 8ea7b5fe0..039e3cec9 100644 --- a/src/parsing/musicxml/chordsymbol.ts +++ b/src/parsing/musicxml/chordsymbol.ts @@ -1,5 +1,7 @@ import * as data from '@/data'; import * as musicxml from '@/musicxml'; +import type * as mdom from '@stringsync/mdom'; +import { CHORD_SYMBOL_DEGREE_TYPES, CHORD_SYMBOL_KINDS } from '@/musicxml'; import { Config } from '@/config'; import { Logger } from '@/debug'; @@ -35,6 +37,49 @@ export class ChordSymbol { ); } + static fromMdom(config: Config, log: Logger, mdom: { harmony: mdom.MElement }): ChordSymbol | null { + const harmony = mdom.harmony; + + const kindElement = harmony.child('kind'); + if (!kindElement) { + return null; + } + const rawKind = kindElement.text; + const kind: data.ChordSymbolKind = rawKind && CHORD_SYMBOL_KINDS.includes(rawKind) ? rawKind : 'none'; + const kindText = kindElement.getAttribute('text'); + + if (harmony.child('offset')) { + log.warn(' with is not supported; anchoring to next entry'); + } + + const stepAndAlter = (parent: 'root' | 'bass'): { step: string; alter: number } | null => { + const node = harmony.child(parent); + if (!node) { + return null; + } + const step = node.child(`${parent}-step`)?.text ?? null; + if (!step) { + return null; + } + const rawAlter = node.child(`${parent}-alter`)?.text; + return { step, alter: rawAlter != null ? parseFloat(rawAlter) : 0 }; + }; + + const degrees = harmony.childrenNamed('degree').map((degree) => { + const rawValue = degree.child('degree-value')?.text; + const rawAlter = degree.child('degree-alter')?.text; + const rawType = degree.child('degree-type')?.text; + return { + value: rawValue != null ? parseInt(rawValue, 10) : 0, + alter: rawAlter != null ? parseFloat(rawAlter) : 0, + degreeType: + rawType && CHORD_SYMBOL_DEGREE_TYPES.includes(rawType) ? rawType : ('add' as data.ChordSymbolDegreeType), + }; + }); + + return new ChordSymbol(config, log, stepAndAlter('root'), kind, kindText, stepAndAlter('bass'), degrees); + } + parse(): data.ChordSymbol { return { type: 'chordsymbol', diff --git a/src/parsing/musicxml/clef.ts b/src/parsing/musicxml/clef.ts index aee2baa31..4c7b49325 100644 --- a/src/parsing/musicxml/clef.ts +++ b/src/parsing/musicxml/clef.ts @@ -1,5 +1,6 @@ import * as data from '@/data'; import * as musicxml from '@/musicxml'; +import type * as mdom from '@stringsync/mdom'; import * as conversions from './conversions'; import { Config } from '@/config'; import { Logger } from '@/debug'; @@ -24,6 +25,15 @@ export class Clef { return new Clef(config, log, partId, musicXML.clef.getStaveNumber(), clefSign, musicXML.clef.getOctaveChange()); } + static fromMdom(config: Config, log: Logger, partId: string, mdom: { clef: mdom.Clef }): Clef { + const clef = mdom.clef; + const clefSign = conversions.fromClefPropertiesToClefSign(clef.sign as musicxml.ClefSign, clef.line); + // Prefer the raw child so an absent stays null (mdom's typed getter defaults to 0). + const rawOctaveChange = clef.child('clef-octave-change')?.text; + const octaveChange = typeof rawOctaveChange === 'string' ? parseInt(rawOctaveChange, 10) : null; + return new Clef(config, log, partId, parseInt(clef.staff, 10), clefSign, octaveChange); + } + parse(): data.Clef { return { type: 'clef', diff --git a/src/parsing/musicxml/curve.ts b/src/parsing/musicxml/curve.ts index ea2673bc3..f391c311c 100644 --- a/src/parsing/musicxml/curve.ts +++ b/src/parsing/musicxml/curve.ts @@ -1,11 +1,29 @@ import * as data from '@/data'; import * as musicxml from '@/musicxml'; +import type * as mdom from '@stringsync/mdom'; import { VoiceEntryContext } from './contexts'; import { Config } from '@/config'; import { Logger } from '@/debug'; type CurvePhase = 'start' | 'continue' | 'stop'; +function curvePlacement(element: mdom.MElement): data.CurvePlacement { + const placement = element.getAttribute('placement'); + return placement === 'above' || placement === 'below' ? placement : 'auto'; +} + +function curveOpening(element: mdom.MElement): data.CurveOpening { + // Yes, these translations are correct. + switch (element.getAttribute('orientation')) { + case 'over': + return 'down'; + case 'under': + return 'up'; + default: + return 'auto'; + } +} + /** A generic way of representing a curved connector in music notation. */ export class Curve { constructor( @@ -22,6 +40,46 @@ export class Curve { return [...Curve.createSlurs(config, log, musicXML), ...Curve.createTies(config, log, musicXML)]; } + /** Builds curves from a single mdom `` element, mirroring {@link create}. */ + static fromMdom(config: Config, log: Logger, mdom: { notations: mdom.MElement }): Curve[] { + const notations = mdom.notations; + const curves = new Array(); + + for (const slur of notations.childrenNamed('slur')) { + const phase: CurvePhase = slur.getAttribute('type') === 'start' ? 'start' : 'continue'; + + let curveNumber = parseInt(slur.getAttribute('number') ?? '1', 10); + let articulation: data.CurveArticulation = 'unspecified'; + + const slides = notations.childrenNamed('slide'); + const technicals = notations.childrenNamed('technical'); + const hammerOns = technicals.flatMap((t) => t.childrenNamed('hammer-on')); + const pullOffs = technicals.flatMap((t) => t.childrenNamed('pull-off')); + if (slides.length > 0) { + curveNumber = parseInt(slides[0].getAttribute('number') ?? '1', 10); + articulation = 'slide'; + } else if (hammerOns.length > 0) { + const number = hammerOns[0].getAttribute('number'); + curveNumber = number !== null ? parseInt(number, 10) : curveNumber; + articulation = 'hammeron'; + } else if (pullOffs.length > 0) { + const number = pullOffs[0].getAttribute('number'); + curveNumber = number !== null ? parseInt(number, 10) : curveNumber; + articulation = 'pulloff'; + } + + curves.push(new Curve(config, log, curveNumber, phase, curvePlacement(slur), curveOpening(slur), articulation)); + } + + for (const tied of notations.childrenNamed('tied')) { + const phase: CurvePhase = tied.getAttribute('type') === 'start' ? 'start' : 'continue'; + const curveNumber = parseInt(tied.getAttribute('number') ?? '1', 10); + curves.push(new Curve(config, log, curveNumber, phase, curvePlacement(tied), curveOpening(tied), 'unspecified')); + } + + return curves; + } + private static createSlurs(config: Config, log: Logger, musicXML: { notation: musicxml.Notations }): Curve[] { const curves = new Array(); diff --git a/src/parsing/musicxml/key.ts b/src/parsing/musicxml/key.ts index 1f02eb5c0..522828102 100644 --- a/src/parsing/musicxml/key.ts +++ b/src/parsing/musicxml/key.ts @@ -1,5 +1,6 @@ import * as data from '@/data'; import * as musicxml from '@/musicxml'; +import type * as mdom from '@stringsync/mdom'; import * as conversions from './conversions'; import { KeyMode } from './enums'; import { Config } from '@/config'; @@ -43,6 +44,18 @@ export class Key { ); } + static fromMdom( + config: Config, + log: Logger, + partId: string, + staveNumber: number, + previousKey: Key | null, + mdom: { key: mdom.Key } + ): Key { + const key = mdom.key; + return new Key(config, log, partId, staveNumber, key.fifths ?? 0, previousKey, (key.mode ?? 'none') as KeyMode); + } + parse(): data.Key { return { type: 'key', diff --git a/src/parsing/musicxml/metronome.ts b/src/parsing/musicxml/metronome.ts index a2ae1808d..f5a7dd74a 100644 --- a/src/parsing/musicxml/metronome.ts +++ b/src/parsing/musicxml/metronome.ts @@ -1,5 +1,7 @@ import * as data from '@/data'; import * as musicxml from '@/musicxml'; +import type * as mdom from '@stringsync/mdom'; +import { NOTE_TYPES } from '@/musicxml'; import * as conversions from './conversions'; import { Config } from '@/config'; import { Logger } from '@/debug'; @@ -50,6 +52,80 @@ export class Metronome { } } + static fromMdom(config: Config, log: Logger, mdom: { metronome: mdom.MElement }): Metronome | null { + const mark = Metronome.markFromMdom(mdom.metronome); + if (!mark) { + return null; + } + + const parenthesis = mdom.metronome.getAttribute('parentheses') === 'yes'; + const duration = conversions.fromNoteTypeToDurationType(mark.left.unit) ?? undefined; + const dots = mark.left.dotCount; + + switch (mark.right.type) { + case 'note': { + const duration2 = conversions.fromNoteTypeToDurationType(mark.right.unit) ?? undefined; + const dots2 = mark.right.dotCount; + return new Metronome(config, log, config.DEFAULT_PLAYBACK_BPM, { + parenthesis, + duration, + dots, + duration2, + dots2, + }); + } + case 'bpm': { + const displayBpm = mark.right.bpm; + return new Metronome(config, log, displayBpm, { parenthesis, duration, dots, displayBpm }); + } + } + } + + /** Reconstructs a {@link musicxml.MetronomeMark} from a raw mdom `` element. */ + private static markFromMdom(metronome: mdom.MElement): musicxml.MetronomeMark | null { + const left = new Array(); + const right = new Array(); + + for (const child of metronome.children) { + const element = child as mdom.MElement; + if (element.tag === 'beat-unit') { + (left.length > 0 ? right : left).push(element); + } else if (element.tag === 'beat-unit-dot') { + (right.length > 0 ? right : left).push(element); + } else if (element.tag === 'per-minute') { + right.push(element); + } + } + + const isWellFormedNote = (elements: mdom.MElement[]): boolean => + elements.length > 0 && + elements[0].tag === 'beat-unit' && + elements.slice(1).every((child) => child.tag === 'beat-unit-dot'); + const isWellFormedBpm = (elements: mdom.MElement[]): boolean => + elements.length === 1 && elements[0].tag === 'per-minute'; + + if (!isWellFormedNote(left)) { + return null; + } + const rightType = isWellFormedNote(right) ? 'note' : isWellFormedBpm(right) ? 'bpm' : 'invalid'; + if (rightType === 'invalid') { + return null; + } + + const noteOperand = (elements: mdom.MElement[]) => { + const rawUnit = elements[0].text; + const unit: musicxml.NoteType = rawUnit && NOTE_TYPES.includes(rawUnit) ? rawUnit : 'quarter'; + const dotCount = elements.slice(1).filter((child) => child.tag === 'beat-unit-dot').length; + return { type: 'note' as const, unit, dotCount }; + }; + const bpmOperand = (elements: mdom.MElement[]) => { + const bpm = elements[0].text != null ? parseInt(elements[0].text, 10) : NaN; + return { type: 'bpm' as const, bpm: Number.isNaN(bpm) ? 120 : bpm }; + }; + + return { left: noteOperand(left), right: rightType === 'note' ? noteOperand(right) : bpmOperand(right) }; + } + parse(): data.Metronome { return { type: 'metronome', diff --git a/src/parsing/musicxml/note.ts b/src/parsing/musicxml/note.ts index f8faabb3f..d165f5c6d 100644 --- a/src/parsing/musicxml/note.ts +++ b/src/parsing/musicxml/note.ts @@ -1,5 +1,6 @@ import * as data from '@/data'; import * as musicxml from '@/musicxml'; +import type * as mdom from '@stringsync/mdom'; import * as conversions from './conversions'; import * as util from '@/util'; import { Notehead, StemDirection } from './enums'; @@ -171,6 +172,123 @@ export class Note { ); } + static fromMdom( + config: Config, + log: Logger, + measureBeat: util.Fraction, + duration: util.Fraction, + mdom: { note: mdom.Note }, + chordSymbol: ChordSymbol | null = null, + /** Grace notes that precede this note in document order (only meaningful for a principal note). */ + graceNotes: mdom.Note[] = [] + ): Note { + const note = mdom.note; + const notations = note.childrenNamed('notations'); + + // Mirror the legacy defaults (step 'C', octave 4) for rests / unpitched notes that carry no . + const pitch = new Pitch(config, log, note.pitch?.step ?? 'C', note.pitch?.octave ?? 4); + const head = conversions.fromNoteheadToNotehead((note.child('notehead')?.text ?? null) as musicxml.Notehead | null); + + let durationType = conversions.fromNoteTypeToDurationType((note.type ?? null) as musicxml.NoteType | null); + let dotCount = note.childrenNamed('dot').length; + if (!durationType) { + [durationType, dotCount] = conversions.fromFractionToDurationType(duration); + } + + const stem = conversions.fromStemToStemDirection((note.child('stem')?.text ?? null) as musicxml.Stem | null); + + const lyricAnnotations = note + .childrenNamed('lyric') + .map((lyric) => Annotation.fromMdomLyric(config, log, { lyric })); + const fingeringAnnotations = notations + .flatMap((n) => n.childrenNamed('technical')) + .flatMap((t) => t.childrenNamed('fingering')) + .map((fingering) => Annotation.fromMdomFingering(config, log, { fingering })) + .filter((a): a is Annotation => a !== null); + const annotations = [...lyricAnnotations, ...fingeringAnnotations]; + + const code = + conversions.fromAccidentalTypeToAccidentalCode( + (note.accidental?.value ?? null) as musicxml.AccidentalType | null + ) ?? + conversions.fromAlterToAccidentalCode(note.pitch?.alter ?? null) ?? + 'n'; + const isCautionary = note.accidental?.cautionary ?? false; + const accidental = new Accidental(config, log, code, isCautionary); + + const curves = notations.flatMap((notations) => Curve.fromMdom(config, log, { notations })); + const tuplets = note.tuplets.map((tuplet) => Tuplet.fromMdom(config, log, { tuplet })); + const vibratos = note.wavyLines.map((wavyLine) => Vibrato.fromMdom(config, log, { wavyLine })); + + // Since data.Note is a superset of data.GraceNote, we use the same model. We terminate recursion by only attaching + // grace notes to a principal (non-grace) note. + const graceEntries = new Array(); + if (!note.isGrace) { + for (let index = 0; index < graceNotes.length; index++) { + const graceNote = graceNotes[index]; + if (graceNote.isChordMember) { + continue; + } + + const head = Note.fromMdom(config, log, measureBeat, util.Fraction.zero(), { note: graceNote }); + + const tail = new Array(); + while (index + 1 < graceNotes.length && graceNotes[index + 1].isChordMember) { + tail.push(Note.fromMdom(config, log, measureBeat, util.Fraction.zero(), { note: graceNotes[index + 1] })); + index++; + } + + if (tail.length > 0) { + graceEntries.push({ type: 'gracechord', head, tail }); + } else { + graceEntries.push({ type: 'gracenote', note: head }); + } + } + } + + // MusicXML encodes each beam line as a separate . We only care about the presence of beams, so we only check + // the first one. + let beam: Beam | null = null; + if (note.beams.length > 0) { + beam = Beam.fromMdom(config, log, { beam: note.beams[0] }); + } + + const articulations = Articulation.fromMdom(config, log, { note }); + + const bends = notations + .flatMap((n) => n.childrenNamed('technical')) + .flatMap((t) => t.childrenNamed('bend')) + .map((bend) => Bend.fromMdom(config, log, { bend })); + + const slash = note.child('grace')?.getAttribute('slash') === 'yes'; + + const tabPositions = TabPosition.fromMdom(config, log, { note }); + + return new Note( + config, + log, + pitch, + head, + durationType, + dotCount, + stem, + new Fraction(duration), + new Fraction(measureBeat), + annotations, + accidental, + curves, + tuplets, + beam, + slash, + graceEntries, + vibratos, + articulations, + bends, + tabPositions, + chordSymbol + ); + } + parse(voiceCtx: VoiceContext): data.Note { const voiceEntryCtx = VoiceEntryContext.note(voiceCtx, this.pitch.getStep(), this.pitch.getOctave()); diff --git a/src/parsing/musicxml/octaveshift.ts b/src/parsing/musicxml/octaveshift.ts index a3405e875..3ba9f2019 100644 --- a/src/parsing/musicxml/octaveshift.ts +++ b/src/parsing/musicxml/octaveshift.ts @@ -1,4 +1,5 @@ import * as musicxml from '@/musicxml'; +import type * as mdom from '@stringsync/mdom'; import { VoiceContext } from './contexts'; import { Config } from '@/config'; import { Logger } from '@/debug'; @@ -37,6 +38,28 @@ export class OctaveShift { return new OctaveShift(config, log, phase, size); } + static fromMdom(config: Config, log: Logger, mdom: { octaveShift: mdom.OctaveShift }): OctaveShift { + const type = mdom.octaveShift.octaveShiftType; + const factor = type === 'down' ? -1 : 1; + const size = factor * mdom.octaveShift.size; + + let phase: OctaveShiftPhase; + switch (type) { + case 'down': + case 'up': + phase = 'start'; + break; + case 'stop': + phase = 'stop'; + break; + default: + phase = 'continue'; + break; + } + + return new OctaveShift(config, log, phase, size); + } + parse(voiceCtx: VoiceContext): void { if (this.phase === 'start') { voiceCtx.beginOctaveShift(this.size); diff --git a/src/parsing/musicxml/pedal.ts b/src/parsing/musicxml/pedal.ts index 0c3ae120a..1f6507697 100644 --- a/src/parsing/musicxml/pedal.ts +++ b/src/parsing/musicxml/pedal.ts @@ -1,5 +1,6 @@ import * as data from '@/data'; import * as musicxml from '@/musicxml'; +import type * as mdom from '@stringsync/mdom'; import { VoiceContext } from './contexts'; import { Config } from '@/config'; import { Logger } from '@/debug'; @@ -55,6 +56,40 @@ export class Pedal { return new Pedal(config, log, phase, pedalType, pedalMarkType); } + static fromMdom(config: Config, log: Logger, mdom: { pedal: mdom.Pedal }): Pedal { + const pedal = mdom.pedal; + + let phase: PedalPhase; + switch (pedal.pedalType) { + case 'start': + phase = 'start'; + break; + case 'stop': + phase = 'stop'; + break; + default: + phase = 'continue'; + break; + } + + const line = pedal.getAttribute('line') === 'yes'; + const sign = pedal.getAttribute('sign') === 'yes'; + let pedalType: data.PedalType; + if (line && sign) { + pedalType = 'mixed'; + } else if (line) { + pedalType = 'bracket'; + } else if (sign) { + pedalType = 'text'; + } else { + pedalType = 'bracket'; + } + + const pedalMarkType: data.PedalMarkType = pedal.pedalType === 'change' ? 'change' : 'default'; + + return new Pedal(config, log, phase, pedalType, pedalMarkType); + } + parse(voiceCtx: VoiceContext): void { if (this.phase === 'start') { voiceCtx.beginPedal(this.pedalType); diff --git a/src/parsing/musicxml/rest.ts b/src/parsing/musicxml/rest.ts index 258ba3933..f7bcab902 100644 --- a/src/parsing/musicxml/rest.ts +++ b/src/parsing/musicxml/rest.ts @@ -1,6 +1,7 @@ import * as data from '@/data'; import * as util from '@/util'; import * as musicxml from '@/musicxml'; +import type * as mdom from '@stringsync/mdom'; import * as conversions from './conversions'; import { Fraction } from './fraction'; import { Pitch } from './pitch'; @@ -73,6 +74,48 @@ export class Rest { ); } + static fromMdom( + config: Config, + log: Logger, + measureBeat: util.Fraction, + duration: util.Fraction, + mdom: { note: mdom.Note }, + chordSymbol: ChordSymbol | null = null + ): Rest { + const note = mdom.note; + const restEl = note.child('rest'); + const displayStep = restEl?.child('display-step')?.text ?? null; + const rawDisplayOctave = restEl?.child('display-octave')?.text; + const displayOctave = typeof rawDisplayOctave === 'string' ? parseInt(rawDisplayOctave, 10) : null; + let displayPitch: Pitch | null = null; + if (displayStep && typeof displayOctave === 'number') { + displayPitch = new Pitch(config, log, displayStep, displayOctave); + } + + let durationType = conversions.fromNoteTypeToDurationType((note.type ?? null) as musicxml.NoteType | null); + let dotCount = note.childrenNamed('dot').length; + if (!durationType) { + [durationType, dotCount] = conversions.fromFractionToDurationType(duration); + } + + // TODO(mdom): port beams and tuplets. + const beam: Beam | null = null; + const tuplets: Tuplet[] = []; + + return new Rest( + config, + log, + measureBeat, + durationType, + dotCount, + duration, + displayPitch, + beam, + tuplets, + chordSymbol + ); + } + static whole(config: Config, log: Logger, time: Time): Rest { const measureBeat = util.Fraction.zero(); const duration = time.toFraction().multiply(new util.Fraction(4, 1)); diff --git a/src/parsing/musicxml/stavelinecount.ts b/src/parsing/musicxml/stavelinecount.ts index 8ea2e372e..0ca10c86d 100644 --- a/src/parsing/musicxml/stavelinecount.ts +++ b/src/parsing/musicxml/stavelinecount.ts @@ -1,4 +1,5 @@ import * as musicxml from '@/musicxml'; +import type * as mdom from '@stringsync/mdom'; import { Config } from '@/config'; import { Logger } from '@/debug'; @@ -25,6 +26,13 @@ export class StaveLineCount { ); } + static fromMdom(config: Config, log: Logger, partId: string, mdom: { staveDetails: mdom.MElement }): StaveLineCount { + const staveDetails = mdom.staveDetails; + const staveNumber = parseInt(staveDetails.getAttribute('number') ?? '1', 10); + const lines = parseInt(staveDetails.child('staff-lines')?.text ?? '5', 10); + return new StaveLineCount(config, log, partId, staveNumber, lines); + } + getPartId(): string { return this.partId; } diff --git a/src/parsing/musicxml/tabposition.ts b/src/parsing/musicxml/tabposition.ts index d4bc36734..d5db426d0 100644 --- a/src/parsing/musicxml/tabposition.ts +++ b/src/parsing/musicxml/tabposition.ts @@ -1,5 +1,6 @@ import * as data from '@/data'; import * as musicxml from '@/musicxml'; +import type * as mdom from '@stringsync/mdom'; import { Config } from '@/config'; import { Logger } from '@/debug'; @@ -41,6 +42,35 @@ export class TabPosition { }); } + static fromMdom(config: Config, log: Logger, mdom: { note: mdom.Note }): TabPosition[] { + const note = mdom.note; + const notehead = note.child('notehead')?.text ?? null; + const dead = notehead === 'cross' || notehead === 'x'; + + return note + .childrenNamed('notations') + .flatMap((n) => n.childrenNamed('technical')) + .flatMap((t) => { + const frets = t.childrenNamed('fret').map((f) => String(f.text != null ? parseInt(f.text, 10) : null)); + const strings = t.childrenNamed('string').map((s) => (s.text != null ? parseInt(s.text, 10) : null)); + const harmonics = t + .childrenNamed('harmonic') + .map((h) => (h.child('natural') ? 'natural' : h.child('artificial') ? 'artificial' : 'unspecified')); + + const count = Math.min(frets.length, strings.length); + const tabPositions = new Array(count); + + for (let index = 0; index < count; index++) { + const fret = dead ? 'X' : frets[index] ?? '0'; + const string = strings[index] ?? 1; + const harmonic = harmonics[index] === 'natural' || harmonics[index] === 'artificial'; + tabPositions[index] = new TabPosition(config, log, fret, string, harmonic); + } + + return tabPositions; + }); + } + parse(): data.TabPosition { return { type: 'tabposition', diff --git a/src/parsing/musicxml/time.ts b/src/parsing/musicxml/time.ts index f0eadc9c3..23d3765c8 100644 --- a/src/parsing/musicxml/time.ts +++ b/src/parsing/musicxml/time.ts @@ -1,5 +1,6 @@ import * as data from '@/data'; import * as musicxml from '@/musicxml'; +import type * as mdom from '@stringsync/mdom'; import * as util from '@/util'; import { Fraction } from './fraction'; import { Config } from '@/config'; @@ -88,6 +89,61 @@ export class Time { return times; } + static fromMdom( + config: Config, + log: Logger, + partId: string, + staveNumber: number, + mdom: { time: mdom.Time } + ): Time | null { + const time = mdom.time; + + if (time.isSenzaMisura) { + return Time.hidden(config, log, partId, staveNumber); + } + + const symbol = time.symbol; + switch (symbol) { + case 'common': + return Time.common(config, log, partId, staveNumber); + case 'cut': + return Time.cut(config, log, partId, staveNumber); + case 'hidden': + return Time.hidden(config, log, partId, staveNumber); + } + + const components = time.components; + const times = new Array