import AssertionError from "../error/AssertionError"; import EtudeError from "../error/EtudeError"; import EtudeParser from "../util/EtudeParser"; import Exceptional from "../util/Exceptional"; import MathUtil from "../util/MathUtil"; import Letter from "./Letter"; import MusicConstants from "./MusicConstants"; import Pitch from "./Pitch"; import Scale from "./Scale"; class Interval { constructor(private quality: Interval.Quality, private distance: number) { if (distance <= 0) { throw EtudeError.forInvalid(Interval, distance, "should be a positive integer"); } switch (quality) { case Interval.Quality.PERFECT: if (!Interval.isPerfect(distance)) { throw EtudeError.forInvalid(Interval, quality.toString() + distance, distance + " cannot have a perfect quality"); } break; case Interval.Quality.MAJOR: case Interval.Quality.MINOR: if (Interval.isPerfect(distance)) { throw EtudeError.forInvalid(Interval, quality.toString() + distance, distance + " cannot have major or minor quality"); } break; case Interval.Quality.DIMINISHED: case Interval.Quality.DOUBLY_DIMINISHED: case Interval.Quality.AUGMENTED: case Interval.Quality.DOUBLY_AUGMENTED: break; } } public static isEnharmonic(a: Interval, b: Interval): boolean { return a.getOffset() === b.getOffset(); } public static between(a: Pitch, b: Pitch): Exceptional { const letterA = a.getKey().getLetter(); const letterB = b.getKey().getLetter(); /** * 1 (because no distance == 1) * + letterDistance (subtracted 2 because C is the start of the octave) * + octaveDistance */ const distance = 1 + (MathUtil.floorMod(letterB.ordinal() - 2, Letter.size) - MathUtil.floorMod(letterA.ordinal() - 2, Letter.size)) + (b.getOctave() - a.getOctave()) * Letter.size; if (distance <= 0) { return Exceptional.empty(EtudeError.forInvalid(Interval, distance, "cannot create an interval with a nonpositive number")); } let offset = (b.getProgramNumber() - a.getProgramNumber()) % MusicConstants.KEYS_IN_OCTAVE; offset -= Scale.Quality.MAJOR .getStepPattern() .slice(0, (distance - 1) % Letter.size) .reduce(MathUtil.add, 0); let quality; switch (offset) { case -3: if (Interval.isPerfect(distance)) { return Exceptional.empty(EtudeError.forInvalid(Interval, distance, "cannot create a perfect interval with an offset of -3")); } quality = Interval.Quality.DOUBLY_DIMINISHED; break; case -2: quality = Interval.isPerfect(distance) ? Interval.Quality.DOUBLY_DIMINISHED : Interval.Quality.DIMINISHED; break; case -1: quality = Interval.isPerfect(distance) ? Interval.Quality.DIMINISHED : Interval.Quality.MINOR; break; case 0: quality = Interval.isPerfect(distance) ? Interval.Quality.PERFECT : Interval.Quality.MAJOR; break; case 1: quality = Interval.Quality.AUGMENTED; break; case 2: quality = Interval.Quality.DOUBLY_AUGMENTED; break; default: return Exceptional.empty(EtudeError.forInvalid(Interval, offset, "offset is too large")); } return Exceptional.of(new Interval(quality, distance)); } public isSimple(): boolean { return this.distance <= Letter.size + 1; } public isCompound(): boolean { return this.distance > Letter.size + 1; } /** * follows the rules on https://en.wikipedia.org/wiki/Interval_(music)#Inversion * important for the inverse of compound intervals for which definitions online differ */ public inverse(): Interval { let invertedQuality: Interval.Quality; switch (this.quality) { case Interval.Quality.PERFECT: invertedQuality = Interval.Quality.PERFECT; break; case Interval.Quality.MAJOR: invertedQuality = Interval.Quality.MINOR; break; case Interval.Quality.MINOR: invertedQuality = Interval.Quality.MAJOR; break; case Interval.Quality.DIMINISHED: invertedQuality = Interval.Quality.AUGMENTED; break; case Interval.Quality.DOUBLY_DIMINISHED: invertedQuality = Interval.Quality.DOUBLY_AUGMENTED; break; case Interval.Quality.AUGMENTED: invertedQuality = Interval.Quality.DIMINISHED; break; case Interval.Quality.DOUBLY_AUGMENTED: invertedQuality = Interval.Quality.DOUBLY_DIMINISHED; break; default: throw new AssertionError("Interval switch case"); } // inverses add up to 9 assuming number is in the range [1, 8] const invertedNumber = this.distance != 1 && this.distance % Letter.size == 1 ? 1 : 9 - (this.distance % Letter.size); return new Interval(invertedQuality, invertedNumber); } public getOffset(): number { // initialize offset to take into account octave let offset = Math.trunc((this.distance - 1) / Letter.size) * MusicConstants.KEYS_IN_OCTAVE; // take into account normalized number (within the range of an octave) offset += Scale.Quality.MAJOR .getStepPattern() .slice(0, this.distance - 1) .reduce(MathUtil.add, 0); // take into account quality switch (this.quality) { case Interval.Quality.PERFECT: case Interval.Quality.MAJOR: break; case Interval.Quality.MINOR: --offset; break; case Interval.Quality.DIMINISHED: offset -= Interval.isPerfect(this.distance) ? 1 : 2; break; case Interval.Quality.DOUBLY_DIMINISHED: offset -= Interval.isPerfect(this.distance) ? 2 : 3; break; case Interval.Quality.AUGMENTED: ++offset; break; case Interval.Quality.DOUBLY_AUGMENTED: offset += 2; break; } return offset; } public static isPerfect(distance: number): boolean { let normalized = distance % 7; return normalized === 1 || normalized === 4 || normalized === 5; } public static fromString(intervalString: string): Exceptional { return EtudeParser .of(intervalString) .filter(o => o != null, EtudeError.forNull(Interval)) /* * has a non-digit / digit before it and a digit / non-digit after it; * a valid intervalString doesn't require this regex (only needs to detect * non-digit then digit) but in order to provide proper exception messages * for invalid intervalStrings, the current regex is required */ .map(s => s.match(/\D+|\d+/g)) .filter(s => s.length >= 2, EtudeError.forInvalid(Interval, intervalString, "missing information")) .filter(s => s.length <= 2, EtudeError.forInvalid(Interval, intervalString, "contains extra information")) .parse(s => Interval.Quality.fromString(s[0])) .parse(s => Exceptional .of(s[1]) .map(i => i.match("\\d+"), EtudeError.forInvalid(Interval, intervalString, "doesn't contain a valid number")) .map(parseInt) ) .get(a => new Interval(a[0] as Interval.Quality, a[1] as number)); } public toString(): string { return this.quality.toString() + this.distance; } public getQuality(): Interval.Quality { return this.quality; } public getDistance(): number { return this.distance; } } module Interval { export class Quality { public static size: number = 0; private static _values: Quality[] = []; public static PERFECT = new Quality("P"); public static MAJOR = new Quality("M"); public static MINOR = new Quality("m"); public static DIMINISHED = new Quality("d"); public static DOUBLY_DIMINISHED = new Quality("dd"); public static AUGMENTED = new Quality("A"); public static DOUBLY_AUGMENTED = new Quality("AA"); private constructor(private symbol: string) { ++Quality.size; Quality._values.push(this); } public static values(): Quality[] { return Quality._values.slice(); } public ordinal(): number { return Quality._values.indexOf(this); } public static valueOf(qualityString: string): Quality { const quality: Quality = Quality[qualityString]; if (quality instanceof Quality) { return quality; } throw EtudeError.forInvalid(Quality, qualityString); } public static fromString(qualityString: string): Exceptional { return EtudeParser .of(qualityString) .filter(o => o != null, EtudeError.forNull(Quality)) .parse(s => { const value = Quality .values() .find(a => s.localeCompare(a) === 0); return Exceptional .ofNullable(value) .withException(EtudeError.forInvalid(Quality, s)); }) .get(a => a[0] as Quality); } public toString(): string { return this.symbol; } } } export default Interval;