smoosic
Version:
<sub>[Github site](https://github.com/Smoosic/smoosic) | [source documentation](https://smoosic.github.io/Smoosic/release/docs/modules.html) | [change notes](https://aarondavidnewman.github.io/Smoosic/changes.html) | [application](https://smoosic.github.i
836 lines (819 loc) • 37.3 kB
text/typescript
// [Smoosic](https://github.com/AaronDavidNewman/Smoosic)
// Copyright (c) Aaron David Newman 2021.
/**
* Logic to convert music XML (finale) to Smo internal format
* @module XmlToSmo
*/
import { XmlHelpers } from './xmlHelpers';
import { XmlVoiceInfo, XmlState, XmlWedgeInfo } from './xmlState';
import { SmoLayoutManager, SmoPageLayout, SmoSystemGroup } from '../data/scoreModifiers';
import { SmoTextGroup } from '../data/scoreText';
import { SmoTempoText, SmoMeasureFormat, SmoMeasureModifierBase, SmoVolta, SmoBarline } from '../data/measureModifiers';
import { SmoScore, isEngravingFont } from '../data/score';
import { SmoMeasure, SmoMeasureParams } from '../data/measure';
import { SmoMusic } from '../data/music';
import { SmoGraceNote, SmoOrnament, SmoArticulation } from '../data/noteModifiers';
import { SmoSystemStaff } from '../data/systemStaff';
import { SmoNote, SmoNoteParams } from '../data/note';
import { Pitch, PitchKey, Clef } from '../data/common';
import { SmoOperation } from '../xform/operations';
import { SmoInstrument, SmoSlur, SmoTie, TieLine } from '../data/staffModifiers';
import { SmoPartInfo } from '../data/partInfo';
import {SmoTupletTree} from "../data/tuplet";
/**
* A class that takes a music XML file and outputs a {@link SmoScore}
* @category serialization
*/
export class XmlToSmo {
static get mmPerPixel() {
return 0.264583;
}
/**
* Vex renders everything as if the font size were 39
*/
static get vexFontSize() {
return 39;
}
static get customProportionDefault(): number {
return SmoScore.defaults.layoutManager?.getGlobalLayout().proportionality ?? 0;
}
static get pageLayoutMap() {
return [
{ xml: 'page-height', smo: 'pageHeight' },
{ xml: 'page-width', smo: 'pageWidth' }
];
}
static get pageMarginMap() {
return [
{ xml: 'left-margin', smo: 'leftMargin' },
{ xml: 'right-margin', smo: 'rightMargin' },
{ xml: 'top-margin', smo: 'topMargin' },
{ xml: 'bottom-margin', smo: 'bottomMargin' }
];
}
static get scoreInfoFields() {
return ['title', 'subTitle', 'composer', 'copyright'];
}
/**
* Convert music XML file from parsed xml to a {@link SmoScore}
* @param xmlDoc
* @returns
*/
static convert(xmlDoc: Document): SmoScore {
try {
const scoreRoots = [...xmlDoc.getElementsByTagName('score-partwise')];
if (!scoreRoots.length) {
// no score node
return SmoScore.getDefaultScore(SmoScore.defaults, SmoMeasure.defaults);
}
const scoreRoot = scoreRoots[0];
const rv: SmoScore = new SmoScore(SmoScore.defaults);
rv.staves = [];
const layoutDefaults = rv.layoutManager as SmoLayoutManager;
// if no scale given in score, default to something small.
layoutDefaults.globalLayout.svgScale = 0.5;
layoutDefaults.globalLayout.zoomScale = 1.0;
const xmlState = new XmlState();
xmlState.newTitle = false;
rv.scoreInfo.name = 'Imported Smoosic';
XmlToSmo.scoreInfoFields.forEach((field) => {
(rv.scoreInfo as any)[field] = '';
});
const childNodes = [...scoreRoot.children];
childNodes.forEach((scoreElement) => {
if (scoreElement.tagName === 'work') {
const scoreNameNode = [...scoreElement.getElementsByTagName('work-title')];
if (scoreNameNode.length && scoreNameNode[0].textContent) {
rv.scoreInfo.title = scoreNameNode[0].textContent;
rv.scoreInfo.name = rv.scoreInfo.title;
xmlState.newTitle = true;
}
} else if (scoreElement.tagName === 'identification') {
const creators = [...scoreElement.getElementsByTagName('creator')];
creators.forEach((creator) => {
if (creator.getAttribute('type') === 'composer' && creator.textContent) {
rv.scoreInfo.composer = creator.textContent;
}
});
} else if (scoreElement.tagName === 'movement-title') {
if (xmlState.newTitle && scoreElement.textContent) {
rv.scoreInfo.subTitle = scoreElement.textContent;
} else if (scoreElement.textContent) {
rv.scoreInfo.title = scoreElement.textContent;
rv.scoreInfo.name = rv.scoreInfo.title;
xmlState.newTitle = true;
}
} else if (scoreElement.tagName === 'defaults') {
XmlToSmo.defaults(scoreElement, rv, layoutDefaults, xmlState);
} else if (scoreElement.tagName === 'part') {
xmlState.initializeForPart();
XmlToSmo.part(scoreElement, xmlState);
} else if (scoreElement.tagName === 'part-list') {
XmlToSmo.partList(scoreElement, rv, xmlState);
}
});
// The entire score is parsed and xmlState now contains the staves.
rv.formattingManager = xmlState.formattingManager;
rv.staves = xmlState.smoStaves;
xmlState.updateStaffGroups();
rv.systemGroups = xmlState.getSystems();
// Fix tempo to be column mapped
rv.staves[0].measures.forEach((measure) => {
const tempoStaff = rv.staves.find((ss) => ss.measures[measure.measureNumber.measureIndex].tempo.display === true);
if (tempoStaff) {
const tempo = tempoStaff.measures[measure.measureNumber.measureIndex].tempo;
rv.staves.forEach((ss) => {
ss.measures[measure.measureNumber.measureIndex].tempo =
SmoMeasureModifierBase.deserialize(tempo);
});
}
});
const lm: SmoLayoutManager = rv.layoutManager as SmoLayoutManager;
if (rv.scoreInfo.title) {
rv.addTextGroup(SmoTextGroup.createTextForLayout(
SmoTextGroup.purposes.TITLE, rv.scoreInfo.title, lm.getScaledPageLayout(0)
));
}
if (rv.scoreInfo.subTitle) {
rv.addTextGroup(SmoTextGroup.createTextForLayout(
SmoTextGroup.purposes.SUBTITLE, rv.scoreInfo.subTitle, lm.getScaledPageLayout(0)
));
}
if (rv.scoreInfo.composer) {
rv.addTextGroup(SmoTextGroup.createTextForLayout(
SmoTextGroup.purposes.COMPOSER, rv.scoreInfo.composer, lm.getScaledPageLayout(0)
));
}
XmlToSmo.setSlurDefaults(rv);
xmlState.completeTies(rv);
rv.preferences.showPiano = false;
XmlToSmo.setVoltas(rv, xmlState);
rv.staves.forEach((staff) => {
});
return rv;
} catch (exc) {
console.warn(exc);
return SmoScore.getDefaultScore(SmoScore.defaults, SmoMeasure.defaults);
}
}
/**
* when building the slurs, we don't always know which direction the beams will go or what other
* voices there will be.
* @param score
*/
static setSlurDefaults(score: SmoScore) {
score.staves.forEach((staff) => {
const slurs = staff.modifiers.filter((mm) =>mm.ctor === 'SmoSlur');
slurs.forEach((ss) => {
const slur = (ss as any) as SmoSlur;
let slurPosition = SmoSlur.positions.AUTO;
if (slur.position === slur.position_end) {
slurPosition = slur.position;
}
const slurParams = SmoOperation.getDefaultSlurDirection(score, ss.startSelector, ss.endSelector);
slur.position = SmoSlur.positions.AUTO;
slur.position_end = SmoSlur.positions.AUTO;
slur.orientation = SmoSlur.orientations.AUTO;
slur.yOffset = slurParams.yOffset;
slur.cp1y = slurParams.cp1y;
slur.cp2y = slurParams.cp2y;
slur.xOffset = slurParams.xOffset;
});
});
}
/**
* After parsing the XML, resolve the voltas we've saved
* @param score
* @param state
*/
static setVoltas(score: SmoScore, state: XmlState) {
const endingMeasures = Object.keys(state.endingMap).map((k) => parseInt(k, 10));
endingMeasures.forEach((em) => {
const endings = state.endingMap[em];
endings.forEach((ending) => {
const defs = SmoVolta.defaults;
defs.number = ending.number;
defs.startBar = ending.start;
defs.endBar = ending.end >= 0 ? ending.end : ending.start;
const volta = new SmoVolta(defs);
SmoOperation.addEnding(score, volta);
});
});
}
static partList(partList: Element, score: SmoScore, state: XmlState) {
const children = partList.children;
let partIndex = 0;
var i = 0;
for (i = 0;i < children.length; ++i) {
const child = children[i];
if (child.tagName === 'score-part') {
const partElement = child;
const partData = new SmoPartInfo(SmoPartInfo.defaults);
partData.partName = XmlHelpers.getTextFromElement(partElement, 'part-name', 'part ' + i);
const partId = XmlHelpers.getTextFromAttribute(partElement, 'id', i.toString());
if (state.openPartGroup) {
state.openPartGroup.parts.push(partIndex);
}
partIndex += 1;
state.parts[partId] = partData;
partData.partAbbreviation = XmlHelpers.getTextFromElement(partElement, 'part-abbreviation', 'p.');
partData.midiDevice = XmlHelpers.getTextFromElement(partElement, 'part-abbreviation', null);
// it seems like musicxml doesn't allow for different music font size in parts vs. score
// partData.layoutManager.globalLayout.svgScale = 0.55;
partData.layoutManager.globalLayout.svgScale = state.musicFontSize / XmlToSmo.vexFontSize;
const midiElements = partElement.getElementsByTagName('midi-instrument');
if (midiElements.length) {
const midiElement = midiElements[0];
partData.midiInstrument = {
channel: XmlHelpers.getNumberFromElement(midiElement, 'midi-channel', 1),
program: XmlHelpers.getNumberFromElement(midiElement, 'midi-program', 1),
volume: XmlHelpers.getNumberFromElement(midiElement, 'volume', 80),
pan: XmlHelpers.getNumberFromElement(midiElement, 'pan', 0)
};
}
} else if (child.tagName === 'part-group') {
const groupElement = child;
if (state.openPartGroup) {
const staffGroup = state.openPartGroup.group;
state.openPartGroup.parts.forEach((part) => {
if (staffGroup.startSelector.staff === 0 || staffGroup.startSelector.staff > part) {
staffGroup.startSelector.staff = part;
}
if (staffGroup.endSelector.staff < part) {
staffGroup.endSelector.staff = part;
}
});
score.systemGroups.push(staffGroup);
state.openPartGroup = null;
} else {
const staffGroup = new SmoSystemGroup(SmoSystemGroup.defaults);
const groupNum = XmlHelpers.getNumberFromAttribute(groupElement, 'number', 1);
const xmlSymbol = XmlHelpers.getTextFromElement(groupElement, 'group-symbol', 'single');
if (xmlSymbol === 'single') {
staffGroup.leftConnector = SmoSystemGroup.connectorTypes['single'];
} else if (xmlSymbol === 'brace') {
staffGroup.leftConnector = SmoSystemGroup.connectorTypes['brace'];
} if (xmlSymbol === 'bracket') {
staffGroup.leftConnector = SmoSystemGroup.connectorTypes['bracket'];
} if (xmlSymbol === 'square') {
staffGroup.leftConnector = SmoSystemGroup.connectorTypes['double'];
}
state.openPartGroup = {
partNum: groupNum,
parts: [],
group: staffGroup
}
}
}
}
}
/**
* page-layout element occurs in a couple of places
* @param defaultsElement
* @param layoutDefaults
* @param xmlState
*/
static pageSizeFromLayout(defaultsElement: Element, layoutDefaults: SmoLayoutManager, xmlState: XmlState) {
const pageLayoutNode = defaultsElement.getElementsByTagName('page-layout');
if (pageLayoutNode.length) {
XmlHelpers.assignDefaults(pageLayoutNode[0], layoutDefaults.globalLayout, XmlToSmo.pageLayoutMap);
layoutDefaults.globalLayout.pageHeight *= xmlState.pixelsPerTenth;
layoutDefaults.globalLayout.pageWidth *= xmlState.pixelsPerTenth;
}
const pageMarginNode = XmlHelpers.getChildrenFromPath(defaultsElement,
['page-layout', 'page-margins']);
if (pageMarginNode.length) {
XmlHelpers.assignDefaults(pageMarginNode[0], layoutDefaults.pageLayouts[0], XmlToSmo.pageMarginMap);
SmoPageLayout.attributes.forEach((attr) => {
layoutDefaults.pageLayouts[0][attr] *= xmlState.pixelsPerTenth;
});
}
}
/**
* /score-partwise/defaults
* @param defaultsElement
* @param score
* @param layoutDefaults
*/
static defaults(defaultsElement: Element, score: SmoScore, layoutDefaults: SmoLayoutManager, xmlState: XmlState) {
// Default scale for mxml
let scale = 1 / 7;
const currentScale = layoutDefaults.getGlobalLayout().svgScale;
const scaleNode = defaultsElement.getElementsByTagName('scaling');
if (scaleNode.length) {
const mm = XmlHelpers.getNumberFromElement(scaleNode[0], 'millimeters', 1);
const tn = XmlHelpers.getNumberFromElement(scaleNode[0], 'tenths', 7);
if (tn > 0 && mm > 0) {
scale = mm / tn;
}
}
const fontNode = defaultsElement.getElementsByTagName('music-font');
// All musicxml sizes are given in 'tenths'. Smoosic and vex use pixels. so find the ratio and
// normalize all values.
xmlState.pixelsPerTenth = scale / XmlToSmo.mmPerPixel;
if (fontNode.length) {
const fontString = fontNode[0].getAttribute('font-size');
if (fontString) {
xmlState.musicFontSize = parseInt(fontString, 10);
}
const fontFamily = fontNode[0].getAttribute('font-family');
if (fontFamily && isEngravingFont(fontFamily)) {
score.engravingFont =fontFamily;
}
}
XmlToSmo.pageSizeFromLayout(defaultsElement, layoutDefaults, xmlState);
// svgScale is the ratio of music font size to the default Vex font size (39).
layoutDefaults.globalLayout.svgScale = xmlState.musicFontSize / XmlToSmo.vexFontSize;
score.scaleTextGroups(currentScale / layoutDefaults.globalLayout.svgScale);
}
/**
* /score-partwise/part
* @param partElement
* @param xmlState
*/
static part(partElement: Element, xmlState: XmlState) {
let staffId = xmlState.smoStaves.length;
const partId = XmlHelpers.getTextFromAttribute(partElement, 'id', '');
console.log('part ' + partId);
xmlState.initializeForPart();
xmlState.partId = partId;
const stavesForPart: SmoSystemStaff[] = [];
const measureElements = [...partElement.getElementsByTagName('measure')];
measureElements.forEach((measureElement) => {
// Parse the measure element, populate staffArray of xmlState with the
// measure data
XmlToSmo.measure(measureElement, xmlState);
const newStaves = xmlState.staffArray;
if (newStaves.length > 1 && stavesForPart.length <= newStaves[0].clefInfo.staffId) {
xmlState.staffGroups.push({ start: staffId, length: newStaves.length });
}
xmlState.globalCursor += (newStaves[0].measure as SmoMeasure).getMaxTicksVoice();
newStaves.forEach((staffMeasure) => {
if (stavesForPart.length <= staffMeasure.clefInfo.staffId) {
const params = SmoSystemStaff.defaults;
params.staffId = staffId;
params.measureInstrumentMap = xmlState.instrumentMap;
const newStaff = new SmoSystemStaff(params);
if (xmlState.parts[partId]) {
console.log('putting part ' + partId + ' in staff ' + newStaff.staffId);
newStaff.partInfo = new SmoPartInfo(xmlState.parts[partId]);
}
console.log('createing stave ' + newStaff.staffId);
stavesForPart.push(newStaff);
staffId += 1;
}
const smoStaff = stavesForPart[staffMeasure.clefInfo.staffId];
smoStaff.measures.push(staffMeasure.measure as SmoMeasure);
});
const oldStaffId = staffId - stavesForPart.length;
xmlState.backtrackHairpins(stavesForPart[0], oldStaffId + 1);
});
if (stavesForPart.length > 1) {
stavesForPart[0].partInfo.stavesAfter = 1;
stavesForPart[0].partInfo.stavesBefore = 0;
console.log('part has stave after ' + stavesForPart[0].staffId);
stavesForPart[1].partInfo.stavesAfter = 0;
stavesForPart[1].partInfo.stavesBefore = 1;
console.log('part has stave before ' + stavesForPart[1].staffId);
}
xmlState.smoStaves = xmlState.smoStaves.concat(stavesForPart);
xmlState.completeSlurs();
xmlState.assignRehearsalMarks();
}
/**
* /score-partwise/measure/direction/sound:tempo
* @param element
* @returns
*/
static tempo(element: Element) {
let tempoText = '';
let customText = tempoText;
const rv: { staffId: number, tempo: SmoTempoText }[] = [];
const soundNodes = XmlHelpers.getChildrenFromPath(element,
['sound']);
soundNodes.forEach((sound) => {
let tempoMode = SmoTempoText.tempoModes.durationMode;
tempoText = sound.getAttribute('tempo') as string;
if (tempoText) {
const bpm = parseInt(tempoText, 10);
const wordNode =
[...element.getElementsByTagName('words')];
tempoText = wordNode.length ? wordNode[0].textContent as string :
tempoText.toString();
if (isNaN(parseInt(tempoText, 10))) {
if (SmoTempoText.tempoTexts[tempoText.toLowerCase()]) {
tempoMode = SmoTempoText.tempoModes.textMode;
} else {
tempoMode = SmoTempoText.tempoModes.customMode;
customText = tempoText;
}
}
const params = SmoTempoText.defaults;
params.tempoMode = tempoMode;
params.bpm = bpm;
params.tempoText = tempoText;
params.customText = customText;
params.display = true;
const tempo = new SmoTempoText(params);
const staffId = XmlHelpers.getStaffId(element);
rv.push({ staffId, tempo });
}
});
return rv;
}
/**
* /score-partwise/measure/direction/dynamics
* @param element
* @returns
*/
static dynamics(directionElement: Element, xmlState: XmlState) {
let offset = 1;
const dynamicNodes = XmlHelpers.getChildrenFromPath(directionElement,
['direction-type', 'dynamics']);
const rehearsalNodes = XmlHelpers.getChildrenFromPath(directionElement,
['direction-type', 'rehearsal']);
const offsetNodes = XmlHelpers.getChildrenFromPath(directionElement,
['offset']);
if (offsetNodes.length) {
offset = parseInt(offsetNodes[0].textContent as string, 10);
}
if (rehearsalNodes.length) {
const rm = rehearsalNodes[0].textContent;
if (rm) {
xmlState.rehearsalMark = rm;
}
}
dynamicNodes.forEach((dynamic) => {
xmlState.dynamics.push({
dynamic: dynamic.children[0].tagName,
offset: (offset / xmlState.divisions) * 4096
});
});
}
// ### attributes
// /score-partwise/part/measure/attributes
static attributes(measureElement: Element, xmlState: XmlState) {
let smoKey: PitchKey = {} as PitchKey;
const attributesNodes = XmlHelpers.getChildrenFromPath(measureElement, ['attributes']);
if (!attributesNodes.length) {
return;
}
const attributesNode = attributesNodes[0];
xmlState.divisions =
XmlHelpers.getNumberFromElement(attributesNode, 'divisions', xmlState.divisions);
const keyNode = XmlHelpers.getChildrenFromPath(attributesNode, ['key']);
// MusicXML expresses keys in 'fifths' from C.
if (keyNode.length) {
const fifths = XmlHelpers.getNumberFromElement(keyNode[0], 'fifths', 0);
if (fifths < 0) {
smoKey = SmoMusic.circleOfFifths[SmoMusic.circleOfFifths.length + fifths];
} else {
smoKey = SmoMusic.circleOfFifths[fifths];
}
xmlState.keySignature = smoKey.letter.toUpperCase();
if (smoKey.accidental !== 'n') {
xmlState.keySignature += smoKey.accidental;
}
}
const transposeNode = XmlHelpers.getChildrenFromPath(attributesNode, ['transpose']);
if (transposeNode.length) {
const offset = XmlHelpers.getNumberFromElement(transposeNode[0], 'chromatic', 0);
if (offset !== xmlState.instrument.keyOffset) {
xmlState.instrument.keyOffset = -1 * offset;
if (xmlState.instrumentMap[xmlState.measureIndex]) {
xmlState.instrumentMap[xmlState.measureIndex].keyOffset = xmlState.instrument.keyOffset;
} else {
const params = xmlState.instrument;
xmlState.instrumentMap[xmlState.measureIndex] = new SmoInstrument(params);
}
}
}
const currentTime = xmlState.timeSignature.split('/');
const timeNodes = XmlHelpers.getChildrenFromPath(attributesNode, ['time']);
if (timeNodes.length) {
const timeNode = timeNodes[0];
const num = XmlHelpers.getNumberFromElement(timeNode, 'beats', parseInt(currentTime[0], 10));
const den = XmlHelpers.getNumberFromElement(timeNode, 'beat-type', parseInt(currentTime[1], 10));
xmlState.timeSignature = '' + num + '/' + den;
}
const clefNodes = XmlHelpers.getChildrenFromPath(attributesNode, ['clef']);
if (clefNodes.length) {
// We expect the number of clefs to equal the number of staves in each measure
clefNodes.forEach((clefNode) => {
let clefNum = 0;
let clef = 'treble';
const clefAttrs = XmlHelpers.nodeAttributes(clefNode);
if (typeof (clefAttrs.number) !== 'undefined') {
// staff numbers index from 1 in mxml
clefNum = parseInt(clefAttrs.number, 10) - 1;
}
const clefType = XmlHelpers.getTextFromElement(clefNode, 'sign', 'G');
const clefLine = XmlHelpers.getNumberFromElement(clefNode, 'line', 2);
// mxml supports a zillion clefs, just implement the basics.
if (clefType === 'F') {
clef = 'bass';
} else if (clefType === 'C') {
if (clefLine === 4) {
clef = 'alto';
} else if (clefLine === 3) {
clef = 'tenor';
} else if (clefLine === 1) {
clef = 'soprano';
}
} else if (clefType === 'percussion') {
clef = 'percussion';
}
if (xmlState.clefInfo.length <= clefNum) {
xmlState.clefInfo.push({ clef, staffId: clefNum });
} else {
xmlState.clefInfo[clefNum].clef = clef;
}
});
}
}
// ### wedge (hairpin)
// /score-partwise/part/measure/direction/direction-type/wedge
static wedge(directionElement: Element, xmlState: XmlState) {
let crescInfo: XmlWedgeInfo | null = null;
const wedgeNodes = XmlHelpers.getChildrenFromPath(directionElement,
['direction-type', 'wedge']);
wedgeNodes.forEach((wedgeNode) => {
crescInfo = { type: wedgeNode.getAttribute('type') as string };
});
// If this is a start hairpin, start it. If an end hairpin, add it to the
// hairpin array with the type and start/stop ticks
if (crescInfo !== null) {
xmlState.processWedge(crescInfo);
}
}
// ### direction
// /score-partwise/part/measure/direction
static direction(directionElement: Element, xmlState: XmlState) {
const tempo = XmlToSmo.tempo(directionElement);
// Only display tempo if changes.
if (tempo.length) {
// TODO: staff ID is with tempo, but tempo is per column in SMO
if (!SmoTempoText.eq(xmlState.tempo, tempo[0].tempo)) {
xmlState.tempo = tempo[0].tempo;
xmlState.tempo.display = true;
}
}
// parse dynamic node and add to xmlState
XmlToSmo.dynamics(directionElement, xmlState);
// parse wedge (hairpin)
XmlToSmo.wedge(directionElement, xmlState);
}
// ### note
// /score-partwise/part/measure/note
static note(noteElement: Element, xmlState: XmlState) {
let grIx = 0;
const staffIndex: number = XmlHelpers.getStaffId(noteElement);
xmlState.staffIndex = staffIndex;
// We assume the clef information from attributes comes before the notes
// xmlState.staffArray[staffIndex] = { clefInfo: { clef }, voices[voiceIndex]: notes[] }
if (xmlState.staffArray.length <= staffIndex) {
// mxml has measures for all staves in a part interleaved. In SMO they are
// each in a separate stave object. Base the staves we expect based on
// the number of clefs in the xml state object
xmlState.clefInfo.forEach((clefInfo) => {
xmlState.staffArray.push({ clefInfo, measure: null, voices: {} as Record<number | string, XmlVoiceInfo> });
});
}
const chordNode = XmlHelpers.getChildrenFromPath(noteElement, ['chord']);
if (chordNode.length === 0) {
xmlState.currentDuration += XmlHelpers.durationFromNode(noteElement, 0);
}
// voices are not sequential, seem to have artitrary numbers and
// persist per part (same with staff IDs). Update XML state if these are new
// staves
const voiceIndex = XmlHelpers.getVoiceId(noteElement);
xmlState.voiceIndex = voiceIndex;
xmlState.initializeStaff(staffIndex, voiceIndex);
const voice = xmlState.staffArray[staffIndex].voices[voiceIndex];
// Calculate the tick and staff index for selectors
const tickIndex = chordNode.length < 1 ? voice.notes.length : voice.notes.length - 1;
const smoVoiceIndex = xmlState.staffVoiceHash[staffIndex].indexOf(voiceIndex);
const pitchIndex = chordNode.length ? xmlState.previousNote.pitches.length : 0;
const smoStaffIndex = xmlState.smoStaves.length + staffIndex;
const selector = {
staff: smoStaffIndex, measure: xmlState.measureIndex, voice: smoVoiceIndex,
tick: tickIndex, pitches: []
};
const divisions = xmlState.divisions;
const printText = noteElement.getAttribute('print-object');
const hideNote = typeof (printText) === 'string' && printText === 'no';
const isGrace = XmlHelpers.isGrace(noteElement);
const restNode = XmlHelpers.getChildrenFromPath(noteElement, ['rest']);
const noteType = restNode.length ? 'r' : 'n';
const durationData = XmlHelpers.ticksFromDuration(noteElement, divisions, 4096);
const tickCount = durationData.tickCount;
//todo nenad: we probably need to handle dotted durations
const stemTicks = XmlHelpers.durationFromType(noteElement, 4096);
if (chordNode.length === 0) {
xmlState.staffArray[staffIndex].voices[voiceIndex].ticksUsed += tickCount;
}
xmlState.tickCursor = (xmlState.currentDuration / divisions) * 4096;
const beamState = XmlHelpers.noteBeamState(noteElement);
const slurInfos = XmlHelpers.getSlurData(noteElement, selector);
const tieInfos = XmlHelpers.getTieData(noteElement, selector, pitchIndex);
const tupletInfos = XmlHelpers.getTupletData(noteElement);
const ornaments = XmlHelpers.articulationsAndOrnaments(noteElement);
const lyrics = XmlHelpers.lyrics(noteElement);
const flagState = XmlHelpers.getStemType(noteElement);
const clefString: Clef = xmlState.staffArray[staffIndex].clefInfo.clef as Clef;
const pitch: Pitch = XmlHelpers.smoPitchFromNote(noteElement,
SmoMeasure.defaultPitchForClef[clefString]);
if (isGrace === false) {
if (chordNode.length) {
// If this is a note in a chord, just add the pitch to previous note.
xmlState.previousNote.pitches.push(pitch);
xmlState.updateTieStates(tieInfos);
} else {
// Create a new note
const noteData: SmoNoteParams = SmoNote.defaults;
noteData.noteType = noteType;
noteData.pitches = [pitch];
// If this is a non-grace note, add any grace notes to the note since SMO
// treats them as note modifiers
noteData.ticks = { numerator: tickCount, denominator: 1, remainder: 0 };
noteData.stemTicks = stemTicks;
noteData.flagState = flagState;
noteData.clef = clefString;
xmlState.previousNote = new SmoNote(noteData);
if (hideNote) {
xmlState.previousNote.makeHidden(true);
}
xmlState.updateDynamics();
ornaments.forEach((ornament) => {
if (ornament.ctor === 'SmoOrnament') {
xmlState.previousNote.setOrnament(ornament as SmoOrnament, true);
} else if (ornament.ctor === 'SmoArticulation') {
xmlState.previousNote.toggleArticulation(ornament as SmoArticulation);
}
});
lyrics.forEach((lyric) => {
xmlState.addLyric(xmlState.previousNote, lyric);
});
for (grIx = 0; grIx < xmlState.graceNotes.length; ++grIx) {
xmlState.previousNote.addGraceNote(xmlState.graceNotes[grIx], grIx);
}
xmlState.graceNotes = []; // clear the grace note array
// If this note starts later than the cursor due to forward, pad with rests
if (xmlState.tickCursor > xmlState.staffArray[staffIndex].voices[voiceIndex].ticksUsed) {
const pads = SmoMusic.splitIntoValidDurations(
xmlState.tickCursor - xmlState.staffArray[staffIndex].voices[voiceIndex].ticksUsed);
console.log(`padding ${pads.length} before ${xmlState.staffIndex}-${xmlState.measureIndex}-${xmlState.voiceIndex}-${tickIndex}`);
pads.forEach((pad) => {
const clefString: Clef = xmlState.staffArray[staffIndex].clefInfo.clef as Clef;
const padNote = SmoMeasure.createRestNoteWithDuration(pad,
clefString);
padNote.makeHidden(true);
voice.notes.push(padNote);
});
// slurs and ties use selector, so this affects them, also
selector.tick += pads.length;
// then reset the cursor since we are now in sync
xmlState.staffArray[staffIndex].voices[voiceIndex].ticksUsed = xmlState.tickCursor;
}
/* slurInfos.forEach((slurInfo) => {
console.log(`xml slur: ${slurInfo.selector.staff}-${slurInfo.selector.measure}-${slurInfo.selector.voice}-${slurInfo.selector.tick} ${slurInfo.type} ${slurInfo.number}`);
console.log(` ${slurInfo.placement}`);
});*/
/* tieInfos.forEach((tieInfo) => {
console.log(`xml tie: ${tieInfo.selector.staff}-${tieInfo.selector.measure}-${tieInfo.selector.voice}-${tieInfo.selector.tick} ${tieInfo.type} `);
console.log(` pitch ${tieInfo.pitchIndex} orient ${tieInfo.orientation} num ${tieInfo.number}`);
});*/
xmlState.updateSlurStates(slurInfos);
xmlState.updateTieStates(tieInfos);
voice.notes.push(xmlState.previousNote);
//todo nenad: check if we need to change something with 'alteration'
xmlState.updateBeamState(beamState, durationData.alteration, voice, voiceIndex);
xmlState.updateTupletStates(tupletInfos, voice,
staffIndex, voiceIndex);
}
} else {
if (chordNode.length) {
xmlState.graceNotes[xmlState.graceNotes.length - 1].pitches.push(pitch);
} else {
// grace note durations don't seem to have explicit duration, so
// get it from note type
xmlState.updateSlurStates(slurInfos);
xmlState.updateTieStates(tieInfos);
xmlState.graceNotes.push(new SmoGraceNote({
pitches: [pitch],
ticks: { numerator: tickCount, denominator: 1, remainder: 0 }
}));
}
}
}
static print(printElement: Element, xmlState: XmlState) {
if (xmlState.parts[xmlState.partId]) {
XmlToSmo.pageSizeFromLayout(printElement, xmlState.parts[xmlState.partId].layoutManager, xmlState);
}
}
/**
* /score-partwise/part/measure
* A measure in music xml might represent several measures in SMO at the same
* column in the score
* @param measureElement
* @param xmlState
*/
static measure(measureElement: Element, xmlState: XmlState) {
xmlState.initializeForMeasure(measureElement);
const elements = [...measureElement.children];
let hasNotes = false;
elements.forEach((element) => {
if (element.tagName === 'backup') {
xmlState.currentDuration -= XmlHelpers.durationFromNode(element, 0);
}
if (element.tagName === 'forward') {
xmlState.currentDuration += XmlHelpers.durationFromNode(element, 0);
}
if (element.tagName === 'attributes') {
// update the running state of the XML with new information from this measure
// if an XML attributes element is present
XmlToSmo.attributes(measureElement, xmlState);
} else if (element.tagName === 'direction') {
XmlToSmo.direction(element, xmlState);
} else if (element.tagName === 'note') {
XmlToSmo.note(element, xmlState);
hasNotes = true;
} else if (element.tagName === 'barline') {
xmlState.updateEndings(element);
} else if (element.tagName === 'print') {
XmlToSmo.print(element, xmlState);
}
});
// If a measure has no notes, just make one with the defaults
if (hasNotes === false && xmlState.staffArray.length < 1 && xmlState.clefInfo.length >= 1) {
xmlState.clefInfo.forEach((clefInfo) => {
xmlState.staffArray.push({ clefInfo, measure: null, voices: {} });
});
}
if (xmlState.rehearsalMark.length) {
xmlState.rehearsalMarks[xmlState.measureIndex] = xmlState.rehearsalMark;
}
xmlState.staffArray.forEach((staffData) => {
const clef = staffData.clefInfo.clef as Clef;
const params: SmoMeasureParams = SmoMeasure.defaults;
params.transposeIndex = xmlState.instrument.keyOffset;
params.clef = clef;
const smoMeasure = SmoMeasure.getDefaultMeasure(params);
smoMeasure.format = new SmoMeasureFormat(SmoMeasureFormat.defaults);
smoMeasure.format.measureIndex = xmlState.measureNumber;
smoMeasure.format.systemBreak = XmlHelpers.isSystemBreak(measureElement);
smoMeasure.tempo = xmlState.tempo;
smoMeasure.format.proportionality = XmlToSmo.customProportionDefault;
xmlState.formattingManager.updateMeasureFormat(smoMeasure.format);
smoMeasure.keySignature = xmlState.keySignature.toLowerCase();
smoMeasure.timeSignature = SmoMeasure.convertLegacyTimeSignature(xmlState.timeSignature);
smoMeasure.measureNumber.localIndex = xmlState.measureNumber;
smoMeasure.measureNumber.measureIndex = xmlState.measureIndex;
smoMeasure.measureNumber.staffId = staffData.clefInfo.staffId + xmlState.smoStaves.length;
const startBarDefs = SmoBarline.defaults;
startBarDefs.position = SmoBarline.positions.start;
startBarDefs.barline = xmlState.startBarline;
const endBarDefs = SmoBarline.defaults;
endBarDefs.position = SmoBarline.positions.end;
endBarDefs.barline = xmlState.endBarline;
smoMeasure.setBarline(new SmoBarline(startBarDefs));
smoMeasure.setBarline(new SmoBarline(endBarDefs));
// voices not in array, put them in an array
Object.keys(staffData.voices).forEach((voiceKey) => {
const voice = staffData.voices[voiceKey];
voice.notes.forEach((note) => {
if (!note.clef) {
note.clef = smoMeasure.clef;
}
});
smoMeasure.voices.push(voice);
const voiceId = smoMeasure.voices.length - 1;
xmlState.addTupletsToMeasure(smoMeasure, staffData.clefInfo.staffId, voiceId);
});
SmoTupletTree.syncTupletIds(smoMeasure.tupletTrees, smoMeasure.voices);
if (smoMeasure.voices.length === 0) {
smoMeasure.voices.push({ notes: SmoMeasure.getDefaultNotes(smoMeasure) });
}
staffData.measure = smoMeasure;
});
// Pad incomplete measures/voices with rests
const maxTicks = xmlState.staffArray.map((staffData) => (staffData.measure as SmoMeasure).getMaxTicksVoice())
.reduce((a, b) => a > b ? a : b);
xmlState.staffArray.forEach((staffData) => {
let i = 0;
let j = 0;
const measure = staffData.measure as SmoMeasure;
for (i = 0; i < measure.voices.length; ++i) {
const curTicks = measure.getTicksFromVoice(i);
if (curTicks < maxTicks) {
const tickAr = SmoMusic.splitIntoValidDurations(maxTicks - curTicks);
for (j = 0; j < tickAr.length; ++j) {
measure.voices[i].notes.push(
SmoMeasure.createRestNoteWithDuration(tickAr[j], measure.clef)
);
}
}
}
});
}
}