import { firstToUpper } from '../utilities/GeneralFunctions.js'; import { NOTE_DURATIONS_AS_SIZE_IN_MEASURE } from '../Constants.js'; import { isRest } from '../utilities/PureMusicUtils.js'; import { Note } from '../types.js'; import { transpose } from '../index.js'; export interface MeasureData { notes: Array; duration: string; name?: string; } export interface NormalizedMeasureData { notes: Set; duration: string; name?: string; } /** * @class Measure * @description Represents a single measure as part of a musical score in musical notation.ds * @param {Number} maxDuration=0 Max duration of the measure(determined by time signature) */ export class Measure { _duration: string; constructor(maxDuration = 64) { this._maxDuration = maxDuration; this._duration = '4n'; this._data = [{ notes: new Set(), duration: '4n' }]; } _maxDuration: number; /** * Returns the maximum sum of durations for the measure as a number, * where each unit is 1/64 bit. * @returns {number} * @readonly */ get maxDuration(): number { return this._maxDuration; } _data: Array; /** * Returns the data of the measure - an array of objects where each * object has a set of notes and the duration for those notes. * @returns {Array} */ get data(): Array { return this._data; } /** * Returns the number of sixteenth notes in the measure. * @returns {number} */ get length(): number { return this.data.reduce((acc, { duration }) => acc + NOTE_DURATIONS_AS_SIZE_IN_MEASURE[duration as keyof typeof NOTE_DURATIONS_AS_SIZE_IN_MEASURE], 0) / 4; } static measureDataToString(notesMember: NormalizedMeasureData): string { const notes = Array.from(notesMember.notes).join('-'); if(notes) { const duration = notesMember.duration; const name = notesMember.name; return `${notes}_${duration}${name ? `_${name}` : ''}`; } return ''; } static parseMeasureNoteMemberString(str: string): MeasureData { const [notes, duration, name] = str.split('_'); return { notes: notes.split('-') as Array, duration, name, }; } static stringToMeasure({ str, maxDuration }: { str: string, maxDuration: number }): Measure { const measure = new Measure(maxDuration); const noteMembers = str.split('__') .map(el => Measure.parseMeasureNoteMemberString(el)); noteMembers.forEach((noteMember, i) => measure.addChord(noteMember, i)); return measure; } /** * Returns a deep clone of the measure. * @returns {Measure} */ clone(): Measure { return this.transpose(0); } /** * Returns the duration left for notes in the measure. * @param {number} position=this.data.length * @returns {number} */ durationLeft(position: number = this.data.length): number { return this.maxDuration - this.data.slice(0, position) .reduce((prev, curr) => { return curr.notes.size ? prev + NOTE_DURATIONS_AS_SIZE_IN_MEASURE[curr.duration as keyof typeof NOTE_DURATIONS_AS_SIZE_IN_MEASURE] : prev; }, 0); } /** * Adds a note to the measure at some position. * @param {Object} data * @param {string} data.note raw note representation. * @param {string} data.duration * @param {number} position The position in the data to add the note to. * @returns {boolean} */ addNote({ note, duration }: { note: Note, duration: string }, position: number): boolean { if(this.canInsertToMeasure(position + 1, duration)) { this.data[position].notes.add(firstToUpper(note) as Note); this.data[position].duration = duration; this.initNext(position + 1, duration); return true; } return false; } /** * Adds notes to the note set at the position. * @param {Array} notes An array of raw notes. * @param {string} duration * @param {number} position The position in the data to add the notes to. * @returns {*} */ addNotes({ notes, duration }: MeasureData, position: number): boolean { return notes.every(note => this.addNote({ note, duration }, position)); } /** * Adds notes to the measure plus a name that represents the chord and is saved in * the data at the position as name * @param notes * @param name * @param duration * @param position * @return {boolean} * @example * measure.addChord({ * notes: ['C3', 'E3', 'G3'], * name: 'C Major', * duration: '4n' * }, 0) // Adds a C major chord at the start of the measure. */ addChord({ notes, name, duration }: MeasureData, position: number): boolean { if(this.canInsertToMeasure(position + 1, duration)) { if(name) { this.data[position].name = name; } return this.addNotes({ notes, duration }, position); } return false; } /** * Delete note at the position. * @param {string} note raw note. * @param {number} position The position in the data to delete the note at. * @returns {boolean} */ deleteNote(note: string, position: number): boolean { return this.data[position].notes.delete(firstToUpper(note) as Note); } /** * Deletes notes from the noteset at the position. * @param {Array} notes An array of raw notes. * @param {number} position The position in the data to delete the notes at. * @returns {*} */ deleteNotes(notes: Array, position: number): boolean { return notes.every(note => this.deleteNote(note, position)); } /** * Delete member from the measure's data - removes all the notes from it * and initializes a new data member with the measure's duration. * @param {number} position Position of the member to delete. * @return {boolean} */ deleteMember(position: number): boolean { if(this.data[position]) { this.data.splice(position, 1); // if the measure doesnt have a new member ready for adding new notes, create one if(this.data[this.data.length - 1].notes.size !== 0) { this.initNext(this.data.length); } return true; } return false; } /** * Returns true if the duration has space, else false. * @param duration * @returns {boolean} */ isFull(duration: string): boolean { return !(NOTE_DURATIONS_AS_SIZE_IN_MEASURE[duration as keyof typeof NOTE_DURATIONS_AS_SIZE_IN_MEASURE] <= this.durationLeft()); } /** * Returns a new measure where all the notes are transposed by the interval. * @param {number} interval Interval to transpose by. * @returns {Measure} */ transpose(interval: number): Measure { const transposedMeasure = new Measure(this.maxDuration); this.data.forEach((data: NormalizedMeasureData, position: number) => { const { name, duration, notes } = data; const transposedNotes: Array = [...notes].map(note => { return isRest(note) ? note : transpose(note, interval); }) as Array; const newData: MeasureData = { notes: transposedNotes, name, duration }; transposedMeasure.addChord(newData, position); }); return transposedMeasure; } /** * Removes all the data from the measure. * @returns {boolean} */ clear(): boolean { this.data.length = 0; this.initNext(0); return true; } toString(): string { return this.data.map(notesMember => Measure.measureDataToString(notesMember)) .filter(el => el !== '') .join('__'); } /** * Creates a slot for the next notes that will be added in the measure if there is space. * Should not be called as it's called automatically when needed. * @param {number} position Position to initialize the next notes to. * @param {string} duration duration to create for the notes * @private */ private initNext(position: number, duration = '4n') { const durationLeft = this.durationLeft(this.data.length); if(durationLeft > 0) { this.data[position] = { notes: new Set(), duration }; } } /** * Checks whether a new data member can be added at a certain position in the measure. * @param {number} position The position to check for. * @param {string} duration duration of new notes * @returns {boolean} */ private canInsertToMeasure(position: number, duration: string): boolean { const isPositionValid = position > this.data.length; const durationSize = NOTE_DURATIONS_AS_SIZE_IN_MEASURE[duration as keyof typeof NOTE_DURATIONS_AS_SIZE_IN_MEASURE]; const enoughDurationAvailable = durationSize > this.durationLeft(position) + durationSize; return !(isPositionValid || enoughDurationAvailable); } }