Files
KGStudio/src/util/chordUtil.ts
T
2026-05-28 22:52:58 -07:00

578 lines
14 KiB
TypeScript

import { Chord, Interval, Note } from 'tonal';
export type ChordQuality = 'maj' | 'min' | 'sus2' | 'sus4' | 'power' | 'aug' | 'dim';
export type ChordExtension =
| 'b5'
| '#5'
| '6'
| '7'
| 'dim7'
| 'maj7'
| 'b9'
| '9'
| '#9'
| '11'
| '#11'
| 'b13'
| '13';
export interface ChordDescriptor {
root: string;
quality: ChordQuality;
extensions: ChordExtension[];
symbol: string;
}
const ROOT_PATTERN = /^[A-G](?:#|b)?$/;
const EXTENSION_ORDER: ChordExtension[] = ['b5', '#5', '6', '7', 'dim7', 'maj7', 'b9', '9', '#9', '11', '#11', 'b13', '13'];
const REMAINING_EXTENSION_ORDER: ChordExtension[] = ['b5', '#5', 'b9', '9', '#9', '11', '#11', 'b13', '13'];
const ADD_EXTENSION_ORDER: ChordExtension[] = ['b9', '9', '#9', '11', '#11', 'b13', '13'];
const CUSTOM_TOKENS = [
'maj7#5',
'm7b5',
'dim7',
'sus2',
'sus4',
'aug',
'dim',
'maj7',
'add#11',
'addb13',
'add13',
'add11',
'add#9',
'addb9',
'add9',
'#11',
'b13',
'#9',
'b9',
'9',
'13',
'11',
'#5',
'b5',
'7',
'6',
'm',
'5',
] as const;
function normalizeRoot(root: string): string | null {
const normalized = root.trim();
if (!ROOT_PATTERN.test(normalized)) {
return null;
}
const parsed = Note.get(normalized);
return parsed.empty || parsed.acc.length > 1 ? null : parsed.pc;
}
function sortExtensions(extensions: Iterable<ChordExtension>): ChordExtension[] {
const unique = new Set(extensions);
return EXTENSION_ORDER.filter(extension => unique.has(extension));
}
function hasExtension(descriptor: Pick<ChordDescriptor, 'extensions'>, extension: ChordExtension): boolean {
return descriptor.extensions.includes(extension);
}
function getDescriptorIntervals(descriptor: Pick<ChordDescriptor, 'quality' | 'extensions'>): string[] {
const intervals = ['1P'];
switch (descriptor.quality) {
case 'maj':
intervals.push('3M', '5P');
break;
case 'min':
intervals.push('3m', '5P');
break;
case 'sus2':
intervals.push('2M', '5P');
break;
case 'sus4':
intervals.push('4P', '5P');
break;
case 'power':
intervals.push('5P');
break;
case 'aug':
intervals.push('3M', '5A');
break;
case 'dim':
intervals.push('3m', '5d');
break;
}
for (const extension of descriptor.extensions) {
switch (extension) {
case 'b5':
if (!intervals.includes('5d')) {
intervals.push('5d');
}
break;
case '#5':
if (!intervals.includes('5A')) {
intervals.push('5A');
}
break;
case '6':
intervals.push('6M');
break;
case '7':
intervals.push('7m');
break;
case 'dim7':
intervals.push('7d');
break;
case 'maj7':
intervals.push('7M');
break;
case 'b9':
intervals.push('9m');
break;
case '9':
intervals.push('9M');
break;
case '#9':
intervals.push('9A');
break;
case '11':
intervals.push('11P');
break;
case '#11':
intervals.push('11A');
break;
case 'b13':
intervals.push('13m');
break;
case '13':
intervals.push('13M');
break;
}
}
return Array.from(new Set(intervals));
}
function parseIntervalsToDescriptor(root: string, intervals: string[]): ChordDescriptor | null {
const intervalSet = new Set(intervals);
let quality: ChordQuality | null = null;
if (intervalSet.has('5A')) {
quality = 'aug';
} else if (intervalSet.has('2M') && !intervalSet.has('3m') && !intervalSet.has('3M') && !intervalSet.has('4P') && intervalSet.has('5P')) {
quality = 'sus2';
} else if (intervalSet.has('4P') && !intervalSet.has('3m') && !intervalSet.has('3M') && intervalSet.has('5P')) {
quality = 'sus4';
} else if (intervalSet.has('3m') && intervalSet.has('5d')) {
quality = 'dim';
} else if (intervalSet.has('3m')) {
quality = 'min';
} else if (intervalSet.has('3M')) {
quality = 'maj';
} else if (intervalSet.has('5P')) {
quality = 'power';
}
if (!quality) {
return null;
}
if (intervalSet.has('3A') || intervalSet.has('4d')) {
return null;
}
const extensions = new Set<ChordExtension>();
if (intervalSet.has('5d')) {
extensions.add('b5');
}
if (intervalSet.has('5A')) {
extensions.add('#5');
}
if (intervalSet.has('6M')) {
extensions.add('6');
}
if (intervalSet.has('7m')) {
extensions.add('7');
}
if (intervalSet.has('7d')) {
extensions.add('dim7');
}
if (intervalSet.has('7M')) {
extensions.add('maj7');
}
if (intervalSet.has('9m') || intervalSet.has('2m')) {
extensions.add('b9');
}
if (intervalSet.has('9M') || (intervalSet.has('2M') && quality !== 'sus2')) {
extensions.add('9');
}
if (intervalSet.has('9A')) {
extensions.add('#9');
}
if (intervalSet.has('11P') && quality !== 'sus4') {
extensions.add('11');
}
if (intervalSet.has('11A')) {
extensions.add('#11');
}
if (intervalSet.has('13m') || intervalSet.has('6m')) {
extensions.add('b13');
}
if (intervalSet.has('13M')) {
extensions.add('13');
}
const descriptor: ChordDescriptor = {
root,
quality,
extensions: sortExtensions(extensions),
symbol: '',
};
const symbol = buildChordSymbol(descriptor);
if (!symbol) {
return null;
}
return {
...descriptor,
symbol,
};
}
function parseCustomChordSymbol(symbol: string): ChordDescriptor | null {
const trimmed = symbol.trim();
const rootMatch = trimmed.match(/^([A-G](?:#|b)?)(.*)$/);
if (!rootMatch) {
return null;
}
const root = normalizeRoot(rootMatch[1]);
if (!root) {
return null;
}
let remainder = rootMatch[2];
let quality: ChordQuality = 'maj';
const extensions = new Set<ChordExtension>();
if (remainder.startsWith('m7b5')) {
quality = 'dim';
extensions.add('b5');
extensions.add('7');
remainder = remainder.slice(4);
} else if (remainder.startsWith('dim7')) {
quality = 'dim';
extensions.add('b5');
extensions.add('dim7');
remainder = remainder.slice(4);
} else if (remainder.startsWith('maj7#5')) {
quality = 'aug';
extensions.add('#5');
extensions.add('maj7');
remainder = remainder.slice(6);
} else if (remainder.startsWith('7#5')) {
quality = 'aug';
extensions.add('#5');
extensions.add('7');
remainder = remainder.slice(3);
} else if (remainder.startsWith('sus2')) {
quality = 'sus2';
remainder = remainder.slice(4);
} else if (remainder.startsWith('sus4')) {
quality = 'sus4';
remainder = remainder.slice(4);
} else if (remainder.startsWith('aug')) {
quality = 'aug';
remainder = remainder.slice(3);
} else if (remainder.startsWith('dim')) {
quality = 'dim';
remainder = remainder.slice(3);
} else if (remainder.startsWith('m')) {
quality = 'min';
remainder = remainder.slice(1);
} else if (remainder.startsWith('5')) {
quality = 'power';
remainder = remainder.slice(1);
}
if (remainder.startsWith('maj7')) {
extensions.add('maj7');
remainder = remainder.slice(4);
} else if (remainder.startsWith('7')) {
extensions.add('7');
remainder = remainder.slice(1);
} else if (remainder.startsWith('6')) {
extensions.add('6');
remainder = remainder.slice(1);
}
while (remainder.length > 0) {
const nextToken = CUSTOM_TOKENS.find(token => remainder.startsWith(token));
if (!nextToken) {
return null;
}
switch (nextToken) {
case 'b5':
extensions.add('b5');
break;
case '#5':
extensions.add('#5');
break;
case 'b9':
case 'addb9':
extensions.add('b9');
break;
case '9':
case 'add9':
extensions.add('9');
break;
case '#9':
case 'add#9':
extensions.add('#9');
break;
case '11':
case 'add11':
extensions.add('11');
break;
case '#11':
case 'add#11':
extensions.add('#11');
break;
case 'b13':
case 'addb13':
extensions.add('b13');
break;
case '13':
case 'add13':
extensions.add('13');
break;
default:
return null;
}
remainder = remainder.slice(nextToken.length);
}
const descriptor: ChordDescriptor = {
root,
quality,
extensions: sortExtensions(extensions),
symbol: '',
};
const canonical = buildChordSymbol(descriptor);
if (!canonical) {
return null;
}
return {
...descriptor,
symbol: canonical,
};
}
function tokenizeTonalIntervals(intervals: string[]): string[] | null {
const normalized = intervals
.map(interval => interval.replace(/^(\d+)([PmMdA])$/, '$1$2').replace(/^(\d+)([dm])$/, '$1$2'))
.filter(Boolean);
if (normalized.length === 0) {
return null;
}
return normalized;
}
export function buildChordSymbol(descriptor: Pick<ChordDescriptor, 'root' | 'quality' | 'extensions'>): string | null {
const root = normalizeRoot(descriptor.root);
if (!root) {
return null;
}
const extensions = sortExtensions(descriptor.extensions);
const has = (extension: ChordExtension) => extensions.includes(extension);
const hasSeventh = has('7') || has('dim7') || has('maj7');
const remainingExtensions = new Set(extensions);
let symbol = root;
if (descriptor.quality === 'dim' && has('dim7')) {
symbol += 'dim7';
remainingExtensions.delete('dim7');
remainingExtensions.delete('b5');
} else if (descriptor.quality === 'dim' && has('7')) {
symbol += 'm7b5';
remainingExtensions.delete('7');
remainingExtensions.delete('b5');
} else if (descriptor.quality === 'aug' && has('7')) {
symbol += '7#5';
remainingExtensions.delete('7');
remainingExtensions.delete('#5');
} else if (descriptor.quality === 'aug' && has('maj7')) {
symbol += 'maj7#5';
remainingExtensions.delete('maj7');
remainingExtensions.delete('#5');
} else {
switch (descriptor.quality) {
case 'maj':
break;
case 'min':
symbol += 'm';
break;
case 'sus2':
symbol += 'sus2';
break;
case 'sus4':
symbol += 'sus4';
break;
case 'power':
symbol += '5';
break;
case 'aug':
symbol += 'aug';
remainingExtensions.delete('#5');
break;
case 'dim':
symbol += 'dim';
remainingExtensions.delete('b5');
break;
}
if (has('dim7')) {
symbol += 'dim7';
remainingExtensions.delete('dim7');
} else if (has('maj7')) {
symbol += 'maj7';
remainingExtensions.delete('maj7');
} else if (has('7')) {
symbol += '7';
remainingExtensions.delete('7');
} else if (has('6')) {
symbol += '6';
remainingExtensions.delete('6');
}
}
for (const extension of REMAINING_EXTENSION_ORDER) {
if (!remainingExtensions.has(extension)) {
continue;
}
if (ADD_EXTENSION_ORDER.includes(extension) && !hasSeventh) {
symbol += `add${extension}`;
} else {
symbol += extension;
}
}
return symbol;
}
export function parseChordSymbol(symbol: string): ChordDescriptor | null {
const trimmed = symbol.trim();
if (!trimmed) {
return null;
}
const tonalChord = Chord.get(trimmed);
if (tonalChord && !tonalChord.empty && tonalChord.tonic && !tonalChord.bass && !tonalChord.root) {
const root = normalizeRoot(tonalChord.tonic);
const intervals = tokenizeTonalIntervals(tonalChord.intervals);
if (root && intervals) {
const parsed = parseIntervalsToDescriptor(root, intervals);
if (parsed) {
return parsed;
}
}
}
return parseCustomChordSymbol(trimmed);
}
export function getChordPitchClasses(symbol: string): number[] {
const descriptor = parseChordSymbol(symbol);
if (!descriptor) {
return [];
}
const root = Note.get(descriptor.root);
if (root.empty || root.chroma === undefined) {
return [];
}
return getDescriptorIntervals(descriptor).map((interval) => {
const semitones = Interval.semitones(interval);
return (root.chroma + semitones + 120) % 12;
});
}
export function getChordMidiPitches(symbol: string, rootMidi: number): number[] {
const descriptor = parseChordSymbol(symbol);
if (!descriptor) {
return [];
}
return getDescriptorIntervals(descriptor).map((interval) => rootMidi + Interval.semitones(interval));
}
export function formatChordSymbolForDisplay(symbol: string): string {
const descriptor = parseChordSymbol(symbol);
if (!descriptor) {
return symbol;
}
const { root, quality, extensions } = descriptor;
const accidentalDisplay = (value: string) => value.replace(/b/g, '♭').replace(/#/g, '♯');
if (quality === 'dim' && extensions.includes('dim7')) {
return `${accidentalDisplay(root)}dim7`;
}
const baseQuality = (() => {
switch (quality) {
case 'maj':
return '';
case 'min':
return 'm';
case 'sus2':
return 'sus2';
case 'sus4':
return 'sus4';
case 'power':
return '5';
case 'aug':
return 'aug';
case 'dim':
return extensions.includes('dim7') || !extensions.includes('7') ? 'dim' : 'm';
}
})();
const inlineExtensions: string[] = [];
const parentheticalExtensions: string[] = [];
for (const extension of extensions) {
if (quality === 'dim' && extension === 'b5' && !extensions.includes('7')) {
continue;
}
if (extension === '7' || extension === 'dim7' || extension === 'maj7' || extension === '6') {
inlineExtensions.push(extension);
continue;
}
parentheticalExtensions.push(accidentalDisplay(extension));
}
const inlineText = inlineExtensions.join('');
const parentheticalText = parentheticalExtensions.length > 0
? `(${parentheticalExtensions.join(', ')})`
: '';
return `${accidentalDisplay(root)}${baseQuality}${inlineText}${parentheticalText}`;
}
export const CHORD_QUALITY_OPTIONS: ChordQuality[] = ['maj', 'min', 'sus2', 'sus4', 'power', 'aug', 'dim'];
export const CHORD_EXTENSION_OPTIONS: ChordExtension[] = EXTENSION_ORDER;