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
596 lines (584 loc) • 24.2 kB
text/typescript
// [Smoosic](https://github.com/AaronDavidNewman/Smoosic)
// Copyright (c) Aaron David Newman 2021.
import { smoSerialize } from '../../common/serializationHelpers';
import { SmoArticulation, SmoNoteModifierBase, SmoOrnament } from '../data/noteModifiers';
import { SmoMusic } from '../data/music';
import { SmoNote } from '../data/note';
import { Pitch, PitchLetter, createXmlAttributes, createXmlAttribute, SmoDynamicCtor } from '../data/common';
import { SmoSelector } from '../xform/selections';
import { SmoBarline } from '../data/measureModifiers';
import { XmlTupletData } from './xmlState';
/**
* @category serialization
*/
export interface XmlOrnamentData {
ctor: string,
params: Record<string, string>
}
/**
* @category serialization
*/
export interface XmlSmoMap {
xml: string, smo: string
}
/**
* @category serialization
*/
export interface XmlDurationAlteration {
noteCount: number, noteDuration: number
}
/**
* @category serialization
*/
export interface XmlDuration {
tickCount: number, duration: number, alteration: XmlDurationAlteration
}
/**
* Store slur information when parsing xml
* @category serialization
*/
export interface XmlSlurType {
number: number, type: string, orientation: string, placement: string, controlX: number, controlY: number, selector: SmoSelector, yOffset: number
}
/**
* Store tie information when parsing xml
* @category serialization
*/
export interface XmlTieType {
number: number, type: string, orientation: string, selector: SmoSelector, pitchIndex: number
}
/**
* Store tuplet information when parsing xml
* @category serialization
*/
export interface XmlTupletType {
number: number,
type: string,
data: XmlTupletData | null,
}
/**
* @category serialization
*/
export interface XmlTimeModificationType {
actualNotes: number,
normalNotes: number,
normalType: number,
//normalDot, todo: check if just bool or list of dots (probably list of dots)
}
/**
* @category serialization
*/
export interface XmlEndingData {
numbers: number[], type: string
}
export type LyricSyllabic = 'begin' | 'end' | 'middle' | 'single';
/**
* Store lyric information when parsing xml
* @category serialization
*/
export interface XmlLyricData {
_text: string, verse: number | string, syllabic: LyricSyllabic
}
/**
* Utilities for parsing and serialzing musicXML.
* @category serialization
*/
export class XmlHelpers {
/**
* mxml note 'types', really s/b stem types.
* For grace notes, we use the note type and not duration
* to get the flag
*/
static get noteTypesToSmoMap(): Record<string, number> {
return {
'breve': 8192 * 4,
'whole': 8192 * 2,
'half': 8192,
'quarter': 4096,
'eighth': 2048,
'16th': 1024,
'32nd': 512,
'64th': 256,
'128th': 128
};
}
static readonly _ticksToNoteTypeMap: Record<number, string> = smoSerialize.reverseMap(XmlHelpers.noteTypesToSmoMap) as Record<number, string>;
static get ticksToNoteTypeMap(): Record<number, string> {
return XmlHelpers._ticksToNoteTypeMap;
}
// ### closestStemType
// smo infers the stem type from the duration, but other applications don't
static closestStemType(ticks: number) {
const nticks = SmoMusic.closestDurationTickLtEq(ticks);
// closestBeamDuration returns the rounded-up beam length for dotted rhythm and tuplets,
// we want the actual stem that's used so cut it in 1/2
const beamDuration = SmoMusic.closestBeamDuration(nticks);
if (beamDuration.ticks === nticks) {
return XmlHelpers.ticksToNoteTypeMap[beamDuration.ticks];
} else {
return XmlHelpers.ticksToNoteTypeMap[beamDuration.ticks / 2];
}
}
static get beamStates(): Record<string, number> {
return {
BEGIN: 1,
END: 2,
AUTO: 3
};
}
static get ornamentXmlToSmoMap(): Record<string, XmlOrnamentData> {
return {
staccato: { ctor: 'SmoArticulation', params: { articulation: SmoArticulation.articulations.staccato } },
tenuto: { ctor: 'SmoArticulation', params: { articulation: SmoArticulation.articulations.tenuto } },
marcato: { ctor: 'SmoArticulation', params: { articulation: SmoArticulation.articulations.marcato } },
accent: { ctor: 'SmoArticulation', params: { articulation: SmoArticulation.articulations.accent } },
doit: { ctor: 'SmoOrnament', params: { ornament: SmoOrnament.ornaments.doitLong } },
falloff: { ctor: 'SmoOrnament', params: { ornament: SmoOrnament.ornaments.fall } },
scoop: { ctor: 'SmoOrnament', params: { ornament: SmoOrnament.ornaments.scoop } },
'delayed-turn': { ctor: 'SmoOrnament', params: { ornament: SmoOrnament.ornaments.turn, offset: SmoOrnament.offsets.after } },
turn: { ctor: 'SmoOrnament', params: { ornament: SmoOrnament.ornaments.turn, offset: SmoOrnament.offsets.on } },
'inverted-turn': { ctor: 'SmoOrnament', params: { ornament: SmoOrnament.ornaments.turnInverted } },
mordent: { ctor: 'SmoOrnament', params: { ornament: SmoOrnament.ornaments.mordent } },
'inverted-mordent': { ctor: 'SmoOrnament', params: { ornament: SmoOrnament.ornaments.mordentInverted } },
shake: { ctor: 'SmoOrnament', params: { ornament: SmoOrnament.ornaments.mordentInverted } },
'trill-mark': { ctor: 'SmoOrnament', params: { ornament: SmoOrnament.ornaments.trill } },
};
}
// ### createRootElement
// Create score-partwise document with prelude
// https://bugzilla.mozilla.org/show_bug.cgi?id=318086
static createRootElement() {
const doc = document.implementation.createDocument('', '', null);
const rootElem = doc.createElement('score-partwise');
const piElement = doc.createProcessingInstruction('xml', 'version="1.0" encoding="UTF-8"');
rootElem.setAttribute('version', '3.1');
doc.appendChild(rootElem);
doc.insertBefore(piElement, rootElem);
return doc;
}
// Parse an element whose child has a number in the textContent
static getNumberFromElement(parent: Element, path: string, defaults: number): number {
let rv = (typeof (defaults) === 'undefined' || defaults === null)
? 0 : defaults;
const tval = XmlHelpers.getTextFromElement(parent, path, defaults);
if (!tval) {
return rv;
}
if (typeof (tval) === 'number') {
return tval;
}
if (tval.indexOf('.')) {
const tf = parseFloat(tval);
rv = isNaN(tf) ? rv : tf;
} else {
const ff = parseInt(tval, 10);
rv = isNaN(ff) ? rv : ff;
}
return rv;
}
// Parse an element whose child has a textContent
static getTextFromElement(parent: Element, path: string, defaults: number | string | null): string {
const rv = (typeof (defaults) === 'undefined' || defaults === null)
? 0 : defaults;
const el = [...parent.getElementsByTagName(path)];
if (!el.length) {
return rv.toString();
}
return el[0].textContent as string;
}
static getNumberFromAttribute(node: Element, attribute: string, defaults: number) {
const str = XmlHelpers.getTextFromAttribute(node, attribute, defaults.toString());
const rv = parseInt(str, 10);
if (isNaN(rv)) {
return defaults;
}
return rv;
}
static getTextFromAttribute(node: Element, attribute: string, defaults: string): string {
const rv = node.getAttribute(attribute);
if (rv) {
return rv;
}
return defaults;
}
// ### getChildrenFromPath
// Like xpath, given ['foo', 'bar'] and parent element
// 'moo' return any element /moo/foo/bar as an array of elements
static getChildrenFromPath(parent: Element, pathAr: string[]): Element[] {
let i = 0;
let node = parent;
const rv: Element[] = [];
for (i = 0; i < pathAr.length; ++i) {
const tag = pathAr[i];
const nodes: Element[] = [...node.getElementsByTagName(tag)];
if (nodes.length === 0) {
return [];
}
if (i < pathAr.length - 1) {
node = nodes[0];
} else {
nodes.forEach((nn: Element) => {
rv.push(nn);
});
}
}
return rv;
}
static getStemType(noteElement: Element) {
const tt = XmlHelpers.getTextFromElement(noteElement, 'stem', '');
if (tt === 'up') {
return SmoNote.flagStates.up;
} else if (tt === 'down') {
return SmoNote.flagStates.down;
}
return SmoNote.flagStates.auto;
}
static getEnding(barlineNode: Element): XmlEndingData | null {
const endingNodes = [...barlineNode.getElementsByTagName('ending')];
if (!endingNodes.length) {
return null;
}
const attrs = XmlHelpers.nodeAttributes(endingNodes[0]);
if (attrs.number && attrs.type) {
return {
numbers: attrs.number.split(',').map((x) => parseInt(x, 10)),
type: attrs.type
};
}
return null;
}
static getBarline(barlineNode: Element): number {
const rptNode = [...barlineNode.getElementsByTagName('repeat')];
if (rptNode.length) {
const repeatattr = XmlHelpers.nodeAttributes(rptNode[0]);
return repeatattr.direction === 'forward' ? SmoBarline.barlines.startRepeat : SmoBarline.barlines.endRepeat;
}
const styleText = XmlHelpers.getTextFromElement(barlineNode, 'bar-style', '');
if (styleText.length) {
const double = styleText.indexOf('-') >= 0;
const heavy = styleText.indexOf('heavy') >= 0;
const light = styleText.indexOf('light') >= 0;
if (double && heavy && light) {
return SmoBarline.barlines.endBar;
}
if (double) {
return SmoBarline.barlines.doubleBar;
}
}
return SmoBarline.barlines.singleBar;
}
// ### assignDefaults
// Map SMO layout data from xml layout data (default node)
static assignDefaults(node: Element, defObj: any, parameters: XmlSmoMap[]) {
parameters.forEach((param) => {
if (!isNaN(parseInt(defObj[param.smo], 10))) {
const smoParam = param.smo;
const xmlParam = param.xml;
defObj[smoParam] = XmlHelpers.getNumberFromElement(node, xmlParam, defObj[smoParam]);
}
});
}
// ### nodeAttributes
// turn the attributes of an element into a JS hash
static nodeAttributes(node: Element): Record<string, string> {
const rv: Record<string, string> = {};
node.getAttributeNames().forEach((attr) => {
const aval: string | null = node.getAttribute(attr);
if (aval) {
rv[attr] = aval;
}
});
return rv;
}
// Some measures have staff ID, some don't.
// convert xml 1 index to array 0 index
static getStaffId(node: Element) {
const staff = [...node.getElementsByTagName('staff')];
if (staff.length && staff[0].textContent) {
return parseInt(staff[0].textContent, 10) - 1;
}
return 0;
}
static noteBeamState(noteNode: Element) {
const beamNodes = [...noteNode.getElementsByTagName('beam')];
if (!beamNodes.length) {
return XmlHelpers.beamStates.AUTO;
}
const beamText = beamNodes[0].textContent;
if (beamText === 'begin') {
return XmlHelpers.beamStates.BEGIN;
} else if (beamText === 'end') {
return XmlHelpers.beamStates.END;
}
return XmlHelpers.beamStates.AUTO;
}
// same with notes and voices. same convert
static getVoiceId(node: Element) {
const voice = [...node.getElementsByTagName('voice')];
if (voice.length && voice[0].textContent) {
return parseInt(voice[0].textContent, 10) - 1;
}
return 0;
}
static smoPitchFromNote(noteNode: Element, defaultPitch: Pitch): Pitch {
const accidentals = ['bb', 'b', 'n', '#', '##'];
const letter: PitchLetter = XmlHelpers.getTextFromElement(noteNode, 'step', defaultPitch.letter).toLowerCase() as PitchLetter;
const octave = XmlHelpers.getNumberFromElement(noteNode, 'octave', defaultPitch.octave);
const xaccidental = XmlHelpers.getNumberFromElement(noteNode, 'alter', 0);
return { letter, accidental: accidentals[xaccidental + 2], octave };
}
static isGrace(noteNode: Element) {
const path = XmlHelpers.getChildrenFromPath(noteNode, ['grace']);
return path?.length > 0;
}
static isSystemBreak(measureNode: Element) {
const printNodes = measureNode.getElementsByTagName('print');
if (printNodes.length) {
const attrs = XmlHelpers.nodeAttributes(printNodes[0]);
if (typeof (attrs['new-system']) !== 'undefined') {
return attrs['new-system'] === 'yes';
}
}
return false;
}
// ### durationFromType
// Get the SMO tick duration of a note, based on the XML type element (quarter, etc)
static durationFromType(noteNode: Element, def: number): number {
const typeNodes = [...noteNode.getElementsByTagName('type')];
if (typeNodes.length) {
const txt = typeNodes[0].textContent;
if (txt && XmlHelpers.noteTypesToSmoMap[txt]) {
return XmlHelpers.noteTypesToSmoMap[txt];
}
}
return def;
}
// ### durationFromNode
// the true duration value, used to handle forward/backward
static durationFromNode(noteNode: Element, def: number) {
const durationNodes = [...noteNode.getElementsByTagName('duration')];
if (durationNodes.length && durationNodes[0].textContent) {
const duration = parseInt(durationNodes[0].textContent, 10);
return duration;
}
return def;
}
static ticksFromDuration(noteNode: Element, divisions: number, def: number): XmlDuration {
const rv: XmlDuration = { tickCount: def, duration: def / divisions, alteration: { noteCount: 1, noteDuration: 1 } };
const durationNodes = [...noteNode.getElementsByTagName('duration')];
const timeAlteration = XmlHelpers.getTimeAlteration(noteNode);
// different ways to declare note duration - from type is the graphical
// type, SMO uses ticks for everything
if (durationNodes.length && durationNodes[0].textContent) {
rv.duration = parseInt(durationNodes[0].textContent, 10);
rv.tickCount = 4096 * (rv.duration / divisions);
} else {
rv.tickCount = XmlHelpers.durationFromType(noteNode, def);
rv.duration = (divisions / 4096) * rv.tickCount;
}
//todo nenad: seems like this is not needed since we keep stemTicks directly on the note object now
// If this is a tuplet, we adjust the note duration back to the graphical type
// and SMO will create the tuplet after. We keep track of tuplet data though for beaming
// if (timeAlteration) {
// rv.tickCount = (rv.tickCount * timeAlteration.noteCount) / timeAlteration.noteDuration;
// rv.alteration = timeAlteration;
// }
return rv;
}
static getTieData(noteNode: Element, selector: SmoSelector, pitchIndex: number): XmlTieType[] {
const rv: XmlTieType[] = [];
let number = 0;
const nNodes = [...noteNode.getElementsByTagName('notations')];
nNodes.forEach((nNode) => {
const slurNodes = [...nNode.getElementsByTagName('tied')];
slurNodes.forEach((slurNode) => {
const orientation = XmlHelpers.getTextFromAttribute(slurNode, 'orientation', 'auto');
const type = slurNode.getAttribute('type') as string;
number = XmlHelpers.getNumberFromAttribute(slurNode, 'number', 1);
rv.push({ number, type, orientation, selector, pitchIndex });
});
});
return rv;
}
static getSlurData(noteNode: Element, selector: SmoSelector): XmlSlurType[] {
const rv: XmlSlurType[] = [];
const nNodes = [...noteNode.getElementsByTagName('notations')];
nNodes.forEach((nNode) => {
const slurNodes = [...nNode.getElementsByTagName('slur')];
slurNodes.forEach((slurNode) => {
const number = parseInt(slurNode.getAttribute('number') as string, 10);
const type = slurNode.getAttribute('type') as string;
const orientation = XmlHelpers.getTextFromAttribute(slurNode, 'orienation', 'auto');
const placement = XmlHelpers.getTextFromAttribute(slurNode, 'placement', 'auto');
const controlX = XmlHelpers.getNumberFromAttribute(slurNode, 'bezier-x', 0);
// Y coordinates are reversed from music XML to SVG, hence the -1
const controlY = XmlHelpers.getNumberFromAttribute(slurNode, 'bezier-y', 15) * -1;
const slurInfo = { number, type, orientation, placement, controlX, controlY, selector, invert: false, yOffset: 0 };
rv.push(slurInfo);
});
});
return rv;
}
static getCrescendoData(directionElement: Element) {
let rv = {};
const nNodes = XmlHelpers.getChildrenFromPath(directionElement,
['direction-type', 'wedge']);
nNodes.forEach((nNode) => {
rv = { type: nNode.getAttribute('type') };
});
return rv;
}
static getTimeModificationType(noteNode: Element): XmlTimeModificationType | null {
const timeModificationNode = noteNode.querySelector('time-modification');
let xmlTimeModification: XmlTimeModificationType | null = null;
if (timeModificationNode) {
const actualNotesNode = timeModificationNode.querySelector('actual-notes');
const normalNotesNode = timeModificationNode.querySelector('normal-notes');
const normalTypeNode = timeModificationNode.querySelector('normal-type');
const noteTypeNode = noteNode.querySelector('type');
let normalType: number | null = null;
if (normalTypeNode) {
normalType = normalTypeNode.textContent ? XmlHelpers.noteTypesToSmoMap[normalTypeNode.textContent] ?? null : null;
} else if (noteTypeNode) {
normalType = noteTypeNode.textContent ? XmlHelpers.noteTypesToSmoMap[noteTypeNode.textContent] ?? null : null;
}
if (actualNotesNode?.textContent && normalNotesNode?.textContent && normalType) {
const actualNotes = parseInt(actualNotesNode.textContent, 10);
const normalNotes = parseInt(normalNotesNode.textContent, 10);
xmlTimeModification = {
actualNotes: actualNotes,
normalNotes: normalNotes,
normalType: normalType
};
}
}
return xmlTimeModification;
}
static getTupletData(noteNode: Element): XmlTupletType[] {
const rv: XmlTupletType[] = [];
const timeModification = XmlHelpers.getTimeModificationType(noteNode);
const notationNode = noteNode.querySelector('notations');
if (notationNode) {
const tupletNodes = [...notationNode.getElementsByTagName('tuplet')];
tupletNodes.forEach((tupletNode) => {
const number = parseInt(tupletNode.getAttribute('number') as string, 10) as number;
const type = tupletNode.getAttribute('type') as string;
const xmlTupletType: XmlTupletType = {
number: number,
type: type,
data: null
};
if (type === 'start') {
let tupletActual = null;
let tupletNormal = null;
const tupletActualNode = tupletNode.querySelector('tuplet-actual');
if (tupletActualNode) {
const tupletNumberNode = tupletActualNode.querySelector('tuplet-number');
const tupletTypeNode = tupletActualNode.querySelector('tuplet-type');
const tupletTypeContent = tupletTypeNode?.textContent;
const tupletType = tupletTypeContent ? XmlHelpers.noteTypesToSmoMap[tupletTypeContent] ?? null : null;
if (tupletNumberNode?.textContent && tupletType) {
const tupletNumber = parseInt(tupletNumberNode.textContent, 10);
tupletActual = {tupletNumber: tupletNumber, tupletType: tupletType};
}
}
const tupletNormalNode = tupletNode.querySelector('tuplet-normal');
if (tupletNormalNode) {
const tupletNumberNode = tupletNormalNode.querySelector('tuplet-number');
const tupletTypeNode = tupletNormalNode.querySelector('tuplet-type');
const tupletTypeContent = tupletTypeNode?.textContent;
const tupletType = tupletTypeContent ? XmlHelpers.noteTypesToSmoMap[tupletTypeContent] ?? null : null;
if (tupletNumberNode?.textContent && tupletType) {
const tupletNumber = parseInt(tupletNumberNode.textContent, 10);
tupletNormal = {tupletNumber: tupletNumber, tupletType: tupletType};
}
}
if (tupletActual && tupletNormal) {
const xmlTupletData: XmlTupletData = {
stemTicks: tupletActual.tupletType,
numNotes: tupletActual.tupletNumber,
notesOccupied: (tupletActual.tupletType / tupletNormal.tupletType) * tupletNormal.tupletNumber
};
xmlTupletType.data = xmlTupletData;
} else if (timeModification) {
const xmlTupletData: XmlTupletData = {
stemTicks: timeModification.normalType,
numNotes: timeModification.actualNotes,
notesOccupied: timeModification.normalNotes
};
xmlTupletType.data = xmlTupletData;
}
}
rv.push(xmlTupletType);
});
}
return rv;
}
static articulationsAndOrnaments(noteNode: Element): SmoNoteModifierBase[] {
const rv: SmoNoteModifierBase[] = [];
const nNodes = [...noteNode.getElementsByTagName('notations')];
nNodes.forEach((nNode) => {
['articulations', 'ornaments'].forEach((typ) => {
const articulations = [...nNode.getElementsByTagName(typ)];
articulations.forEach((articulation) => {
Object.keys(XmlHelpers.ornamentXmlToSmoMap).forEach((key) => {
if (articulation.getElementsByTagName(key).length) {
rv.push(SmoDynamicCtor[XmlHelpers.ornamentXmlToSmoMap[key].ctor](XmlHelpers.ornamentXmlToSmoMap[key]));
}
});
});
});
});
return rv;
}
static lyrics(noteNode: Element): XmlLyricData[] {
const rv: XmlLyricData[] = [];
const nNodes = [...noteNode.getElementsByTagName('lyric')];
nNodes.forEach((nNode) => {
let verse = nNode.getAttribute('number');
const text = XmlHelpers.getTextFromElement(nNode, 'text', '_');
const name = nNode.getAttribute('name') as string;
const syllabic = XmlHelpers.getTextFromElement(nNode, 'syllabic', 'end') as LyricSyllabic;
// Per xml spec, verse can be specified by a string (name), as in 'chorus'
if (!verse) {
verse = name;
}
const obj: XmlLyricData = { _text: text, verse, syllabic };
rv.push(obj);
});
return rv;
}
static getTimeAlteration(noteNode: Element): XmlDurationAlteration | null {
const timeNodes = XmlHelpers.getChildrenFromPath(noteNode, ['time-modification']);
if (timeNodes.length) {
return {
noteCount: XmlHelpers.getNumberFromElement(timeNodes[0], 'actual-notes', 1),
noteDuration: XmlHelpers.getNumberFromElement(timeNodes[0], 'normal-notes', 1)
};
}
return null;
}
// ### createTextElementChild
// In: ../parent
// Out: ../parent/elementName/obj[field]
// returns elementName element. If obj is null, just creates and returns child
// if obj is a string, it uses it as the text value
static createTextElementChild(parentElement: Element, elementName: string, obj: any, field: string): Element {
const el = parentElement.ownerDocument.createElement(elementName);
if (obj) {
if (typeof (obj) === 'string') {
el.textContent = obj;
} else {
el.textContent = obj[field];
}
}
parentElement.appendChild(el);
return el;
}
static createAttributes(element: Element, obj: any) {
createXmlAttributes(element, obj);
}
static createAttribute(element: Element, name: string, value: any) {
createXmlAttribute(element, name, value);
}
}