import EtudeError from "../error/EtudeError"; import EtudeParser from "../util/EtudeParser"; import Exceptional from "../util/Exceptional"; import ImmutablePrioritySet from "../util/ImmutablePrioritySet"; import MathUtil from "../util/MathUtil"; import Accidental from "./Accidental"; import Interval from "./Interval"; import Key from "./Key"; import Letter from "./Letter"; import KeySignature from "./KeySignature"; import MusicConstants from "./MusicConstants"; import Policy from "./Policy"; export default class Pitch { constructor(private key: Key, private octave: number) { const programNumber = this.getProgramNumber(); if (programNumber < MusicConstants.SMALLEST_PROGRAM_NUMBER || programNumber > MusicConstants.LARGEST_PROGRAM_NUMBER) { throw EtudeError.forInvalid(Pitch, programNumber, "out of program number range"); } } public apply(keySignature: KeySignature): Pitch { const key = this.key.apply(keySignature); if (this.key === key) { return this; } return new Pitch(key, this.octave); } public step(amount: Interval | number, policies: ImmutablePrioritySet = Policy.DEFAULT_PRIORITY): Exceptional { if (amount instanceof Interval) { if (arguments.length !== 1) { throw EtudeError.forIllegalArgument(Letter, amount, "Invalid number of arguments"); } const interval = amount as Interval; // determine the letter const letters: Letter[] = Letter.getLetters(this.key.getLetter()); const letter: Letter = letters[MathUtil.floorMod(interval.getDistance() - 1, Letter.size)]; // initialize accidental to be the accidental of the new letter in the key signature of this key let accidental: Exceptional = new Key(letter).apply(KeySignature.fromKey(this.key, KeySignature.Quality.MAJOR)).getAccidental(); const accidentalOffset: number = accidental.map(a => a.getOffset()).orElse(0); // change accidental based on interval's quality switch (interval.getQuality()) { case Interval.Quality.PERFECT: case Interval.Quality.MAJOR: break; case Interval.Quality.MINOR: accidental = Accidental.fromOffset(accidentalOffset - 1); break; case Interval.Quality.DIMINISHED: accidental = Accidental.fromOffset(accidentalOffset - (Interval.isPerfect(interval.getDistance()) ? 1 : 2)); break; case Interval.Quality.DOUBLY_DIMINISHED: accidental = Accidental.fromOffset(accidentalOffset - (Interval.isPerfect(interval.getDistance()) ? 2 : 3)); break; case Interval.Quality.AUGMENTED: accidental = Accidental.fromOffset(accidentalOffset + 1); break; case Interval.Quality.DOUBLY_AUGMENTED: accidental = Accidental.fromOffset(accidentalOffset + 2); break; } // prefer null over natural accidental = accidental.filter(a => a !== Accidental.NATURAL); // refer to Interval.between for how this equation was derived const octaveOffset: number = Math.trunc( (interval.getDistance() - 1 + (MathUtil.floorMod(this.getKey().getLetter().ordinal() - 2, Letter.size) - MathUtil.floorMod(letter.ordinal() - 2, Letter.size)) ) / Letter.size ); return Exceptional.of(new Pitch(new Key(letter, accidental), this.octave + octaveOffset)); } else if (typeof amount === "number") { return Pitch.fromProgramNumber(this.getProgramNumber() + amount, policies); } } public halfStepUp(policies: ImmutablePrioritySet = Policy.DEFAULT_PRIORITY): Exceptional { return Pitch.fromProgramNumber(this.getProgramNumber() + 1, policies); } public halfStepDown(policies: ImmutablePrioritySet = Policy.DEFAULT_PRIORITY): Exceptional { return Pitch.fromProgramNumber(this.getProgramNumber() - 1, policies); } public getHigherPitch(key: Key): Exceptional { // TODO: remove try catch try{ let pitch: Pitch = new Pitch(key, this.octave); // should never loop more than twice while(!pitch.isHigherThan(this)){ pitch = new Pitch(key, pitch.getOctave() + 1); } return Exceptional.of(pitch); } catch(e){ return Exceptional.empty(); } } public getLowerPitch(key: Key): Exceptional { // TODO: remove try catch try{ let pitch: Pitch = new Pitch(key, this.octave); // should never loop more than twice while(!pitch.isLowerThan(this)){ pitch = new Pitch(key, pitch.getOctave() - 1); } return Exceptional.of(pitch); } catch(e){ return Exceptional.empty(); } } public isHigherThan(pitch: Pitch): boolean { return this.getProgramNumber() > pitch.getProgramNumber(); } public isLowerThan(pitch: Pitch): boolean { return this.getProgramNumber() < pitch.getProgramNumber(); } public compareTo(pitch: Pitch): number { return MathUtil.compare(this.getProgramNumber(), pitch.getProgramNumber()); } public static isEnharmonic(a: Pitch, b: Pitch): boolean { return a.getProgramNumber() === b.getProgramNumber(); } public static fromProgramNumber(programNumber: number, policies: ImmutablePrioritySet = Policy.DEFAULT_PRIORITY): Exceptional { return EtudeParser .of(programNumber) .filter(p => MusicConstants.SMALLEST_PROGRAM_NUMBER <= p && p <= MusicConstants.LARGEST_PROGRAM_NUMBER, EtudeError.forInvalid(Pitch, programNumber, "out of range")) .parse(p => { const key: Exceptional = Key.fromOffset(MathUtil.floorMod(p, MusicConstants.KEYS_IN_OCTAVE), policies); if (!key.isPresent()) { return Exceptional.empty(key.getException()); } const actualKey: Key = key.get(); let octave: number = Math.trunc(p / MusicConstants.KEYS_IN_OCTAVE); /** * Key offsets are bounded by the range [0, MusicConstants.KEYS_IN_OCTAVE) whereas program numbers go across octave boundaries. * If [actual key offset] is equal to [offset after normalizing], then octave is not changed. * If [actual key offset] is lower than [offset after normalizing], then octave is raised by 1. * If [actual key offset] is higher than [offset after normalizing], then octave is lowered by 1. */ octave += (actualKey.getOffset() - (actualKey.getLetter().getOffset() + actualKey.getAccidental().map(a => a.getOffset()).orElse(0))) / MusicConstants.KEYS_IN_OCTAVE; return Exceptional.of(new Pitch(actualKey, octave)); }) .get(a => a[0] as Pitch); } public getProgramNumber(): number { return this.octave * MusicConstants.KEYS_IN_OCTAVE + this.key.getLetter().getOffset() + this.key.getAccidental().map(a => a.getOffset()).orElse(0); } /** * Any input in the form * - ${key}${octave} * - ${key}${octave}(${program number}) * is accepted and converted into a Pitch. * ${program number} is intentionally not accepted because #fromProgramNumber * exists and should be used instead. */ public static fromString(pitchString: string): Exceptional { return EtudeParser .of(pitchString) .filter(o => o != null, EtudeError.forNull(Pitch)) .parse(s => { // longest prefix that contains only letters or # const keyString: string = s.match(/^[a-zA-Z#]*/g)[0]; return Key.fromString(keyString); }) .parse(s => Exceptional // first number of length greater than 0 thats followed by an open parentheses (if there is any) .of(s.match("\\d+(?![^(]*\\))")[0], EtudeError.forInvalid(Pitch, pitchString, "doesn't contain a valid octave")) .map(parseInt) ) .get(a => new Pitch(a[0] as Key, a[1] as number)) .filter(p => { // a number that has an open parentheses somewhere before it const programNumber: string[] = pitchString.match(/\((\d+)/g); return programNumber == null || p.getProgramNumber() == parseInt(programNumber[0].substring(0)); }, EtudeError.forInvalid(Pitch, pitchString, "program number doesn't match key and octave")) .filter(p => { let converted: string = p.toString(); const programNumber: string[] = pitchString.match(/\((\d+)/g); if (programNumber == null) { converted = converted.substring(0, converted.indexOf("(")); } return converted.localeCompare(pitchString) === 0; }, EtudeError.forInvalid(Pitch, pitchString)); } public toString(): string { return this.key.toString() + this.octave + "(" + this.getProgramNumber() + ")"; } public equals(other: any): boolean { if (!(other instanceof Pitch)) { return false; } if (other === this) { return true; } let otherPitch = other as Pitch; return this.key.equals(otherPitch.getKey()) && this.octave === otherPitch.getOctave(); } public getKey(): Key { return this.key; } public getOctave(): number { return this.octave; } }