Seanitzel/Note-Art

View on GitHub
src/notation/Measure.ts

Summary

Maintainability
A
2 hrs
Test Coverage
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<Note>;
  duration: string;
  name?: string;
}

export interface NormalizedMeasureData {
  notes: Set<Note>;
  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<NormalizedMeasureData>;

  /**
   * 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<NormalizedMeasureData> {
    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<Note>,
      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<string>, 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<Note> = [...notes].map(note => {
        return isRest(note) ? note : transpose(note, interval);
      }) as Array<Note>;

      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);
  }
}