feat: implemented user-friendly chord guide editor; added real time chord guide info to the status bar

This commit is contained in:
Xiaohan-Tian
2026-05-29 12:51:58 -07:00
parent 72297cb731
commit cd2938fe15
16 changed files with 1056 additions and 160 deletions
+6 -3
View File
@@ -19,9 +19,11 @@ import type { RenderingEvent } from './core/audio-interface/KGOfflineRenderer';
import { KGCore } from './core/KGCore'; import { KGCore } from './core/KGCore';
import { ConfigManager } from './core/config/ConfigManager'; import { ConfigManager } from './core/config/ConfigManager';
import { validateFunctionalChordsJSON } from './util/scaleUtil'; import { validateFunctionalChordsJSON } from './util/scaleUtil';
import { buildChordGuideDataFromDefaultsAndConfig } from './util/chordGuideConfigUtil';
import { showAlert } from './util/dialogUtil'; import { showAlert } from './util/dialogUtil';
import { KGProjectStorage } from './core/io/KGProjectStorage'; import { KGProjectStorage } from './core/io/KGProjectStorage';
import { RESERVED_PROJECT_NAME } from './util/projectNameUtil'; import { RESERVED_PROJECT_NAME } from './util/projectNameUtil';
import type { ChordGuideCustomConfig } from './core/ChordGuideTypes';
function App() { function App() {
// Enable global keyboard handler for copy/paste and undo/redo // Enable global keyboard handler for copy/paste and undo/redo
@@ -60,7 +62,8 @@ function App() {
loadProject(null); loadProject(null);
// Initialize ConfigManager first to load config.json and user settings // Initialize ConfigManager first to load config.json and user settings
await ConfigManager.instance().initialize(); const configManager = ConfigManager.instance();
await configManager.initialize();
// Check for kgone-server.json (managed deployment override) // Check for kgone-server.json (managed deployment override)
try { try {
@@ -99,11 +102,11 @@ function App() {
// 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; const customChordGuideItems = configManager.get('chord_guide.custom_items') as ChordGuideCustomConfig | null | undefined;
KGCore.CHORD_GUIDE_DATA = buildChordGuideDataFromDefaultsAndConfig(chordGuideData, customChordGuideItems);
console.log(`Loaded chord guide data for ${Object.keys(chordGuideData).length} modes`); 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 customDefinition = configManager.get('chord_guide.chord_definition') as string; const customDefinition = configManager.get('chord_guide.chord_definition') as string;
if (customDefinition && customDefinition.trim()) { if (customDefinition && customDefinition.trim()) {
+83
View File
@@ -0,0 +1,83 @@
import React from 'react';
import { act, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import StatusBar from './StatusBar';
import { KGPianoRollState } from '../core/state/KGPianoRollState';
import { KGCore } from '../core/KGCore';
import type { ChordGuideData } from '../core/ChordGuideTypes';
import { resolveChordGuideItems } from '../util/chordGuideDataUtil';
import type { ResolvedChordGuideItem } from '../core/ChordGuideTypes';
import chordGuideDataJson from '../../public/resources/modes/chord_guide.json';
const chordGuideData = chordGuideDataJson as ChordGuideData;
const storeState = {
currentStatus: 'Ready',
};
type StoreState = typeof storeState;
type StoreSelector = (state: StoreState) => unknown;
vi.mock('../stores/projectStore', () => ({
useProjectStore: Object.assign(
(selector?: StoreSelector) => (selector ? selector(storeState) : storeState),
{ getState: () => storeState }
),
}));
describe('StatusBar', () => {
beforeEach(() => {
vi.stubGlobal('__APP_VERSION__', 'test-version');
storeState.currentStatus = 'Ready';
KGCore.CHORD_GUIDE_DATA = structuredClone(chordGuideData);
KGPianoRollState.instance().setCurrentHoveredChordGuideCandidate(null);
});
it('shows the normal status when no chord-guide candidate is hovered', () => {
render(<StatusBar />);
expect(screen.getByText('Ready')).toBeTruthy();
});
it('shows hovered chord-guide candidate text and restores the prior status when cleared', () => {
render(<StatusBar />);
const hoveredCandidate: ResolvedChordGuideItem = {
name: 'Dm7',
roman: 'ii7',
notes: ['D', 'F', 'A', 'C'],
source: 'Diatonic',
note: 'Core ii-V-I predominant sonority.',
resolvedNotes: ['D', 'F', 'A', 'C'],
pitchClasses: [2, 5, 9, 12],
};
act(() => {
KGPianoRollState.instance().setCurrentHoveredChordGuideCandidate(hoveredCandidate);
});
expect(
screen.getByText('Chord Guide Candidate: Dm7 — D F A C — Core ii-V-I predominant sonority.')
).toBeTruthy();
act(() => {
KGPianoRollState.instance().setCurrentHoveredChordGuideCandidate(null);
});
expect(screen.getByText('Ready')).toBeTruthy();
});
it('uses resolved notes from the current key signature in the status text', () => {
const transposedCandidate = resolveChordGuideItems('D major', 'ionian', 'T')[0];
render(<StatusBar />);
act(() => {
KGPianoRollState.instance().setCurrentHoveredChordGuideCandidate(transposedCandidate);
});
expect(
screen.getByText('Chord Guide Candidate: C — D F# A — Most stable tonic triad.')
).toBeTruthy();
});
});
+18 -3
View File
@@ -1,14 +1,28 @@
import React from 'react'; import React, { useSyncExternalStore } from 'react';
import './StatusBar.css'; import './StatusBar.css';
import { useProjectStore } from '../stores/projectStore'; import { useProjectStore } from '../stores/projectStore';
import { KGPianoRollState } from '../core/state/KGPianoRollState';
import type { ResolvedChordGuideItem } from '../core/ChordGuideTypes';
function formatChordGuideCandidateStatus(candidate: ResolvedChordGuideItem): string {
return `Chord Guide Candidate: ${candidate.name}${candidate.resolvedNotes.join(' ')}${candidate.note}`;
}
const StatusBar: React.FC = () => { const StatusBar: React.FC = () => {
const { currentStatus } = useProjectStore(); const { currentStatus } = useProjectStore();
const hoveredChordGuideCandidate = useSyncExternalStore(
(listener) => KGPianoRollState.instance().subscribe(listener),
() => KGPianoRollState.instance().getCurrentHoveredChordGuideCandidate(),
() => null,
);
const statusText = hoveredChordGuideCandidate
? formatChordGuideCandidateStatus(hoveredChordGuideCandidate)
: currentStatus;
return ( return (
<div className="status-bar"> <div className="status-bar">
<div className="status-left"> <div className="status-left" title={statusText}>
{currentStatus} {statusText}
</div> </div>
<div className="status-right"> <div className="status-right">
<span>K.G.Studio (v{__APP_VERSION__})</span> <span>K.G.Studio (v{__APP_VERSION__})</span>
@@ -18,3 +32,4 @@ const StatusBar: React.FC = () => {
}; };
export default StatusBar; export default StatusBar;
export { formatChordGuideCandidateStatus };
+23 -12
View File
@@ -11,7 +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'; import { getMatchingChordGuideCandidatesForPitch } from '../../util/chordGuideDataUtil';
interface PianoGridProps { interface PianoGridProps {
gridRef: MutableRefObject<HTMLDivElement | null>; gridRef: MutableRefObject<HTMLDivElement | null>;
@@ -160,24 +160,28 @@ const PianoGrid: React.FC<PianoGridProps> = ({
}; };
// Get all matching chords for the current hover position // Get all matching chords for the current hover position
const matchingChords = useMemo(() => { const matchingCandidates = useMemo(() => {
if (!cursorPosition) return []; if (!cursorPosition) return [];
// If chord guide is disabled, return empty array // If chord guide is disabled, return empty array
if (chordGuide === 'N') return []; if (chordGuide === 'N') return [];
// Use the utility function to get matching chords // Use the utility function to get matching chords in the same order used by candidate cycling.
const functionType = chordGuide as 'T' | 'S' | 'D'; const functionType = chordGuide as 'T' | 'S' | 'D';
return getMatchingChordGuideChordsForPitch(cursorPosition.pitch, chordGuideKeySignature, chordGuideMode, functionType); return getMatchingChordGuideCandidatesForPitch(cursorPosition.pitch, chordGuideKeySignature, chordGuideMode, functionType);
}, [cursorPosition, chordGuide, chordGuideKeySignature, chordGuideMode]); }, [cursorPosition, chordGuide, chordGuideKeySignature, chordGuideMode]);
const matchingChords = useMemo(() => (
matchingCandidates.map((candidate) => candidate.displayPitchClasses)
), [matchingCandidates]);
// Calculate chord highlights based on selected chord index // Calculate chord highlights based on selected chord index
const chordHighlights = useMemo(() => { const chordHighlights = useMemo(() => {
if (matchingChords.length === 0) return []; if (matchingCandidates.length === 0) return [];
// Use the selected chord index (wrap around if needed) // Use the selected chord index (wrap around if needed)
const chordIndex = selectedChordIndex % matchingChords.length; const chordIndex = selectedChordIndex % matchingCandidates.length;
const matchedChordPitches = matchingChords[chordIndex]; const matchedChordPitches = matchingCandidates[chordIndex].displayPitchClasses;
// Convert pitch classes to actual pitches in the same octave as cursor // Convert pitch classes to actual pitches in the same octave as cursor
const highlights: Array<{ pitch: number; beat: number }> = []; const highlights: Array<{ pitch: number; beat: number }> = [];
@@ -195,15 +199,22 @@ const PianoGrid: React.FC<PianoGridProps> = ({
} }
return highlights; return highlights;
}, [cursorPosition, matchingChords, selectedChordIndex]); }, [cursorPosition, matchingCandidates, selectedChordIndex]);
const cursorPitch = cursorPosition?.pitch ?? null; const cursorPitch = cursorPosition?.pitch ?? null;
const selectedHoverCandidate = useMemo(() => {
if (matchingCandidates.length === 0) {
return null;
}
return matchingCandidates[selectedChordIndex % matchingCandidates.length].item;
}, [matchingCandidates, selectedChordIndex]);
useEffect(() => { useEffect(() => {
const pianoRollState = KGPianoRollState.instance(); const pianoRollState = KGPianoRollState.instance();
pianoRollState.setCurrentMatchingChords(matchingChords); pianoRollState.setCurrentMatchingChords(matchingChords);
pianoRollState.setCurrentChordCursorPitch(cursorPitch); pianoRollState.setCurrentChordCursorPitch(cursorPitch);
}, [matchingChords, cursorPitch]); pianoRollState.setCurrentHoveredChordGuideCandidate(selectedHoverCandidate);
}, [matchingChords, cursorPitch, selectedHoverCandidate]);
useEffect(() => { useEffect(() => {
KGPianoRollState.instance().setCurrentSelectedChordIndex(selectedChordIndex); KGPianoRollState.instance().setCurrentSelectedChordIndex(selectedChordIndex);
@@ -219,8 +230,8 @@ const PianoGrid: React.FC<PianoGridProps> = ({
// Expose switchChord function via window for hotkey handler // Expose switchChord function via window for hotkey handler
useEffect(() => { useEffect(() => {
const switchChord = (direction: 1 | -1 = 1) => { const switchChord = (direction: 1 | -1 = 1) => {
if (matchingChords.length > 1) { if (matchingCandidates.length > 1) {
setSelectedChordIndex(prev => getNextChordCandidateIndex(prev, matchingChords.length, direction)); setSelectedChordIndex(prev => getNextChordCandidateIndex(prev, matchingCandidates.length, direction));
} }
}; };
@@ -232,7 +243,7 @@ const PianoGrid: React.FC<PianoGridProps> = ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (window as any).__pianoGridSwitchChord; delete (window as any).__pianoGridSwitchChord;
}; };
}, [matchingChords.length]); }, [matchingCandidates.length]);
return ( return (
<div className="piano-grid-container"> <div className="piano-grid-container">
@@ -0,0 +1,96 @@
import React from 'react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { KGCore } from '../../core/KGCore';
import type { ChordGuideData } from '../../core/ChordGuideTypes';
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import { getMatchingChordGuideCandidatesForPitch } from '../../util/chordGuideDataUtil';
import chordGuideDataJson from '../../../public/resources/modes/chord_guide.json';
const chordGuideData = chordGuideDataJson as ChordGuideData;
vi.mock('../common', () => ({
Playhead: () => null,
}));
import PianoGrid from './PianoGrid';
describe('PianoGrid chord-guide hover candidate state', () => {
beforeEach(() => {
document.documentElement.style.setProperty('--region-grid-beat-width', '40');
document.documentElement.style.setProperty('--region-piano-key-height', '20');
KGCore.CHORD_GUIDE_DATA = structuredClone(chordGuideData);
KGCore.FUNCTIONAL_CHORDS_DATA = {
ionian: { steps: [2, 2, 1, 2, 2, 2, 1] },
aeolian: { steps: [2, 1, 2, 2, 1, 2, 2] },
} as unknown as typeof KGCore.FUNCTIONAL_CHORDS_DATA;
KGPianoRollState.instance().setCurrentHoveredChordGuideCandidate(null);
KGPianoRollState.instance().setCurrentMatchingChords([]);
KGPianoRollState.instance().setCurrentSelectedChordIndex(0);
KGPianoRollState.instance().setCurrentChordCursorPitch(null);
});
it('tracks the hovered candidate, updates on candidate cycling, and clears on mouse leave', async () => {
const gridRef = { current: null as HTMLDivElement | null };
const { container } = render(
<PianoGrid
gridRef={gridRef}
onDoubleClick={() => {}}
onClick={() => {}}
onMouseDown={() => {}}
isBoxSelecting={false}
selectionBox={{ startX: 0, startY: 0, endX: 0, endY: 0 }}
selectedMode="ionian"
keySignature="C major"
chordGuide="T"
chordGuideKeySignature="C major"
chordGuideMode="ionian"
>
{null}
</PianoGrid>
);
const pianoGrid = container.querySelector('.piano-grid') as HTMLDivElement;
pianoGrid.getBoundingClientRect = () => ({
left: 0,
top: 0,
width: 800,
height: 1200,
right: 800,
bottom: 1200,
x: 0,
y: 0,
toJSON: () => ({}),
});
fireEvent.mouseMove(pianoGrid, { clientX: 10, clientY: 941 });
const expectedCandidates = getMatchingChordGuideCandidatesForPitch(60, 'C major', 'ionian', 'T');
expect(expectedCandidates.length).toBeGreaterThan(1);
await waitFor(() => {
expect(KGPianoRollState.instance().getCurrentHoveredChordGuideCandidate()).toMatchObject({
name: expectedCandidates[0].item.name,
resolvedNotes: expectedCandidates[0].item.resolvedNotes,
note: expectedCandidates[0].item.note,
});
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).__pianoGridSwitchChord(1);
await waitFor(() => {
expect(KGPianoRollState.instance().getCurrentHoveredChordGuideCandidate()).toMatchObject({
name: expectedCandidates[1].item.name,
resolvedNotes: expectedCandidates[1].item.resolvedNotes,
note: expectedCandidates[1].item.note,
});
});
fireEvent.mouseLeave(pianoGrid);
await waitFor(() => {
expect(KGPianoRollState.instance().getCurrentHoveredChordGuideCandidate()).toBeNull();
});
});
});
+2
View File
@@ -741,6 +741,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Disabled - clear chord data // Disabled - clear chord data
pianoRollState.setCurrentSuitableChords([]); pianoRollState.setCurrentSuitableChords([]);
pianoRollState.setCurrentSuitableChordsPitchClasses({}); pianoRollState.setCurrentSuitableChordsPitchClasses({});
pianoRollState.setCurrentHoveredChordGuideCandidate(null);
if (DEBUG_MODE.PIANO_ROLL) { if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Chord guide disabled - cleared suitable chords`); console.log(`Chord guide disabled - cleared suitable chords`);
@@ -756,6 +757,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Update piano roll state // Update piano roll state
pianoRollState.setCurrentSuitableChords(suitableChords); pianoRollState.setCurrentSuitableChords(suitableChords);
pianoRollState.setCurrentSuitableChordsPitchClasses(chordsPitchClasses); pianoRollState.setCurrentSuitableChordsPitchClasses(chordsPitchClasses);
pianoRollState.setCurrentHoveredChordGuideCandidate(null);
if (DEBUG_MODE.PIANO_ROLL) { if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Chord guide updated: ${chordGuide} (${functionType})`); console.log(`Chord guide updated: ${chordGuide} (${functionType})`);
+22
View File
@@ -319,6 +319,28 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
.settings-chord-guide-reset-row {
justify-content: flex-end;
}
.settings-chord-guide-toolbar {
margin-top: 10px;
margin-bottom: 12px;
}
.settings-chord-guide-toolbar .event-list-toolbar-group:first-child {
gap: 8px;
}
.settings-chord-guide-toolbar .settings-chord-guide-add-button {
border: 1px solid #444;
border-radius: 3px;
}
.settings-chord-guide-toolbar .settings-chord-guide-add-button:hover {
border: 1px solid #5a5a5a;
}
button.settings-help { button.settings-help {
color: #5a9fd4; color: #5a9fd4;
text-decoration: underline; text-decoration: underline;
@@ -1,10 +1,15 @@
import React from 'react'; import React from 'react';
import { render, screen } from '@testing-library/react'; import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import ChordGuideSettings from './ChordGuideSettings'; import ChordGuideSettings from './ChordGuideSettings';
import chordGuideDataJson from '../../../../public/resources/modes/chord_guide.json';
import type { ChordGuideData } from '../../../core/ChordGuideTypes';
const chordGuideData = chordGuideDataJson as ChordGuideData;
const showAlertMock = vi.fn();
const configState = new Map<string, unknown>([ const configState = new Map<string, unknown>([
['chord_guide.chord_definition', ''], ['chord_guide.custom_items', null],
]); ]);
const configManagerMock = { const configManagerMock = {
@@ -22,38 +27,178 @@ vi.mock('../../../core/config/ConfigManager', () => ({
}, },
})); }));
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', () => ({ vi.mock('../../../util/dialogUtil', () => ({
showAlert: vi.fn(), showAlert: (...args: unknown[]) => showAlertMock(...args),
})); }));
vi.mock('../../../core/KGCore', () => ({ vi.mock('../../../core/KGCore', () => ({
KGCore: { KGCore: {
ORIGINAL_FUNCTIONAL_CHORDS_DATA: {}, CHORD_GUIDE_DATA: {
FUNCTIONAL_CHORDS_DATA: {}, ionian: { T: [], S: [], D: [] },
aeolian: { T: [], S: [], D: [] },
},
}, },
})); }));
describe('ChordGuideSettings', () => { describe('ChordGuideSettings', () => {
const getGroup = (heading: 'Major Candidate Chords' | 'Minor Candidate Chords') => (
screen.getByText(heading).closest('.settings-group') as HTMLElement
);
beforeEach(() => { beforeEach(() => {
configState.set('chord_guide.chord_definition', ''); configState.set('chord_guide.custom_items', null);
configManagerMock.get.mockClear(); configManagerMock.get.mockClear();
configManagerMock.set.mockClear(); configManagerMock.set.mockClear();
showAlertMock.mockClear();
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
const url = String(input);
if (url.includes('resources/modes/chord_guide.json')) {
return {
ok: true,
status: 200,
json: async () => structuredClone(chordGuideData),
};
}
throw new Error(`Unexpected fetch: ${url}`);
}));
}); });
it('renders the legacy notice for chord guide definitions', async () => { it('renders major and minor groups with base-key guidance', async () => {
render(<ChordGuideSettings />); render(<ChordGuideSettings />);
expect(await screen.findByText('Legacy Chord Definition')).toBeTruthy(); expect(await screen.findByText('Major Candidate Chords')).toBeTruthy();
expect( expect(screen.getByText('Minor Candidate Chords')).toBeTruthy();
screen.getByText(/no longer affects chord-guide suggestions in the piano roll/i) expect(screen.getByText(/relative to C major/i)).toBeTruthy();
).toBeTruthy(); expect(screen.getByText(/relative to A minor/i)).toBeTruthy();
});
it('switches T/S/D tabs as a mutex control', async () => {
render(<ChordGuideSettings />);
await screen.findByText('Major Candidate Chords');
const majorGroup = getGroup('Major Candidate Chords');
expect(within(majorGroup).getByTitle('C')).toBeTruthy();
fireEvent.click(within(majorGroup).getByRole('button', { name: 'S' }));
expect(within(majorGroup).getByTitle('Fmaj7')).toBeTruthy();
expect(within(majorGroup).queryByTitle('Cmaj7')).toBeNull();
});
it('adds and deletes rows in the active table', async () => {
render(<ChordGuideSettings />);
await screen.findByText('Major Candidate Chords');
const addButtons = screen.getAllByTitle(/Add .* chord/);
fireEvent.click(addButtons[0]);
await waitFor(() => {
expect(configManagerMock.set).toHaveBeenCalledWith(
'chord_guide.custom_items',
expect.objectContaining({
major: expect.objectContaining({
T: expect.arrayContaining([expect.objectContaining({ name: 'C' })]),
}),
})
);
});
const rows = screen.getAllByRole('row');
fireEvent.click(rows[1]);
const deleteButtons = screen.getAllByTitle('Delete selected rows');
fireEvent.click(deleteButtons[0]);
await waitFor(() => {
expect(configManagerMock.set).toHaveBeenCalledTimes(2);
});
});
it('updates notes and source after a valid chord edit', async () => {
render(<ChordGuideSettings />);
await screen.findByText('Major Candidate Chords');
const majorGroup = getGroup('Major Candidate Chords');
fireEvent.click(within(majorGroup).getByRole('button', { name: 'S' }));
const chordCell = within(majorGroup).getByTitle('F');
fireEvent.doubleClick(chordCell);
const input = screen.getByDisplayValue('F');
fireEvent.change(input, { target: { value: 'D7' } });
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => {
const saved = configManagerMock.set.mock.calls.at(-1)?.[1] as {
major: { S: Array<{ name: string; notes: string[]; source: string }> };
};
expect(saved.major.S[0]).toMatchObject({
name: 'D7',
notes: ['D', 'F#', 'A', 'C'],
source: 'Non-Diatonic',
});
});
});
it('rejects invalid chord edits', async () => {
render(<ChordGuideSettings />);
await screen.findByText('Major Candidate Chords');
const chordCell = screen.getAllByTitle('C')[0];
fireEvent.doubleClick(chordCell);
const input = screen.getByDisplayValue('C');
fireEvent.change(input, { target: { value: 'invalid' } });
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => {
expect(showAlertMock).toHaveBeenCalledWith(expect.stringContaining('valid chord symbol'));
});
});
it('enforces the 128-character note limit', async () => {
render(<ChordGuideSettings />);
await screen.findByText('Major Candidate Chords');
const noteCell = screen.getAllByRole('cell').find((cell) => cell.textContent === chordGuideData.ionian.T[0].note) as HTMLElement;
fireEvent.doubleClick(noteCell);
const input = screen.getByDisplayValue(chordGuideData.ionian.T[0].note);
fireEvent.change(input, { target: { value: 'x'.repeat(200) } });
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => {
const saved = configManagerMock.set.mock.calls.at(-1)?.[1] as { major: { T: Array<{ note: string }> } };
expect(saved.major.T[0].note).toHaveLength(128);
});
});
it('resets back to bundled defaults', async () => {
const customItems = structuredClone(chordGuideData);
customItems.ionian.T = [{
name: 'G',
notes: ['G', 'B', 'D'],
source: 'Diatonic',
note: 'custom',
}];
configState.set('chord_guide.custom_items', {
major: {
T: customItems.ionian.T,
S: customItems.ionian.S,
D: customItems.ionian.D,
},
minor: {
T: customItems.aeolian.T,
S: customItems.aeolian.S,
D: customItems.aeolian.D,
},
});
render(<ChordGuideSettings />);
expect((await screen.findAllByTitle('G')).length).toBeGreaterThan(0);
fireEvent.click(screen.getByText('Reset to Default'));
await waitFor(() => {
expect(configManagerMock.set).toHaveBeenCalledWith('chord_guide.custom_items', null);
expect(screen.getAllByTitle('C')[0]).toBeTruthy();
});
}); });
}); });
@@ -1,108 +1,415 @@
import React, { useState, useEffect, useCallback } from 'react'; import React from 'react';
import { FaPlus, FaTrash } from 'react-icons/fa';
import '../../EventListPanel.css';
import { ConfigManager } from '../../../core/config/ConfigManager'; import { ConfigManager } from '../../../core/config/ConfigManager';
import { validateFunctionalChordsJSON } from '../../../util/scaleUtil';
import { KGCore } from '../../../core/KGCore'; import { KGCore } from '../../../core/KGCore';
import type {
ChordGuideCustomConfig,
ChordGuideData,
ChordGuideGroupKey,
ChordGuideItem,
ChordGuideModeDefinition,
} from '../../../core/ChordGuideTypes';
import {
buildChordGuideCustomConfigFromData,
buildChordGuideDataFromDefaultsAndConfig,
buildDerivedChordGuideItem,
createDefaultChordForGroup,
} from '../../../util/chordGuideConfigUtil';
import { parseChordSymbol } from '../../../util/chordUtil';
import { showAlert } from '../../../util/dialogUtil'; import { showAlert } from '../../../util/dialogUtil';
import { isModifierKeyPressed } from '../../../util/osUtil';
type FunctionType = 'T' | 'S' | 'D';
type EditableColumn = 'name' | 'note';
interface EditingCell {
group: ChordGuideGroupKey;
rowIndex: number;
column: EditableColumn;
value: string;
}
const FUNCTION_BUTTONS: Array<{ value: FunctionType; label: string; title: string }> = [
{ value: 'T', label: 'T', title: 'Tonic' },
{ value: 'S', label: 'S', title: 'Subdominant' },
{ value: 'D', label: 'D', title: 'Dominant' },
];
const GROUP_LABELS: Record<ChordGuideGroupKey, string> = {
major: 'Major Candidate Chords',
minor: 'Minor Candidate Chords',
};
const GROUP_HELP: Record<ChordGuideGroupKey, string> = {
major: 'Enter chords relative to C major. The app will transpose them automatically for other major key signatures.',
minor: 'Enter chords relative to A minor. The app will transpose them automatically for other minor key signatures.',
};
const CONFIG_KEY = 'chord_guide.custom_items';
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] })),
};
}
function cloneCustomConfig(config: ChordGuideCustomConfig): ChordGuideCustomConfig {
return {
major: cloneModeDefinition(config.major),
minor: cloneModeDefinition(config.minor),
};
}
function getModeDefinition(config: ChordGuideCustomConfig, group: ChordGuideGroupKey): ChordGuideModeDefinition {
return group === 'major' ? config.major : config.minor;
}
function setModeDefinition(
config: ChordGuideCustomConfig,
group: ChordGuideGroupKey,
definition: ChordGuideModeDefinition,
): ChordGuideCustomConfig {
return group === 'major'
? { ...config, major: definition }
: { ...config, minor: definition };
}
async function loadBundledChordGuideData(): Promise<ChordGuideData> {
const response = await fetch(`${import.meta.env.BASE_URL}resources/modes/chord_guide.json`);
if (!response.ok) {
throw new Error(`Failed to fetch chord_guide.json: ${response.status}`);
}
return response.json() as Promise<ChordGuideData>;
}
const ChordGuideSettings: React.FC = () => { const ChordGuideSettings: React.FC = () => {
const [chordDefinition, setChordDefinition] = useState<string>('');
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const configManager = ConfigManager.instance(); const configManager = ConfigManager.instance();
const [defaultsData, setDefaultsData] = React.useState<ChordGuideData | null>(null);
const [customConfig, setCustomConfig] = React.useState<ChordGuideCustomConfig | null>(null);
const [activeFunctions, setActiveFunctions] = React.useState<Record<ChordGuideGroupKey, FunctionType>>({
major: 'T',
minor: 'T',
});
const [selectedRows, setSelectedRows] = React.useState<Record<ChordGuideGroupKey, Set<number>>>({
major: new Set(),
minor: new Set(),
});
const [editingCell, setEditingCell] = React.useState<EditingCell | null>(null);
const editInputRef = React.useRef<HTMLInputElement | null>(null);
const previousEditingCellKeyRef = React.useRef<string | null>(null);
// Load configuration values on component mount React.useEffect(() => {
useEffect(() => { const initialize = async () => {
const loadConfig = async () => {
if (!configManager.getIsInitialized()) { if (!configManager.getIsInitialized()) {
await configManager.initialize(); await configManager.initialize();
} }
setChordDefinition((configManager.get('chord_guide.chord_definition') as string) || ''); const bundledData = await loadBundledChordGuideData();
const persisted = configManager.get(CONFIG_KEY) as ChordGuideCustomConfig | null | undefined;
setDefaultsData(bundledData);
setCustomConfig(persisted ? cloneCustomConfig(persisted) : buildChordGuideCustomConfigFromData(bundledData));
}; };
loadConfig(); void initialize().catch((error) => {
console.error('Failed to initialize chord guide settings:', error);
void showAlert('Failed to load chord guide settings.');
});
}, [configManager]); }, [configManager]);
// Debounced save function for textarea React.useEffect(() => {
const debouncedSave = useCallback((value: string) => { if (!editingCell) {
const timeoutId = setTimeout(async () => { previousEditingCellKeyRef.current = null;
try { return;
await configManager.set('chord_guide.chord_definition', value); }
console.log('Chord definition saved');
} catch (error) { const editingCellKey = `${editingCell.group}-${editingCell.rowIndex}-${editingCell.column}`;
console.error('Failed to save chord definition:', error); if (previousEditingCellKeyRef.current === editingCellKey) {
return;
}
previousEditingCellKeyRef.current = editingCellKey;
editInputRef.current?.focus();
editInputRef.current?.select();
}, [editingCell]);
const persistCustomConfig = React.useCallback(async (nextConfig: ChordGuideCustomConfig, persistValue: ChordGuideCustomConfig | null = nextConfig) => {
if (!defaultsData) {
return;
}
const cloned = cloneCustomConfig(nextConfig);
setCustomConfig(cloned);
KGCore.CHORD_GUIDE_DATA = buildChordGuideDataFromDefaultsAndConfig(defaultsData, cloned);
await configManager.set(CONFIG_KEY, persistValue ? cloneCustomConfig(persistValue) : null);
}, [configManager, defaultsData]);
const updateGroupRows = React.useCallback(async (
group: ChordGuideGroupKey,
functionType: FunctionType,
updater: (rows: ChordGuideItem[]) => ChordGuideItem[],
) => {
if (!customConfig) {
return;
}
const nextConfig = cloneCustomConfig(customConfig);
const modeDefinition = getModeDefinition(nextConfig, group);
const nextDefinition: ChordGuideModeDefinition = {
...modeDefinition,
[functionType]: updater(modeDefinition[functionType]),
};
const updatedConfig = setModeDefinition(nextConfig, group, nextDefinition);
await persistCustomConfig(updatedConfig);
}, [customConfig, persistCustomConfig]);
const commitEditingCell = React.useCallback(async () => {
if (!editingCell || !customConfig) {
return;
}
const { group, rowIndex, column } = editingCell;
const functionType = activeFunctions[group];
const modeDefinition = getModeDefinition(customConfig, group);
const row = modeDefinition[functionType][rowIndex];
if (!row) {
setEditingCell(null);
return;
}
const trimmedValue = editingCell.value.trim();
if (column === 'name') {
if (!trimmedValue || parseChordSymbol(trimmedValue) === null) {
await showAlert('Please enter a valid chord symbol. Example: Bm7b5');
return;
} }
}, 1000); // 1 second debounce for longer text const derived = buildDerivedChordGuideItem(group, { name: trimmedValue, note: row.note });
if (!derived) {
await showAlert('Unable to derive notes for this chord. Please use a supported chord symbol.');
return;
}
await updateGroupRows(group, functionType, (rows) => rows.map((candidate, index) => (
index === rowIndex
? { ...derived, roman: undefined }
: candidate
)));
} else {
await updateGroupRows(group, functionType, (rows) => rows.map((candidate, index) => (
index === rowIndex
? { ...candidate, note: trimmedValue.slice(0, 128) }
: candidate
)));
}
return () => clearTimeout(timeoutId); setEditingCell(null);
}, [configManager]); }, [activeFunctions, customConfig, editingCell, updateGroupRows]);
// Save configuration when value changes const handleEditInputBlur = () => {
const handleChordDefinitionChange = (value: string) => { void commitEditingCell();
setChordDefinition(value); };
// Validate the JSON const handleEditInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (value.trim()) { event.stopPropagation();
const validationResult = validateFunctionalChordsJSON(value); if (event.key === 'Enter') {
setValidationErrors(validationResult.valid ? [] : validationResult.errors); event.preventDefault();
void commitEditingCell();
}
if (event.key === 'Escape') {
event.preventDefault();
setEditingCell(null);
}
};
// Update FUNCTIONAL_CHORDS_DATA if valid and non-empty const handleRowClick = (
if (validationResult.valid) { group: ChordGuideGroupKey,
try { rowIndex: number,
KGCore.FUNCTIONAL_CHORDS_DATA = JSON.parse(value); event: React.MouseEvent<HTMLTableRowElement>,
console.log('Applied custom chord definition'); ) => {
} catch (error) { event.stopPropagation();
console.error('Failed to parse chord definition:', error); if (editingCell) {
KGCore.FUNCTIONAL_CHORDS_DATA = KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA; return;
} }
const nextSelected = new Set(selectedRows[group]);
if (isModifierKeyPressed(event)) {
if (nextSelected.has(rowIndex)) {
nextSelected.delete(rowIndex);
} else { } else {
// Revert to original if invalid nextSelected.add(rowIndex);
KGCore.FUNCTIONAL_CHORDS_DATA = KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA;
console.log('Invalid chord definition, reverted to original');
} }
} else { } else {
setValidationErrors([]); nextSelected.clear();
// Revert to original if empty nextSelected.add(rowIndex);
KGCore.FUNCTIONAL_CHORDS_DATA = KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA;
console.log('Chord definition cleared, reverted to original');
} }
debouncedSave(value); setSelectedRows((previous) => ({ ...previous, [group]: nextSelected }));
}; };
// Load default template from functional_chords.json (preserving original formatting) const handleTableBackgroundMouseDown = (group: ChordGuideGroupKey, event: React.MouseEvent<HTMLDivElement>) => {
const handleLoadDefaultTemplate = async () => { if (event.target !== event.currentTarget) {
try { return;
const response = await fetch(`${import.meta.env.BASE_URL}resources/modes/functional_chords.json`);
if (!response.ok) {
throw new Error(`Failed to fetch functional_chords.json: ${response.status}`);
}
// Get the raw text to preserve original formatting
const rawText = await response.text();
setChordDefinition(rawText);
setValidationErrors([]); // Clear errors when loading valid template
await configManager.set('chord_guide.chord_definition', rawText);
// Revert to original if empty
KGCore.FUNCTIONAL_CHORDS_DATA = KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA;
console.log('Loaded default chord template');
} catch (error) {
console.error('Failed to load default template:', error);
await showAlert('Failed to load default template. Please check the console for details.');
} }
setSelectedRows((previous) => ({ ...previous, [group]: new Set() }));
}; };
// Clear the chord definition const handleAddRow = async (group: ChordGuideGroupKey) => {
const handleClear = async () => { const functionType = activeFunctions[group];
setChordDefinition(''); const defaultChord = createDefaultChordForGroup(group);
setValidationErrors([]); // Clear errors when clearing await updateGroupRows(group, functionType, (rows) => [...rows, { ...defaultChord, roman: undefined }]);
await configManager.set('chord_guide.chord_definition', ''); setSelectedRows((previous) => ({ ...previous, [group]: new Set([getModeDefinition(customConfig!, group)[functionType].length]) }));
};
// Revert to original if empty const handleDeleteRows = async (group: ChordGuideGroupKey) => {
KGCore.FUNCTIONAL_CHORDS_DATA = KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA; const selection = selectedRows[group];
if (selection.size === 0) {
return;
}
const functionType = activeFunctions[group];
await updateGroupRows(group, functionType, (rows) => rows.filter((_, index) => !selection.has(index)));
setSelectedRows((previous) => ({ ...previous, [group]: new Set() }));
};
console.log('Chord definition cleared'); const handleResetToDefault = async () => {
if (!defaultsData) {
return;
}
const resetConfig = buildChordGuideCustomConfigFromData(defaultsData);
setEditingCell(null);
setSelectedRows({ major: new Set(), minor: new Set() });
setCustomConfig(resetConfig);
KGCore.CHORD_GUIDE_DATA = buildChordGuideDataFromDefaultsAndConfig(defaultsData, resetConfig);
await configManager.set(CONFIG_KEY, null);
};
const renderGroup = (group: ChordGuideGroupKey) => {
if (!customConfig) {
return null;
}
const functionType = activeFunctions[group];
const rows = getModeDefinition(customConfig, group)[functionType];
const selection = selectedRows[group];
return (
<div className="settings-group" key={group}>
<div className="settings-group-header">
<h4>{GROUP_LABELS[group]}</h4>
</div>
<div className="settings-description">{GROUP_HELP[group]}</div>
<div className="event-list-tabs" role="tablist" aria-label={`${GROUP_LABELS[group]} functions`}>
{FUNCTION_BUTTONS.map((button) => (
<button
key={button.value}
className={`event-list-tab${functionType === button.value ? ' active' : ''}`}
type="button"
title={button.title}
onClick={() => {
setActiveFunctions((previous) => ({ ...previous, [group]: button.value }));
setSelectedRows((previous) => ({ ...previous, [group]: new Set() }));
setEditingCell(null);
}}
>
{button.label}
</button>
))}
</div>
<div className="event-list-toolbar settings-chord-guide-toolbar">
<div className="event-list-toolbar-group">
<button
className="event-list-add-button settings-chord-guide-add-button"
title={`Add ${GROUP_LABELS[group]} chord`}
type="button"
onClick={() => { void handleAddRow(group); }}
>
<FaPlus />
</button>
</div>
<div className="event-list-toolbar-group event-list-toolbar-group-right">
<button
className="event-list-delete-button"
title="Delete selected rows"
type="button"
onClick={() => { void handleDeleteRows(group); }}
disabled={selection.size === 0}
>
<FaTrash />
</button>
</div>
</div>
<div className="event-list-table-shell" onMouseDown={(event) => handleTableBackgroundMouseDown(group, event)}>
<table className="event-list-table">
<thead>
<tr>
<th>Chord</th>
<th>Notes</th>
<th>Source</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => {
const isEditingName = editingCell?.group === group && editingCell.rowIndex === rowIndex && editingCell.column === 'name';
const isEditingNote = editingCell?.group === group && editingCell.rowIndex === rowIndex && editingCell.column === 'note';
return (
<tr
key={`${group}-${functionType}-${rowIndex}-${row.name}`}
className={selection.has(rowIndex) ? 'selected' : ''}
onClick={(event) => handleRowClick(group, rowIndex, event)}
>
<td
title={row.name}
onDoubleClick={(event) => {
event.stopPropagation();
setEditingCell({ group, rowIndex, column: 'name', value: row.name });
}}
>
{isEditingName ? (
<input
ref={editInputRef}
className="event-list-cell-input"
value={editingCell.value}
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
onBlur={handleEditInputBlur}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={handleEditInputKeyDown}
/>
) : row.name}
</td>
<td title={row.notes.join(' ')}>{row.notes.join(' ')}</td>
<td title={row.source}>{row.source}</td>
<td
title={row.note}
onDoubleClick={(event) => {
event.stopPropagation();
setEditingCell({ group, rowIndex, column: 'note', value: row.note });
}}
>
{isEditingNote ? (
<input
ref={editInputRef}
className="event-list-cell-input"
maxLength={128}
value={editingCell.value}
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value.slice(0, 128) })}
onBlur={handleEditInputBlur}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={handleEditInputKeyDown}
/>
) : row.note}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}; };
return ( return (
@@ -112,45 +419,13 @@ const ChordGuideSettings: React.FC = () => {
</div> </div>
<div className="settings-section-content"> <div className="settings-section-content">
<div className="settings-group"> <div className="settings-help-links settings-chord-guide-reset-row">
<h4>Legacy Chord Definition</h4> <button className="settings-help" type="button" onClick={() => { void handleResetToDefault(); }}>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginBottom: '8px' }}> Reset to Default
This editor is kept for legacy mode and highlighting behavior. It no longer affects chord-guide suggestions in the piano roll. </button>
</div>
<div className="settings-help-links">
<button
className="settings-help"
onClick={handleLoadDefaultTemplate}
>
Load Default Template
</button>
<button
className="settings-help"
onClick={handleClear}
>
Clear
</button>
</div>
<div className="settings-item">
<textarea
className="settings-textarea"
placeholder="Please input your legacy chord definitions"
rows={8}
value={chordDefinition}
onChange={(e) => handleChordDefinitionChange(e.target.value)}
/>
{validationErrors.length > 0 && (
<div className="settings-validation-errors">
{validationErrors.map((error, index) => (
<div key={index} className="settings-validation-error">
{error}
</div>
))}
</div>
)}
</div>
</div> </div>
{renderGroup('major')}
{renderGroup('minor')}
</div> </div>
</div> </div>
); );
+11 -2
View File
@@ -1,8 +1,10 @@
export type ChordGuideSource = 'Diatonic' | 'Non-Diatonic';
export interface ChordGuideItem { export interface ChordGuideItem {
name: string; name: string;
roman: string; roman?: string;
notes: string[]; notes: string[];
source: string; source: ChordGuideSource;
note: string; note: string;
} }
@@ -21,3 +23,10 @@ export interface ResolvedChordGuideItem extends ChordGuideItem {
resolvedNotes: string[]; resolvedNotes: string[];
pitchClasses: number[]; pitchClasses: number[];
} }
export type ChordGuideGroupKey = 'major' | 'minor';
export interface ChordGuideCustomConfig {
major: ChordGuideModeDefinition;
minor: ChordGuideModeDefinition;
}
+4 -1
View File
@@ -1,3 +1,4 @@
import type { ChordGuideCustomConfig } from '../ChordGuideTypes';
import { KGConfigStorage } from '../io/KGConfigStorage'; import { KGConfigStorage } from '../io/KGConfigStorage';
/** /**
@@ -103,6 +104,7 @@ interface AppConfig {
}; };
chord_guide: { chord_guide: {
chord_definition: string; chord_definition: string;
custom_items: ChordGuideCustomConfig | null;
}; };
[key: string]: unknown; [key: string]: unknown;
} }
@@ -300,7 +302,8 @@ export class ConfigManager {
custom_instructions: '' custom_instructions: ''
}, },
chord_guide: { chord_guide: {
chord_definition: '' chord_definition: '',
custom_items: null,
} }
}; };
console.log('Using fallback hardcoded config due to load error'); console.log('Using fallback hardcoded config due to load error');
+25
View File
@@ -28,6 +28,8 @@ export class KGPianoRollState {
private currentMatchingChords: number[][] = []; private currentMatchingChords: number[][] = [];
private currentSelectedChordIndex: number = 0; private currentSelectedChordIndex: number = 0;
private currentChordCursorPitch: number | null = null; private currentChordCursorPitch: number | null = null;
private currentHoveredChordGuideCandidate: ResolvedChordGuideItem | null = null;
private listeners = new Set<() => void>();
private constructor() { private constructor() {
console.log("KGPianoRollState initialized"); console.log("KGPianoRollState initialized");
@@ -143,6 +145,7 @@ export class KGPianoRollState {
public setCurrentMatchingChords(chords: number[][]): void { public setCurrentMatchingChords(chords: number[][]): void {
this.currentMatchingChords = chords; this.currentMatchingChords = chords;
this.emitChange();
} }
public getCurrentSelectedChordIndex(): number { public getCurrentSelectedChordIndex(): number {
@@ -151,6 +154,7 @@ export class KGPianoRollState {
public setCurrentSelectedChordIndex(index: number): void { public setCurrentSelectedChordIndex(index: number): void {
this.currentSelectedChordIndex = index; this.currentSelectedChordIndex = index;
this.emitChange();
} }
public getCurrentChordCursorPitch(): number | null { public getCurrentChordCursorPitch(): number | null {
@@ -159,5 +163,26 @@ export class KGPianoRollState {
public setCurrentChordCursorPitch(pitch: number | null): void { public setCurrentChordCursorPitch(pitch: number | null): void {
this.currentChordCursorPitch = pitch; this.currentChordCursorPitch = pitch;
this.emitChange();
}
public getCurrentHoveredChordGuideCandidate(): ResolvedChordGuideItem | null {
return this.currentHoveredChordGuideCandidate;
}
public setCurrentHoveredChordGuideCandidate(candidate: ResolvedChordGuideItem | null): void {
this.currentHoveredChordGuideCandidate = candidate;
this.emitChange();
}
public subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
private emitChange(): void {
this.listeners.forEach((listener) => listener());
} }
} }
+70
View File
@@ -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);
});
});
+115
View File
@@ -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;
}
+4 -1
View File
@@ -1,9 +1,12 @@
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
import { Note } from 'tonal'; import { Note } from 'tonal';
import { KGCore } from '../core/KGCore'; import { KGCore } from '../core/KGCore';
import type { ChordGuideData } from '../core/ChordGuideTypes';
import { getMatchingChordGuideChordsForPitch, resolveChordGuideItems } from './chordGuideDataUtil'; import { getMatchingChordGuideChordsForPitch, resolveChordGuideItems } from './chordGuideDataUtil';
import { getChordPitchClasses, parseChordSymbol } from './chordUtil'; 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[] { function getExpectedPitchClassesFromNotes(notes: string[]): number[] {
return notes.map((note) => { return notes.map((note) => {
+25 -6
View File
@@ -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 ChordGuideMode = 'ionian' | 'aeolian';
export type ChordGuideFunctionType = 'T' | 'S' | 'D'; export type ChordGuideFunctionType = 'T' | 'S' | 'D';
export interface MatchingChordGuideCandidate {
item: ResolvedChordGuideItem;
matchedPitchIndex: number;
displayPitchClasses: number[];
}
function getReferenceTonic(mode: ChordGuideMode): string { function getReferenceTonic(mode: ChordGuideMode): string {
return mode === 'aeolian' ? 'A' : 'C'; return mode === 'aeolian' ? 'A' : 'C';
} }
@@ -85,13 +91,23 @@ export function getMatchingChordGuideChordsForPitch(
mode: ChordGuideMode, mode: ChordGuideMode,
functionType: ChordGuideFunctionType functionType: ChordGuideFunctionType
): number[][] { ): 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); const resolvedChords = resolveChordGuideItems(keySignature, mode, functionType);
if (resolvedChords.length === 0) { if (resolvedChords.length === 0) {
return []; return [];
} }
const hoverPitchClass = hoverPitch % 12; const hoverPitchClass = ((hoverPitch % 12) + 12) % 12;
const matchesByPosition: number[][][] = []; const matchesByPosition: MatchingChordGuideCandidate[][] = [];
for (const item of resolvedChords) { for (const item of resolvedChords) {
for (let i = 0; i < item.pitchClasses.length; i++) { for (let i = 0; i < item.pitchClasses.length; i++) {
@@ -103,10 +119,13 @@ export function getMatchingChordGuideChordsForPitch(
matchesByPosition[i] = []; matchesByPosition[i] = [];
} }
const chord = item.pitchClasses[i] >= 12 matchesByPosition[i].push({
? item.pitchClasses.map((pitch) => pitch - 12) item,
: item.pitchClasses; matchedPitchIndex: i,
matchesByPosition[i].push(chord); displayPitchClasses: item.pitchClasses[i] >= 12
? item.pitchClasses.map((pitch) => pitch - 12)
: item.pitchClasses,
});
break; break;
} }
} }