refactor: chord guide system

This commit is contained in:
Xiaohan-Tian
2026-05-28 22:52:58 -07:00
parent ad17263b82
commit 72297cb731
16 changed files with 783 additions and 48 deletions
+26
View File
@@ -32,6 +32,32 @@ describe('ChordPickerPopup', () => {
expect(screen.getByText('Unable to parse chord')).toBeInTheDocument();
});
it('supports selecting dim7 from the popup controls', () => {
const onChange = vi.fn();
render(<ChordPickerPopup value="Gdim" onChange={onChange} />);
fireEvent.click(screen.getByRole('button', { name: 'dim7' }));
expect(onChange).toHaveBeenCalledWith('Gdim7');
expect(screen.getByRole('button', { name: 'Dim' }).className).toContain('selected');
expect(screen.getByRole('button', { name: 'dim7' }).className).toContain('selected');
});
it('syncs the dim7 button when parsing text input', () => {
const onChange = vi.fn();
render(<ChordPickerPopup value="C" onChange={onChange} />);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'G#dim7' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onChange).toHaveBeenCalledWith('G#dim7');
expect(screen.getByRole('button', { name: 'Dim' }).className).toContain('selected');
expect(screen.getByRole('button', { name: 'dim7' }).className).toContain('selected');
});
it('intercepts tab and delegates popup bar navigation', () => {
const onTabNavigate = vi.fn();
+10 -5
View File
@@ -34,6 +34,7 @@ const EXTENSION_LABELS: Record<ChordExtension, string> = {
'#5': '#5',
'6': '6',
'7': '7',
dim7: 'dim7',
maj7: 'maj7',
b9: 'b9',
'9': '9',
@@ -51,8 +52,9 @@ const QUALITY_ROWS: ChordQuality[][] = [
const EXTENSION_ROWS: ChordExtension[][] = [
['b5', '#5', '6', '7'],
['maj7', 'b9', '9', '#9'],
['11', '#11', 'b13', '13'],
['dim7', 'maj7', 'b9', '9'],
['#9', '11', '#11', 'b13'],
['13'],
];
function createFallbackDescriptor(value: string): ChordDescriptor {
@@ -86,13 +88,16 @@ function normalizeDescriptor(descriptor: ChordDescriptor): ChordDescriptor {
removeExtensions(['#5']);
}
if (extensions.includes('7')) {
removeExtensions(['maj7', '6']);
removeExtensions(['dim7', 'maj7', '6']);
}
if (extensions.includes('dim7')) {
removeExtensions(['7', 'maj7', '6']);
}
if (extensions.includes('maj7')) {
removeExtensions(['7', '6']);
removeExtensions(['7', 'dim7', '6']);
}
if (extensions.includes('6')) {
removeExtensions(['7', 'maj7', '13', 'b13']);
removeExtensions(['7', 'dim7', 'maj7', '13', 'b13']);
}
if (extensions.includes('13') || extensions.includes('b13')) {
removeExtensions(['6']);
+3 -2
View File
@@ -3,7 +3,7 @@ import type { MutableRefObject } from 'react';
import { Playhead } from '../common';
import SelectionBox from './SelectionBox';
import { isModifierKeyPressed } from '../../util/osUtil';
import { generatePianoGridBackground, getMatchingChordsForPitch } from '../../util/scaleUtil';
import { generatePianoGridBackground } from '../../util/scaleUtil';
import type { KeySignature } from '../../core/KGProject';
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import SpectrogramCanvas from './SpectrogramCanvas';
@@ -11,6 +11,7 @@ import AudioWaveformCanvas from './AudioWaveformCanvas';
import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil';
import { getNextChordCandidateIndex } from './chordGuideUtil';
import { getMatchingChordGuideChordsForPitch } from '../../util/chordGuideDataUtil';
interface PianoGridProps {
gridRef: MutableRefObject<HTMLDivElement | null>;
@@ -167,7 +168,7 @@ const PianoGrid: React.FC<PianoGridProps> = ({
// Use the utility function to get matching chords
const functionType = chordGuide as 'T' | 'S' | 'D';
return getMatchingChordsForPitch(cursorPosition.pitch, chordGuideKeySignature, chordGuideMode, functionType);
return getMatchingChordGuideChordsForPitch(cursorPosition.pitch, chordGuideKeySignature, chordGuideMode, functionType);
}, [cursorPosition, chordGuide, chordGuideKeySignature, chordGuideMode]);
// Calculate chord highlights based on selected chord index
+6 -24
View File
@@ -19,9 +19,9 @@ import { beatsToBar } from '../../util/midiUtil';
import { ReplaceChordRegionsInRangeCommand, UpdateRegionCommand } from '../../core/commands';
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil';
import { showAlert, showChordDetectionOptions, showMidiChordDetectionOptions, showTempoApply, showTempoDetectionOptions } from '../../util/dialogUtil';
import { matchesKeyboardShortcut } from '../../util/osUtil';
import { resolveChordGuideItems } from '../../util/chordGuideDataUtil';
import {
normalizeSpectrogramHeightResolution,
type SpectrogramHeightResolution,
@@ -739,7 +739,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
if (chordGuide === 'N') {
// Disabled - clear chord data
pianoRollState.setCurrentSuitableChords({});
pianoRollState.setCurrentSuitableChords([]);
pianoRollState.setCurrentSuitableChordsPitchClasses({});
if (DEBUG_MODE.PIANO_ROLL) {
@@ -748,28 +748,10 @@ const PianoRoll: React.FC<PianoRollProps> = ({
} else {
// Get suitable chords for the selected function (T/S/D)
const functionType = chordGuide as 'T' | 'S' | 'D';
const suitableChords = getSuitableChords(effectiveChordGuideKeySignature, chordGuideMode, functionType);
// Convert note names to pitch classes (ensuring ascending order)
const chordsPitchClasses: Record<string, number[]> = {};
for (const [chordSymbol, noteNames] of Object.entries(suitableChords)) {
const pitchClasses: number[] = [];
let previousPitch = -1;
for (const noteName of noteNames) {
let pitchClass = noteNameToPitchClass(noteName);
// If this pitch is lower than the previous one, add an octave
if (previousPitch >= 0 && pitchClass <= previousPitch) {
pitchClass += 12;
}
pitchClasses.push(pitchClass);
previousPitch = pitchClass;
}
chordsPitchClasses[chordSymbol] = pitchClasses;
}
const suitableChords = resolveChordGuideItems(effectiveChordGuideKeySignature, chordGuideMode, functionType);
const chordsPitchClasses = Object.fromEntries(
suitableChords.map((item) => [item.name, item.pitchClasses])
);
// Update piano roll state
pianoRollState.setCurrentSuitableChords(suitableChords);
@@ -118,6 +118,17 @@ vi.mock('./PianoRollToolbar', () => ({
},
}));
vi.mock('./chordGuideUtil', async () => {
const actual = await vi.importActual<typeof import('./chordGuideUtil')>('./chordGuideUtil');
return {
...actual,
resolveChordGuideContext: vi.fn(() => ({
keySignature: 'C major',
mode: 'ionian',
})),
};
});
describe('PianoRoll zoom persistence', () => {
beforeEach(() => {
latestToolbarProps = null;
@@ -0,0 +1,59 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import ChordGuideSettings from './ChordGuideSettings';
const configState = new Map<string, unknown>([
['chord_guide.chord_definition', ''],
]);
const configManagerMock = {
getIsInitialized: vi.fn(() => true),
initialize: vi.fn().mockResolvedValue(undefined),
get: vi.fn((key: string) => configState.get(key)),
set: vi.fn(async (key: string, value: unknown) => {
configState.set(key, value);
}),
};
vi.mock('../../../core/config/ConfigManager', () => ({
ConfigManager: {
instance: () => configManagerMock,
},
}));
vi.mock('../../../util/scaleUtil', async () => {
const actual = await vi.importActual<typeof import('../../../util/scaleUtil')>('../../../util/scaleUtil');
return {
...actual,
validateFunctionalChordsJSON: vi.fn(() => ({ valid: true, errors: [] })),
};
});
vi.mock('../../../util/dialogUtil', () => ({
showAlert: vi.fn(),
}));
vi.mock('../../../core/KGCore', () => ({
KGCore: {
ORIGINAL_FUNCTIONAL_CHORDS_DATA: {},
FUNCTIONAL_CHORDS_DATA: {},
},
}));
describe('ChordGuideSettings', () => {
beforeEach(() => {
configState.set('chord_guide.chord_definition', '');
configManagerMock.get.mockClear();
configManagerMock.set.mockClear();
});
it('renders the legacy notice for chord guide definitions', async () => {
render(<ChordGuideSettings />);
expect(await screen.findByText('Legacy Chord Definition')).toBeTruthy();
expect(
screen.getByText(/no longer affects chord-guide suggestions in the piano roll/i)
).toBeTruthy();
});
});
@@ -113,7 +113,10 @@ const ChordGuideSettings: React.FC = () => {
<div className="settings-section-content">
<div className="settings-group">
<h4>Chord Definition</h4>
<h4>Legacy Chord Definition</h4>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginBottom: '8px' }}>
This editor is kept for legacy mode and highlighting behavior. It no longer affects chord-guide suggestions in the piano roll.
</div>
<div className="settings-help-links">
<button
className="settings-help"
@@ -132,7 +135,7 @@ const ChordGuideSettings: React.FC = () => {
<div className="settings-item">
<textarea
className="settings-textarea"
placeholder="Please input your chord definitions"
placeholder="Please input your legacy chord definitions"
rows={8}
value={chordDefinition}
onChange={(e) => handleChordDefinitionChange(e.target.value)}