feat: implemented user-friendly chord guide editor; added real time chord guide info to the status bar
This commit is contained in:
+6
-3
@@ -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()) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -17,4 +31,5 @@ const StatusBar: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default StatusBar;
|
export default StatusBar;
|
||||||
|
export { formatChordGuideCandidateStatus };
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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})`);
|
||||||
|
|||||||
@@ -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) {
|
||||||
console.log('Chord definition cleared');
|
return;
|
||||||
|
}
|
||||||
|
const functionType = activeFunctions[group];
|
||||||
|
await updateGroupRows(group, functionType, (rows) => rows.filter((_, index) => !selection.has(index)));
|
||||||
|
setSelectedRows((previous) => ({ ...previous, [group]: new Set() }));
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import chordGuideDataJson from '../../public/resources/modes/chord_guide.json';
|
||||||
|
import type { ChordGuideData } from '../core/ChordGuideTypes';
|
||||||
|
import {
|
||||||
|
buildChordGuideCustomConfigFromData,
|
||||||
|
buildChordGuideDataFromDefaultsAndConfig,
|
||||||
|
buildDerivedChordGuideItem,
|
||||||
|
createDefaultChordForGroup,
|
||||||
|
deriveChordGuideNotes,
|
||||||
|
deriveChordGuideSource,
|
||||||
|
} from './chordGuideConfigUtil';
|
||||||
|
|
||||||
|
const chordGuideData = chordGuideDataJson as ChordGuideData;
|
||||||
|
|
||||||
|
describe('chordGuideConfigUtil', () => {
|
||||||
|
it('derives canonical note names from a chord symbol', () => {
|
||||||
|
expect(deriveChordGuideNotes('Dm7')).toEqual(['D', 'F', 'A', 'C']);
|
||||||
|
expect(deriveChordGuideNotes('Eaug')).toEqual(['E', 'G#', 'C']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies major-group diatonic and non-diatonic chords', () => {
|
||||||
|
expect(deriveChordGuideSource('Dm7', 'major')).toBe('Diatonic');
|
||||||
|
expect(deriveChordGuideSource('Bb', 'major')).toBe('Non-Diatonic');
|
||||||
|
expect(deriveChordGuideSource('D7', 'major')).toBe('Non-Diatonic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies minor-group borrowed chords as non-diatonic', () => {
|
||||||
|
expect(deriveChordGuideSource('Am', 'minor')).toBe('Diatonic');
|
||||||
|
expect(deriveChordGuideSource('E7', 'minor')).toBe('Non-Diatonic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a derived chord guide item with trimmed note text', () => {
|
||||||
|
const result = buildDerivedChordGuideItem('major', {
|
||||||
|
name: 'Cmaj7',
|
||||||
|
note: ' bright tonic ',
|
||||||
|
roman: 'Imaj7',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
name: 'Cmaj7',
|
||||||
|
roman: 'Imaj7',
|
||||||
|
notes: ['C', 'E', 'G', 'B'],
|
||||||
|
source: 'Diatonic',
|
||||||
|
note: 'bright tonic',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates parser-valid default chords for each group', () => {
|
||||||
|
expect(createDefaultChordForGroup('major').name).toBe('C');
|
||||||
|
expect(createDefaultChordForGroup('minor').name).toBe('Am');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts bundled defaults into a persisted custom config shape', () => {
|
||||||
|
const result = buildChordGuideCustomConfigFromData(chordGuideData);
|
||||||
|
|
||||||
|
expect(result.major.T[0].name).toBe(chordGuideData.ionian.T[0].name);
|
||||||
|
expect(result.minor.D[0].name).toBe(chordGuideData.aeolian.D[0].name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers persisted custom config over bundled defaults for runtime data', () => {
|
||||||
|
const custom = buildChordGuideCustomConfigFromData(chordGuideData);
|
||||||
|
custom.major.T = [createDefaultChordForGroup('major')];
|
||||||
|
|
||||||
|
const result = buildChordGuideDataFromDefaultsAndConfig(chordGuideData, custom);
|
||||||
|
|
||||||
|
expect(result.ionian.T).toHaveLength(1);
|
||||||
|
expect(result.ionian.T[0].name).toBe('C');
|
||||||
|
expect(result.aeolian.T[0].name).toBe(chordGuideData.aeolian.T[0].name);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import type {
|
||||||
|
ChordGuideCustomConfig,
|
||||||
|
ChordGuideData,
|
||||||
|
ChordGuideGroupKey,
|
||||||
|
ChordGuideItem,
|
||||||
|
ChordGuideModeDefinition,
|
||||||
|
ChordGuideSource,
|
||||||
|
} from '../core/ChordGuideTypes';
|
||||||
|
import { getChordMidiPitches, parseChordSymbol } from './chordUtil';
|
||||||
|
import { noteNameToPitchClass } from './scaleUtil';
|
||||||
|
|
||||||
|
const SHARP_NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||||
|
const REFERENCE_SCALE_PITCH_CLASSES: Record<ChordGuideGroupKey, number[]> = {
|
||||||
|
major: [0, 2, 4, 5, 7, 9, 11], // C major
|
||||||
|
minor: [9, 11, 0, 2, 4, 5, 7], // A natural minor
|
||||||
|
};
|
||||||
|
|
||||||
|
function toCanonicalNoteNames(pitches: number[]): string[] {
|
||||||
|
return pitches.map((pitch) => SHARP_NOTE_NAMES[((pitch % 12) + 12) % 12]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReferenceRootMidi(symbol: string): number | null {
|
||||||
|
const descriptor = parseChordSymbol(symbol);
|
||||||
|
if (!descriptor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return noteNameToPitchClass(descriptor.root);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveChordGuideNotes(symbol: string): string[] | null {
|
||||||
|
const rootMidi = getReferenceRootMidi(symbol);
|
||||||
|
if (rootMidi === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const pitches = getChordMidiPitches(symbol, rootMidi);
|
||||||
|
if (pitches.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return toCanonicalNoteNames(pitches);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveChordGuideSource(symbol: string, group: ChordGuideGroupKey): ChordGuideSource | null {
|
||||||
|
const rootMidi = getReferenceRootMidi(symbol);
|
||||||
|
if (rootMidi === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const pitches = getChordMidiPitches(symbol, rootMidi);
|
||||||
|
if (pitches.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceScale = new Set(REFERENCE_SCALE_PITCH_CLASSES[group]);
|
||||||
|
const isDiatonic = pitches.every((pitch) => referenceScale.has(((pitch % 12) + 12) % 12));
|
||||||
|
return isDiatonic ? 'Diatonic' : 'Non-Diatonic';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDerivedChordGuideItem(
|
||||||
|
group: ChordGuideGroupKey,
|
||||||
|
item: Pick<ChordGuideItem, 'name' | 'note'> & Partial<Pick<ChordGuideItem, 'roman'>>
|
||||||
|
): ChordGuideItem | null {
|
||||||
|
const notes = deriveChordGuideNotes(item.name);
|
||||||
|
const source = deriveChordGuideSource(item.name, group);
|
||||||
|
if (!notes || !source) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
roman: item.roman,
|
||||||
|
notes,
|
||||||
|
source,
|
||||||
|
note: item.note.trim().slice(0, 128),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneModeDefinition(definition: ChordGuideModeDefinition): ChordGuideModeDefinition {
|
||||||
|
return {
|
||||||
|
T: definition.T.map((item) => ({ ...item, notes: [...item.notes] })),
|
||||||
|
S: definition.S.map((item) => ({ ...item, notes: [...item.notes] })),
|
||||||
|
D: definition.D.map((item) => ({ ...item, notes: [...item.notes] })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildChordGuideCustomConfigFromData(data: ChordGuideData): ChordGuideCustomConfig {
|
||||||
|
return {
|
||||||
|
major: cloneModeDefinition(data.ionian),
|
||||||
|
minor: cloneModeDefinition(data.aeolian),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildChordGuideDataFromDefaultsAndConfig(
|
||||||
|
defaults: ChordGuideData,
|
||||||
|
customConfig: ChordGuideCustomConfig | null | undefined,
|
||||||
|
): ChordGuideData {
|
||||||
|
if (!customConfig) {
|
||||||
|
return {
|
||||||
|
ionian: cloneModeDefinition(defaults.ionian),
|
||||||
|
aeolian: cloneModeDefinition(defaults.aeolian),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ionian: cloneModeDefinition(customConfig.major),
|
||||||
|
aeolian: cloneModeDefinition(customConfig.minor),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultChordForGroup(group: ChordGuideGroupKey): ChordGuideItem {
|
||||||
|
const baseName = group === 'minor' ? 'Am' : 'C';
|
||||||
|
const derived = buildDerivedChordGuideItem(group, { name: baseName, note: '' });
|
||||||
|
if (!derived) {
|
||||||
|
throw new Error(`Unable to create default chord for group ${group}`);
|
||||||
|
}
|
||||||
|
return derived;
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { beforeEach, describe, expect, it } from 'vitest';
|
import { 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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user