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,212 lines (1,195 loc) • 84.1 kB
text/typescript
// [Smoosic](https://github.com/AaronDavidNewman/Smoosic)
// Copyright (c) Aaron David Newman 2021.
import { SuiScoreView, updateStaffModifierFunc } from './scoreView';
import { SmoScore, engravingFontType } from '../../smo/data/score';
import { SmoSystemStaffParams, SmoSystemStaff } from '../../smo/data/systemStaff';
import { SmoPartInfo } from '../../smo/data/partInfo';
import { SmoMeasure } from '../../smo/data/measure';
import { SmoNote } from '../../smo/data/note';
import { KeyEvent, SvgBox, Pitch, PitchLetter, RemoveElementLike, ElementLike } from '../../smo/data/common';
import { SmoRenderConfiguration } from './configuration';
import { SmoSystemGroup, SmoPageLayout, SmoGlobalLayout, SmoLayoutManager, SmoAudioPlayerSettings,
SmoScorePreferences, SmoScoreInfo } from '../../smo/data/scoreModifiers';
import { SmoTextGroup } from '../../smo/data/scoreText';
import { SmoDynamicText, SmoNoteModifierBase, SmoGraceNote, SmoArticulation,
SmoOrnament, SmoLyric, SmoMicrotone, SmoArpeggio, SmoArpeggioType, SmoClefChange,
SmoTabNote} from '../../smo/data/noteModifiers';
import { SmoTempoText, SmoVolta, SmoBarline, SmoRepeatSymbol,
SmoRehearsalMark, SmoMeasureFormat, TimeSignature } from '../../smo/data/measureModifiers';
import { UndoBuffer } from '../../smo/xform/undo';
import {
SmoOperation, createStaffModifierType, MakeTupletOperation
} from '../../smo/xform/operations';
import { BatchSelectionOperation } from '../../smo/xform/operations';
import { smoSerialize } from '../../common/serializationHelpers';
import { FontInfo } from '../../common/vex';
import { SmoMusic } from '../../smo/data/music';
import { SuiOscillator } from '../audio/oscillator';
import { XmlToSmo } from '../../smo/mxml/xmlToSmo';
import { SuiAudioPlayer } from '../audio/player';
import { SuiXhrLoader } from '../../ui/fileio/xhrLoader';
import { SmoSelection, SmoSelector } from '../../smo/xform/selections';
import { StaffModifierBase,
SmoInstrument, SmoInstrumentParams, SmoStaffTextBracket, SmoTabStave } from '../../smo/data/staffModifiers';
import { SuiPiano } from './piano';
import { SvgHelpers } from './svgHelpers';
import { PromiseHelpers } from '../../common/promiseHelpers';
import { parseJsonText } from 'typescript';
declare var $: any;
declare var SmoConfig: SmoRenderConfiguration;
/**
* MVVM-like operations on the displayed score.
*
* All operations that can be performed on a 'live' score go through this
* module. It maps the score view to the actual score and makes sure the
* model and view stay in sync.
*
* Because this object operates on the current selections,
* all operations return promise so applications can wait for the
* operation to complete and update the selection list.
* @category SuiRender
*/
export class SuiScoreViewOperations extends SuiScoreView {
/**
* Add a new text group to the score
* @param textGroup a new text group
* @returns
*/
async addTextGroup(textGroup: SmoTextGroup): Promise<void> {
const altNew = SmoTextGroup.deserializePreserveId(textGroup.serialize());
const isPartExposed = this.isPartExposed();
let selector = textGroup.selector ?? SmoSelector.default;
const partInfo = this.score.staves[0].partInfo;
const bufType = isPartExposed && partInfo.preserveTextGroups
? UndoBuffer.bufferTypes.PART_MODIFIER : UndoBuffer.bufferTypes.SCORE_MODIFIER;
if (bufType === UndoBuffer.bufferTypes.PART_MODIFIER) {
selector.staff = this.staffMap[0];
}
this.storeUndo.addBuffer('remove text group', bufType,
selector, textGroup, UndoBuffer.bufferSubtypes.ADD);
if (isPartExposed && partInfo.preserveTextGroups) {
this.score.staves[0].partInfo.updateTextGroup(textGroup, true);
const partInfo = this.storeScore.staves[this._getEquivalentStaff(0)].partInfo;
partInfo.updateTextGroup(altNew, true);
} else {
this.score.updateTextGroup(textGroup, true);
this.storeScore.updateTextGroup(altNew, true);
}
await this.renderer.rerenderTextGroups();
}
/**
* Remove the text group from the score
* @param textGroup
* @returns
*/
async removeTextGroup(textGroup: SmoTextGroup): Promise<void> {
let selector = textGroup.selector ?? SmoSelector.default;
const partInfo = this.score.staves[0].partInfo;
const isPartExposed = this.isPartExposed();
const bufType = isPartExposed && partInfo.preserveTextGroups
? UndoBuffer.bufferTypes.PART_MODIFIER : UndoBuffer.bufferTypes.SCORE_MODIFIER;
let ogText = this.storeScore.textGroups.find((tg) => tg.attrs.id === textGroup.attrs.id);
if (isPartExposed && partInfo.preserveTextGroups) {
ogText = partInfo.textGroups.find((tg) => tg.attrs.id === textGroup.attrs.id);
}
if (bufType === UndoBuffer.bufferTypes.PART_MODIFIER) {
selector.staff = this.staffMap[0];
} else {
selector.staff = this.staffMap[selector.staff];
}
if (!ogText) {
return;
}
this.storeUndo.addBuffer('remove text group', bufType,
selector, ogText, UndoBuffer.bufferSubtypes.REMOVE);
const altGroup = SmoTextGroup.deserializePreserveId(textGroup.serialize());
textGroup.elements.forEach((el: ElementLike) => RemoveElementLike(el));
textGroup.elements = [];
if (isPartExposed && partInfo.preserveTextGroups) {
partInfo.updateTextGroup(textGroup, false);
this.storeScore.staves[this._getEquivalentStaff(0)].partInfo.updateTextGroup(altGroup, false);
} else {
this.score.updateTextGroup(textGroup, false);
this.storeScore.updateTextGroup(altGroup, false);
}
await this.renderer.rerenderTextGroups();
}
/**
* UPdate an existing text group. The original is passed in, because since TG not tied to a musical
* element, we need to find the one we're updating.
* @param oldVersion
* @param newVersion
* @returns
*/
async updateTextGroup(newVersion: SmoTextGroup): Promise<void> {
const selector = newVersion.selector ?? SmoSelector.default;
const isPartExposed = this.isPartExposed();
const partInfo = this.score.staves[0].partInfo;
// Back up the original score text
let ogtg = this.storeScore.textGroups.find((tg) => tg.attrs.id === newVersion.attrs.id);
if (isPartExposed && partInfo.preserveTextGroups) {
ogtg = partInfo.textGroups.find((tg) => tg.attrs.id === newVersion.attrs.id);
}
if (!ogtg) {
// there is nothing to update, return.
return;
}
if (ogtg) {
const bufType = isPartExposed && partInfo.preserveTextGroups
? UndoBuffer.bufferTypes.PART_MODIFIER : UndoBuffer.bufferTypes.SCORE_MODIFIER;
// if this is part text, make sure the undo buffer is associated with the part stave
// in the full score, so undo works properly
if (bufType === UndoBuffer.bufferTypes.PART_MODIFIER) {
selector.staff = this.staffMap[0];
} else {
selector.staff = this.staffMap[selector.staff];
}
this.storeUndo.addBuffer('modify text',
bufType, selector, ogtg, UndoBuffer.bufferSubtypes.UPDATE);
}
const altNew = SmoTextGroup.deserializePreserveId(newVersion.serialize());
this.score.updateTextGroup(newVersion, true);
// If this is part text, don't store it in the score text, except for the displayed score
if (!isPartExposed) {
this.storeScore.updateTextGroup(altNew, true);
} else {
this.storeScore.staves[this._getEquivalentStaff(0)].partInfo.updateTextGroup(altNew, true);
}
// TODO: only render the one TG.
await this.renderer.rerenderTextGroups();
// return this.renderer.updatePromise();
}
/**
* load an mxml score remotely, return a promise that
* completes when the file is loaded
* @param url where to find the xml file
* @returns
*/
async loadRemoteXml(url: string): Promise<any> {
const req = new SuiXhrLoader(url);
const self = this;
// Shouldn't we return promise of actually displaying the score?
await req.loadAsync();
const parser = new DOMParser();
const xml = parser.parseFromString(req.value, 'text/xml');
const score = XmlToSmo.convert(xml);
score.layoutManager!.zoomToWidth($('body').width());
await self.changeScore(score);
}
/**
* load a remote score in SMO format
* @param url url to find the score
* @returns
*/
async loadRemoteJson(url: string) : Promise<any> {
const req = new SuiXhrLoader(url);
await req.loadAsync();
const score = SmoScore.deserialize(req.value);
await this.changeScore(score);
}
/**
* Load a remote score, return promise when it's been loaded
* from afar.
* @param pref
* @returns
*/
async loadRemoteScore(url: string): Promise<any> {
if (url.endsWith('xml') || url.endsWith('mxl')) {
return this.loadRemoteXml(url);
} else {
return this.loadRemoteJson(url);
}
}
async updateAudioSettings(pref: SmoAudioPlayerSettings) {
this._undoScorePreferences('Update preferences');
this.score.audioSettings = pref;
this.storeScore.audioSettings = new SmoAudioPlayerSettings(pref);
// No rendering to be done
return this.renderer.updatePromise();
}
/**
* Global settings that control how the score editor behaves
* @param pref
* @returns
*/
async updateScorePreferences(pref: SmoScorePreferences): Promise<void> {
this._undoScorePreferences('Update preferences');
const oldXpose = this.score.preferences.transposingScore;
const curXpose = pref.transposingScore;
this.score.updateScorePreferences(new SmoScorePreferences(pref));
this.storeScore.updateScorePreferences(new SmoScorePreferences(pref));
if (curXpose === false && oldXpose === true) {
this.score.setNonTransposing();
} else if (curXpose === true && oldXpose === false) {
this.score.setTransposing();
}
this.renderer.setDirty();
return this.renderer.updatePromise();
}
/**
* Update information about the score, composer etc.
* @param scoreInfo
* @returns
*/
async updateScoreInfo(scoreInfo: SmoScoreInfo): Promise<void> {
this._undoScorePreferences('Update preferences');
this.score.scoreInfo = scoreInfo;
this.storeScore.scoreInfo = JSON.parse(JSON.stringify(scoreInfo));
return this.renderer.updatePromise()
}
async addRemoveArpeggio(code: SmoArpeggioType) {
await this.modifyCurrentSelections('add/remove addRemoveArpeggio',
(score, selections) => {
selections.forEach((sel) => {
if (sel.note) {
if (code === 'none') {
sel.note.arpeggio = undefined;
} else {
sel.note.arpeggio = new SmoArpeggio({ type: code });
}
}
});
});
}
/**
* A clef change mid-measure (clefNote)
* @param clef
*/
async addRemoveClefChange(clef: SmoClefChange) {
await this.modifyCurrentSelections('add/remove addRemoveClefChange',
(score, selections) => {
selections.forEach((sel) => {
const measureClef = sel.measure.clef;
if (sel.note) {
if (measureClef === clef.clef) {
sel.note.clefNote = null;
} else {
sel.note.clefNote = clef;
}
}
});
});
}
/**
* Modify the dynamics assoicated with the specific selection
* @param selection
* @param dynamic
* @returns
*/
async addDynamic(selection: SmoSelection, dynamic: SmoDynamicText): Promise<void> {
await this.modifySelection('add dynamic', selection, (score, selections) => {
SmoOperation.addDynamic(selections[0], dynamic);
});
}
/**
* Remove dynamics from the selection
* @param selection
* @param dynamic
* @returns
*/
async _removeDynamic(selection: SmoSelection, dynamic: SmoDynamicText): Promise<void> {
const equiv = this._getEquivalentSelection(selection);
if (equiv !== null && equiv.note !== null) {
const altModifiers = equiv.note.getModifiers('SmoDynamicText');
SmoOperation.removeDynamic(selection, dynamic);
if (altModifiers.length) {
SmoOperation.removeDynamic(equiv, altModifiers[0] as SmoDynamicText);
}
}
await this.renderer.updatePromise();
}
/**
* Remove dynamics from the current selection
* @param dynamic
* @returns
*/
async removeDynamic(dynamic: SmoDynamicText): Promise<void> {
const sel = this.tracker.modifierSelections[0];
if (!sel.selection) {
return PromiseHelpers.emptyPromise();
}
this.tracker.selections = [sel.selection];
this._undoFirstMeasureSelection('remove dynamic');
this._removeDynamic(sel.selection, dynamic);
this.renderer.addToReplaceQueue(sel.selection);
await this.renderer.updatePromise()
}
/**
* we never really delete a note, but we will convert it into a rest and if it's
* already a rest we will try to hide it.
* Operates on current selections
* */
async deleteNote(): Promise<void> {
const measureSelections = this.undoTrackerMeasureSelections('delete note');
this.tracker.selections.forEach((sel) => {
if (sel.note) {
const altSel = this._getEquivalentSelection(sel);
// set the pitch to be a good position for the rest
const pitch = JSON.parse(JSON.stringify(
SmoMeasure.defaultPitchForClef[sel.measure.clef]));
const altPitch = JSON.parse(JSON.stringify(
SmoMeasure.defaultPitchForClef[altSel!.measure.clef]));
sel.note.pitches = [pitch];
altSel!.note!.pitches = [altPitch];
// If the note is a note, make it into a rest. If the note is a rest already,
// make it invisible. If it is invisible already, make it back into a rest.
if (sel.note.isRest() && !sel.note.isHidden()) {
sel.note.makeHidden(true);
altSel!.note!.makeHidden(true);
} else {
sel.note.clearArticulations();
sel.note.makeRest();
altSel!.note!.makeRest();
altSel!.note!.clearArticulations();
sel.note.makeHidden(false);
altSel!.note!.makeHidden(false);
}
}
});
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise()
}
/**
* The lyric editor moves around, so we can't depend on the tracker for the
* correct selection. We get it directly from the editor.
*
* @param selector - the selector of the note with the lyric to remove
* @param lyric - a copy of the lyric to remove. We use the verse, parser to identify it
* @returns render promise
*/
async removeLyric(selector: SmoSelector, lyric: SmoLyric): Promise<void> {
const selection = SmoSelection.noteFromSelector(this.score, selector);
if (selection === null) {
return PromiseHelpers.emptyPromise();
}
this._undoSelection('remove lyric', selection);
selection.note!.removeLyric(lyric);
const equiv = this._getEquivalentSelection(selection);
const storeLyric = equiv!.note!.getLyricForVerse(lyric.verse, lyric.parser);
if (typeof (storeLyric) !== 'undefined') {
equiv!.note!.removeLyric(lyric);
}
this.renderer.addToReplaceQueue(selection);
lyric.deleted = true;
await this.renderer.updatePromise();
}
/**
* @param selector where to add or update the lyric
* @param lyric a copy of the lyric to remove
* @returns
*/
async addOrUpdateLyric(selector: SmoSelector, lyric: SmoLyric): Promise<void> {
const selection = SmoSelection.noteFromSelector(this.score, selector);
if (selection === null) {
return;
}
this._undoSelection('update lyric', selection);
selection.note!.addLyric(lyric);
const equiv = this._getEquivalentSelection(selection);
const altLyric = SmoNoteModifierBase.deserialize(lyric.serialize() as any) as SmoLyric;
equiv!.note!.addLyric(altLyric);
this.renderer.addToReplaceQueue(selection);
await this.renderer.updatePromise();
}
/**
* Delete all the notes for the currently selected voice
* @returns
*/
async depopulateVoice(): Promise<void> {
const measureSelections = this.undoTrackerMeasureSelections('depopulate voice');
measureSelections.forEach((selection) => {
const ix = selection.measure.getActiveVoice();
if (ix !== 0) {
SmoOperation.depopulateVoice(selection, ix);
const equiv = this._getEquivalentSelection(selection);
SmoOperation.depopulateVoice(equiv!, ix);
}
});
SmoOperation.setActiveVoice(this.score, 0);
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
/**
* Change the active voice in a multi-voice measure.
* @param index
* @returns
*/
_changeActiveVoice(index: number): SmoSelection[] {
const measuresToAdd: SmoSelection[] = [];
const measureSelections = SmoSelection.getMeasureList(this.tracker.selections);
measureSelections.forEach((measureSelection) => {
if (index === measureSelection.measure.voices.length) {
measuresToAdd.push(measureSelection);
}
});
return measuresToAdd;
}
/**
* Populate a new voice with default notes
* @param index the voice to populate
* @returns
*/
async populateVoice(index: number): Promise<void> {
const measuresToAdd = this._changeActiveVoice(index);
if (measuresToAdd.length === 0) {
SmoOperation.setActiveVoice(this.score, index);
this.tracker.selectActiveVoice();
return this.renderer.updatePromise();
}
measuresToAdd.forEach((selection) => {
this._undoSelection('popualteVoice', selection);
SmoOperation.populateVoice(selection, index);
const equiv = this._getEquivalentSelection(selection);
SmoOperation.populateVoice(equiv!, index);
});
SmoOperation.setActiveVoice(this.score, index);
this._renderChangedMeasures(measuresToAdd);
await this.renderer.updatePromise();
this.tracker.selectActiveVoice();
}
async swapVoices(voice1: number, voice2: number): Promise<void> {
const selections = this.tracker.getSelectedMeasures();
const altSelections = this._getEquivalentSelections(selections);
SmoOperation.swapVoice(selections, voice1, voice2);
SmoOperation.swapVoice(altSelections, voice1, voice2);
this._renderChangedMeasures(selections);
}
/**
* Assign an instrument to a set of measures
* @param instrument the instrument to assign to the selections
* @param selections
* @returns
*/
async changeInstrument(instrument: SmoInstrument, selections: SmoSelection[]): Promise<void> {
if (typeof (selections) === 'undefined') {
selections = this.tracker.selections;
}
this._undoSelections('change instrument', selections);
const altSelections = this._getEquivalentSelections(selections);
SmoOperation.changeInstrument(instrument, selections);
SmoOperation.changeInstrument(instrument, altSelections);
this._renderChangedMeasures(selections);
await this.renderer.updatePromise();
}
/**
* Set the time signature for a selection
* @param timeSignature actual time signature
*/
async setTimeSignature(timeSignature: TimeSignature): Promise<void> {
this._undoScore('Set time signature');
const selections = this.tracker.selections;
const altSelections = this._getEquivalentSelections(selections);
SmoOperation.setTimeSignature(this.score, selections, timeSignature);
SmoOperation.setTimeSignature(this.storeScore, altSelections, timeSignature);
this._renderChangedMeasures(SmoSelection.getMeasureList(this.tracker.selections));
return this.renderer.updatePromise();
}
/**
* Move selected staff up or down in the score.
* @param index direction to move
* @returns
*/
async moveStaffUpDown(index: number): Promise<void> {
this._undoScore('re-order staves');
// Get staff to move
const selection = this._getEquivalentSelection(this.tracker.selections[0]);
// Make the move in the model, and reset the view so we can see the new
// arrangement
SmoOperation.moveStaffUpDown(this.storeScore, selection!, index);
this.viewAll();
await this.renderer.updatePromise();
}
/**
* Update the staff group for a score, which determines how the staves
* are justified and bracketed
* @param staffGroup
*/
async addOrUpdateStaffGroup(staffGroup: SmoSystemGroup): Promise<void> {
this._undoScore('group staves');
// Assume that the view is now set to full score
this.score.addOrReplaceSystemGroup(staffGroup);
this.storeScore.addOrReplaceSystemGroup(staffGroup);
this.renderer.setDirty();
await this.renderer.updatePromise();
}
async updateTabStave(tabStave: SmoTabStave) {
const selections = SmoSelection.getMeasuresBetween(this.score, tabStave.startSelector, tabStave.endSelector);
const altSelections = this._getEquivalentSelections(selections);
if (selections.length === 0) {
return;
}
this._undoSelections('updateTabStave', selections);
const staff: number = selections[0].selector.staff;
const altStaff = altSelections[0].selector.staff;
const altTabStave = new SmoTabStave(tabStave.serialize());
altTabStave.startSelector.staff = altStaff;
altTabStave.endSelector.staff = altStaff;
altTabStave.attrs.id = tabStave.attrs.id;
this.score.staves[staff].updateTabStave(tabStave);
this.storeScore.staves[altStaff].updateTabStave(altTabStave);
this._renderChangedMeasures(SmoSelection.getMeasureList(this.tracker.selections));
await this.renderer.updatePromise();
}
async removeTabStave() {
const selections = this.tracker.selections;
const altSelections = this._getEquivalentSelections(selections);
if (selections.length === 0) {
return;
}
this._undoSelections('updateTabStave', selections);
const stavesToRemove: SmoTabStave[] = [];
const altStavesToRemove: SmoTabStave[] = [];
const added: Record<string, SmoTabStave> = {};
selections.forEach((sel, ix) => {
const altSel = altSelections[ix];
const tabStave = sel.staff.getTabStaveForMeasure(sel.selector);
const altTabStave = altSel.staff.getTabStaveForMeasure(altSel.selector);
if (tabStave && altTabStave) {
if (!added[tabStave.attrs.id]) {
added[tabStave.attrs.id] = tabStave;
stavesToRemove.push(tabStave);
altStavesToRemove.push(altTabStave);
}
}
});
selections[0].staff.removeTabStaves(stavesToRemove);
altSelections[0].staff.removeTabStaves(altStavesToRemove);
this._renderChangedMeasures(SmoSelection.getMeasureList(this.tracker.selections));
await this.renderer.updatePromise();
}
/**
* Update tempo for all or part of the score
* @param measure the measure with the tempo. Tempo is measure-wide parameter
* @param scoreMode if true, update whole score. Else selections
* @returns
*/
async updateTempoScore(measure: SmoMeasure, tempo: SmoTempoText, scoreMode: boolean, selectionMode: boolean): Promise<void> {
let measureIndex = 0;
const originalTempo = new SmoTempoText(measure.tempo);
this._undoColumn('update tempo', measure.measureNumber.measureIndex);
let startMeasure = measure.measureNumber.measureIndex;
let endMeasure = this.score.staves[0].measures.length;
let displayed = false;
if (selectionMode) {
const endSel = this.tracker.getExtremeSelection(1);
if (endSel.selector.measure > startMeasure) {
endMeasure = endSel.selector.measure;
}
}
// If we are only changing the position of the text, it only affects the tempo measure.
if (SmoTempoText.eq(originalTempo, tempo) && tempo.yOffset !== originalTempo.yOffset && endMeasure > startMeasure) {
endMeasure = startMeasure + 1;
}
for (measureIndex = startMeasure; measureIndex < endMeasure; ++measureIndex) {
if (!scoreMode && !selectionMode) {
// If not whole score or selections, change until the tempo doesn't match previous measure's tempo (next tempo change)
const compMeasure = this.score.staves[0].measures[measureIndex];
if (SmoTempoText.eq(originalTempo, compMeasure.tempo) || displayed === false) {
const sel = SmoSelection.measureSelection(this.score, 0, measureIndex);
const altSel = SmoSelection.measureSelection(this.storeScore, 0, measureIndex);
if (sel && sel.measure.tempo.display && !displayed) {
this.renderer.addToReplaceQueue(sel);
displayed = true;
}
if (sel) {
SmoOperation.addTempo(this.score, sel, tempo);
}
if (altSel) {
SmoOperation.addTempo(this.storeScore, altSel, tempo);
}
} else {
break;
}
} else {
const sel = SmoSelection.measureSelection(this.score, 0, measureIndex);
const altSel = SmoSelection.measureSelection(this.storeScore, 0, measureIndex);
if (sel) {
SmoOperation.addTempo(this.score, sel, tempo);
if (!displayed) {
this.renderer.addToReplaceQueue(sel);
displayed = true;
}
}
if (altSel) {
SmoOperation.addTempo(this.storeScore, altSel, tempo);
}
}
}
await this.renderer.updatePromise();
}
async updateTabNote(tabNote: SmoTabNote) {
const selections = SmoSelection.getMeasuresBetween(this.score,
this.tracker.getExtremeSelection(-1).selector, this.tracker.getExtremeSelection(1).selector);
const altSelections = this._getEquivalentSelections(selections);
this._undoSelections('updateTabNote', selections);
SmoOperation.updateTabNote(selections, tabNote);
SmoOperation.updateTabNote(altSelections, tabNote);
this.renderer.addToReplaceQueue(selections);
await this.renderer.updatePromise();
}
async removeTabNote() {
const selections = SmoSelection.getMeasuresBetween(this.score,
this.tracker.getExtremeSelection(-1).selector, this.tracker.getExtremeSelection(1).selector);
const altSelections = this._getEquivalentSelections(selections);
this._undoSelections('updateTabNote', selections);
SmoOperation.removeTabNote(selections);
SmoOperation.removeTabNote(altSelections);
this.renderer.addToReplaceQueue(selections);
await this.renderer.updatePromise();
}
/**
* 'remove' tempo, which means either setting the bars to the
* default tempo, or the previously-set tempo.
* @param scoreMode whether to reset entire score
*/
async removeTempo(measure: SmoMeasure, tempo: SmoTempoText, scoreMode: boolean, selectionMode: boolean): Promise<void> {
const startSelection = this.tracker.selections[0];
if (startSelection.selector.measure > 0) {
const measureIx = startSelection.selector.measure - 1;
const target = startSelection.staff.measures[measureIx];
const tempo = target.getTempo();
const newTempo = new SmoTempoText(tempo);
newTempo.display = false;
this.updateTempoScore(measure, newTempo, scoreMode, selectionMode);
} else {
this.updateTempoScore(measure, new SmoTempoText(SmoTempoText.defaults), scoreMode, selectionMode);
}
await this.renderer.updatePromise();
}
/**
* Add a grace note to the selected real notes.
*/
async addGraceNote(): Promise<void> {
const selections = this.tracker.selections;
const measureSelections = this.undoTrackerMeasureSelections('add grace note');
selections.forEach((selection) => {
const index = selection.note!.getGraceNotes().length;
const pitches = JSON.parse(JSON.stringify(selection.note!.pitches));
const grace = new SmoGraceNote({
pitches, ticks:
{ numerator: 2048, denominator: 1, remainder: 0 }
});
SmoOperation.addGraceNote(selection, grace, index);
const altPitches = JSON.parse(JSON.stringify(selection.note!.pitches));
const altGrace = new SmoGraceNote({
pitches: altPitches, ticks:
{ numerator: 2048, denominator: 1, remainder: 0 }
});
altGrace.attrs.id = grace.attrs.id;
const altSelection = this._getEquivalentSelection(selection);
SmoOperation.addGraceNote(altSelection!, altGrace, index);
});
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
/**
* remove selected grace note
* @returns
*/
async removeGraceNote(): Promise<void> {
const selections = this.tracker.selections;
const measureSelections = this.undoTrackerMeasureSelections('remove grace note');
selections.forEach((selection) => {
// TODO: get the correct offset
SmoOperation.removeGraceNote(selection, 0);
const altSel = (this._getEquivalentSelection(selection));
SmoOperation.removeGraceNote(altSel!, 0);
});
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
/**
* Toggle slash in stem of grace note
*/
async slashGraceNotes(): Promise<void> {
const grace = this.tracker.getSelectedGraceNotes();
const measureSelections = this.undoTrackerMeasureSelections('slash grace note toggle');
grace.forEach((gn) => {
SmoOperation.slashGraceNotes(gn);
if (gn.selection !== null) {
const altSelection = this._getEquivalentSelection(gn.selection);
const altGn = this._getEquivalentGraceNote(altSelection!, gn.modifier as SmoGraceNote);
SmoOperation.slashGraceNotes({
selection: altSelection, modifier: altGn as any,
box: SvgBox.default, index: 0
});
}
});
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
async transposeScore(offset: number): Promise<void> {
this._undoScore('transpose score');
SmoOperation.transposeScore(this.score, offset);
SmoOperation.transposeScore(this.storeScore, offset);
this.renderer.rerenderAll();
await this.renderer.updatePromise();
}
/**
* transpose selected notes
* @param offset 1/2 steps
* @returns
*/
async transposeSelections(offset: number): Promise<void> {
const selections = this.tracker.selections;
const measureSelections = this.undoTrackerMeasureSelections('transpose');
const grace = this.tracker.getSelectedGraceNotes();
if (grace.length) {
grace.forEach((artifact) => {
if (artifact.selection !== null && artifact.selection.note !== null) {
const gn1 = artifact.modifier as SmoGraceNote;
const index = artifact.selection.note.graceNotes.findIndex((x) => x.attrs.id === gn1.attrs.id);
const altSelection = this._getEquivalentSelection(artifact.selection);
if (altSelection && altSelection.note !== null) {
const gn2 = altSelection.note.graceNotes[index];
SmoOperation.transposeGraceNotes(altSelection!, [gn2], offset);
}
SmoOperation.transposeGraceNotes(artifact.selection, [gn1], offset);
}
});
} else {
selections.forEach((selected) => {
SmoOperation.transpose(selected, offset);
const altSel = this._getEquivalentSelection(selected);
SmoOperation.transpose(altSel!, offset);
});
if (selections.length === 1 && this.score.preferences.autoPlay) {
SuiOscillator.playSelectionNow(selections[0], this.score, 1);
}
}
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
/**
* toggle the accidental spelling of the selected notes
* @returns
*/
async toggleEnharmonic(): Promise<void> {
const selections = this.tracker.selections;
const measureSelections = this.undoTrackerMeasureSelections('toggle enharmonic');
const grace = this.tracker.getSelectedGraceNotes();
if (grace.length) {
grace.forEach((artifact) => {
SmoOperation.toggleGraceNoteEnharmonic(artifact.selection!, [artifact.modifier as SmoGraceNote]);
const altSelection = this._getEquivalentSelection(artifact.selection!);
const altGr = this._getEquivalentGraceNote(altSelection!, artifact.modifier as SmoGraceNote);
SmoOperation.toggleGraceNoteEnharmonic(altSelection!,
[altGr]);
});
} else {
selections.forEach((selected) => {
if (typeof (selected.selector.pitches) === 'undefined') {
selected.selector.pitches = [];
}
SmoOperation.toggleEnharmonic(selected);
const altSel = this._getEquivalentSelection(selected);
SmoOperation.toggleEnharmonic(altSel!);
});
}
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
/**
* Toggle cautionary/courtesy accidentals
*/
async toggleCourtesyAccidentals(): Promise<void> {
const selections = this.tracker.selections;
const measureSelections = this.undoTrackerMeasureSelections('toggle courtesy accidental');
const grace = this.tracker.getSelectedGraceNotes();
if (grace.length) {
grace.forEach((artifact) => {
const gn1 = [artifact.modifier] as SmoGraceNote[];
SmoOperation.toggleGraceNoteCourtesy(artifact.selection, gn1);
const altSel = this._getEquivalentSelection(artifact.selection!);
const gn2 = this._getEquivalentGraceNote(altSel!, gn1[0]);
SmoOperation.toggleGraceNoteCourtesy(altSel!, [gn2]);
});
} else {
selections.forEach((selection) => {
SmoOperation.toggleCourtesyAccidental(selection);
const altSel = this._getEquivalentSelection(selection);
SmoOperation.toggleCourtesyAccidental(altSel!);
});
}
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
/**
* change the duration of notes for selected, creating more
* or fewer notes.
* After the change, reset the selection so it's as close as possible
* to the original length
* @param operation
* @returns
*/
async batchDurationOperation(operation: BatchSelectionOperation): Promise<void> {
const selections = this.tracker.selections;
const measureSelections = this.undoTrackerMeasureSelections('change duration');
const grace = this.tracker.getSelectedGraceNotes();
const graceMap: Record<string, BatchSelectionOperation> = {
doubleDuration: 'doubleGraceNoteDuration',
halveDuration: 'halveGraceNoteDuration'
};
if (grace.length && typeof (graceMap[operation]) !== 'undefined') {
operation = graceMap[operation];
grace.forEach((artifact) => {
(SmoOperation as any)[operation](artifact.selection, artifact.modifier);
const altSelection = this._getEquivalentSelection(artifact.selection!);
const gn2 = this._getEquivalentGraceNote(altSelection!, artifact.modifier as SmoGraceNote);
(SmoOperation as any)[operation](altSelection!, gn2);
});
} else {
const altAr = this._getEquivalentSelections(selections);
SmoOperation.batchSelectionOperation(this.score, selections, operation);
SmoOperation.batchSelectionOperation(this.storeScore, altAr, operation);
}
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
/**
* Toggle selected modifier on selected notes
* @param modifier
* @param ctor parent class constructor (e.g. SmoOrnament)
* @returns
*/
async toggleArticulation(modifier: string, ctor: string): Promise<void> {
const measureSelections = this.undoTrackerMeasureSelections('toggle articulation');
this.tracker.selections.forEach((sel) => {
if (ctor === 'SmoArticulation') {
const aa = new SmoArticulation({ articulation: modifier });
const altAa = new SmoArticulation({ articulation: modifier });
altAa.attrs.id = aa.attrs.id;
if (sel.note) {
sel.note.toggleArticulation(aa);
}
const altSelection = this._getEquivalentSelection(sel);
if (altSelection && altSelection.note) {
altSelection.note.toggleArticulation(altAa);
}
} else {
const aa = new SmoOrnament({ ornament: modifier });
const altAa = new SmoOrnament({ ornament: modifier });
altAa.attrs.id = aa.attrs.id;
const altSelection = this._getEquivalentSelection(sel!);
if (sel.note) {
sel.note.toggleOrnament(aa);
}
if (altSelection && altSelection.note) {
altSelection.note.toggleOrnament(altAa);
}
}
});
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
async setArticulation(modifier: SmoArticulation, set: boolean): Promise<void> {
const measureSelections = this.undoTrackerMeasureSelections('set articulation');
this.tracker.selections.forEach((sel) => {
const altAa = new SmoArticulation(modifier);
if (sel.note) {
sel.note.setArticulation(modifier, set);
}
const altSelection = this._getEquivalentSelection(sel);
if (altSelection && altSelection.note) {
altSelection.note.toggleArticulation(altAa);
}
});
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
async setOrnament(modifier: SmoOrnament, set: boolean): Promise<void> {
const measureSelections = this.undoTrackerMeasureSelections('set articulation');
this.tracker.selections.forEach((sel) => {
const altAa = new SmoOrnament(modifier);
if (sel.note) {
sel.note.setOrnament(modifier, set);
}
const altSelection = this._getEquivalentSelection(sel);
if (altSelection && altSelection.note) {
altSelection.note.setOrnament(altAa, set);
}
});
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
/**
* convert non-tuplet not to a tuplet
* @param params
*/
async makeTuplet(params: MakeTupletOperation): Promise<void> {
const selection = this.tracker.selections[0];
const measureSelections = this.undoTrackerMeasureSelections('make tuplet');
SmoOperation.makeTuplet(selection, params);
const altSelection = this._getEquivalentSelection(selection!);
SmoOperation.makeTuplet(altSelection!, params);
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
/**
* Convert selected tuplet to a single (if possible) non-tuplet
*/
async unmakeTuplet(): Promise<void> {
const selection = this.tracker.selections[0];
const measureSelections = this.undoTrackerMeasureSelections('unmake tuplet');
SmoOperation.unmakeTuplet(selection);
const altSelection = this._getEquivalentSelection(selection);
SmoOperation.unmakeTuplet(altSelection!);
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
/**
* Create a chord by adding an interval to selected note
* @param interval 1/2 steps
* @returns
*/
async setInterval(interval: number): Promise<void> {
const selections = this.tracker.selections;
const measureSelections = this.undoTrackerMeasureSelections('set interval');
selections.forEach((selected) => {
SmoOperation.interval(selected, interval);
const altSelection = this._getEquivalentSelection(selected);
SmoOperation.interval(altSelection!, interval);
});
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
/**
* change the selected chord into a single note
* @returns
*/
async collapseChord(): Promise<void> {
const selections = this.tracker.selections;
const measureSelections = this.undoTrackerMeasureSelections('collapse chord');
selections.forEach((selected) => {
const note: SmoNote | null = selected.note;
if (note) {
const pp = JSON.parse(JSON.stringify(note.pitches[0]));
const altpp = JSON.parse(JSON.stringify(note.pitches[0]));
// No operation for this?
note.pitches = [pp];
const altSelection = this._getEquivalentSelection(selected);
altSelection!.note!.pitches = [altpp];
}
});
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
/**
* Toggle chicken-scratches, for jazz improv, comping etc.
*/
async toggleSlash(): Promise<void> {
const selections = this.tracker.selections;
const measureSelections = this.undoTrackerMeasureSelections('make slash');
selections.forEach((selection) => {
SmoOperation.toggleSlash(selection);
const altSel = this._getEquivalentSelection(selection);
SmoOperation.toggleSlash(altSel!);
});
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
/**
* make selected notes into a rest, or visa-versa
* @returns
*/
async makeRest(): Promise<void> {
const selections = this.tracker.selections;
const measureSelections = this.undoTrackerMeasureSelections('make rest');
selections.forEach((selection) => {
SmoOperation.toggleRest(selection);
const altSel = this._getEquivalentSelection(selection);
SmoOperation.toggleRest(altSel!);
});
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
async clearAllBeams(): Promise<void> {
this._undoScore('clearAllBeams');
SmoOperation.clearAllBeamGroups(this.score);
SmoOperation.clearAllBeamGroups(this.storeScore);
await this.awaitRender();
}
async clearSelectedBeams() {
const selections = this.tracker.selections;
const measures = SmoSelection.getMeasureList(selections);
const altSelections = this._getEquivalentSelections(selections);
SmoOperation.clearBeamGroups(this.score, selections);
SmoOperation.clearBeamGroups(this.storeScore, altSelections);
this._renderChangedMeasures(measures);
await this.renderer.updatePromise();
}
/**
* toggle the 'end beam' flag for selected notes
* @returns
*/
async unbeamSelections(): Promise<void> {
const selections = this.tracker.selections;
const measureSelections = this.undoTrackerMeasureSelections('toggle beam group');
selections.forEach((selection) => {
SmoOperation.unbeamSelections(selection);
const altSel = this._getEquivalentSelection(selection);
SmoOperation.unbeamSelections(altSel!);
});
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
async toggleCue() {
const measureSelections = this.undoTrackerMeasureSelections('toggle note cue');
this.tracker.selections.forEach((selection) => {
const altSelection = this._getEquivalentSelection(selection);
if (selection.note && selection.note.isRest() === false) {
selection.note.isCue = !selection.note.isCue;
if (altSelection && altSelection.note) {
altSelection.note.isCue = selection.note.isCue;
}
}
});
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
/**
* up or down
* @returns
*/
async toggleBeamDirection(): Promise<void> {
const selections = this.tracker.selections;
if (selections.length < 1) {
return PromiseHelpers.emptyPromise();
}
const measureSelections = this.undoTrackerMeasureSelections('toggle beam direction');
SmoOperation.toggleBeamDirection(selections);
SmoOperation.toggleBeamDirection(this._getEquivalentSelections(selections));
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
/**
* Add the selected notes to a beam group
*/
async beamSelections(): Promise<void> {
const selections = this.tracker.selections;
const measureSelections = this.undoTrackerMeasureSelections('beam selections');
SmoOperation.beamSelections(this.score, selections);
SmoOperation.beamSelections(this.storeScore, this._getEquivalentSelections(selections));
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
/**
* change key signature for selected measures
* @param keySignature vex key signature
*/
async addKeySignature(keySignature: string): Promise<void> {
const measureSelections = this.undoTrackerMeasureSelections('set key signature ' + keySignature);
measureSelections.forEach((sel) => {
SmoOperation.addKeySignature(this.score, sel, keySignature);
const altSel = this._getEquivalentSelection(sel);
SmoOperation.addKeySignature(this.storeScore, altSel!, keySignature);
});
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
/**
* Sets a pitch from the piano widget.
* @param pitch {Pitch}
* @param chordPedal {boolean} - indicates we are adding to a chord
*/
async setPitchPiano(pitch: Pitch, chordPedal: boolean): Promise<void> {
const measureSelections = this.undoTrackerMeasureSelections(
'setAbsolutePitch ' + pitch.letter + '/' + pitch.accidental);
this.tracker.selections.forEach((selected) => {
const npitch: Pitch = {
letter: pitch.letter,
accidental: pitch.accidental, octave: pitch.octave
};
const octave = SmoMeasure.defaultPitchForClef[selected.measure.clef].octave;
npitch.octave += octave;
const altSel = this._getEquivalentSelection(selected);
if (chordPedal && selected.note) {
selected.note.toggleAddPitch(npitch);
altSel!.note!.toggleAddPitch(npitch);
} else {
SmoOperation.setPitch(selected, [npitch]);
SmoOperation.setPitch(altSel!, [npitch]);
}
});
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
/**
* show or hide the piano widget
* @param value to show it
*/
async showPiano(value: boolean): Promise<void> {
this.score.preferences.showPiano = value;
this.storeScore.preferences.showPiano = value;
if (value) {
SuiPiano.showPiano();
} else {
SuiPiano.hidePiano();
}
await this.renderer.updatePromise();
}
/**
* Render a pitch for each letter name-pitch in the string,
* @param pitches letter names for pitches
* @returns promise, resolved when all pitches rendered
* @see setPitch
*/
async setPitchesPromise(pitches: PitchLetter[]): Promise<any> {
const self = this;
const promise = new Promise((resolve: any) => {
const fc = async (index: number) => {
if (index >= pitches.length) {
resolve();
} else {
await self.setPitch(pitches[index]);
fc(index + 1);
}
};
fc(0);
});
await promise;
}
/**
* Add a pitch to the score at the cursor. This tries to find the best pitch
* to match the letter key (F vs F# for instance) based on key and surrounding notes
* @param letter string
*/
async setPitch(letter: PitchLetter): Promise<void> {
const selections = this.tracker.selections;
const measureSelections = this.undoTrackerMeasureSelections('set pitch ' + letter);
selections.forEach((selected) => {
const selector = selected.selector;
let hintSel = SmoSelection.lastNoteSelectionNonRest(this.score,
selector.staff, selector.measure, selector.voice, selector.tick);
if (!hintSel) {
hintSel = SmoSelection.nextNoteSelectionNonRest(this.score,
selector.staff, selector.measure, selector.voice, selector.tick);
}
// The selection no longer exists, possibly deleted
if (hintSel === null || hintSel.note === null) {
return PromiseHelpers.emptyPromise();
}
const pitch = SmoMusic.getLetterNotePitch(hintSel.note.pitches[0],
letter, hintSel.measure.keySignature);
SmoOperation.setPitch(selected, [pitch]);
const altSel = this._getEquivalentSelection(selected);
SmoOperation.setPitch(altSel!, [pitch]);
if (this.score.preferences.autoAdvance) {
// Don't play the next note and the added pitch at the same time.
this.tracker.deferNextAutoPlay();
this.tracker.moveSelectionRight();
}
});
if (selections.length === 1 && this.score.preferences.autoPlay) {
SuiOscillator.playSelectionNow(selections[0], this.score, 1);
}
this._renderChangedMeasures(measureSelections);
await this.renderer.updatePromise();
}
/**
* Generic clipboard copy action
*/
async copy(): Promise<void> {
const altAr: SmoSelection[] = [];
this.tracker.selections.forEach((sel) => {
const noteSelection = this._getEquivalentSel