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
1,287 lines (1,244 loc) • 46.5 kB
text/typescript
// [Smoosic](https://github.com/AaronDavidNewman/Smoosic)
// Copyright (c) Aaron David Newman 2021.
/**
* Classes to support {@link SmoScore}
* @module /smo/data/score
*/
import { SmoMusic } from './music';
import { Clef, SvgDimensions } from './common';
import { SmoMeasure, SmoMeasureParams, ColumnMappedParams, SmoMeasureParamsSer } from './measure';
import { SmoNoteModifierBase } from './noteModifiers';
import {
SmoTempoText, SmoMeasureFormat, SmoMeasureModifierBase, TimeSignature, TimeSignatureParameters,
SmoMeasureFormatParamsSer
} from './measureModifiers';
import { StaffModifierBase, SmoInstrument } from './staffModifiers';
import {
SmoSystemGroup, SmoSystemGroupParamsSer, SmoScoreModifierBase, SmoPageLayout,
SmoFormattingManager, SmoAudioPlayerSettings, SmoAudioPlayerParameters, SmoLayoutManagerParamsSer,
SmoLayoutManager, FontPurpose,
SmoScoreInfo, SmoScoreInfoKeys, ScoreMetadataSer, SmoScorePreferences, SmoPageLayoutParams,
SmoLayoutManagerParams, SmoFormattingManagerParams
} from './scoreModifiers';
import { SmoTextGroup, SmoScoreText, SmoTextGroupParamsSer } from './scoreText';
import { SmoSystemStaff, SmoSystemStaffParams, SmoSystemStaffParamsSer } from './systemStaff';
import { SmoSelector, SmoSelection } from '../xform/selections';
import { smoSerialize } from '../../common/serializationHelpers';
import { FontInfo } from '../../common/vex';
/**
* List of engraving fonts available in Smoosic
*/
export type engravingFontType = 'Bravura' | 'Gonville' | 'Petaluma' | 'Leland';
/**
* Arrary of engraving fonts available in Smoosic
*/
export const engravingFontTypes: engravingFontType[] = ['Bravura', 'Gonville', 'Petaluma', 'Leland'];
export function isEngravingFont(et: engravingFontType | string): et is engravingFontType {
return (engravingFontTypes as any[]).indexOf(et) >= 0;
}
/**
* Constructor parameters. Usually you will call
* {@link SmoScore.defaults}, and modify the parameters you need to change.
* A new score with the defaults will create a single, empty measure.
* @category SmoObject
*/
export interface SmoScoreParams {
/**
* global font defaults for this score
*/
fonts: FontPurpose[],
/**
* identifying information about the score
*/
scoreInfo: SmoScoreInfo,
/**
* customized editor behavior
*/
preferences: SmoScorePreferences,
/**
* contained {@link SmoSystemStaffParams} objects
*/
staves: SmoSystemStaffParams[],
activeStaff?: number,
/**
* score text, not part of specific music
*/
textGroups: SmoTextGroup[],
/**
* System groups for formatting/justification
*/
systemGroups: SmoSystemGroup[],
/**
* future: global audio settings
*/
audioSettings: SmoAudioPlayerParameters,
/**
* layout manager, for svg and div geometry, page sizes, header sizes etc.
*/
layoutManager?: SmoLayoutManager,
/**
* measure-specific formatting
*/
formattingManager?: SmoFormattingManager
}
function isSmoScoreParams(params: Partial<SmoScoreParams>): params is SmoScoreParams {
if (params.fonts && params.fonts.length) {
return true;
}
return false;
}
/**
* Serialization structure for the entire score. Score is deserialized from this
* @category serialization
*/
export interface SmoScoreParamsSer {
/**
* some information about the score, mostly non-musical
*/
metadata: ScoreMetadataSer,
/**
* contained {@link SmoSystemStaffParams} objects
*/
staves: SmoSystemStaffParamsSer[],
/**
* score text, not part of specific music
*/
textGroups: SmoTextGroupParamsSer[],
/**
* System groups for formatting/justification
*/
systemGroups: SmoSystemGroupParamsSer[],
/**
* future: global audio settings
*/
audioSettings: SmoAudioPlayerParameters,
/**
* layout manager, for svg and div geometry, page sizes, header sizes etc.
*/
layoutManager?: SmoLayoutManagerParamsSer,
/**
* map of measure formats to measure
*/
measureFormats: SmoMeasureFormatParamsSer[],
/**
* tempo, key and other column-mapped parameters
*/
columnAttributeMap: ColumnParamsMapType,
/**
* dictionary compression for serialization
*/
dictionary: Record<string, string>
}
/**
* @category SmoObject
*/
export interface SmoScoreSerializeOptions {
skipStaves: boolean,
useDictionary: boolean,
preserveStaffIds: boolean // preserve staff modifiers IDs to keep in sync
}
// dont' deserialize trivial text blocks saved by mistake
export function isEmptyTextBlock(params: Partial<SmoTextGroupParamsSer>): params is SmoTextGroupParamsSer {
if (Array.isArray(params?.textBlocks) || Array.isArray((params as any)?.blocks)) {
return false;
}
return true;
}
/**
* @category SmoObject
*/
export interface ColumnParamsMapType {
keySignature: Record<number, string>,
tempo: Record<number, SmoTempoText>,
timeSignature: Record<number, TimeSignature>,
renumberingMap: Record<number, number>
}
// SmoScoreParemsSer
export function isSmoScoreParemsSer(params: Partial<SmoScoreParamsSer>): params is SmoScoreParamsSer {
if (Array.isArray(params.staves)) {
return true;
}
return false;
}
/**
* Union of modifier types Smo modifier types
*/
export type SmoModifier = SmoNoteModifierBase | SmoMeasureModifierBase | StaffModifierBase | SmoScoreModifierBase;
/**
* Score is a container of staves, and metadata about the score. Serializing the score serializes the
* child object. It is the highest-level object in Smoosic.
* @category SmoObject
*/
export class SmoScore {
/**
* Map of instruments to staves, used in serialization.
*
* @type {any[]}
* @memberof SmoScore
*/
instrumentMap: any[] = [];
/**
* Default fonts in this score, for each type of text (lyrics, etc)
*
* @type {FontPurpose[]}
* @memberof SmoScore
*/
fonts: FontPurpose[] = [];
/**
* General info about the score, used for export and library
*
* @type {SmoScoreInfo}
* @memberof SmoScore
*/
scoreInfo: SmoScoreInfo = SmoScore.scoreInfoDefaults;
/**
* Default behavior for this score. Indicates some global behavior like whether to advance the cursor.
*
* @type {SmoScorePreferences}
* @memberof SmoScore
*/
preferences: SmoScorePreferences = new SmoScorePreferences(SmoScorePreferences.defaults);
/**
* The staves that make up the music of the score
*
* @type {SmoSystemStaff[]}
* @memberof SmoScore
*/
staves: SmoSystemStaff[] = [];
/**
* The active staff, used for some types of selections. Not serialized.
*
* @type {number}
* @memberof SmoScore
*/
activeStaff: number = 0;
/**
* Text associated with the score, but not a specific musical element (e.g. lyrics are contains by notes)
*
* @type {SmoTextGroup[]}
* @memberof SmoScore
*/
textGroups: SmoTextGroup[] = [];
/**
* A logical grouping of staves for justification
*
* @type {SmoSystemGroup[]}
* @memberof SmoScore
*/
systemGroups: SmoSystemGroup[] = [];
/**
* some audio player defaults
*
* @type {SmoAudioPlayerSettings}
* @memberof SmoScore
*/
audioSettings: SmoAudioPlayerSettings;
/**
* Preserve a map of measures to their actual measure numbers
*
* @type {Record<number, number>}
* @memberof SmoScore
*/
renumberingMap: Record<number, number> = {};
/**
* page and rendering layout of the score, including the ppi and scaling of the pages.
*
* @type {SmoLayoutManager}
* @memberof SmoScore
*/
layoutManager?: SmoLayoutManager;
/**
* per-measure formatting customizations.
*
* @type {SmoFormattingManager}
* @memberof SmoScore
*/
formattingManager?: SmoFormattingManager
constructor(params: SmoScoreParams) {
smoSerialize.vexMerge(this, SmoScore.defaults);
smoSerialize.vexMerge(this, params);
if (!this.layoutManager) {
this.layoutManager = new SmoLayoutManager(SmoLayoutManager.defaults);
}
if (!this.formattingManager) {
this.formattingManager = new SmoFormattingManager(SmoFormattingManager.defaults);
}
// Set beaming rules based on preferences.
const pref = this.preferences;
SmoMeasure.defaultDupleDuration = pref.defaultDupleDuration;
SmoMeasure.defaultTripleDuration = pref.defaultTripleDuration;
if (this.staves.length) {
this.numberStaves();
}
if (typeof (this.preferences.showPiano) === 'undefined') {
this.preferences.showPiano = false;
}
this.audioSettings = new SmoAudioPlayerSettings(params.audioSettings);
this.updateMeasureFormats();
this.updateSystemGroups();
}
static get engravingFonts(): Record<string, string> {
return { Bravura: 'Bravura', Gonville: 'Gonville', Petaluma: 'Petaluma' };
}
static get fontPurposes(): Record<string, number> {
return { ENGRAVING: 1, SCORE: 2, CHORDS: 3, LYRICS: 4 };
}
static get scoreInfoDefaults(): SmoScoreInfo {
return JSON.parse(JSON.stringify({
name: 'Smoosical',
title: 'Smoosical',
subTitle: '(Op. 1)',
composer: 'Me',
copyright: '',
version: 1
}));
}
static get scoreMetadataDefaults(): ScoreMetadataSer {
return JSON.parse(JSON.stringify({
fonts: [{ name: 'engraving', purpose: SmoScore.fontPurposes.ENGRAVING, family: 'Bravura', size: 1, custom: false },
{ name: 'score', purpose: SmoScore.fontPurposes.SCORE, family: 'Merriweather', size: 14, custom: false },
{ name: 'chords', purpose: SmoScore.fontPurposes.CHORDS, family: 'Roboto Slab', size: 14, custom: false },
{ name: 'lyrics', purpose: SmoScore.fontPurposes.LYRICS, family: 'Merriweather', size: 12, custom: false } ],
scoreInfo: SmoScore.scoreInfoDefaults,
renumberingMap: {},
preferences: new SmoScorePreferences(SmoScorePreferences.defaults)
}));
}
static get defaults(): SmoScoreParams {
return {
// legacy layout structure. Now we use pages.
fonts: [
{ name: 'engraving', purpose: SmoScore.fontPurposes.ENGRAVING, family: 'Bravura', size: 1, custom: false },
{ name: 'score', purpose: SmoScore.fontPurposes.SCORE, family: 'Merriweather', size: 14, custom: false },
{ name: 'chords', purpose: SmoScore.fontPurposes.CHORDS, family: 'Roboto Slab', size: 14, custom: false },
{ name: 'lyrics', purpose: SmoScore.fontPurposes.LYRICS, family: 'Merriweather', size: 12, custom: false }
],
scoreInfo: SmoScore.scoreInfoDefaults,
audioSettings: new SmoAudioPlayerSettings(SmoAudioPlayerSettings.defaults),
preferences: new SmoScorePreferences(SmoScorePreferences.defaults),
staves: [],
activeStaff: 0,
textGroups: [],
systemGroups: []
};
}
static get pageSizes(): string[] {
return ['letter', 'tabloid', 'A4', 'A4Landscape', 'custom'];
}
static get pageDimensions(): Record<string, SvgDimensions> {
return {
'letter': { width: 8 * 96 + 48, height: 11 * 96 },
'letterLandscape': { width: 11 * 96, height: 8 * 96 + 48 },
'tabloid': { width: 1632, height: 1056 },
'A4': { width: 794, height: 1122 },
'A4Landscape': { width: 1122, height: 794 },
'custom': { width: 1, height: 1 }
};
}
static pageSizeFromDimensions(width: number, height: number): string | null {
const rv =
SmoScore.pageSizes.find((sz) => SmoScore.pageDimensions[sz].width === width && SmoScore.pageDimensions[sz].height === height)
?? null;
return rv;
}
static get preferences() {
return ['preferences', 'fonts', 'scoreInfo', 'audioSettings'];
}
/**
* serialize the keySignature, tempo and time signature, which are mapped
* to a column at a measure index
* @returns
*/
serializeColumnMapped(func: (measure: SmoMeasure) => ColumnMappedParams) {
const keySignature: Record<number, string> = {};
const tempo: Record<number, SmoTempoText> = {};
const timeSignature: Record<number, TimeSignature> = {};
const renumberingMap: Record<number, number> = {};
let previous: ColumnMappedParams | null = null;
this.staves[0].measures.forEach((measure) => {
const current = func(measure);
const ix = measure.measureNumber.measureIndex;
const currentInstrument = this.staves[0].getStaffInstrument(ix);
current.keySignature = SmoMusic.vexKeySigWithOffset(current.keySignature, -1 * currentInstrument.keyOffset);
if (ix === 0) {
keySignature[0] = current.keySignature;
tempo[0] = current.tempo;
timeSignature[0] = current.timeSignature;
renumberingMap[0] = 0;
// Even measure 0 can remap to a different number
if (typeof (this.renumberingMap[measure.measureNumber.measureIndex]) === 'number') {
renumberingMap[0] = this.renumberingMap[measure.measureNumber.measureIndex];
}
previous = current;
} else {
if (typeof (this.renumberingMap[measure.measureNumber.measureIndex]) === 'number') {
renumberingMap[measure.measureNumber.measureIndex] = this.renumberingMap[measure.measureNumber.measureIndex];
}
if (current.keySignature !== previous!.keySignature) {
previous!.keySignature = current.keySignature;
keySignature[ix] = current.keySignature;
}
if (!(TimeSignature.equal(current.timeSignature, previous!.timeSignature))) {
previous!.timeSignature = current.timeSignature;
timeSignature[ix] = current.timeSignature;
}
if (!(SmoTempoText.eq(current.tempo, previous!.tempo))) {
previous!.tempo = current.tempo;
tempo[ix] = current.tempo;
}
}
});
return { keySignature, tempo, timeSignature, renumberingMap };
}
/**
* Column-mapped attributes stay the same in each measure until
* changed, like key-signatures. We don't store each measure value to
* make the files smaller
* @param scoreObj - the json blob that contains the score data
* @returns
*/
static deserializeColumnMapped(scoreObj: any) {
let curValue: any;
let mapIx: number = 0;
if (!scoreObj.columnAttributeMap) {
return;
}
const attrs = Object.keys(scoreObj.columnAttributeMap);
scoreObj.staves.forEach((staff: any) => {
const attrIxMap: any = {};
attrs.forEach((attr) => {
attrIxMap[attr] = 0;
});
staff.measures.forEach((measure: any) => {
attrs.forEach((attr) => {
mapIx = attrIxMap[attr];
const curHash = scoreObj.columnAttributeMap[attr];
const attrKeys: any = Object.keys(curHash);
curValue = curHash[attrKeys[mapIx.toString()]];
attrKeys.sort((a: string, b: string) => parseInt(a, 10) > parseInt(b, 10) ? 1 : -1);
if (attrKeys.length > mapIx + 1) {
if (measure.measureNumber.measureIndex >= attrKeys[mapIx + 1]) {
mapIx += 1;
curValue = curHash[attrKeys[mapIx.toString()]];
}
}
// legacy timeSignature format was just a string 2/4, 3/8 etc.
if (attr === 'timeSignature') {
const ts = new TimeSignature(TimeSignature.defaults);
if (typeof (curValue) === 'string') {
ts.timeSignature = curValue;
measure[attr] = ts;
} else {
measure[attr] = TimeSignature.createFromPartial(curValue);
}
} else {
measure[attr] = curValue;
}
attrIxMap[attr] = mapIx;
});
});
});
}
/**
* Serialize the entire score.
* @returns JSON object
*/
serialize(options?: SmoScoreSerializeOptions): SmoScoreParamsSer {
const skipStaves = options?.skipStaves ?? false;
const useDictionary = options?.skipStaves ?? true;
const preserveIds = options?.preserveStaffIds ?? false;
let obj: Partial<SmoScoreParamsSer> = {
layoutManager: { ctor: 'SmoLayoutManager', ...SmoLayoutManager.defaults },
audioSettings: {},
measureFormats: [],
staves: [],
textGroups: [],
systemGroups: [],
metadata: SmoScore.scoreMetadataDefaults
};
if (this.layoutManager) {
obj.layoutManager = this.layoutManager.serialize();
}
obj.metadata!.fonts = JSON.parse(JSON.stringify(this.fonts));
obj.metadata!.renumberingMap = JSON.parse(JSON.stringify(this.renumberingMap));
obj.metadata!.preferences = this.preferences.serialize();
obj.metadata!.scoreInfo = JSON.parse(JSON.stringify(this.scoreInfo));
if (typeof (obj?.metadata?.scoreInfo?.version) !== 'number') {
obj.metadata!.scoreInfo.version = 0;
}
if (this.formattingManager) {
obj.measureFormats = this.formattingManager.serialize();
}
obj.audioSettings = this.audioSettings.serialize();
if (!skipStaves) {
this.staves.forEach((staff: SmoSystemStaff) => {
obj.staves!.push(staff.serialize({ skipMaps: true, preserveIds: preserveIds }));
});
} else {
obj.staves = [];
}
// Score text is not part of text group, so don't save separately.
this.textGroups.forEach((tg) => {
if (tg.isTextVisible()) {
obj.textGroups!.push(tg.serialize());
}
});
this.systemGroups.forEach((gg) => {
obj.systemGroups!.push(gg.serialize());
});
const getSerMeasure = (measure: SmoMeasure): ColumnMappedParams => {
return measure.serializeColumnMapped();
}
obj.columnAttributeMap = this.serializeColumnMapped(getSerMeasure);
if (useDictionary) {
smoSerialize.jsonTokens(obj);
obj = smoSerialize.detokenize(obj, smoSerialize.tokenValues);
obj.dictionary = smoSerialize.tokenMap;
}
return obj as SmoScoreParamsSer;
}
updateScorePreferences(pref: SmoScorePreferences) {
this.preferences = pref;
SmoMeasure.defaultDupleDuration = pref.defaultDupleDuration;
SmoMeasure.defaultTripleDuration = pref.defaultTripleDuration;
}
get engravingFont(): engravingFontType {
const efont = this.fonts.find((x) => x.purpose === SmoScore.fontPurposes.ENGRAVING);
if (efont) {
const val: engravingFontType | undefined = engravingFontTypes.find((x) => x === efont.family);
if (val) {
return val;
}
}
return 'Bravura';
}
set engravingFont(value: engravingFontType) {
const efont = this.fonts.find((x) => x.purpose === SmoScore.fontPurposes.ENGRAVING);
if (efont && isEngravingFont(value)) {
efont.family = value;
}
}
static upConvertGlobalLayout(jsonObj: any) {
// upconvert global layout, which used to be directly on layoutManager
if (typeof (jsonObj.layoutManager.globalLayout) === 'undefined') {
jsonObj.layoutManager.globalLayout = {
svgScale: jsonObj.layoutManager.svgScale,
zoomScale: jsonObj.layoutManager.zoomScale,
pageWidth: jsonObj.layoutManager.pageWidth,
pageHeight: jsonObj.layoutManager.pageHeight,
noteSpacing: jsonObj.layoutManager.noteSpacing
};
if (!jsonObj.layoutManager.globalLayout.noteSpacing) {
jsonObj.layoutManager.globalLayout.noteSpacing = 1.0;
}
}
}
/**
* Convert legacy score layout to layoutManager object parameters
* @param jsonObj
*/
static upConvertLayout(jsonObj: any) {
let i = 0;
jsonObj.layoutManager = {};
SmoLayoutManager.attributes.forEach((attr) => {
jsonObj.layoutManager[attr] = jsonObj.score.layout[attr];
});
jsonObj.layoutManager.pageLayouts = [];
for (i = 0; i < jsonObj.score.layout.pages; ++i) {
const pageSetting = JSON.parse(JSON.stringify(SmoPageLayout.defaults));
SmoPageLayout.attributes.forEach((attr) => {
if (typeof (jsonObj.score.layout[attr]) !== 'undefined') {
pageSetting[attr] = jsonObj.score.layout[attr];
}
});
jsonObj.layoutManager.pageLayouts.push(pageSetting);
}
SmoScore.upConvertGlobalLayout(jsonObj);
}
/**
* Hack: for the case of a score containing only a single part, use the text from the
* part.
* @param jsonObj
* @returns
*/
static fixTextGroupSinglePart(jsonObj: any) {
if (jsonObj.staves.length !== 1) {
return;
}
if (!jsonObj.staves[0].partInfo) {
return;
}
if (!jsonObj.staves[0].partInfo.textGroups || jsonObj.staves[0].partInfo.textGroups.length < 1) {
return;
}
jsonObj.textGroups = JSON.parse(JSON.stringify(jsonObj.staves[0].partInfo.textGroups));
}
/**
* Deserialize an entire score
* @param jsonString
* @returns SmoScore
*/
static deserialize(jsonString: string): SmoScore {
let jsonObj: Partial<SmoScoreParamsSer> = JSON.parse(jsonString);
let upconvertFormat = false;
let formattingManager = null;
if (jsonObj.dictionary) {
jsonObj = smoSerialize.detokenize(jsonObj, jsonObj.dictionary);
}
SmoScore.fixTextGroupSinglePart(jsonObj);
upconvertFormat = typeof (jsonObj.measureFormats) === 'undefined';
const params: Partial<SmoScoreParams> = {};
const staves: SmoSystemStaff[] = [];
jsonObj.textGroups = jsonObj.textGroups ? jsonObj.textGroups : [];
// Explode the sparse arrays of attributes into the measures
SmoScore.deserializeColumnMapped(jsonObj);
// 'score' attribute name changes to 'metadata'
if (typeof ((jsonObj as any).score) !== 'undefined') {
jsonObj.metadata = (jsonObj as any).score;
}
// meaning of customProportion has changed, backwards-compatiblity
if (typeof (jsonObj.metadata) === 'undefined') {
jsonObj.metadata = SmoScore.scoreMetadataDefaults;
}
// upconvert old proportion operator
const jsonPropUp = jsonObj.metadata.preferences as any;
if (typeof (jsonPropUp) !== 'undefined' && typeof (jsonPropUp.customProportion) === 'number') {
SmoMeasureFormat.defaults.proportionality = jsonPropUp.customProportion;
if (SmoMeasureFormat.defaults.proportionality === SmoMeasureFormat.legacyProportionality) {
SmoMeasureFormat.defaults.proportionality = SmoMeasureFormat.defaultProportionality;
}
}
// up-convert legacy layout data
if ((jsonObj.metadata as any).layout) {
SmoScore.upConvertLayout(jsonObj);
}
if (jsonObj.layoutManager && !jsonObj.layoutManager.globalLayout) {
SmoScore.upConvertGlobalLayout(jsonObj);
}
if (!jsonObj.layoutManager) {
jsonObj.layoutManager = { ctor: "SmoLayoutManager", ...SmoLayoutManager.defaults };
}
const layoutManagerParams: SmoLayoutManagerParams = {
globalLayout: jsonObj.layoutManager.globalLayout,
/**
* page margins for each page
*/
pageLayouts: []
}
jsonObj.layoutManager.pageLayouts.forEach((pl) => {
const pageLayout = new SmoPageLayout(pl);
layoutManagerParams.pageLayouts.push(pageLayout);
});
const layoutManager = new SmoLayoutManager(layoutManagerParams);
// params.layout = JSON.parse(JSON.stringify(SmoScore.defaults.layout));
smoSerialize.serializedMerge(
['renumberingMap', 'fonts'],
SmoScore.scoreMetadataDefaults, params);
smoSerialize.serializedMerge(
['renumberingMap', 'fonts'],
jsonObj.metadata, params);
if (jsonObj.metadata.preferences) {
params.preferences = new SmoScorePreferences(jsonObj.metadata.preferences);
} else {
params.preferences = new SmoScorePreferences(SmoScorePreferences.defaults);
}
if (jsonObj.metadata.scoreInfo) {
const scoreInfo: Partial<SmoScoreInfo> = {};
smoSerialize.serializedMerge(SmoScoreInfoKeys, SmoScore.scoreInfoDefaults, scoreInfo);
smoSerialize.serializedMerge(SmoScoreInfoKeys, jsonObj.metadata.scoreInfo, scoreInfo);
params.scoreInfo = (scoreInfo as SmoScoreInfo);
} else {
params.scoreInfo = SmoScore.scoreInfoDefaults;
}
if (!jsonObj.audioSettings) {
params.audioSettings = new SmoAudioPlayerSettings(SmoAudioPlayerSettings.defaults);
} else {
params.audioSettings = SmoScoreModifierBase.deserialize(jsonObj.audioSettings);
}
params.preferences.transposingScore = params.preferences.transposingScore ?? false;
if (jsonObj.staves && jsonObj.staves.length === 1) {
// Ignore serialized tranpose of score if there is only a single part.
params.preferences.transposingScore = false;
}
params.preferences.hideEmptyLines = params.preferences.hideEmptyLines ?? false;
let renumberingMap: Record<number, number> = { 0: 0 };
if (jsonObj.columnAttributeMap && jsonObj.columnAttributeMap.renumberingMap) {
renumberingMap = jsonObj.columnAttributeMap.renumberingMap;
}
if (!jsonObj.staves) {
throw 'bad score, no staves: ' + JSON.stringify(jsonObj);
}
jsonObj.staves.forEach((staffObj: any, staffIx: number) => {
staffObj.staffId = staffIx;
staffObj.renumberingMap = renumberingMap;
const staff = SmoSystemStaff.deserialize(staffObj);
staves.push(staff);
});
const textGroups: SmoTextGroup[] = [];
jsonObj.textGroups.forEach((tg: any) => {
if (!isEmptyTextBlock(tg)) {
textGroups.push(SmoTextGroup.deserializePreserveId(tg));
}
});
const systemGroups: SmoSystemGroup[] = [];
if (jsonObj.systemGroups) {
jsonObj.systemGroups.forEach((tt: any) => {
var st = SmoScoreModifierBase.deserialize(tt);
st.autoLayout = false; // since this has been layed out, presumably, before save
systemGroups.push(st);
});
}
params.staves = staves;
if (upconvertFormat) {
formattingManager = SmoScore.measureFormatFromLegacyScore(params as any, jsonObj);
} else {
const measureParams: SmoFormattingManagerParams = {
measureFormats: [],
partIndex: -1
}
if (jsonObj.measureFormats) {
jsonObj.measureFormats.forEach((mf: SmoMeasureFormatParamsSer) => {
const mfObj = new SmoMeasureFormat(mf);
measureParams.measureFormats?.push(mfObj);
});
}
params.formattingManager = new SmoFormattingManager(measureParams);
}
params.layoutManager = layoutManager;
if (!isSmoScoreParams(params)) {
throw 'Bad score, missing params: ' + JSON.stringify(params, null, ' ');
}
const score = new SmoScore(params);
score.textGroups = textGroups;
score.systemGroups = systemGroups;
score.scoreInfo.version += 1;
return score;
}
/**
* Convert measure formatting from legacy scores, that had the formatting
* per measure, to the new way that has a separate formatting object.
* **/
static measureFormatFromLegacyScore(score: SmoScore, jsonObj: any): SmoFormattingManager | null {
let current: SmoMeasureFormat | null = null;
let previous: SmoMeasureFormat | null = null;
const measureFormats: SmoMeasureFormat[] = [];
score.staves[0].measures.forEach((measure: SmoMeasure) => {
if (current === null) {
current = SmoMeasureFormat.fromLegacyMeasure(jsonObj.staves[0].measures[measure.measureNumber.measureIndex]);
measureFormats[measure.measureNumber.measureIndex] = current;
} else {
previous = current;
current = SmoMeasureFormat.fromLegacyMeasure(jsonObj.staves[0].measures[measure.measureNumber.measureIndex]);
if (!current.eq(previous)) {
measureFormats[measure.measureNumber.measureIndex] = current;
}
}
});
return new SmoFormattingManager({ measureFormats });
}
/**
* Return a default score with all default setting and one measure of notes
* @param scoreDefaults
* @param measureDefaults
* @returns
*/
static getDefaultScore(scoreDefaults: SmoScoreParams, measureDefaults: SmoMeasureParams | null) {
measureDefaults = measureDefaults !== null ? measureDefaults : SmoMeasure.defaults;
const score = new SmoScore(scoreDefaults);
score.formattingManager = new SmoFormattingManager(SmoFormattingManager.defaults);
score.addStaff(SmoSystemStaff.defaults);
const measure: SmoMeasure = SmoMeasure.getDefaultMeasure(measureDefaults as SmoMeasureParams);
score.addMeasure(0);
measure.voices.push({
notes: SmoMeasure.getDefaultNotes(measureDefaults as SmoMeasureParams)
});
// Since this is a new score, a part and the score are the same. So make sure
// we don't multi-measure rest the entire score.
score.staves[0].partInfo.expandMultimeasureRests = true;
return score;
}
/**
* Return an 'empty' score, with one measure of rests
* @param scoreDefaults
* @returns
*/
static getEmptyScore(scoreDefaults: SmoScoreParams) {
const score = new SmoScore(scoreDefaults);
score.addStaff(SmoSystemStaff.defaults);
return score;
}
/**
* We have deleted a measure, update the renumber index to
* shuffle back.
* @param indexToDelete
*/
updateRenumberForAddDelete(indexToDelete: number, toAdd: boolean) {
if (!toAdd && indexToDelete === 0) {
return;
}
const maxIndex = this.staves[0].measures.length - 1;
const increment = toAdd ? 1 : -1;
for (var i = indexToDelete; i < maxIndex; ++i) {
if (typeof (this.renumberingMap[i]) === 'number') {
this.renumberingMap[i] = this.renumberingMap[i] + increment;
}
}
if (typeof (this.renumberingMap[maxIndex]) === 'number' && !toAdd) {
delete this.renumberingMap[maxIndex];
}
}
updateRenumberingMap(measureIndex: number, localIndex: number) {
if (measureIndex === 0) {
this.renumberingMap[0] = localIndex;
} else if (typeof (this.renumberingMap[measureIndex]) === 'number') {
if (measureIndex === localIndex) {
delete this.renumberingMap[measureIndex];
} else {
this.renumberingMap[measureIndex] = localIndex;
}
} else {
this.renumberingMap[measureIndex] = localIndex;
}
this.staves.forEach((staff) => {
staff.renumberingMap = this.renumberingMap;
});
this.numberStaves();
}
/**
* Iteratively number the staves, like when adding a measure
*/
numberStaves() {
let i = 0;
for (i = 0; i < this.staves.length; ++i) {
const stave = this.staves[i];
stave.staffId = i;
stave.numberMeasures();
}
}
/**
* determine if the measure at this index could be a multi-measure rest
* @param measureIndex - the measure index we are considering to add
* @param start - the measure index would be the start of the rest
* @returns
*/
isMultimeasureRest(measureIndex: number, start: boolean, forceRest: boolean) {
let i = 0;
for (i = 0; i < this.staves.length; ++i) {
if (!forceRest && !this.staves[i].isRest(measureIndex)) {
return false;
}
if (this.staves[i].getVoltasForMeasure(measureIndex).length > 0) {
return false;
}
if (this.staves[i].isRepeatSymbol(measureIndex)) {
return false;
}
if (!start && measureIndex > 0 && this.staves[i].isRepeat(measureIndex - 1)) {
return false;
}
if (this.staves[i].isRehearsal(measureIndex)) {
return false;
}
// instrument change other than the initial measure
if (this.staves[i].measureInstrumentMap[measureIndex] && i > 0) {
return false;
}
}
if (measureIndex > 0) {
const measure = this.staves[0].measures[measureIndex];
const prev = this.staves[0].measures[measureIndex - 1];
if (!start && !TimeSignature.equal(measure.timeSignature, prev.timeSignature)) {
return false;
}
if (!start && measure.keySignature !== prev.keySignature) {
return false;
}
}
return true;
}
/**
* Restore measure formats stored when a score is serialized
*/
updateMeasureFormats() {
this.staves.forEach((staff) => {
staff.measures.forEach((measure) => {
(this.formattingManager as SmoFormattingManager).updateFormat(measure);
});
});
}
/**
* Add a measure to the score with the supplied parameters at the supplied index.
* The defaults per staff may be different depending on the clef, key of the staff.
*/
addDefaultMeasureWithNotes(measureIndex: number, parameters: SmoMeasureParams) {
this.updateRenumberForAddDelete(measureIndex, true);
this.staves.forEach((staff) => {
const defaultMeasure =
SmoMeasure.getDefaultMeasureWithNotes(parameters);
staff.addMeasure(measureIndex, defaultMeasure);
});
}
getLocalMeasureIndex(measureIndex: number) {
let maxKey = -1;
const keys = Object.keys(this.updateRenumberForAddDelete);
keys.forEach((key) => {
const numKey = parseInt(key, 10);
if (numKey <= measureIndex && numKey > maxKey) {
maxKey = numKey;
}
});
if (maxKey < 0) {
return measureIndex;
}
return this.renumberingMap[maxKey] + (measureIndex - maxKey);
}
/**
* delete the measure at the supplied index in all the staves
*/
deleteMeasure(measureIndex: number) {
this.staves.forEach((staff) => {
staff.deleteMeasure(measureIndex);
});
// adjust offset if text was attached to any missing measures after the deleted one.
this.textGroups.forEach((tg: SmoTextGroup) => {
if (tg.attachToSelector && (tg.selector as SmoSelector).measure >= measureIndex && (tg.selector as SmoSelector).measure > 0) {
(tg.selector as SmoSelector).measure -= 1;
}
});
this.updateRenumberForAddDelete(measureIndex, false);
}
/**
* coordinate the ids of the display score with the stored score
* @param other
*/
synchronizeTextGroups(other: SmoTextGroup[]) {
this.textGroups = [];
other.forEach((tg) => {
const ntg = SmoTextGroup.deserializePreserveId(tg);
this.textGroups.push(ntg);
});
}
/**
* get a measure 'compatible' with the measure at the given index, in terms
* of key, time signature etc.
* @param measureIndex
* @param staffIndex
* @returns
*/
getPrototypeMeasure(measureIndex: number, staffIndex: number) {
const staff = this.staves[staffIndex];
let protomeasure: SmoMeasureParams = {} as SmoMeasureParams;
// Since this staff may already have instrument settings, use the
// immediately preceeding or post-ceding measure if it exists.
if (measureIndex < staff.measures.length) {
protomeasure = staff.measures[measureIndex];
const instrument = staff.getStaffInstrument(measureIndex);
protomeasure.lines = instrument.lines;
} else if (staff.measures.length) {
protomeasure = staff.measures[staff.measures.length - 1];
const instrument = staff.getStaffInstrument(staff.measures.length - 1);
protomeasure.lines = instrument.lines;
} else {
protomeasure = SmoMeasure.defaults;
}
return SmoMeasure.getDefaultMeasureWithNotes(protomeasure);
}
/**
* Give a measure prototype, create a new measure and add it to each staff, with the
* correct settings for current time signature/clef.
* @param measureIndex
*/
addMeasure(measureIndex: number) {
let i = 0;
for (i = 0; i < this.staves.length; ++i) {
const staff = this.staves[i];
const nmeasure = this.getPrototypeMeasure(measureIndex, i);
if (nmeasure.voices.length <= nmeasure.getActiveVoice()) {
nmeasure.setActiveVoice(0);
}
staff.addMeasure(measureIndex, nmeasure);
}
// Update offsets for score modifiers that have a selector
this.textGroups.forEach((tg: SmoTextGroup) => {
if (typeof (tg.selector) === 'undefined') {
return;
}
if (tg.attachToSelector && tg.selector.measure >= measureIndex && tg.selector.measure < this.staves[0].measures.length) {
tg.selector.measure += 1;
}
});
this.updateRenumberForAddDelete(measureIndex, true);
this.numberStaves();
}
/**
* Replace the measure at the given location. Probably due to an undo operation or paste.
* @param selector
* @param measure
*/
replaceMeasure(selector: SmoSelector, measure: SmoMeasure) {
var staff = this.staves[selector.staff];
staff.measures[selector.measure] = measure;
}
getSystemGroupForStaff(selection: SmoSelection) {
const staffId: number = selection.staff.staffId;
const measureIndex: number = selection.measure.measureNumber.measureIndex;
const exist = this.systemGroups.find((sg: SmoSystemGroup) =>
sg.startSelector.staff <= staffId &&
sg.endSelector.staff >= staffId &&
(sg.mapType === SmoSystemGroup.mapTypes.allMeasures ||
(sg.startSelector.measure <= measureIndex &&
sg.endSelector.measure >= measureIndex))
);
return exist;
}
getStavesForGroup(group: SmoSystemGroup) {
return this.staves.filter((staff) => staff.staffId >= group.startSelector.staff &&
staff.staffId <= group.endSelector.staff);
}
// ### addOrReplaceSystemGroup
// Add a new staff grouping, or replace it if it overlaps and is different, or
// remove it if it is identical (toggle)
addOrReplaceSystemGroup(newGroup: SmoSystemGroup) {
// Replace this group for any groups that overlap it.
this.systemGroups = this.systemGroups.filter((sg) => !sg.overlaps(newGroup));
this.systemGroups.push(newGroup);
}
isPartExposed(): boolean {
if (this.staves.length > 2) {
return false;
}
const staff = this.staves[0];
const staveCount = staff.partInfo.stavesAfter + staff.partInfo.stavesBefore + 1;
return staveCount === this.staves.length
&& staff.partInfo.stavesBefore === 0;
}
/**
* Probably due to an undo operation, replace the staff at the given index.
* @param index
* @param staff
*/
replaceStaff(index: number, staff: SmoSystemStaff) {
const staves = [];
let i = 0;
for (i = 0; i < this.staves.length; ++i) {
if (i !== index) {
staves.push(this.staves[i]);
} else {
staves.push(staff);
}
}
this.staves = staves;
}
/**
*
* @param measureIndex
* @param key
*/
addKeySignature(measureIndex: number, key: string) {
this.staves.forEach((staff) => {
// Consider transpose for key of instrument
const netOffset = staff.measures[measureIndex].transposeIndex;
const newKey = SmoMusic.vexKeySigWithOffset(key, netOffset);
staff.addKeySignature(measureIndex, newKey);
});
}
/**
* If the part is a transposing part, remove the transposition from the notes/staff. This logic
* assumes the measures previously had transposeIndex set up by the instrument map.
*/
setTransposing() {
this.staves.forEach((staff) => {
staff.measures.forEach((mm) => {
if (mm.transposeIndex !== 0) {
const concert = SmoMusic.vexKeySigWithOffset(mm.keySignature, -1 * mm.transposeIndex);
mm.transposeToOffset(0, concert);
mm.transposeIndex = 0;
mm.keySignature = concert;
}
});
});
}
/**
* If the score is switching from transposing to non-transposing, update the index
* and pitches. This logic assumes we are changing from transposing to non-transposing.
*/
setNonTransposing() {
this.staves.forEach((staff) => {
staff.measures.forEach((mm) => {
const inst = staff.getStaffInstrument(mm.measureNumber.measureIndex);
if (inst.keyOffset !== 0) {
const concert = SmoMusic.vexKeySigWithOffset(mm.keySignature, inst.keyOffset);
mm.transposeToOffset(inst.keyOffset, concert);
mm.transposeIndex = inst.keyOffset;
mm.keySignature = concert;
}
});
});
}
// ### addInstrument
// add a new staff (instrument) to the score
addStaff(parameters: SmoSystemStaffParams): SmoSystemStaff {
let i = 0;
if (this.staves.length === 0) {
const staff = new SmoSystemStaff(parameters);
this.staves.push(staff);
this.activeStaff = 0;
// For part views, we renumber the staves even if there is only one staff.
if (staff.measures.length) {
this.numberStaves();
}
return staff;
}
if (!parameters) {
parameters = SmoSystemStaff.defaults;
}
const proto = this.staves[0];
const measures = [];
for (i = 0; i < proto.measures.length; ++i) {
const measure: SmoMeasure = proto.measures[i];
const jsonObj: SmoMeasureParamsSer = measure.serialize();
// Need to do this since score serialization doesn't include TS in each measure
jsonObj.timeSignature = measure.timeSignature.serialize();
jsonObj.tempo = measure.tempo.serialize();
jsonObj.tupletTrees = []; // assume no tuplets in a prototype measure
let newMeasure = SmoMeasure.deserialize(jsonObj);
newMeasure.measureNumber = measure.measureNumber;
newMeasure.clef = parameters.measureInstrumentMap[0].clef as Clef;
newMeasure.modifiers = [];
newMeasure.transposeIndex = 0;
// Consider key change if the proto measure is non-concert pitch
newMeasure.keySignature =
SmoMusic.vexKeySigWithOffset(newMeasure.keySignature,
newMeasure.transposeIndex - measure.transposeIndex);
newMeasure.voices = [{ notes: SmoMeasure.getDefaultNotes(newMeasure) }];
measure.modifiers.forEach((modifier) => {
const nmod: SmoMeasureModifierBase = SmoMeasureModifierBase.deserialize(modifier);
newMeasure.modifiers.push(nmod);
});
measures.push(newMeasure);
}
parameters.measures = measures;
const staff = new SmoSystemStaff(parameters);
this.staves.push(staff);
this.activeStaff = this.staves.length - 1;
this.numberStaves();
return staff;
}
/**
* delete any system groups that apply to deleted staves
*/
updateSystemGroups() {
const grpToKeep: SmoSystemGroup[] = [];
this.systemGroups.forEach((grp) => {
if (grp.startSelector.staff < this.staves.length &&
grp.endSelector.staff < this.staves.length
) {
grpToKeep.push(grp);
}
});
this.systemGroups = grpToKeep;
}
// ### removeStaff
// Remove stave at the given index
removeStaff(index: number) {
const staves: SmoSystemStaff[] = [];
let ix = 0;
this.staves.forEach((staff) => {
if (ix !== index) {
staves.push(staff);
}
ix += 1;
});
this.staves = staves;
this.numberStaves();
this.updateSystemGroups();
}
getStaffInstrument(selector: SmoSelector): SmoInstrument {
const staff: SmoSystemStaff = this.staves[selector.staff];
return staff.getStaffInstrument(selector.measure);
}
swapStaves(index1: number, index2: number): void {
if (this.staves.length < index1 || this.staves.length < index2) {
return;
}
const tmpStaff = this.staves[index1];
this.staves[index1] = this.staves[index2];
this.staves[index2] = tmpStaff;
this.staves.forEach((staff) => {
staff.mapStaffFromTo(index1, index2);
staff.mapStaffFromTo(index2, index1);
});
this.numberStaves();
}
updateTextGroup(textGroup: SmoTextGroup, toAdd: boolean) {
const tgid = typeof (textGroup) === 'string' ? textGroup :
textGroup.attrs.id;
const ar = this.textGroups.filter((tg) => tg.attrs.id !== tgid);
this.textGroups = ar;
if (toAdd) {
this.textGroups.push(textGroup);
}
}
addTextGroup(textGroup: SmoTextGroup) {
this.updateTextGroup(textGroup, true);
}
getTextGroups() {
return this.textGroups;
}
scaleTextGroups(scale: number) {
this.textGroups.forEach((tg: SmoTextGroup) => {
tg.scaleText(scale);
});
}
removeTextGroup(textGroup: SmoTextGroup) {
this.updateTextGroup(textGroup, false);
}
setLyricAdjustWidth(adjustNoteWidth: boolean) {
this.staves.forEach((staff) => {
staff.setLyricAdjustWidth(adjustNoteWidth);
});
}
setChordAdjustWidth(adjustNoteWidth: boolean) {
this.staves.forEach((staff) => {
staff.setChordAdjustWidth(adjustNoteWidth);
});
}
// ### setLyricFont
// set the font for lyrics, which are the same for all lyrics in the score
setLyricFont(fontInfo: FontInfo) {
this.staves.forEach((staff) => {
staff.setLyricFont(fontInfo);
});
const fontInst: FontPurpose | undefined = this.fonts.find((fn) => fn.purpose === SmoScore.fontPurposes.LYRICS);
if (typeof (fontInst) === 'undefined') {
return;
}
fontInst.family = fontInfo.family ?? '';
fontInst.size = parseInt(SmoScoreText.fontPointSize(fontInfo.size).toString());
fontInst.custom = true;
}
setChordFont(fontInfo: FontInfo) {
this.staves.forEach((staff) => {
staff.setChordFont(fontInfo);
});
}
get measures() {
if (this.staves.length === 0) {
return [];
}
return this.staves[this.activeStaff].measures;
}
incrementActiveStaff(offset: number) {
if (offset < 0) {
offset = offset + this.staves.length;
}
const nextStaff = (this.activeStaff + offset) % this.staves.length;
if (nextStaff >= 0 && nextStaff < this.staves.length) {
this.activeStaff = nextStaff;
}
return this.activeStaff;
}
setActiveStaff(index: number) {
this.activeStaff = index <= this.staves.length ? index : this.activeStaff;
}
}