src/key.ts
import {
ChordType,
FLAT,
MAJOR,
MINOR,
Modifier,
ModifierMaybe,
NO_MODIFIER,
NUMERAL,
NUMERIC,
ROMAN_NUMERALS,
SHARP,
SOLFEGE,
SYMBOL,
} from './constants';
import { KEY_TO_GRADE } from './scales';
import ENHARMONIC_MAPPING from './normalize_mappings/enharmonic-normalize';
import { gradeToKey } from './utilities';
const regexes: Record<ChordType, RegExp> = {
symbol: /^(?<key>((?<note>[A-Ga-g])(?<modifier>#|b)?))(?<minor>m)?$/,
solfege: /^(?<key>((?<note>Do|Re|Mi|Fa|Sol|La|Si|do|re|mi|fa|sol|la|si)(?<modifier>#|b)?))(?<minor>m)?$/,
numeric: /^(?<key>(?<modifier>#|b)?(?<note>[1-7]))(?<minor>m)?$/,
numeral: /^(?<key>(?<modifier>#|b)?(?<note>I{1,3}|IV|VI{0,2}|i{1,3}|iv|vi{0,2}))$/,
};
interface KeyProperties {
grade?: number | null;
number?: number | null;
type?: ChordType;
minor?: boolean;
modifier?: Modifier | null;
referenceKeyGrade?: number | null;
preferredModifier?: Modifier | null,
}
const KEY_TYPES: ChordType[] = [SYMBOL, SOLFEGE, NUMERIC, NUMERAL];
const NATURAL_MINORS = [1, 2, 3, 4, 5, 8, 9, 10];
const NO_FLAT_GRADES = [4, 11];
const NO_FLAT_NUMBERS = [1, 4];
const NO_SHARP_GRADES = [5, 0];
const NO_SHARP_NUMBERS = [3, 7];
/**
* Represents a key, such as Eb (symbol), #3 (numeric) or VII (numeral).
*
* The only function considered public API is `Key.distance`
*/
class Key implements KeyProperties {
grade: number | null;
number: number | null = null;
modifier: Modifier | null;
type: ChordType;
get unicodeModifier(): string | null {
switch (this.modifier) {
case FLAT:
return '\u266d';
case SHARP:
return '\u266f';
default:
return null;
}
}
minor = false;
referenceKeyGrade: number | null = null;
originalKeyString: string | null = null;
preferredModifier: Modifier | null;
static parse(keyString: string | null): null | Key {
if (!keyString) return null;
const trimmed = keyString.trim();
if (!trimmed) return null;
for (let i = 0, count = KEY_TYPES.length; i < count; i += 1) {
const resolvedKey = this.parseAsType(trimmed, KEY_TYPES[i]);
if (resolvedKey) return resolvedKey;
}
return null;
}
static parseAsType(trimmed: string, keyType: ChordType) {
const match = trimmed.match(regexes[keyType]);
if (!match) return null;
const { minor, note, modifier } = match.groups as { minor?: string, note: string, modifier?: Modifier };
return this.resolve({
key: note,
keyType,
minor: minor || false,
modifier: modifier || null,
});
}
static resolve(
{
key,
keyType,
minor,
modifier,
}: {
key: string | number,
keyType: ChordType,
minor: string | boolean,
modifier: Modifier | null,
},
): Key | null {
const keyString = `${key}`;
const isMinor = this.isMinor(keyString, keyType, minor);
if (keyType === SYMBOL || keyType === SOLFEGE) {
const grade = this.toGrade(keyString, modifier || NO_MODIFIER, keyType, isMinor);
if (grade !== null) {
return new Key({
grade: 0,
minor: isMinor,
type: keyType,
modifier: modifier || null,
preferredModifier: modifier || null,
referenceKeyGrade: grade,
originalKeyString: keyString,
});
}
}
const number = this.getNumberFromKey(keyString, keyType);
return new Key({
number,
minor: isMinor,
type: keyType,
modifier: modifier || null,
preferredModifier: modifier || null,
originalKeyString: keyString,
});
}
static getNumberFromKey(keyString: string, keyType: ChordType) {
if (keyType === NUMERIC) {
return parseInt(keyString, 10);
}
const uppercaseKey = keyString.toUpperCase();
return ROMAN_NUMERALS.findIndex((numeral) => uppercaseKey === numeral) + 1;
}
static keyWithModifier(key: string, modifier: Modifier | null, type: ChordType): string {
const normalizedKey = key.toUpperCase();
const modifierString = modifier || '';
if (type === SOLFEGE) {
return `${key.charAt(0).toUpperCase() + key.slice(1).toLowerCase()}${modifierString}`;
}
if (type === SYMBOL) {
return `${normalizedKey}${modifierString}`;
}
return `${modifierString}${normalizedKey}`;
}
static toGrade(key: string, modifier: ModifierMaybe, type: ChordType, isMinor: boolean): number | null {
const mode = (isMinor ? MINOR : MAJOR);
const grades = KEY_TO_GRADE[type][mode][modifier];
if (key in grades) {
return grades[key];
}
const upperCaseKey = key.toUpperCase();
if (upperCaseKey in grades) {
return grades[upperCaseKey];
}
return null;
}
static isMinor(key: string, keyType: ChordType, minor: string | undefined | boolean) {
switch (keyType) {
case 'numeral':
return key.toLowerCase() === key;
default:
switch (typeof minor) {
case 'string':
return minor === 'm' || minor.toLowerCase() === 'min';
case 'boolean':
return minor;
default:
return false;
}
}
}
static parseOrFail(keyString: string | null): Key {
const parsed = this.parse(keyString);
if (!parsed) throw new Error(`Failed to parse ${keyString}`);
return parsed;
}
static wrap(keyStringOrObject: Key | string | null): Key | null {
if (keyStringOrObject instanceof Key) return keyStringOrObject;
if (keyStringOrObject === null) return null;
return this.parse(keyStringOrObject);
}
static wrapOrFail(keyStringOrObject: Key | string | null = null): Key {
if (keyStringOrObject === null) throw new Error('Unexpected null key');
const wrapped = this.wrap(keyStringOrObject);
if (wrapped === null) throw new Error(`Failed: invalid key ${keyStringOrObject}`);
return wrapped;
}
static toString(keyStringOrObject: Key | string) {
return `${Key.wrapOrFail(keyStringOrObject)}`;
}
/**
* Calculates the distance in semitones between one key and another.
* @param {Key | string} oneKey the key
* @param {Key | string} otherKey the other key
* @return {number} the distance in semitones
*/
static distance(oneKey: Key | string, otherKey: Key | string): number {
return this.wrapOrFail(oneKey).distanceTo(otherKey);
}
constructor(
{
grade = null,
number = null,
minor,
type,
modifier,
referenceKeyGrade = null,
originalKeyString = null,
preferredModifier = null,
}: {
grade?: number | null,
number?: number | null,
minor: boolean,
type: ChordType,
modifier: Modifier | null,
referenceKeyGrade?: number | null,
originalKeyString?: string | null,
preferredModifier: Modifier | null,
},
) {
this.grade = grade;
this.number = number;
this.minor = minor;
this.type = type;
this.modifier = modifier;
this.preferredModifier = preferredModifier;
this.referenceKeyGrade = referenceKeyGrade;
this.originalKeyString = originalKeyString;
}
distanceTo(otherKey: Key | string): number {
const otherKeyObj = Key.wrapOrFail(otherKey);
return Key.shiftGrade(otherKeyObj.effectiveGrade - this.effectiveGrade);
}
get effectiveGrade(): number {
if (this.grade === null) {
throw new Error('Cannot calculate effectiveGrade without a grade');
}
return Key.shiftGrade(this.grade + (this.referenceKeyGrade || 0));
}
isMinor(): boolean {
return this.minor;
}
makeMinor(): Key {
return this.set({ minor: true });
}
get relativeMajor(): Key {
return this.changeGrade(+3).set({ minor: false });
}
get relativeMinor(): Key {
return this.changeGrade(-3).set({ minor: true });
}
toMajor(): Key {
if (this.isMinor()) {
return this.transpose(3).set({ minor: false });
}
return this.clone();
}
clone(): Key {
return this.set({});
}
private ensureGrade() {
if (this.grade === null) {
this.calculateGradeFromNumber();
}
}
private calculateGradeFromNumber() {
if (this.number === null) {
throw new Error('Cannot calculate grade, number is null');
}
this.grade = Key.toGrade(
this.number.toString(),
this.modifier || NO_MODIFIER,
NUMERIC,
this.isMinor(),
);
this.number = null;
}
toChordSymbol(key: Key | string): Key {
if (this.isChordSymbol()) return this.clone();
const { modifier } = this;
this.ensureGrade();
const keyObj = Key.wrapOrFail(key);
const chordSymbol = this.set({
referenceKeyGrade: Key.shiftGrade(this.effectiveGrade + keyObj.effectiveGrade),
grade: 0,
type: SYMBOL,
modifier: null,
preferredModifier: modifier || keyObj.modifier,
});
const normalized = chordSymbol.normalizeEnharmonics(keyObj);
return modifier ? normalized.set({ preferredModifier: modifier, modifier: null }) : normalized;
}
toChordSolfege(key: Key | string): Key {
if (this.isChordSolfege()) return this.clone();
const { modifier } = this;
this.ensureGrade();
const keyObj = Key.wrapOrFail(key);
const chordSolfege = this.set({
referenceKeyGrade: Key.shiftGrade(this.effectiveGrade + keyObj.effectiveGrade),
grade: 0,
type: SOLFEGE,
modifier: null,
preferredModifier: modifier || keyObj.modifier,
});
const normalized = chordSolfege.normalizeEnharmonics(keyObj);
return modifier ? normalized.set({ preferredModifier: modifier, modifier: null }) : normalized;
}
toChordSymbolString(key: Key): string {
return this.toChordSymbol(key).toString();
}
toChordSolfegeString(key: Key): string {
return this.toChordSolfege(key).toString();
}
is(type: ChordType): boolean {
return this.type === type;
}
isNumeric(): boolean {
return this.is(NUMERIC);
}
isChordSymbol(): boolean {
return this.is(SYMBOL);
}
isChordSolfege(): boolean {
return this.is(SOLFEGE);
}
isNumeral(): boolean {
return this.is(NUMERAL);
}
equals(otherKey: Key): boolean {
return this.grade === otherKey.grade &&
this.number === otherKey.number &&
this.modifier === otherKey.modifier &&
this.preferredModifier === otherKey.preferredModifier &&
this.type === otherKey.type &&
this.minor === otherKey.minor;
}
static equals(oneKey: Key | null, otherKey: Key | null) {
if (oneKey === null) {
return otherKey === null;
}
if (otherKey === null) {
return false;
}
return oneKey.equals(otherKey);
}
toNumeric(key: Key | string | null = null): Key {
if (this.isNumeric()) {
return this.clone();
}
if (this.isNumeral()) {
return this.set({ type: NUMERIC });
}
const referenceKey = Key.wrapOrFail(key);
const referenceKeyGrade = referenceKey.effectiveGrade;
return this.set({
type: NUMERIC,
grade: Key.shiftGrade(this.effectiveGrade - referenceKeyGrade),
referenceKeyGrade: 0,
modifier: null,
preferredModifier: referenceKey.modifier,
});
}
toNumericString(key: Key | null = null): string {
return this.toNumeric(key).toString();
}
toNumeral(key: Key | string | null = null): Key {
if (this.isNumeral()) {
return this.clone();
}
if (this.isNumeric()) {
return this.set({ type: NUMERAL });
}
const referenceKey = Key.wrapOrFail(key);
const referenceKeyGrade = referenceKey.effectiveGrade;
return this.set({
type: NUMERAL,
grade: Key.shiftGrade(this.effectiveGrade - referenceKeyGrade),
referenceKeyGrade: 0,
modifier: null,
preferredModifier: referenceKey.modifier || this.modifier,
});
}
toNumeralString(key: Key | null = null): string {
return this.toNumeral(key).toString();
}
toString({ showMinor = true, useUnicodeModifier = false } = {}): string {
let { note } = this;
if (useUnicodeModifier) {
note = note.replace('#', '\u266f').replace('b', '\u266d');
}
return `${note}${showMinor ? this.minorSign : ''}`;
}
get note(): string {
if (this.grade === null) {
return this.getNoteForNumber();
}
if ((this.isChordSymbol() || this.isChordSolfege()) && this.referenceKeyGrade === null) {
throw new Error('Not possible, reference key grade is null');
}
return gradeToKey({
type: this.type,
modifier: this.modifier,
preferredModifier: this.preferredModifier,
grade: this.effectiveGrade,
minor: this.minor,
});
}
private getNoteForNumber() {
if (this.number === null) throw new Error('Not possible, grade and number are null');
if (this.isNumeric()) {
return `${this.modifier || ''}${this.number}`;
}
const numeral = ROMAN_NUMERALS[this.number - 1];
return `${this.modifier || ''}${this.isMinor() ? numeral.toLowerCase() : numeral}`;
}
get minorSign() {
if (!this.minor) return '';
switch (this.type) {
case SYMBOL:
return 'm';
case SOLFEGE:
return 'm';
case NUMERIC:
return this.isNaturalMinor() ? '' : 'm';
default:
return '';
}
}
private isNaturalMinor() {
this.ensureGrade();
if (!this.grade) {
throw new Error('Expected grade to be set, but it is is still empty.');
}
return NATURAL_MINORS.includes(this.grade);
}
transpose(delta: number): Key {
if (delta === 0) return this;
const originalModifier = this.modifier;
let transposedKey = this.clone();
const func = (delta < 0) ? 'transposeDown' : 'transposeUp';
for (let i = 0, count = Math.abs(delta); i < count; i += 1) {
transposedKey = transposedKey[func]();
}
return transposedKey.useModifier(originalModifier);
}
changeGrade(delta) {
if (this.referenceKeyGrade) {
return this.set({ referenceKeyGrade: Key.shiftGrade(this.referenceKeyGrade + delta) });
}
this.ensureGrade();
return this.set({ grade: Key.shiftGrade(this.grade + delta) });
}
transposeUp(): Key {
const normalizedKey = this.normalize();
let key: Key = normalizedKey.changeGrade(+1);
if (this.modifier || !key.canBeSharp()) {
key = key.useModifier(null);
} else if (key.canBeSharp()) {
key = key.useModifier(SHARP);
}
key = key.set({ preferredModifier: SHARP }).normalize();
return key;
}
transposeDown(): Key {
const normalizedKey = this.normalize();
let key: Key = normalizedKey.changeGrade(-1);
if (this.modifier || !key.canBeFlat()) {
key = key.useModifier(null);
} else if (key.canBeFlat()) {
key = key.useModifier(FLAT);
}
return key.set({ preferredModifier: FLAT });
}
canBeFlat() {
if (this.number !== null) {
return !NO_FLAT_NUMBERS.includes(this.number);
}
return !NO_FLAT_GRADES.includes(this.effectiveGrade);
}
canBeSharp() {
if (this.number !== null) {
return !NO_SHARP_NUMBERS.includes(this.number);
}
return !NO_SHARP_GRADES.includes(this.effectiveGrade);
}
setGrade(newGrade: number): Key {
return this.set({
grade: Key.shiftGrade(newGrade),
});
}
static shiftGrade(grade: number) {
if (grade < 0) {
return this.shiftGrade(grade + 12);
}
return grade % 12;
}
useModifier(newModifier: Modifier | null): Key {
this.ensureGrade();
return this.set({ modifier: newModifier });
}
normalize(): Key {
this.ensureGrade();
if (this.modifier === SHARP && !this.canBeSharp()) {
return this.set({ modifier: null });
}
if (this.modifier === FLAT && !this.canBeFlat()) {
return this.set({ modifier: null });
}
return this.clone();
}
normalizeEnharmonics(key: Key | string | null): Key {
if (key) {
const rootKeyString = Key.wrapOrFail(key).toString({ showMinor: true });
const enharmonics = ENHARMONIC_MAPPING[rootKeyString];
const thisKeyString = this.toString({ showMinor: false });
if (enharmonics && enharmonics[thisKeyString]) {
return Key
.parseOrFail(enharmonics[thisKeyString])
.set({ minor: this.minor });
}
}
return this.clone();
}
private set(attributes: KeyProperties, overwrite = true): Key {
return new Key({
...(overwrite ? {} : attributes),
grade: this.grade,
number: this.number,
type: this.type,
modifier: this.modifier,
minor: this.minor,
referenceKeyGrade: this.referenceKeyGrade,
originalKeyString: this.originalKeyString,
preferredModifier: this.preferredModifier,
...(overwrite ? attributes : {}),
});
}
}
export default Key;