refactor: chord guide system
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { Note } from 'tonal';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
import { getMatchingChordGuideChordsForPitch, resolveChordGuideItems } from './chordGuideDataUtil';
|
||||
import { getChordPitchClasses, parseChordSymbol } from './chordUtil';
|
||||
import chordGuideData from '../../public/resources/modes/chord_guide.json';
|
||||
|
||||
function getExpectedPitchClassesFromNotes(notes: string[]): number[] {
|
||||
return notes.map((note) => {
|
||||
const parsed = Note.get(note);
|
||||
if (parsed.empty || parsed.chroma === undefined) {
|
||||
throw new Error(`Unable to convert note "${note}" to pitch class`);
|
||||
}
|
||||
return parsed.chroma;
|
||||
});
|
||||
}
|
||||
|
||||
describe('chordGuideDataUtil', () => {
|
||||
beforeEach(() => {
|
||||
KGCore.CHORD_GUIDE_DATA = chordGuideData;
|
||||
KGCore.FUNCTIONAL_CHORDS_DATA = {
|
||||
ionian: { name: 'Broken', steps: [2, 2, 2, 2, 2, 1, 1], T: [], S: [], D: [], chords: {} },
|
||||
};
|
||||
});
|
||||
|
||||
it('parses every chord symbol used by chord_guide.json', () => {
|
||||
const allItems = [
|
||||
...chordGuideData.ionian.T,
|
||||
...chordGuideData.ionian.S,
|
||||
...chordGuideData.ionian.D,
|
||||
...chordGuideData.aeolian.T,
|
||||
...chordGuideData.aeolian.S,
|
||||
...chordGuideData.aeolian.D,
|
||||
];
|
||||
|
||||
for (const item of allItems) {
|
||||
const parsed = parseChordSymbol(item.name);
|
||||
expect(parsed, item.name).not.toBeNull();
|
||||
expect(parsed?.symbol).toBe(item.name);
|
||||
}
|
||||
});
|
||||
|
||||
it('maps every chord guide symbol to the corresponding notes declared in chord_guide.json', () => {
|
||||
const allItems = [
|
||||
...chordGuideData.ionian.T,
|
||||
...chordGuideData.ionian.S,
|
||||
...chordGuideData.ionian.D,
|
||||
...chordGuideData.aeolian.T,
|
||||
...chordGuideData.aeolian.S,
|
||||
...chordGuideData.aeolian.D,
|
||||
];
|
||||
|
||||
for (const item of allItems) {
|
||||
const actualPitchClasses = getChordPitchClasses(item.name).map((pitch) => ((pitch % 12) + 12) % 12);
|
||||
const expectedPitchClasses = getExpectedPitchClassesFromNotes(item.notes);
|
||||
|
||||
expect(
|
||||
actualPitchClasses,
|
||||
`${item.name} should resolve to ${item.notes.join(' ')}`
|
||||
).toEqual(expectedPitchClasses);
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves ionian tonic chords in C major from the new chord guide data', () => {
|
||||
const result = resolveChordGuideItems('C major', 'ionian', 'T');
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
name: 'C',
|
||||
roman: 'I',
|
||||
notes: ['C', 'E', 'G'],
|
||||
resolvedNotes: ['C', 'E', 'G'],
|
||||
pitchClasses: [0, 4, 7],
|
||||
});
|
||||
expect(result.find((item) => item.name === 'Am')?.pitchClasses).toEqual([9, 12, 16]);
|
||||
});
|
||||
|
||||
it('transposes ionian tonic chords away from C major', () => {
|
||||
const result = resolveChordGuideItems('D major', 'ionian', 'T');
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
name: 'C',
|
||||
resolvedNotes: ['D', 'F#', 'A'],
|
||||
pitchClasses: [2, 6, 9],
|
||||
});
|
||||
expect(result.find((item) => item.name === 'Am')?.resolvedNotes).toEqual(['B', 'D', 'F#']);
|
||||
});
|
||||
|
||||
it('uses A minor as the aeolian reference tonic', () => {
|
||||
const result = resolveChordGuideItems('A minor', 'aeolian', 'T');
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
name: 'Am',
|
||||
resolvedNotes: ['A', 'C', 'E'],
|
||||
pitchClasses: [9, 12, 16],
|
||||
});
|
||||
expect(result.find((item) => item.name === 'Amadd9')?.resolvedNotes).toEqual(['A', 'C', 'E', 'B']);
|
||||
});
|
||||
|
||||
it('transposes aeolian tonic chords away from A minor', () => {
|
||||
const result = resolveChordGuideItems('E minor', 'aeolian', 'T');
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
name: 'Am',
|
||||
resolvedNotes: ['E', 'G', 'B'],
|
||||
pitchClasses: [4, 7, 11],
|
||||
});
|
||||
expect(result.find((item) => item.name === 'C')?.resolvedNotes).toEqual(['G', 'B', 'D']);
|
||||
});
|
||||
|
||||
it('matches hover chords without depending on functional chord config', () => {
|
||||
const result = getMatchingChordGuideChordsForPitch(60, 'C major', 'ionian', 'T');
|
||||
|
||||
expect(result[0]).toEqual([0, 4, 7]);
|
||||
expect(result.some((chord) => chord.includes(0))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { KGCore } from '../core/KGCore';
|
||||
import type { KeySignature } from '../core/KGProject';
|
||||
import type { ChordGuideData, ChordGuideItem, ResolvedChordGuideItem } from '../core/ChordGuideTypes';
|
||||
import { getChordMidiPitches, parseChordSymbol } from './chordUtil';
|
||||
import { getRootNoteFromKeySignature, noteNameToPitchClass } from './scaleUtil';
|
||||
|
||||
const SHARP_NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
|
||||
export type ChordGuideMode = 'ionian' | 'aeolian';
|
||||
export type ChordGuideFunctionType = 'T' | 'S' | 'D';
|
||||
|
||||
function getReferenceTonic(mode: ChordGuideMode): string {
|
||||
return mode === 'aeolian' ? 'A' : 'C';
|
||||
}
|
||||
|
||||
function getChordGuideData(): ChordGuideData {
|
||||
return KGCore.CHORD_GUIDE_DATA ?? {
|
||||
ionian: { T: [], S: [], D: [] },
|
||||
aeolian: { T: [], S: [], D: [] },
|
||||
};
|
||||
}
|
||||
|
||||
function transposePitchClass(pitchClass: number, semitones: number): number {
|
||||
return (pitchClass + semitones + 120) % 12;
|
||||
}
|
||||
|
||||
function normalizeSemitoneOffset(semitones: number): number {
|
||||
return ((semitones % 12) + 12) % 12;
|
||||
}
|
||||
|
||||
function toResolvedNoteNames(pitches: number[]): string[] {
|
||||
return pitches.map((pitch) => SHARP_NOTE_NAMES[((pitch % 12) + 12) % 12]);
|
||||
}
|
||||
|
||||
function resolveChordRootPitchClass(item: ChordGuideItem, semitones: number): number | null {
|
||||
const descriptor = parseChordSymbol(item.name);
|
||||
if (!descriptor) {
|
||||
console.warn(`Unable to parse chord guide symbol: ${item.name}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return transposePitchClass(noteNameToPitchClass(descriptor.root), semitones);
|
||||
}
|
||||
|
||||
export function resolveChordGuideItems(
|
||||
keySignature: KeySignature,
|
||||
mode: ChordGuideMode,
|
||||
functionType: ChordGuideFunctionType
|
||||
): ResolvedChordGuideItem[] {
|
||||
const data = getChordGuideData()[mode];
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items = data[functionType] ?? [];
|
||||
const referenceTonic = getReferenceTonic(mode);
|
||||
const targetTonic = getRootNoteFromKeySignature(keySignature);
|
||||
const semitoneOffset = normalizeSemitoneOffset(
|
||||
noteNameToPitchClass(targetTonic) - noteNameToPitchClass(referenceTonic)
|
||||
);
|
||||
|
||||
return items.flatMap((item) => {
|
||||
const rootPitchClass = resolveChordRootPitchClass(item, semitoneOffset);
|
||||
if (rootPitchClass === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const pitchClasses = getChordMidiPitches(item.name, rootPitchClass);
|
||||
if (pitchClasses.length === 0) {
|
||||
console.warn(`Unable to resolve pitch classes for chord guide symbol: ${item.name}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{
|
||||
...item,
|
||||
resolvedNotes: toResolvedNoteNames(pitchClasses),
|
||||
pitchClasses,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
export function getMatchingChordGuideChordsForPitch(
|
||||
hoverPitch: number,
|
||||
keySignature: KeySignature,
|
||||
mode: ChordGuideMode,
|
||||
functionType: ChordGuideFunctionType
|
||||
): number[][] {
|
||||
const resolvedChords = resolveChordGuideItems(keySignature, mode, functionType);
|
||||
if (resolvedChords.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hoverPitchClass = hoverPitch % 12;
|
||||
const matchesByPosition: number[][][] = [];
|
||||
|
||||
for (const item of resolvedChords) {
|
||||
for (let i = 0; i < item.pitchClasses.length; i++) {
|
||||
if (item.pitchClasses[i] % 12 !== hoverPitchClass) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!matchesByPosition[i]) {
|
||||
matchesByPosition[i] = [];
|
||||
}
|
||||
|
||||
const chord = item.pitchClasses[i] >= 12
|
||||
? item.pitchClasses.map((pitch) => pitch - 12)
|
||||
: item.pitchClasses;
|
||||
matchesByPosition[i].push(chord);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return matchesByPosition.flatMap((matches) => matches ?? []);
|
||||
}
|
||||
@@ -15,6 +15,16 @@ describe('chordUtil', () => {
|
||||
expect(parsed?.symbol).toBe('Bm7b5');
|
||||
});
|
||||
|
||||
it('parses diminished seventh chords as a distinct canonical shape', () => {
|
||||
const parsed = parseChordSymbol('G#dim7');
|
||||
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.root).toBe('G#');
|
||||
expect(parsed?.quality).toBe('dim');
|
||||
expect(parsed?.extensions).toContain('dim7');
|
||||
expect(parsed?.symbol).toBe('G#dim7');
|
||||
});
|
||||
|
||||
it('preserves enharmonic root spelling in the canonical symbol', () => {
|
||||
expect(buildChordSymbol({
|
||||
root: 'Bb',
|
||||
@@ -37,11 +47,13 @@ describe('chordUtil', () => {
|
||||
it('derives stable pitch classes and midi pitches from the stored symbol', () => {
|
||||
expect(getChordPitchClasses('Bm7b5')).toEqual([11, 2, 5, 9]);
|
||||
expect(getChordMidiPitches('Bm7b5', 59)).toEqual([59, 62, 65, 69]);
|
||||
expect(getChordMidiPitches('Ddim7', 50)).toEqual([50, 53, 56, 59]);
|
||||
});
|
||||
|
||||
it('formats the preview using standard chord display conventions', () => {
|
||||
expect(formatChordSymbolForDisplay('Bm7b5')).toBe('Bm7(♭5)');
|
||||
expect(formatChordSymbolForDisplay('Bbmaj7#11')).toBe('B♭maj7(♯11)');
|
||||
expect(formatChordSymbolForDisplay('G#dim7')).toBe('G♯dim7');
|
||||
});
|
||||
|
||||
it('maps C-root chords into the C4-C5 range', () => {
|
||||
|
||||
+34
-7
@@ -6,6 +6,7 @@ export type ChordExtension =
|
||||
| '#5'
|
||||
| '6'
|
||||
| '7'
|
||||
| 'dim7'
|
||||
| 'maj7'
|
||||
| 'b9'
|
||||
| '9'
|
||||
@@ -23,12 +24,13 @@ export interface ChordDescriptor {
|
||||
}
|
||||
|
||||
const ROOT_PATTERN = /^[A-G](?:#|b)?$/;
|
||||
const EXTENSION_ORDER: ChordExtension[] = ['b5', '#5', '6', '7', 'maj7', 'b9', '9', '#9', '11', '#11', 'b13', '13'];
|
||||
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',
|
||||
@@ -120,6 +122,9 @@ function getDescriptorIntervals(descriptor: Pick<ChordDescriptor, 'quality' | 'e
|
||||
case '7':
|
||||
intervals.push('7m');
|
||||
break;
|
||||
case 'dim7':
|
||||
intervals.push('7d');
|
||||
break;
|
||||
case 'maj7':
|
||||
intervals.push('7M');
|
||||
break;
|
||||
@@ -174,7 +179,7 @@ function parseIntervalsToDescriptor(root: string, intervals: string[]): ChordDes
|
||||
return null;
|
||||
}
|
||||
|
||||
if (intervalSet.has('7d') || intervalSet.has('3A') || intervalSet.has('4d')) {
|
||||
if (intervalSet.has('3A') || intervalSet.has('4d')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -191,6 +196,9 @@ function parseIntervalsToDescriptor(root: string, intervals: string[]): ChordDes
|
||||
if (intervalSet.has('7m')) {
|
||||
extensions.add('7');
|
||||
}
|
||||
if (intervalSet.has('7d')) {
|
||||
extensions.add('dim7');
|
||||
}
|
||||
if (intervalSet.has('7M')) {
|
||||
extensions.add('maj7');
|
||||
}
|
||||
@@ -255,6 +263,11 @@ function parseCustomChordSymbol(symbol: string): ChordDescriptor | null {
|
||||
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');
|
||||
@@ -382,12 +395,16 @@ export function buildChordSymbol(descriptor: Pick<ChordDescriptor, 'root' | 'qua
|
||||
|
||||
const extensions = sortExtensions(descriptor.extensions);
|
||||
const has = (extension: ChordExtension) => extensions.includes(extension);
|
||||
const hasSeventh = has('7') || has('maj7');
|
||||
const hasSeventh = has('7') || has('dim7') || has('maj7');
|
||||
const remainingExtensions = new Set(extensions);
|
||||
|
||||
let symbol = root;
|
||||
|
||||
if (descriptor.quality === 'dim' && has('7')) {
|
||||
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');
|
||||
@@ -425,7 +442,10 @@ export function buildChordSymbol(descriptor: Pick<ChordDescriptor, 'root' | 'qua
|
||||
break;
|
||||
}
|
||||
|
||||
if (has('maj7')) {
|
||||
if (has('dim7')) {
|
||||
symbol += 'dim7';
|
||||
remainingExtensions.delete('dim7');
|
||||
} else if (has('maj7')) {
|
||||
symbol += 'maj7';
|
||||
remainingExtensions.delete('maj7');
|
||||
} else if (has('7')) {
|
||||
@@ -507,6 +527,9 @@ export function formatChordSymbolForDisplay(symbol: string): string {
|
||||
|
||||
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':
|
||||
@@ -522,7 +545,7 @@ export function formatChordSymbolForDisplay(symbol: string): string {
|
||||
case 'aug':
|
||||
return 'aug';
|
||||
case 'dim':
|
||||
return 'm';
|
||||
return extensions.includes('dim7') || !extensions.includes('7') ? 'dim' : 'm';
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -530,7 +553,11 @@ export function formatChordSymbolForDisplay(symbol: string): string {
|
||||
const parentheticalExtensions: string[] = [];
|
||||
|
||||
for (const extension of extensions) {
|
||||
if (extension === '7' || extension === 'maj7' || extension === '6') {
|
||||
if (quality === 'dim' && extension === 'b5' && !extensions.includes('7')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (extension === '7' || extension === 'dim7' || extension === 'maj7' || extension === '6') {
|
||||
inlineExtensions.push(extension);
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user