refactor: chord guide system
This commit is contained in:
@@ -0,0 +1,340 @@
|
|||||||
|
{
|
||||||
|
"ionian": {
|
||||||
|
"T": [
|
||||||
|
{
|
||||||
|
"name": "C",
|
||||||
|
"roman": "I",
|
||||||
|
"notes": ["C", "E", "G"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Most stable tonic triad."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cmaj7",
|
||||||
|
"roman": "Imaj7",
|
||||||
|
"notes": ["C", "E", "G", "B"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Soft modern tonic color."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Am",
|
||||||
|
"roman": "vi",
|
||||||
|
"notes": ["A", "C", "E"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Common tonic substitute."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Am7",
|
||||||
|
"roman": "vi7",
|
||||||
|
"notes": ["A", "C", "E", "G"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Frequent pop tonic substitute."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Em",
|
||||||
|
"roman": "iii",
|
||||||
|
"notes": ["E", "G", "B"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Weak tonic-function option."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Em7",
|
||||||
|
"roman": "iii7",
|
||||||
|
"notes": ["E", "G", "B", "D"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Floating tonic color."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "C6",
|
||||||
|
"roman": "I6",
|
||||||
|
"notes": ["C", "E", "G", "A"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Common pop and jazz tonic sonority."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cadd9",
|
||||||
|
"roman": "Iadd9",
|
||||||
|
"notes": ["C", "E", "G", "D"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Bright modern tonic color used often in acoustic pop and J-pop."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"S": [
|
||||||
|
{
|
||||||
|
"name": "F",
|
||||||
|
"roman": "IV",
|
||||||
|
"notes": ["F", "A", "C"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Standard subdominant triad."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fmaj7",
|
||||||
|
"roman": "IVmaj7",
|
||||||
|
"notes": ["F", "A", "C", "E"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Lyrical subdominant color common in Japanese pop."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dm",
|
||||||
|
"roman": "ii",
|
||||||
|
"notes": ["D", "F", "A"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Strong predominant chord."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dm7",
|
||||||
|
"roman": "ii7",
|
||||||
|
"notes": ["D", "F", "A", "C"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Core ii-V-I predominant sonority."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bb",
|
||||||
|
"roman": "bVII",
|
||||||
|
"notes": ["Bb", "D", "F"],
|
||||||
|
"source": "Borrowed from Mixolydian",
|
||||||
|
"note": "Adds a direct rock-inflected predominant color."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bbmaj7",
|
||||||
|
"roman": "bVIImaj7",
|
||||||
|
"notes": ["Bb", "D", "F", "A"],
|
||||||
|
"source": "Borrowed from Mixolydian",
|
||||||
|
"note": "A lush borrowed color often heard in city pop."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "F6",
|
||||||
|
"roman": "IV6",
|
||||||
|
"notes": ["F", "A", "C", "D"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Gentle subdominant expansion."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "D7",
|
||||||
|
"roman": "V/V",
|
||||||
|
"notes": ["D", "F#", "A", "C"],
|
||||||
|
"source": "Secondary dominant",
|
||||||
|
"note": "Targets G with clear forward pull."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"D": [
|
||||||
|
{
|
||||||
|
"name": "G",
|
||||||
|
"roman": "V",
|
||||||
|
"notes": ["G", "B", "D"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Standard dominant triad."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "G7",
|
||||||
|
"roman": "V7",
|
||||||
|
"notes": ["G", "B", "D", "F"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Classic dominant seventh."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bdim",
|
||||||
|
"roman": "vii°",
|
||||||
|
"notes": ["B", "D", "F"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Leading-tone diminished triad."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bm7b5",
|
||||||
|
"roman": "viiø7",
|
||||||
|
"notes": ["B", "D", "F", "A"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Leading-tone half-diminished seventh."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ddim7",
|
||||||
|
"roman": "#ii°7",
|
||||||
|
"notes": ["D", "F", "Ab", "B"],
|
||||||
|
"source": "Passing diminished",
|
||||||
|
"note": "Creates strong chromatic tension as a passing diminished sonority."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "E7",
|
||||||
|
"roman": "V/vi",
|
||||||
|
"notes": ["E", "G#", "B", "D"],
|
||||||
|
"source": "Secondary dominant",
|
||||||
|
"note": "Targets Am directly."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "A7",
|
||||||
|
"roman": "V/ii",
|
||||||
|
"notes": ["A", "C#", "E", "G"],
|
||||||
|
"source": "Secondary dominant",
|
||||||
|
"note": "Targets Dm directly."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"aeolian": {
|
||||||
|
"T": [
|
||||||
|
{
|
||||||
|
"name": "Am",
|
||||||
|
"roman": "i",
|
||||||
|
"notes": ["A", "C", "E"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Primary tonic triad."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Am7",
|
||||||
|
"roman": "i7",
|
||||||
|
"notes": ["A", "C", "E", "G"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Common tonic minor sonority."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "C",
|
||||||
|
"roman": "III",
|
||||||
|
"notes": ["C", "E", "G"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "A stable tonic-function substitute."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cmaj7",
|
||||||
|
"roman": "IIImaj7",
|
||||||
|
"notes": ["C", "E", "G", "B"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "A lyrical tonic substitute common in Japanese pop writing."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Em",
|
||||||
|
"roman": "v",
|
||||||
|
"notes": ["E", "G", "B"],
|
||||||
|
"source": "Natural minor",
|
||||||
|
"note": "A weak dominant with tonic-leaning color."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Em7",
|
||||||
|
"roman": "v7",
|
||||||
|
"notes": ["E", "G", "B", "D"],
|
||||||
|
"source": "Natural minor",
|
||||||
|
"note": "A floating modal dominant color."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Amadd9",
|
||||||
|
"roman": "iadd9",
|
||||||
|
"notes": ["A", "C", "E", "B"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "A bright modern tonic extension."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Am6",
|
||||||
|
"roman": "i6",
|
||||||
|
"notes": ["A", "C", "E", "F#"],
|
||||||
|
"source": "Borrowed from Dorian",
|
||||||
|
"note": "An airy tonic color with modal brightness."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"S": [
|
||||||
|
{
|
||||||
|
"name": "Dm",
|
||||||
|
"roman": "iv",
|
||||||
|
"notes": ["D", "F", "A"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Standard subdominant in minor."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dm7",
|
||||||
|
"roman": "iv7",
|
||||||
|
"notes": ["D", "F", "A", "C"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Common minor predominant sonority."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "F",
|
||||||
|
"roman": "VI",
|
||||||
|
"notes": ["F", "A", "C"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "A broadening minor-mode subdominant color."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fmaj7",
|
||||||
|
"roman": "VImaj7",
|
||||||
|
"notes": ["F", "A", "C", "E"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Dreamy subdominant expansion."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "D",
|
||||||
|
"roman": "IV",
|
||||||
|
"notes": ["D", "F#", "A"],
|
||||||
|
"source": "Borrowed from Dorian",
|
||||||
|
"note": "Adds a bright modal predominant color."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "D7",
|
||||||
|
"roman": "IV7",
|
||||||
|
"notes": ["D", "F#", "A", "C"],
|
||||||
|
"source": "Borrowed from Dorian and jazz practice",
|
||||||
|
"note": "A brighter predominant with modal-jazz pull."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bb",
|
||||||
|
"roman": "bII",
|
||||||
|
"notes": ["Bb", "D", "F"],
|
||||||
|
"source": "Borrowed from Phrygian",
|
||||||
|
"note": "A heavy, dramatic borrowed predominant."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"D": [
|
||||||
|
{
|
||||||
|
"name": "E",
|
||||||
|
"roman": "V",
|
||||||
|
"notes": ["E", "G#", "B"],
|
||||||
|
"source": "Harmonic minor",
|
||||||
|
"note": "Primary strong dominant in minor."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "E7",
|
||||||
|
"roman": "V7",
|
||||||
|
"notes": ["E", "G#", "B", "D"],
|
||||||
|
"source": "Harmonic minor",
|
||||||
|
"note": "Classic minor-key dominant seventh."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "G#dim",
|
||||||
|
"roman": "vii°",
|
||||||
|
"notes": ["G#", "B", "D"],
|
||||||
|
"source": "Harmonic minor",
|
||||||
|
"note": "Leading-tone diminished triad."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "G#dim7",
|
||||||
|
"roman": "vii°7",
|
||||||
|
"notes": ["G#", "B", "D", "F"],
|
||||||
|
"source": "Harmonic minor",
|
||||||
|
"note": "Classical leading-tone diminished seventh with strong cadential force."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bdim",
|
||||||
|
"roman": "ii°",
|
||||||
|
"notes": ["B", "D", "F"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "A weak dominant-leaning diminished chord."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bm7b5",
|
||||||
|
"roman": "iiø7",
|
||||||
|
"notes": ["B", "D", "F", "A"],
|
||||||
|
"source": "Diatonic",
|
||||||
|
"note": "Half-diminished predominant or weak dominant option."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "G",
|
||||||
|
"roman": "VII",
|
||||||
|
"notes": ["G", "B", "D"],
|
||||||
|
"source": "Natural minor",
|
||||||
|
"note": "A modal dominant color with weaker cadential pull."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Eaug",
|
||||||
|
"roman": "V+",
|
||||||
|
"notes": ["E", "G#", "B#"],
|
||||||
|
"source": "Harmonic minor variant",
|
||||||
|
"note": "A dramatic altered dominant color."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-4
@@ -86,17 +86,21 @@ function App() {
|
|||||||
|
|
||||||
// Load all mode and chord data files in parallel
|
// Load all mode and chord data files in parallel
|
||||||
try {
|
try {
|
||||||
const [functionalChordsResponse] = await Promise.all([
|
const [functionalChordsResponse, chordGuideResponse] = await Promise.all([
|
||||||
fetch(`${import.meta.env.BASE_URL}resources/modes/functional_chords.json`)
|
fetch(`${import.meta.env.BASE_URL}resources/modes/functional_chords.json`),
|
||||||
|
fetch(`${import.meta.env.BASE_URL}resources/modes/chord_guide.json`)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [functionalChordsData] = await Promise.all([
|
const [functionalChordsData, chordGuideData] = await Promise.all([
|
||||||
functionalChordsResponse.json()
|
functionalChordsResponse.json(),
|
||||||
|
chordGuideResponse.json(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Store original functional chords data
|
// Store original functional chords data
|
||||||
KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA = functionalChordsData;
|
KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA = functionalChordsData;
|
||||||
console.log(`Loaded original functional chords for ${Object.keys(functionalChordsData).length} modes`);
|
console.log(`Loaded original functional chords for ${Object.keys(functionalChordsData).length} modes`);
|
||||||
|
KGCore.CHORD_GUIDE_DATA = chordGuideData;
|
||||||
|
console.log(`Loaded chord guide data for ${Object.keys(chordGuideData).length} modes`);
|
||||||
|
|
||||||
// Check if custom chord definition exists and is valid
|
// Check if custom chord definition exists and is valid
|
||||||
const configManager = ConfigManager.instance();
|
const configManager = ConfigManager.instance();
|
||||||
@@ -130,6 +134,10 @@ function App() {
|
|||||||
};
|
};
|
||||||
KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA = fallbackData;
|
KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA = fallbackData;
|
||||||
KGCore.FUNCTIONAL_CHORDS_DATA = fallbackData;
|
KGCore.FUNCTIONAL_CHORDS_DATA = fallbackData;
|
||||||
|
KGCore.CHORD_GUIDE_DATA = {
|
||||||
|
ionian: { T: [], S: [], D: [] },
|
||||||
|
aeolian: { T: [], S: [], D: [] },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log maxBars after initialization completes
|
// Log maxBars after initialization completes
|
||||||
|
|||||||
@@ -32,6 +32,32 @@ describe('ChordPickerPopup', () => {
|
|||||||
expect(screen.getByText('Unable to parse chord')).toBeInTheDocument();
|
expect(screen.getByText('Unable to parse chord')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('supports selecting dim7 from the popup controls', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
render(<ChordPickerPopup value="Gdim" onChange={onChange} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'dim7' }));
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith('Gdim7');
|
||||||
|
expect(screen.getByRole('button', { name: 'Dim' }).className).toContain('selected');
|
||||||
|
expect(screen.getByRole('button', { name: 'dim7' }).className).toContain('selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs the dim7 button when parsing text input', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
render(<ChordPickerPopup value="C" onChange={onChange} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox');
|
||||||
|
fireEvent.change(input, { target: { value: 'G#dim7' } });
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith('G#dim7');
|
||||||
|
expect(screen.getByRole('button', { name: 'Dim' }).className).toContain('selected');
|
||||||
|
expect(screen.getByRole('button', { name: 'dim7' }).className).toContain('selected');
|
||||||
|
});
|
||||||
|
|
||||||
it('intercepts tab and delegates popup bar navigation', () => {
|
it('intercepts tab and delegates popup bar navigation', () => {
|
||||||
const onTabNavigate = vi.fn();
|
const onTabNavigate = vi.fn();
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const EXTENSION_LABELS: Record<ChordExtension, string> = {
|
|||||||
'#5': '#5',
|
'#5': '#5',
|
||||||
'6': '6',
|
'6': '6',
|
||||||
'7': '7',
|
'7': '7',
|
||||||
|
dim7: 'dim7',
|
||||||
maj7: 'maj7',
|
maj7: 'maj7',
|
||||||
b9: 'b9',
|
b9: 'b9',
|
||||||
'9': '9',
|
'9': '9',
|
||||||
@@ -51,8 +52,9 @@ const QUALITY_ROWS: ChordQuality[][] = [
|
|||||||
|
|
||||||
const EXTENSION_ROWS: ChordExtension[][] = [
|
const EXTENSION_ROWS: ChordExtension[][] = [
|
||||||
['b5', '#5', '6', '7'],
|
['b5', '#5', '6', '7'],
|
||||||
['maj7', 'b9', '9', '#9'],
|
['dim7', 'maj7', 'b9', '9'],
|
||||||
['11', '#11', 'b13', '13'],
|
['#9', '11', '#11', 'b13'],
|
||||||
|
['13'],
|
||||||
];
|
];
|
||||||
|
|
||||||
function createFallbackDescriptor(value: string): ChordDescriptor {
|
function createFallbackDescriptor(value: string): ChordDescriptor {
|
||||||
@@ -86,13 +88,16 @@ function normalizeDescriptor(descriptor: ChordDescriptor): ChordDescriptor {
|
|||||||
removeExtensions(['#5']);
|
removeExtensions(['#5']);
|
||||||
}
|
}
|
||||||
if (extensions.includes('7')) {
|
if (extensions.includes('7')) {
|
||||||
removeExtensions(['maj7', '6']);
|
removeExtensions(['dim7', 'maj7', '6']);
|
||||||
|
}
|
||||||
|
if (extensions.includes('dim7')) {
|
||||||
|
removeExtensions(['7', 'maj7', '6']);
|
||||||
}
|
}
|
||||||
if (extensions.includes('maj7')) {
|
if (extensions.includes('maj7')) {
|
||||||
removeExtensions(['7', '6']);
|
removeExtensions(['7', 'dim7', '6']);
|
||||||
}
|
}
|
||||||
if (extensions.includes('6')) {
|
if (extensions.includes('6')) {
|
||||||
removeExtensions(['7', 'maj7', '13', 'b13']);
|
removeExtensions(['7', 'dim7', 'maj7', '13', 'b13']);
|
||||||
}
|
}
|
||||||
if (extensions.includes('13') || extensions.includes('b13')) {
|
if (extensions.includes('13') || extensions.includes('b13')) {
|
||||||
removeExtensions(['6']);
|
removeExtensions(['6']);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { MutableRefObject } from 'react';
|
|||||||
import { Playhead } from '../common';
|
import { Playhead } from '../common';
|
||||||
import SelectionBox from './SelectionBox';
|
import SelectionBox from './SelectionBox';
|
||||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||||
import { generatePianoGridBackground, getMatchingChordsForPitch } from '../../util/scaleUtil';
|
import { generatePianoGridBackground } from '../../util/scaleUtil';
|
||||||
import type { KeySignature } from '../../core/KGProject';
|
import type { KeySignature } from '../../core/KGProject';
|
||||||
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||||
import SpectrogramCanvas from './SpectrogramCanvas';
|
import SpectrogramCanvas from './SpectrogramCanvas';
|
||||||
@@ -11,6 +11,7 @@ import AudioWaveformCanvas from './AudioWaveformCanvas';
|
|||||||
import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
||||||
import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil';
|
import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil';
|
||||||
import { getNextChordCandidateIndex } from './chordGuideUtil';
|
import { getNextChordCandidateIndex } from './chordGuideUtil';
|
||||||
|
import { getMatchingChordGuideChordsForPitch } from '../../util/chordGuideDataUtil';
|
||||||
|
|
||||||
interface PianoGridProps {
|
interface PianoGridProps {
|
||||||
gridRef: MutableRefObject<HTMLDivElement | null>;
|
gridRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
@@ -167,7 +168,7 @@ const PianoGrid: React.FC<PianoGridProps> = ({
|
|||||||
|
|
||||||
// Use the utility function to get matching chords
|
// Use the utility function to get matching chords
|
||||||
const functionType = chordGuide as 'T' | 'S' | 'D';
|
const functionType = chordGuide as 'T' | 'S' | 'D';
|
||||||
return getMatchingChordsForPitch(cursorPosition.pitch, chordGuideKeySignature, chordGuideMode, functionType);
|
return getMatchingChordGuideChordsForPitch(cursorPosition.pitch, chordGuideKeySignature, chordGuideMode, functionType);
|
||||||
}, [cursorPosition, chordGuide, chordGuideKeySignature, chordGuideMode]);
|
}, [cursorPosition, chordGuide, chordGuideKeySignature, chordGuideMode]);
|
||||||
|
|
||||||
// Calculate chord highlights based on selected chord index
|
// Calculate chord highlights based on selected chord index
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import { beatsToBar } from '../../util/midiUtil';
|
|||||||
import { ReplaceChordRegionsInRangeCommand, UpdateRegionCommand } from '../../core/commands';
|
import { ReplaceChordRegionsInRangeCommand, UpdateRegionCommand } from '../../core/commands';
|
||||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||||
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
|
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
|
||||||
import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil';
|
|
||||||
import { showAlert, showChordDetectionOptions, showMidiChordDetectionOptions, showTempoApply, showTempoDetectionOptions } from '../../util/dialogUtil';
|
import { showAlert, showChordDetectionOptions, showMidiChordDetectionOptions, showTempoApply, showTempoDetectionOptions } from '../../util/dialogUtil';
|
||||||
import { matchesKeyboardShortcut } from '../../util/osUtil';
|
import { matchesKeyboardShortcut } from '../../util/osUtil';
|
||||||
|
import { resolveChordGuideItems } from '../../util/chordGuideDataUtil';
|
||||||
import {
|
import {
|
||||||
normalizeSpectrogramHeightResolution,
|
normalizeSpectrogramHeightResolution,
|
||||||
type SpectrogramHeightResolution,
|
type SpectrogramHeightResolution,
|
||||||
@@ -739,7 +739,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
|
|
||||||
if (chordGuide === 'N') {
|
if (chordGuide === 'N') {
|
||||||
// Disabled - clear chord data
|
// Disabled - clear chord data
|
||||||
pianoRollState.setCurrentSuitableChords({});
|
pianoRollState.setCurrentSuitableChords([]);
|
||||||
pianoRollState.setCurrentSuitableChordsPitchClasses({});
|
pianoRollState.setCurrentSuitableChordsPitchClasses({});
|
||||||
|
|
||||||
if (DEBUG_MODE.PIANO_ROLL) {
|
if (DEBUG_MODE.PIANO_ROLL) {
|
||||||
@@ -748,28 +748,10 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
// Get suitable chords for the selected function (T/S/D)
|
// Get suitable chords for the selected function (T/S/D)
|
||||||
const functionType = chordGuide as 'T' | 'S' | 'D';
|
const functionType = chordGuide as 'T' | 'S' | 'D';
|
||||||
const suitableChords = getSuitableChords(effectiveChordGuideKeySignature, chordGuideMode, functionType);
|
const suitableChords = resolveChordGuideItems(effectiveChordGuideKeySignature, chordGuideMode, functionType);
|
||||||
|
const chordsPitchClasses = Object.fromEntries(
|
||||||
// Convert note names to pitch classes (ensuring ascending order)
|
suitableChords.map((item) => [item.name, item.pitchClasses])
|
||||||
const chordsPitchClasses: Record<string, number[]> = {};
|
);
|
||||||
for (const [chordSymbol, noteNames] of Object.entries(suitableChords)) {
|
|
||||||
const pitchClasses: number[] = [];
|
|
||||||
let previousPitch = -1;
|
|
||||||
|
|
||||||
for (const noteName of noteNames) {
|
|
||||||
let pitchClass = noteNameToPitchClass(noteName);
|
|
||||||
|
|
||||||
// If this pitch is lower than the previous one, add an octave
|
|
||||||
if (previousPitch >= 0 && pitchClass <= previousPitch) {
|
|
||||||
pitchClass += 12;
|
|
||||||
}
|
|
||||||
|
|
||||||
pitchClasses.push(pitchClass);
|
|
||||||
previousPitch = pitchClass;
|
|
||||||
}
|
|
||||||
|
|
||||||
chordsPitchClasses[chordSymbol] = pitchClasses;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update piano roll state
|
// Update piano roll state
|
||||||
pianoRollState.setCurrentSuitableChords(suitableChords);
|
pianoRollState.setCurrentSuitableChords(suitableChords);
|
||||||
|
|||||||
@@ -118,6 +118,17 @@ vi.mock('./PianoRollToolbar', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('./chordGuideUtil', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('./chordGuideUtil')>('./chordGuideUtil');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
resolveChordGuideContext: vi.fn(() => ({
|
||||||
|
keySignature: 'C major',
|
||||||
|
mode: 'ionian',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('PianoRoll zoom persistence', () => {
|
describe('PianoRoll zoom persistence', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
latestToolbarProps = null;
|
latestToolbarProps = null;
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import ChordGuideSettings from './ChordGuideSettings';
|
||||||
|
|
||||||
|
const configState = new Map<string, unknown>([
|
||||||
|
['chord_guide.chord_definition', ''],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const configManagerMock = {
|
||||||
|
getIsInitialized: vi.fn(() => true),
|
||||||
|
initialize: vi.fn().mockResolvedValue(undefined),
|
||||||
|
get: vi.fn((key: string) => configState.get(key)),
|
||||||
|
set: vi.fn(async (key: string, value: unknown) => {
|
||||||
|
configState.set(key, value);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../../../core/config/ConfigManager', () => ({
|
||||||
|
ConfigManager: {
|
||||||
|
instance: () => configManagerMock,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../util/scaleUtil', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('../../../util/scaleUtil')>('../../../util/scaleUtil');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
validateFunctionalChordsJSON: vi.fn(() => ({ valid: true, errors: [] })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../../util/dialogUtil', () => ({
|
||||||
|
showAlert: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../core/KGCore', () => ({
|
||||||
|
KGCore: {
|
||||||
|
ORIGINAL_FUNCTIONAL_CHORDS_DATA: {},
|
||||||
|
FUNCTIONAL_CHORDS_DATA: {},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ChordGuideSettings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
configState.set('chord_guide.chord_definition', '');
|
||||||
|
configManagerMock.get.mockClear();
|
||||||
|
configManagerMock.set.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the legacy notice for chord guide definitions', async () => {
|
||||||
|
render(<ChordGuideSettings />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Legacy Chord Definition')).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/no longer affects chord-guide suggestions in the piano roll/i)
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -113,7 +113,10 @@ const ChordGuideSettings: React.FC = () => {
|
|||||||
|
|
||||||
<div className="settings-section-content">
|
<div className="settings-section-content">
|
||||||
<div className="settings-group">
|
<div className="settings-group">
|
||||||
<h4>Chord Definition</h4>
|
<h4>Legacy Chord Definition</h4>
|
||||||
|
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginBottom: '8px' }}>
|
||||||
|
This editor is kept for legacy mode and highlighting behavior. It no longer affects chord-guide suggestions in the piano roll.
|
||||||
|
</div>
|
||||||
<div className="settings-help-links">
|
<div className="settings-help-links">
|
||||||
<button
|
<button
|
||||||
className="settings-help"
|
className="settings-help"
|
||||||
@@ -132,7 +135,7 @@ const ChordGuideSettings: React.FC = () => {
|
|||||||
<div className="settings-item">
|
<div className="settings-item">
|
||||||
<textarea
|
<textarea
|
||||||
className="settings-textarea"
|
className="settings-textarea"
|
||||||
placeholder="Please input your chord definitions"
|
placeholder="Please input your legacy chord definitions"
|
||||||
rows={8}
|
rows={8}
|
||||||
value={chordDefinition}
|
value={chordDefinition}
|
||||||
onChange={(e) => handleChordDefinitionChange(e.target.value)}
|
onChange={(e) => handleChordDefinitionChange(e.target.value)}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export interface ChordGuideItem {
|
||||||
|
name: string;
|
||||||
|
roman: string;
|
||||||
|
notes: string[];
|
||||||
|
source: string;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChordGuideModeDefinition {
|
||||||
|
T: ChordGuideItem[];
|
||||||
|
S: ChordGuideItem[];
|
||||||
|
D: ChordGuideItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChordGuideData {
|
||||||
|
ionian: ChordGuideModeDefinition;
|
||||||
|
aeolian: ChordGuideModeDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedChordGuideItem extends ChordGuideItem {
|
||||||
|
resolvedNotes: string[];
|
||||||
|
pitchClasses: number[];
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { KGRegion } from './region/KGRegion';
|
|||||||
import { generateUniqueId } from '../util/miscUtil';
|
import { generateUniqueId } from '../util/miscUtil';
|
||||||
import { KGCommand, KGCommandHistory } from './commands';
|
import { KGCommand, KGCommandHistory } from './commands';
|
||||||
import { getEffectiveBpmAtBeat } from '../util/globalTrackUtil';
|
import { getEffectiveBpmAtBeat } from '../util/globalTrackUtil';
|
||||||
|
import type { ChordGuideData } from './ChordGuideTypes';
|
||||||
|
|
||||||
interface PlaybackStartOptions {
|
interface PlaybackStartOptions {
|
||||||
preserveLoopPreroll?: boolean;
|
preserveLoopPreroll?: boolean;
|
||||||
@@ -28,6 +29,10 @@ export class KGCore {
|
|||||||
// Global music data resources
|
// Global music data resources
|
||||||
public static ORIGINAL_FUNCTIONAL_CHORDS_DATA: Record<string, { name: string; steps: number[]; T: string[]; S: string[]; D: string[]; chords: Record<string, string[]> }> = {}; // Original functional chords loaded from functional_chords.json
|
public static ORIGINAL_FUNCTIONAL_CHORDS_DATA: Record<string, { name: string; steps: number[]; T: string[]; S: string[]; D: string[]; chords: Record<string, string[]> }> = {}; // Original functional chords loaded from functional_chords.json
|
||||||
public static FUNCTIONAL_CHORDS_DATA: Record<string, { name: string; steps: number[]; T: string[]; S: string[]; D: string[]; chords: Record<string, string[]> }> = {}; // Active functional chords (either original or custom from user settings)
|
public static FUNCTIONAL_CHORDS_DATA: Record<string, { name: string; steps: number[]; T: string[]; S: string[]; D: string[]; chords: Record<string, string[]> }> = {}; // Active functional chords (either original or custom from user settings)
|
||||||
|
public static CHORD_GUIDE_DATA: ChordGuideData = {
|
||||||
|
ionian: { T: [], S: [], D: [] },
|
||||||
|
aeolian: { T: [], S: [], D: [] },
|
||||||
|
};
|
||||||
|
|
||||||
private currentProject: KGProject = new KGProject();
|
private currentProject: KGProject = new KGProject();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ResolvedChordGuideItem } from '../ChordGuideTypes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KGPianoRollState - State management for the piano roll
|
* KGPianoRollState - State management for the piano roll
|
||||||
* Implements the singleton pattern for global access
|
* Implements the singleton pattern for global access
|
||||||
@@ -21,8 +23,8 @@ export class KGPianoRollState {
|
|||||||
private sheetQuantization: string = '16,48';
|
private sheetQuantization: string = '16,48';
|
||||||
|
|
||||||
// Chord guide state
|
// Chord guide state
|
||||||
private currentSuitableChords: Record<string, string[]> = {}; // Map of chord symbols to note names (e.g., {"I": ["C", "E", "G"]})
|
private currentSuitableChords: ResolvedChordGuideItem[] = [];
|
||||||
private currentSuitableChordsPitchClasses: Record<string, number[]> = {}; // Map of chord symbols to pitch classes (e.g., {"I": [0, 4, 7]})
|
private currentSuitableChordsPitchClasses: Record<string, number[]> = {};
|
||||||
private currentMatchingChords: number[][] = [];
|
private currentMatchingChords: number[][] = [];
|
||||||
private currentSelectedChordIndex: number = 0;
|
private currentSelectedChordIndex: number = 0;
|
||||||
private currentChordCursorPitch: number | null = null;
|
private currentChordCursorPitch: number | null = null;
|
||||||
@@ -119,11 +121,11 @@ export class KGPianoRollState {
|
|||||||
this.sheetQuantization = value;
|
this.sheetQuantization = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCurrentSuitableChords(): Record<string, string[]> {
|
public getCurrentSuitableChords(): ResolvedChordGuideItem[] {
|
||||||
return this.currentSuitableChords;
|
return this.currentSuitableChords;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setCurrentSuitableChords(chords: Record<string, string[]>): void {
|
public setCurrentSuitableChords(chords: ResolvedChordGuideItem[]): void {
|
||||||
this.currentSuitableChords = chords;
|
this.currentSuitableChords = chords;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
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', () => {
|
it('preserves enharmonic root spelling in the canonical symbol', () => {
|
||||||
expect(buildChordSymbol({
|
expect(buildChordSymbol({
|
||||||
root: 'Bb',
|
root: 'Bb',
|
||||||
@@ -37,11 +47,13 @@ describe('chordUtil', () => {
|
|||||||
it('derives stable pitch classes and midi pitches from the stored symbol', () => {
|
it('derives stable pitch classes and midi pitches from the stored symbol', () => {
|
||||||
expect(getChordPitchClasses('Bm7b5')).toEqual([11, 2, 5, 9]);
|
expect(getChordPitchClasses('Bm7b5')).toEqual([11, 2, 5, 9]);
|
||||||
expect(getChordMidiPitches('Bm7b5', 59)).toEqual([59, 62, 65, 69]);
|
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', () => {
|
it('formats the preview using standard chord display conventions', () => {
|
||||||
expect(formatChordSymbolForDisplay('Bm7b5')).toBe('Bm7(♭5)');
|
expect(formatChordSymbolForDisplay('Bm7b5')).toBe('Bm7(♭5)');
|
||||||
expect(formatChordSymbolForDisplay('Bbmaj7#11')).toBe('B♭maj7(♯11)');
|
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', () => {
|
it('maps C-root chords into the C4-C5 range', () => {
|
||||||
|
|||||||
+34
-7
@@ -6,6 +6,7 @@ export type ChordExtension =
|
|||||||
| '#5'
|
| '#5'
|
||||||
| '6'
|
| '6'
|
||||||
| '7'
|
| '7'
|
||||||
|
| 'dim7'
|
||||||
| 'maj7'
|
| 'maj7'
|
||||||
| 'b9'
|
| 'b9'
|
||||||
| '9'
|
| '9'
|
||||||
@@ -23,12 +24,13 @@ export interface ChordDescriptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ROOT_PATTERN = /^[A-G](?:#|b)?$/;
|
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 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 ADD_EXTENSION_ORDER: ChordExtension[] = ['b9', '9', '#9', '11', '#11', 'b13', '13'];
|
||||||
const CUSTOM_TOKENS = [
|
const CUSTOM_TOKENS = [
|
||||||
'maj7#5',
|
'maj7#5',
|
||||||
'm7b5',
|
'm7b5',
|
||||||
|
'dim7',
|
||||||
'sus2',
|
'sus2',
|
||||||
'sus4',
|
'sus4',
|
||||||
'aug',
|
'aug',
|
||||||
@@ -120,6 +122,9 @@ function getDescriptorIntervals(descriptor: Pick<ChordDescriptor, 'quality' | 'e
|
|||||||
case '7':
|
case '7':
|
||||||
intervals.push('7m');
|
intervals.push('7m');
|
||||||
break;
|
break;
|
||||||
|
case 'dim7':
|
||||||
|
intervals.push('7d');
|
||||||
|
break;
|
||||||
case 'maj7':
|
case 'maj7':
|
||||||
intervals.push('7M');
|
intervals.push('7M');
|
||||||
break;
|
break;
|
||||||
@@ -174,7 +179,7 @@ function parseIntervalsToDescriptor(root: string, intervals: string[]): ChordDes
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intervalSet.has('7d') || intervalSet.has('3A') || intervalSet.has('4d')) {
|
if (intervalSet.has('3A') || intervalSet.has('4d')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +196,9 @@ function parseIntervalsToDescriptor(root: string, intervals: string[]): ChordDes
|
|||||||
if (intervalSet.has('7m')) {
|
if (intervalSet.has('7m')) {
|
||||||
extensions.add('7');
|
extensions.add('7');
|
||||||
}
|
}
|
||||||
|
if (intervalSet.has('7d')) {
|
||||||
|
extensions.add('dim7');
|
||||||
|
}
|
||||||
if (intervalSet.has('7M')) {
|
if (intervalSet.has('7M')) {
|
||||||
extensions.add('maj7');
|
extensions.add('maj7');
|
||||||
}
|
}
|
||||||
@@ -255,6 +263,11 @@ function parseCustomChordSymbol(symbol: string): ChordDescriptor | null {
|
|||||||
extensions.add('b5');
|
extensions.add('b5');
|
||||||
extensions.add('7');
|
extensions.add('7');
|
||||||
remainder = remainder.slice(4);
|
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')) {
|
} else if (remainder.startsWith('maj7#5')) {
|
||||||
quality = 'aug';
|
quality = 'aug';
|
||||||
extensions.add('#5');
|
extensions.add('#5');
|
||||||
@@ -382,12 +395,16 @@ export function buildChordSymbol(descriptor: Pick<ChordDescriptor, 'root' | 'qua
|
|||||||
|
|
||||||
const extensions = sortExtensions(descriptor.extensions);
|
const extensions = sortExtensions(descriptor.extensions);
|
||||||
const has = (extension: ChordExtension) => extensions.includes(extension);
|
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);
|
const remainingExtensions = new Set(extensions);
|
||||||
|
|
||||||
let symbol = root;
|
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';
|
symbol += 'm7b5';
|
||||||
remainingExtensions.delete('7');
|
remainingExtensions.delete('7');
|
||||||
remainingExtensions.delete('b5');
|
remainingExtensions.delete('b5');
|
||||||
@@ -425,7 +442,10 @@ export function buildChordSymbol(descriptor: Pick<ChordDescriptor, 'root' | 'qua
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (has('maj7')) {
|
if (has('dim7')) {
|
||||||
|
symbol += 'dim7';
|
||||||
|
remainingExtensions.delete('dim7');
|
||||||
|
} else if (has('maj7')) {
|
||||||
symbol += 'maj7';
|
symbol += 'maj7';
|
||||||
remainingExtensions.delete('maj7');
|
remainingExtensions.delete('maj7');
|
||||||
} else if (has('7')) {
|
} else if (has('7')) {
|
||||||
@@ -507,6 +527,9 @@ export function formatChordSymbolForDisplay(symbol: string): string {
|
|||||||
|
|
||||||
const { root, quality, extensions } = descriptor;
|
const { root, quality, extensions } = descriptor;
|
||||||
const accidentalDisplay = (value: string) => value.replace(/b/g, '♭').replace(/#/g, '♯');
|
const accidentalDisplay = (value: string) => value.replace(/b/g, '♭').replace(/#/g, '♯');
|
||||||
|
if (quality === 'dim' && extensions.includes('dim7')) {
|
||||||
|
return `${accidentalDisplay(root)}dim7`;
|
||||||
|
}
|
||||||
const baseQuality = (() => {
|
const baseQuality = (() => {
|
||||||
switch (quality) {
|
switch (quality) {
|
||||||
case 'maj':
|
case 'maj':
|
||||||
@@ -522,7 +545,7 @@ export function formatChordSymbolForDisplay(symbol: string): string {
|
|||||||
case 'aug':
|
case 'aug':
|
||||||
return 'aug';
|
return 'aug';
|
||||||
case 'dim':
|
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[] = [];
|
const parentheticalExtensions: string[] = [];
|
||||||
|
|
||||||
for (const extension of extensions) {
|
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);
|
inlineExtensions.push(extension);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user