feat: implemented user-friendly chord guide editor; added real time chord guide info to the status bar
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import chordGuideDataJson from '../../public/resources/modes/chord_guide.json';
|
||||
import type { ChordGuideData } from '../core/ChordGuideTypes';
|
||||
import {
|
||||
buildChordGuideCustomConfigFromData,
|
||||
buildChordGuideDataFromDefaultsAndConfig,
|
||||
buildDerivedChordGuideItem,
|
||||
createDefaultChordForGroup,
|
||||
deriveChordGuideNotes,
|
||||
deriveChordGuideSource,
|
||||
} from './chordGuideConfigUtil';
|
||||
|
||||
const chordGuideData = chordGuideDataJson as ChordGuideData;
|
||||
|
||||
describe('chordGuideConfigUtil', () => {
|
||||
it('derives canonical note names from a chord symbol', () => {
|
||||
expect(deriveChordGuideNotes('Dm7')).toEqual(['D', 'F', 'A', 'C']);
|
||||
expect(deriveChordGuideNotes('Eaug')).toEqual(['E', 'G#', 'C']);
|
||||
});
|
||||
|
||||
it('classifies major-group diatonic and non-diatonic chords', () => {
|
||||
expect(deriveChordGuideSource('Dm7', 'major')).toBe('Diatonic');
|
||||
expect(deriveChordGuideSource('Bb', 'major')).toBe('Non-Diatonic');
|
||||
expect(deriveChordGuideSource('D7', 'major')).toBe('Non-Diatonic');
|
||||
});
|
||||
|
||||
it('classifies minor-group borrowed chords as non-diatonic', () => {
|
||||
expect(deriveChordGuideSource('Am', 'minor')).toBe('Diatonic');
|
||||
expect(deriveChordGuideSource('E7', 'minor')).toBe('Non-Diatonic');
|
||||
});
|
||||
|
||||
it('builds a derived chord guide item with trimmed note text', () => {
|
||||
const result = buildDerivedChordGuideItem('major', {
|
||||
name: 'Cmaj7',
|
||||
note: ' bright tonic ',
|
||||
roman: 'Imaj7',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'Cmaj7',
|
||||
roman: 'Imaj7',
|
||||
notes: ['C', 'E', 'G', 'B'],
|
||||
source: 'Diatonic',
|
||||
note: 'bright tonic',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates parser-valid default chords for each group', () => {
|
||||
expect(createDefaultChordForGroup('major').name).toBe('C');
|
||||
expect(createDefaultChordForGroup('minor').name).toBe('Am');
|
||||
});
|
||||
|
||||
it('converts bundled defaults into a persisted custom config shape', () => {
|
||||
const result = buildChordGuideCustomConfigFromData(chordGuideData);
|
||||
|
||||
expect(result.major.T[0].name).toBe(chordGuideData.ionian.T[0].name);
|
||||
expect(result.minor.D[0].name).toBe(chordGuideData.aeolian.D[0].name);
|
||||
});
|
||||
|
||||
it('prefers persisted custom config over bundled defaults for runtime data', () => {
|
||||
const custom = buildChordGuideCustomConfigFromData(chordGuideData);
|
||||
custom.major.T = [createDefaultChordForGroup('major')];
|
||||
|
||||
const result = buildChordGuideDataFromDefaultsAndConfig(chordGuideData, custom);
|
||||
|
||||
expect(result.ionian.T).toHaveLength(1);
|
||||
expect(result.ionian.T[0].name).toBe('C');
|
||||
expect(result.aeolian.T[0].name).toBe(chordGuideData.aeolian.T[0].name);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
ChordGuideCustomConfig,
|
||||
ChordGuideData,
|
||||
ChordGuideGroupKey,
|
||||
ChordGuideItem,
|
||||
ChordGuideModeDefinition,
|
||||
ChordGuideSource,
|
||||
} from '../core/ChordGuideTypes';
|
||||
import { getChordMidiPitches, parseChordSymbol } from './chordUtil';
|
||||
import { noteNameToPitchClass } from './scaleUtil';
|
||||
|
||||
const SHARP_NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const REFERENCE_SCALE_PITCH_CLASSES: Record<ChordGuideGroupKey, number[]> = {
|
||||
major: [0, 2, 4, 5, 7, 9, 11], // C major
|
||||
minor: [9, 11, 0, 2, 4, 5, 7], // A natural minor
|
||||
};
|
||||
|
||||
function toCanonicalNoteNames(pitches: number[]): string[] {
|
||||
return pitches.map((pitch) => SHARP_NOTE_NAMES[((pitch % 12) + 12) % 12]);
|
||||
}
|
||||
|
||||
function getReferenceRootMidi(symbol: string): number | null {
|
||||
const descriptor = parseChordSymbol(symbol);
|
||||
if (!descriptor) {
|
||||
return null;
|
||||
}
|
||||
return noteNameToPitchClass(descriptor.root);
|
||||
}
|
||||
|
||||
export function deriveChordGuideNotes(symbol: string): string[] | null {
|
||||
const rootMidi = getReferenceRootMidi(symbol);
|
||||
if (rootMidi === null) {
|
||||
return null;
|
||||
}
|
||||
const pitches = getChordMidiPitches(symbol, rootMidi);
|
||||
if (pitches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return toCanonicalNoteNames(pitches);
|
||||
}
|
||||
|
||||
export function deriveChordGuideSource(symbol: string, group: ChordGuideGroupKey): ChordGuideSource | null {
|
||||
const rootMidi = getReferenceRootMidi(symbol);
|
||||
if (rootMidi === null) {
|
||||
return null;
|
||||
}
|
||||
const pitches = getChordMidiPitches(symbol, rootMidi);
|
||||
if (pitches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const referenceScale = new Set(REFERENCE_SCALE_PITCH_CLASSES[group]);
|
||||
const isDiatonic = pitches.every((pitch) => referenceScale.has(((pitch % 12) + 12) % 12));
|
||||
return isDiatonic ? 'Diatonic' : 'Non-Diatonic';
|
||||
}
|
||||
|
||||
export function buildDerivedChordGuideItem(
|
||||
group: ChordGuideGroupKey,
|
||||
item: Pick<ChordGuideItem, 'name' | 'note'> & Partial<Pick<ChordGuideItem, 'roman'>>
|
||||
): ChordGuideItem | null {
|
||||
const notes = deriveChordGuideNotes(item.name);
|
||||
const source = deriveChordGuideSource(item.name, group);
|
||||
if (!notes || !source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: item.name,
|
||||
roman: item.roman,
|
||||
notes,
|
||||
source,
|
||||
note: item.note.trim().slice(0, 128),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneModeDefinition(definition: ChordGuideModeDefinition): ChordGuideModeDefinition {
|
||||
return {
|
||||
T: definition.T.map((item) => ({ ...item, notes: [...item.notes] })),
|
||||
S: definition.S.map((item) => ({ ...item, notes: [...item.notes] })),
|
||||
D: definition.D.map((item) => ({ ...item, notes: [...item.notes] })),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildChordGuideCustomConfigFromData(data: ChordGuideData): ChordGuideCustomConfig {
|
||||
return {
|
||||
major: cloneModeDefinition(data.ionian),
|
||||
minor: cloneModeDefinition(data.aeolian),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildChordGuideDataFromDefaultsAndConfig(
|
||||
defaults: ChordGuideData,
|
||||
customConfig: ChordGuideCustomConfig | null | undefined,
|
||||
): ChordGuideData {
|
||||
if (!customConfig) {
|
||||
return {
|
||||
ionian: cloneModeDefinition(defaults.ionian),
|
||||
aeolian: cloneModeDefinition(defaults.aeolian),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ionian: cloneModeDefinition(customConfig.major),
|
||||
aeolian: cloneModeDefinition(customConfig.minor),
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultChordForGroup(group: ChordGuideGroupKey): ChordGuideItem {
|
||||
const baseName = group === 'minor' ? 'Am' : 'C';
|
||||
const derived = buildDerivedChordGuideItem(group, { name: baseName, note: '' });
|
||||
if (!derived) {
|
||||
throw new Error(`Unable to create default chord for group ${group}`);
|
||||
}
|
||||
return derived;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { Note } from 'tonal';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
import type { ChordGuideData } from '../core/ChordGuideTypes';
|
||||
import { getMatchingChordGuideChordsForPitch, resolveChordGuideItems } from './chordGuideDataUtil';
|
||||
import { getChordPitchClasses, parseChordSymbol } from './chordUtil';
|
||||
import chordGuideData from '../../public/resources/modes/chord_guide.json';
|
||||
import chordGuideDataJson from '../../public/resources/modes/chord_guide.json';
|
||||
|
||||
const chordGuideData = chordGuideDataJson as ChordGuideData;
|
||||
|
||||
function getExpectedPitchClassesFromNotes(notes: string[]): number[] {
|
||||
return notes.map((note) => {
|
||||
|
||||
@@ -9,6 +9,12 @@ const SHARP_NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A',
|
||||
export type ChordGuideMode = 'ionian' | 'aeolian';
|
||||
export type ChordGuideFunctionType = 'T' | 'S' | 'D';
|
||||
|
||||
export interface MatchingChordGuideCandidate {
|
||||
item: ResolvedChordGuideItem;
|
||||
matchedPitchIndex: number;
|
||||
displayPitchClasses: number[];
|
||||
}
|
||||
|
||||
function getReferenceTonic(mode: ChordGuideMode): string {
|
||||
return mode === 'aeolian' ? 'A' : 'C';
|
||||
}
|
||||
@@ -85,13 +91,23 @@ export function getMatchingChordGuideChordsForPitch(
|
||||
mode: ChordGuideMode,
|
||||
functionType: ChordGuideFunctionType
|
||||
): number[][] {
|
||||
return getMatchingChordGuideCandidatesForPitch(hoverPitch, keySignature, mode, functionType)
|
||||
.map((candidate) => candidate.displayPitchClasses);
|
||||
}
|
||||
|
||||
export function getMatchingChordGuideCandidatesForPitch(
|
||||
hoverPitch: number,
|
||||
keySignature: KeySignature,
|
||||
mode: ChordGuideMode,
|
||||
functionType: ChordGuideFunctionType
|
||||
): MatchingChordGuideCandidate[] {
|
||||
const resolvedChords = resolveChordGuideItems(keySignature, mode, functionType);
|
||||
if (resolvedChords.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hoverPitchClass = hoverPitch % 12;
|
||||
const matchesByPosition: number[][][] = [];
|
||||
const hoverPitchClass = ((hoverPitch % 12) + 12) % 12;
|
||||
const matchesByPosition: MatchingChordGuideCandidate[][] = [];
|
||||
|
||||
for (const item of resolvedChords) {
|
||||
for (let i = 0; i < item.pitchClasses.length; i++) {
|
||||
@@ -103,10 +119,13 @@ export function getMatchingChordGuideChordsForPitch(
|
||||
matchesByPosition[i] = [];
|
||||
}
|
||||
|
||||
const chord = item.pitchClasses[i] >= 12
|
||||
? item.pitchClasses.map((pitch) => pitch - 12)
|
||||
: item.pitchClasses;
|
||||
matchesByPosition[i].push(chord);
|
||||
matchesByPosition[i].push({
|
||||
item,
|
||||
matchedPitchIndex: i,
|
||||
displayPitchClasses: item.pitchClasses[i] >= 12
|
||||
? item.pitchClasses.map((pitch) => pitch - 12)
|
||||
: item.pitchClasses,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user