martijnversluis/ChordSheetJS

View on GitHub
src/note.ts

Summary

Maintainability
A
0 mins
Test Coverage
import {
  ChordType,
  NUMERAL,
  NUMERIC,
  ROMAN_NUMERALS,
  SOLFEGE,
  SYMBOL,
} from './constants';

const A = 'A'.charCodeAt(0);
const G = 'G'.charCodeAt(0);
const solfegeNotes = ['Do', 'Re', 'Mi', 'Fa', 'Sol', 'La', 'Si'];

const TRANSPOSE_DISTANCE_MAJOR: Record<number, number> = {
  1: 0,
  2: 2,
  3: 4,
  4: 5,
  5: 7,
  6: 9,
  7: 11,
};

const TRANSPOSE_DISTANCE_MINOR: Record<number, number> = {
  1: 0,
  2: 2,
  3: 3,
  4: 5,
  5: 7,
  6: 8,
  7: 11,
};

function keyToCharCode(key: string): number {
  return key.toUpperCase().charCodeAt(0);
}

function clamp(note: number, min: number, max: number): number {
  let newNote = note;

  if (newNote < min) {
    newNote += 7;
  } else if (newNote > max) {
    newNote -= 7;
  }

  return newNote;
}

function numeralToNumber(numeral: string): null | number {
  for (let i = 0, count = ROMAN_NUMERALS.length; i < count; i += 1) {
    const romanNumeral = ROMAN_NUMERALS[i];

    if (romanNumeral === numeral || romanNumeral.toLowerCase() === numeral) {
      return i + 1;
    }
  }

  return null;
}

function numberToNumeral(number: number): string {
  return ROMAN_NUMERALS[number - 1];
}

type AtomicNote = string | number;

interface NoteProperties {
  note?: AtomicNote;
  type?: ChordType;
  minor?: boolean;
}

class Note implements NoteProperties {
  readonly _note: AtomicNote;

  type: ChordType;

  minor = false;

  constructor({ note, type, minor = false }: { note: AtomicNote, type: ChordType, minor?: boolean }) {
    this._note = note;
    this.type = type;
    this.minor = minor;
  }

  static parse(note: string | number): Note {
    const noteString = note.toString();

    if (/^Do|Re|Mi|Fa|Sol|La|Si$/i.test(noteString)) {
      return new Note({ note: noteString.charAt(0).toUpperCase() + noteString.slice(1).toLowerCase(), type: SOLFEGE });
    }

    if (/^[A-Ga-g]$/.test(noteString)) {
      return new Note({ note: noteString.toUpperCase(), type: SYMBOL });
    }

    if (/^[1-7]$/.test(noteString)) {
      return new Note({ note: parseInt(noteString, 10), type: NUMERIC });
    }

    const romanNumeralValue = numeralToNumber(noteString);

    if (romanNumeralValue) {
      return new Note({
        note: romanNumeralValue,
        type: NUMERAL,
        minor: (noteString.toLowerCase() === note),
      });
    }

    throw new Error(`Invalid note ${note}`);
  }

  toNumeral(): Note {
    if (this.isNumeral()) {
      return this.clone();
    }

    if (this.isNumeric()) {
      return this.set({ type: NUMERAL });
    }

    throw new Error(`Converting a ${this.type} note to numeral is not supported`);
  }

  toNumeric(): Note {
    if (this.isNumeric()) {
      return this.clone();
    }

    if (this.isNumeral()) {
      return this.set({ type: NUMERIC });
    }

    throw new Error(`Converting a ${this.type} note to numeric is not supported`);
  }

  isMinor(): boolean {
    return this.minor;
  }

  equals(otherNote: Note): boolean {
    return this._note === otherNote._note && this.type === otherNote.type && this.minor === otherNote.minor;
  }

  clone(): Note {
    return this.set({});
  }

  up(): Note {
    return this.change(1);
  }

  down(): Note {
    return this.change(-1);
  }

  isOneOf(...options: AtomicNote[]): boolean {
    return options.includes(this._note);
  }

  isNumeric(): boolean {
    return this.is(NUMERIC);
  }

  isChordSymbol(): boolean {
    return this.is(SYMBOL);
  }

  isChordSolfege(): boolean {
    return this.is(SOLFEGE);
  }

  isNumeral(): boolean {
    return this.is(NUMERAL);
  }

  is(noteType: ChordType): boolean {
    return this.type === noteType;
  }

  getTransposeDistance(minor: boolean): number {
    if (typeof this._note === 'number') {
      if (minor && this._note in TRANSPOSE_DISTANCE_MINOR) {
        return TRANSPOSE_DISTANCE_MINOR[this._note];
      }

      if (this._note in TRANSPOSE_DISTANCE_MAJOR) {
        return TRANSPOSE_DISTANCE_MAJOR[this._note];
      }
    }

    return 0;
  }

  change(delta: number): Note {
    if (this.isChordSolfege()) {
      const solfegeNote = this._note as string;
      const currentIndex = solfegeNotes.indexOf(solfegeNote);

      return this.set({ note: solfegeNotes[(currentIndex + delta) % 7] });
    }

    if (this.isChordSymbol()) {
      let charCode;
      charCode = keyToCharCode(this._note as string);
      charCode += delta;
      charCode = clamp(charCode, A, G);

      return this.set({ note: String.fromCharCode(charCode) });
    }

    let newNote = clamp((this._note as number) + delta, 1, 7);

    if (newNote < 1) {
      newNote += 7;
    } else if (newNote > 7) {
      newNote -= 7;
    }

    return this.set({ note: newNote });
  }

  get note(): string | number {
    if (this.isNumeral()) {
      const numeral = numberToNumeral(this._note as number);

      if (this.isMinor()) {
        return numeral.toLowerCase();
      }

      return numeral;
    }

    return this._note;
  }

  toString(): string {
    const note = this.note as string;

    switch (this.type) {
      case NUMERAL:
        return `${this.minor ? note.toLowerCase() : note.toUpperCase()}`;
      default:
        return `${this.note}`;
    }
  }

  private set(properties: NoteProperties): Note {
    return new Note({
      note: this._note,
      type: this.type,
      minor: this.minor,
      ...properties,
    });
  }
}

export default Note;