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
480 lines (433 loc) • 16.6 kB
text/typescript
// [Smoosic](https://github.com/AaronDavidNewman/Smoosic)
// Copyright (c) Aaron David Newman 2021.
import { SmoNote, TupletInfo } from '../data/note';
import { SmoTuplet, SmoTupletTree } from '../data/tuplet';
import { SmoMusic } from '../data/music';
import { SmoMeasure, SmoVoice } from '../data/measure';
import { Ticks } from '../data/common';
import { TickMap } from './tickMap';
/**
* Abstract class for classes that modifiy duration.
* @category SmoTransform
* @param note the note we're iterating over
* @param tickmap the tickmap for the measure
* @param index the index into the tickmap
* @returns the note or notes that replace this one. Null if this note is no longer in the measure
*/
export abstract class TickIteratorBase {
// es
iterateOverTick(note: SmoNote, tickmap: TickMap, index: number): SmoNote | SmoNote[] | null {
return null;
}
}
/**
* SmoTickIterator
* this is a local helper class that follows a pattern of iterating of the notes. Most of the
* duration changers iterate over a selection, and return:
* - A note, if the duration changes
* - An array of notes, if the notes split
* - null if the note stays the same
* - empty array, remove the note from the group
* @category SmoTransform
*/
export class SmoTickIterator {
notes: SmoNote[] = [];
newNotes: SmoNote[] = [];
actor: TickIteratorBase;
measure: SmoMeasure;
voice: number = 0;
keySignature: string;
constructor(measure: SmoMeasure, actor: TickIteratorBase, voiceIndex: number) {
this.notes = measure.voices[voiceIndex].notes;
this.measure = measure;
this.voice = typeof (voiceIndex) === 'number' ? voiceIndex : 0;
this.newNotes = [];
// eslint-disable-next-line
this.actor = actor;
this.keySignature = 'C';
}
static nullActor(note: SmoNote) {
return note;
}
/**
*
* @param measure {SmoMeasure}
* @param actor {}
* @param voiceIndex
*/
static iterateOverTicks(measure: SmoMeasure, actor: TickIteratorBase, voiceIndex: number) {
measure.clearBeamGroups();
const transformer = new SmoTickIterator(measure, actor, voiceIndex);
transformer.run();
measure.voices[voiceIndex].notes = transformer.notes;
}
// ### transformNote
// call the actors for each note, and put the result in the note array.
// The note from the original array is copied and sent to each actor.
//
// Because the resulting array can have a different number of notes than the existing
// array, the actors communicate with the transformer in the following, jquery-ish
// but somewhat unintuitive way:
//
// 1. if the actor returns null, the next actor is called and the results of that actor are used
// 2. if all the actors return null, the copy is used.
// 3. if a note object is returned, that is used for the current tick and no more actors are called.
// 4. if an array of notes is returned, it is concatenated to the existing note array and no more actors are called.
// Note that *return note;* and *return [note];* produce the same result.
// 5. if an empty array [] is returned, that copy is not added to the result. The note is effectively deleted.
iterateOverTick(tickmap: TickMap, index: number, note: SmoNote) {
const actor: TickIteratorBase = this.actor;
const newNote: SmoNote[] | SmoNote | null = actor.iterateOverTick(note, tickmap, index);
if (newNote === null) {
this.newNotes.push(note); // no change
return note;
}
if (Array.isArray(newNote)) {
if (newNote.length === 0) {
return null;
}
this.newNotes = this.newNotes.concat(newNote);
return null;
}
this.newNotes.push(newNote as SmoNote);
return null;
}
run() {
let i = 0;
const tickmap = this.measure.tickmapForVoice(this.voice);
for (i = 0; i < tickmap.durationMap.length; ++i) {
this.iterateOverTick(tickmap, i, this.measure.voices[this.voice].notes[i]);
}
this.notes = this.newNotes;
return this.newNotes;
}
}
/**
* used to create a contract/dilate operation on a note via {@link SmoContractNoteActor}
* @category SmoTransform
*/
export interface SmoContractNoteParams {
startIndex: number,
measure: SmoMeasure,
voice: number,
newStemTicks: number
}
/**
* Contract the duration of a note, filling in the space with another note
* or rest.
* @category SmoTransform
* */
export class SmoContractNoteActor extends TickIteratorBase {
startIndex: number;
newStemTicks: number;
measure: SmoMeasure;
voice: number;
constructor(params: SmoContractNoteParams) {
super();
this.startIndex = params.startIndex;
this.measure = params.measure;
this.voice = params.voice;
this.newStemTicks = params.newStemTicks;
}
static apply(params: SmoContractNoteParams) {
const actor = new SmoContractNoteActor(params);
SmoTickIterator.iterateOverTicks(actor.measure,
actor, actor.voice);
}
iterateOverTick(note: SmoNote, tickmap: TickMap, index: number): SmoNote | SmoNote[] | null {
if (index === this.startIndex) {
let newTicks: Ticks = { numerator: this.newStemTicks, denominator: 1, remainder: 0 };
const multiplier = note.tickCount / note.stemTicks;
if (note.isTuplet) {
const numerator = this.newStemTicks * multiplier;
newTicks = { numerator: Math.floor(numerator), denominator: 1, remainder: numerator % 1 };
}
const replacingNote = SmoNote.cloneWithDuration(note, newTicks, this.newStemTicks);
const oldStemTicks = note.stemTicks;
const notes = [];
const remainderStemTicks = oldStemTicks - this.newStemTicks;
notes.push(replacingNote);
if (remainderStemTicks > 0) {
if (remainderStemTicks < 128) {
return null;
}
const lmap = SmoMusic.gcdMap(remainderStemTicks);
lmap.forEach((stemTick) => {
const numerator = stemTick * multiplier;
const nnote = SmoNote.cloneWithDuration(note, {numerator: Math.floor(numerator), denominator: 1, remainder: numerator % 1}, stemTick);
notes.push(nnote);
});
}
//accumulate all remainders in the first note
let remainder: number = 0;
notes.forEach((note: SmoNote) => {
if (note.ticks.remainder > 0) {
remainder += note.ticks.remainder;
note.ticks.remainder = 0;
}
});
notes[0].ticks.numerator += Math.round(remainder);
SmoTupletTree.adjustTupletIndexes(this.measure.tupletTrees, this.voice, index, notes.length - 1);
return notes;
}
return null;
}
}
/**
* Constructor when we want to double or dot the duration of a note (stretch)
* for {@link SmoStretchNoteActor}
* @param startIndex tick index into the measure
* @param measure the container measure
* @param voice the voice index
* @param newTicks the ticks the new note will take up
* @category SmoTransform
*/
export interface SmoStretchNoteParams {
startIndex: number,
measure: SmoMeasure,
voice: number,
newStemTicks: number
}
/**
* increase the length of a note, removing future notes in the measure as required
* @category SmoTransform
*/
export class SmoStretchNoteActor extends TickIteratorBase {
startIndex: number;
newStemTicks: number;
measure: SmoMeasure;
voice: number;
notes: SmoNote[];
notesToInsert: SmoNote[] = [];
numberOfNotesToDelete: number = 0;
constructor(params: SmoStretchNoteParams) {
super();
this.startIndex = params.startIndex;
this.measure = params.measure;
this.voice = params.voice;
this.newStemTicks = params.newStemTicks;
this.notes = this.measure.voices[this.voice].notes;
const originalNote: SmoNote = this.notes[this.startIndex];
const multiplier = originalNote.tickCount / originalNote.stemTicks;
const newTicks = this.calculateNewTicks(originalNote, multiplier);
const replacingNote = SmoNote.cloneWithDuration(originalNote, newTicks, this.newStemTicks);
const {stemTicksUsed, crossedTupletBoundary} = this.determineNotesToDelete(originalNote);
// if crossing a tuplet boundary, abort stretching
if (crossedTupletBoundary) {
this.numberOfNotesToDelete = 0;
} else {
this.prepareNotesToInsert(originalNote, replacingNote, stemTicksUsed, multiplier);
}
}
private calculateNewTicks(originalNote: SmoNote, multiplier: number): Ticks {
if (originalNote.isTuplet) {
const numerator = this.newStemTicks * multiplier
return { numerator: Math.floor(numerator), denominator: 1, remainder: numerator % 1};
} else {
return { numerator: this.newStemTicks, denominator: 1, remainder: 0};
}
}
private determineNotesToDelete(originalNote: SmoNote) {
let crossedTupletBoundary = false;
let stemTicksUsed = originalNote.stemTicks;
for (let i = this.startIndex + 1; i < this.notes.length; ++i) {
const nextNote = this.notes[i];
const areTupletsBothNull = SmoTupletTree.areTupletsBothNull(originalNote, nextNote);
const areNotesPartOfTheSmeTuplet = SmoTupletTree.areNotesPartOfTheSameTuplet(originalNote, nextNote);
if (!areTupletsBothNull && !areNotesPartOfTheSmeTuplet) {
crossedTupletBoundary = true;
break;
}
stemTicksUsed += nextNote.stemTicks;
++this.numberOfNotesToDelete;
if (stemTicksUsed >= this.newStemTicks) {
break;
}
}
return {stemTicksUsed, crossedTupletBoundary};
}
private prepareNotesToInsert(
originalNote: SmoNote,
replacingNote: SmoNote,
stemTicksUsed: number,
multiplier: number
) {
const remainingTicks = stemTicksUsed - this.newStemTicks;
if (remainingTicks >= 0) {
this.notesToInsert.push(replacingNote);
const tickMap = SmoMusic.gcdMap(remainingTicks);
tickMap.forEach((stemTick) => {
const numerator = stemTick * multiplier;
const newNote = SmoNote.cloneWithDuration(originalNote, {numerator: Math.floor(numerator), denominator: 1, remainder: numerator % 1}, stemTick)
this.notesToInsert.push(newNote);
});
// Adjust tuplet indexes due to note insertion/deletion
const noteCountDiff = (this.notesToInsert.length - this.numberOfNotesToDelete) - 1;
SmoTupletTree.adjustTupletIndexes(this.measure.tupletTrees, this.voice, this.startIndex, noteCountDiff);
//accumulate all remainders in the first note
let totalRemainder: number = 0;
this.notesToInsert.forEach((note: SmoNote) => {
if (note.ticks.remainder > 0) {
totalRemainder += note.ticks.remainder;
note.ticks.remainder = 0;
}
});
this.notesToInsert[0].ticks.numerator += Math.round(totalRemainder);
}
}
static apply(params: SmoStretchNoteParams) {
const actor = new SmoStretchNoteActor(params);
SmoTickIterator.iterateOverTicks(actor.measure, actor, actor.voice);
}
iterateOverTick(note: SmoNote, tickmap: TickMap, index: number) {
if (this.startIndex === index && this.notesToInsert.length) {
return this.notesToInsert;
} else if (index > this.startIndex && this.numberOfNotesToDelete > 0) {
--this.numberOfNotesToDelete;
return [];
}
return null;
}
}
/**
* constructor parameters for {@link SmoMakeTupletActor}
* @category SmoTransform
*/
export interface SmoMakeTupletParams {
measure: SmoMeasure,
numNotes: number,
notesOccupied: number,
ratioed: boolean,
bracketed: boolean,
voice: number,
index: number
}
/**
* Turn a tuplet into a non-tuplet of the same length
* @category SmoTransform
*
* */
export class SmoMakeTupletActor extends TickIteratorBase {
measure: SmoMeasure;
numNotes: number;
voice: number;
index: number;
notesOccupied: number;
ratioed: boolean;
bracketed: boolean;
constructor(params: SmoMakeTupletParams) {
super();
this.measure = params.measure;
this.index = params.index;
this.voice = params.voice;
this.numNotes = params.numNotes;
this.notesOccupied = params.notesOccupied;
this.ratioed = params.ratioed;
this.bracketed = params.bracketed;
}
static apply(params: SmoMakeTupletParams) {
const actor = new SmoMakeTupletActor(params);
SmoTickIterator.iterateOverTicks(actor.measure, actor, actor.voice);
}
iterateOverTick(note: SmoNote, tickmap: TickMap, index: number) {
if (this.measure === null) {
return [];
}
if (index !== this.index) {
return null;
}
const stemTicks = note.stemTicks / this.notesOccupied
if (!(stemTicks in SmoMusic.validDurations)) {
return null;
}
this.measure.clearBeamGroups();
const tuplet = new SmoTuplet({
numNotes: this.numNotes,
notesOccupied: this.notesOccupied,
stemTicks: stemTicks,
totalTicks: note.tickCount,
ratioed: this.ratioed,
bracketed: this.bracketed,
voice: this.voice,
startIndex: this.index,
endIndex: this.index,
});
const tupletNotes = this._generateNotesForTuplet(tuplet, note, tuplet.stemTicks);
tuplet.endIndex += tupletNotes.length - 1;
SmoTupletTree.adjustTupletIndexes(this.measure.tupletTrees, this.voice, index, tupletNotes.length - 1);
const parentTuplet: SmoTuplet | null = SmoTupletTree.getTupletForNoteIndex(this.measure.tupletTrees, this.voice, this.index);
if (parentTuplet === null) {
const tupletTree = new SmoTupletTree({tuplet: tuplet});
this.measure.tupletTrees.push(tupletTree);
} else {
parentTuplet.childrenTuplets.push(tuplet);
}
return tupletNotes;
}
private _generateNotesForTuplet(tuplet: SmoTuplet, originalNote: SmoNote, stemTicks: number): SmoNote[] {
const totalTicks = originalNote.tickCount;
const tupletNotes: SmoNote[] = [];
const numerator = totalTicks / this.numNotes;
for (let i = 0; i < this.numNotes; ++i) {
const note: SmoNote = SmoNote.cloneWithDuration(originalNote, { numerator: Math.floor(numerator), denominator: 1, remainder: 0 }, stemTicks);
// Don't clone modifiers, except for first one.
note.textModifiers = i === 0 ? note.textModifiers : [];
note.tupletId = tuplet.attrs.id;
tupletNotes.push(note);
}
const tickSum = tupletNotes.map((x) => x.tickCount).reduce((a,b) => a + b);
if (tuplet.totalTicks > tickSum) {
tupletNotes[0].ticks.numerator += tuplet.totalTicks - tickSum;
}
return tupletNotes;
}
}
/**
* Constructor params for {@link SmoUnmakeTupletActor}
* @category SmoTransform
*/
export interface SmoUnmakeTupletParams {
startIndex: number,
endIndex: number,
measure: SmoMeasure,
voice: number
}
/**
* Convert a tuplet into a single note that takes up the whole duration
* @category SmoTransform
*/
export class SmoUnmakeTupletActor extends TickIteratorBase {
startIndex: number = 0;
endIndex: number = 0;
measure: SmoMeasure;
voice: number;
constructor(parameters: SmoUnmakeTupletParams) {
super();
this.startIndex = parameters.startIndex;
this.endIndex = parameters.endIndex;
this.measure = parameters.measure;
this.voice = parameters.voice;
}
static apply(params: SmoUnmakeTupletParams) {
const actor = new SmoUnmakeTupletActor(params);
SmoTickIterator.iterateOverTicks(actor.measure, actor, actor.voice);
}
iterateOverTick(note: SmoNote, tickmap: TickMap, index: number) {
if (index < this.startIndex || index > this.endIndex) {
return null;
}
if (index === this.startIndex) {
const tuplet = SmoTupletTree.getTupletForNoteIndex(this.measure.tupletTrees, this.voice, index);
if (tuplet === null) {
return [];
}
const ticks = tuplet.totalTicks;
const nn: SmoNote = SmoNote.cloneWithDuration(note, { numerator: ticks, denominator: 1, remainder: 0 });
nn.tupletId = null;
SmoTupletTree.removeTupletForNoteIndex(this.measure, this.voice, index);
SmoTupletTree.adjustTupletIndexes(this.measure.tupletTrees, this.voice, this.startIndex, this.startIndex - this.endIndex);
return [nn];
}
return [];
}
}