feat: simplify chord guide feature; updated help doc; added hotkeys doc

This commit is contained in:
Xiaohan-Tian
2026-05-28 21:03:02 -07:00
parent cd4d5847eb
commit 4d415a7f7d
18 changed files with 522 additions and 51 deletions
+10 -5
View File
@@ -10,6 +10,7 @@ import SpectrogramCanvas from './SpectrogramCanvas';
import AudioWaveformCanvas from './AudioWaveformCanvas';
import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil';
import { getNextChordCandidateIndex } from './chordGuideUtil';
interface PianoGridProps {
gridRef: MutableRefObject<HTMLDivElement | null>;
@@ -27,7 +28,9 @@ interface PianoGridProps {
regionStartBeat?: number;
selectedMode: string;
keySignature: KeySignature;
chordGuide: string;
chordGuide: 'N' | 'T' | 'S' | 'D';
chordGuideKeySignature: KeySignature;
chordGuideMode: 'ionian' | 'aeolian';
audioRegion?: KGAudioRegion;
trackId?: string;
projectName?: string;
@@ -59,6 +62,8 @@ const PianoGrid: React.FC<PianoGridProps> = ({
selectedMode,
keySignature,
chordGuide,
chordGuideKeySignature,
chordGuideMode,
audioRegion,
trackId,
projectName,
@@ -162,8 +167,8 @@ const PianoGrid: React.FC<PianoGridProps> = ({
// Use the utility function to get matching chords
const functionType = chordGuide as 'T' | 'S' | 'D';
return getMatchingChordsForPitch(cursorPosition.pitch, keySignature, selectedMode, functionType);
}, [cursorPosition, chordGuide, keySignature, selectedMode]);
return getMatchingChordsForPitch(cursorPosition.pitch, chordGuideKeySignature, chordGuideMode, functionType);
}, [cursorPosition, chordGuide, chordGuideKeySignature, chordGuideMode]);
// Calculate chord highlights based on selected chord index
const chordHighlights = useMemo(() => {
@@ -212,9 +217,9 @@ const PianoGrid: React.FC<PianoGridProps> = ({
// Expose switchChord function via window for hotkey handler
useEffect(() => {
const switchChord = () => {
const switchChord = (direction: 1 | -1 = 1) => {
if (matchingChords.length > 1) {
setSelectedChordIndex(prev => (prev + 1) % matchingChords.length);
setSelectedChordIndex(prev => getNextChordCandidateIndex(prev, matchingChords.length, direction));
}
};
+37
View File
@@ -253,6 +253,12 @@
margin-right: 5px;
}
.piano-roll-chord-guide-toolbar-group {
display: flex;
align-items: center;
margin-right: 5px;
}
.piano-roll-toolbar .automation-toggle-button {
width: 20px;
min-width: 20px;
@@ -290,6 +296,37 @@
font-size: 10px;
}
.piano-roll-toolbar .chord-guide-toggle-button {
margin: 0;
border-radius: 0;
border: 1px solid #444;
border-right-width: 0;
}
.piano-roll-toolbar .chord-guide-toggle-button-0 {
margin-left: 3px;
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
}
.piano-roll-toolbar .chord-guide-toggle-button-3 {
border-right-width: 1px;
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
}
.piano-roll-toolbar .chord-guide-toggle-button.active {
border-color: #e0e0e0;
}
.piano-roll-toolbar .chord-guide-toggle-button.active:hover {
border-color: #f0f0f0;
}
.piano-roll-toolbar .chord-guide-toggle-button.active:active {
border-color: #d6d6d6;
}
/* Spectrogram toolbar controls */
.spectrogram-toolbar-controls {
display: flex;
+38 -12
View File
@@ -21,6 +21,7 @@ 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 {
normalizeSpectrogramHeightResolution,
type SpectrogramHeightResolution,
@@ -61,6 +62,7 @@ import {
getScrollLeftForViewportRequest,
type PendingModeSwitchRequest,
} from './pianoRollViewport';
import { getNextChordGuideSelection, resolveChordGuideContext, type ChordGuideFunction } from './chordGuideUtil';
interface PianoRollProps {
onClose: () => void;
@@ -126,7 +128,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const [snapping, setSnapping] = useState<string>('NO SNAP');
// Chord guide state
const [chordGuide, setChordGuide] = useState<string>('N');
const [chordGuide, setChordGuide] = useState<ChordGuideFunction>('N');
// Piano roll state with temporary initial values
const [position, setPosition] = useState(initialPosition || { x: 0, y: 0 });
@@ -161,6 +163,12 @@ const PianoRoll: React.FC<PianoRollProps> = ({
() => parseSheetQuantization(sheetQuantization),
[sheetQuantization]
);
const chordGuideContext = useMemo(() => {
const project = KGCore.instance().getCurrentProject();
return resolveChordGuideContext(project, playheadPosition);
}, [playheadPosition]);
const effectiveChordGuideKeySignature = chordGuideContext.keySignature;
const chordGuideMode = chordGuideContext.mode;
const pianoRollRef = useRef<HTMLDivElement>(null);
const pianoRollContentRef = useRef<HTMLDivElement>(null);
@@ -721,11 +729,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
}, [setSelectedMode]);
// Handle chord guide selection
const handleChordGuideSelect = useCallback((value: string) => {
const handleChordGuideSelect = useCallback((value: ChordGuideFunction) => {
setChordGuide(value);
}, []);
// Update suitable chords whenever chord guide, key signature, or mode changes
// Update suitable chords whenever chord guide selection or effective key signature changes
useEffect(() => {
const pianoRollState = KGPianoRollState.instance();
@@ -740,7 +748,7 @@ 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(keySignature, selectedMode, functionType);
const suitableChords = getSuitableChords(effectiveChordGuideKeySignature, chordGuideMode, functionType);
// Convert note names to pitch classes (ensuring ascending order)
const chordsPitchClasses: Record<string, number[]> = {};
@@ -769,11 +777,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Chord guide updated: ${chordGuide} (${functionType})`);
console.log(`Suitable chords for ${keySignature} in ${selectedMode} mode:`, suitableChords);
console.log(`Suitable chords for ${effectiveChordGuideKeySignature} in ${chordGuideMode} mode:`, suitableChords);
console.log(`Pitch classes:`, chordsPitchClasses);
}
}
}, [chordGuide, keySignature, selectedMode]);
}, [chordGuide, chordGuideMode, effectiveChordGuideKeySignature]);
// Handler for receiving the setNoteUpdateCounter function from PianoRollContent
const handleSetNoteUpdateTrigger = (setNoteFn: React.Dispatch<React.SetStateAction<number>>) => {
@@ -1363,14 +1371,30 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Handle piano roll hotkeys
const configManager = ConfigManager.instance();
if (configManager.getIsInitialized()) {
// Chord guide switch hotkey
const switch_key = configManager.get('hotkeys.piano_roll.switch') as string;
if (event.key && event.key.toLowerCase() === switch_key.toLowerCase()) {
// Call the switchChord function exposed by PianoGrid
const chordGuideSwitchShortcut = configManager.get('hotkeys.piano_roll.switch') as string;
const chordGuideSwitchVoicingShortcut = configManager.get('hotkeys.piano_roll.switch_voicing') as string;
if (chordGuideSwitchShortcut && matchesKeyboardShortcut(event, chordGuideSwitchShortcut)) {
event.preventDefault();
setChordGuide((current) => getNextChordGuideSelection(current));
return;
}
if (chordGuide !== 'N' && matchesKeyboardShortcut(event, 'tab')) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const switchChord = (window as any).__pianoGridSwitchChord;
if (typeof switchChord === 'function') {
switchChord();
switchChord(1);
event.preventDefault();
}
return;
}
if (chordGuide !== 'N' && chordGuideSwitchVoicingShortcut && matchesKeyboardShortcut(event, chordGuideSwitchVoicingShortcut)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const switchChord = (window as any).__pianoGridSwitchChord;
if (typeof switchChord === 'function') {
switchChord(-1);
event.preventDefault();
}
return;
@@ -1486,7 +1510,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
return () => {
window.removeEventListener('keydown', handlePianoRollKeyDown);
};
}, [handleQuantSelect, handleSnappingSelect]);
}, [chordGuide, handleQuantSelect, handleSnappingSelect]);
// Get the title for the piano roll based on the active region
const getPianoRollTitle = () => {
@@ -1594,6 +1618,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
selectedMode={selectedMode}
keySignature={keySignature}
chordGuide={chordGuide}
chordGuideKeySignature={effectiveChordGuideKeySignature}
chordGuideMode={chordGuideMode}
mode={currentMode}
audioRegion={audioRegion}
trackId={trackId}
@@ -76,7 +76,7 @@ describe('PianoRollContent', () => {
tracks: [],
selectedMode: 'ionian',
keySignature: 'C major' as KeySignature,
chordGuide: 'N',
chordGuide: 'N' as const,
bpm: 120,
};
@@ -36,7 +36,9 @@ interface PianoRollContentProps {
onSetDeleteNotesTrigger?: (deleteFn: () => boolean) => void;
selectedMode: string;
keySignature: KeySignature;
chordGuide: string;
chordGuide: 'N' | 'T' | 'S' | 'D';
chordGuideKeySignature?: KeySignature;
chordGuideMode?: 'ionian' | 'aeolian';
mode?: 'midi-edit' | 'audio-waveform' | 'spectrogram' | 'hybrid';
audioRegion?: KGAudioRegion;
trackId?: string;
@@ -73,6 +75,8 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
selectedMode,
keySignature,
chordGuide,
chordGuideKeySignature = keySignature,
chordGuideMode = 'ionian',
mode = 'midi-edit',
audioRegion,
trackId,
@@ -376,6 +380,8 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
selectedMode={selectedMode}
keySignature={keySignature}
chordGuide={chordGuide}
chordGuideKeySignature={chordGuideKeySignature}
chordGuideMode={chordGuideMode}
audioRegion={audioRegion}
trackId={trackId}
projectName={projectName}
@@ -56,7 +56,7 @@ describe('PianoRollToolbar', () => {
onSnappingSelect: vi.fn(),
selectedMode: 'ionian',
onModeChange: vi.fn(),
chordGuide: 'N',
chordGuide: 'N' as const,
onChordGuideChange: vi.fn(),
zoom: 1,
onZoomChange: vi.fn(),
@@ -106,6 +106,46 @@ describe('PianoRollToolbar', () => {
expect(onAutomationTypeChange).toHaveBeenCalledWith('cc-11');
});
it('renders chord guide toggle buttons and marks off as active by default', () => {
render(
<PianoRollToolbar
{...baseProps}
mode="midi-edit"
showAutomationControls={false}
/>
);
expect(screen.getByRole('button', { name: 'Chord guide off' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Chord guide tonic' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Chord guide subdominant' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Chord guide dominant' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Chord guide off' }).className).toContain('active');
expect(screen.queryByRole('button', { name: 'Chord' })).not.toBeInTheDocument();
});
it('emits the selected chord guide value when toggle buttons are clicked', () => {
const onChordGuideChange = vi.fn();
render(
<PianoRollToolbar
{...baseProps}
mode="midi-edit"
showAutomationControls={false}
onChordGuideChange={onChordGuideChange}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Chord guide off' }));
fireEvent.click(screen.getByRole('button', { name: 'Chord guide tonic' }));
fireEvent.click(screen.getByRole('button', { name: 'Chord guide subdominant' }));
fireEvent.click(screen.getByRole('button', { name: 'Chord guide dominant' }));
expect(onChordGuideChange).toHaveBeenNthCalledWith(1, 'N');
expect(onChordGuideChange).toHaveBeenNthCalledWith(2, 'T');
expect(onChordGuideChange).toHaveBeenNthCalledWith(3, 'S');
expect(onChordGuideChange).toHaveBeenNthCalledWith(4, 'D');
});
it('hides automation controls in spectrogram mode', () => {
render(
<PianoRollToolbar
+24 -15
View File
@@ -16,6 +16,13 @@ const POWER_OPTIONS = [
{ label: 'Strong', value: '0.3' },
];
const CHORD_GUIDE_BUTTONS: Array<{ label: string; value: 'N' | 'T' | 'S' | 'D'; ariaLabel: string }> = [
{ label: '⊘', value: 'N', ariaLabel: 'Chord guide off' },
{ label: 'T', value: 'T', ariaLabel: 'Chord guide tonic' },
{ label: 'S', value: 'S', ariaLabel: 'Chord guide subdominant' },
{ label: 'D', value: 'D', ariaLabel: 'Chord guide dominant' },
];
interface PianoRollToolbarProps {
showAudioSpectrogramToggle?: boolean;
audioSpectrogramEnabled?: boolean;
@@ -37,8 +44,8 @@ interface PianoRollToolbarProps {
onSnappingSelect: (value: string) => void;
selectedMode: string;
onModeChange: (value: string) => void;
chordGuide: string;
onChordGuideChange: (value: string) => void;
chordGuide: 'N' | 'T' | 'S' | 'D';
onChordGuideChange: (value: 'N' | 'T' | 'S' | 'D') => void;
blinkButton?: string | null;
mode?: 'midi-edit' | 'audio-waveform' | 'spectrogram' | 'hybrid';
thresholdDb?: number;
@@ -208,19 +215,21 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
buttonClassName="mode-dropdown"
showValueAsLabel={true}
/>
<KGDropdown
options={[
{ label: 'Guide: Disabled', value: 'N' },
{ label: 'Chord Guide: T', value: 'T' },
{ label: 'Chord Guide: S', value: 'S' },
{ label: 'Chord Guide: D', value: 'D' }
]}
value={chordGuide}
onChange={(value) => onChordGuideChange(value)}
label="Chord"
buttonClassName="chord-guide-dropdown"
showValueAsLabel={true}
/>
<div className="piano-roll-chord-guide-toolbar-group" role="group" aria-label="Chord guide">
{CHORD_GUIDE_BUTTONS.map((button, index) => (
<button
key={button.value}
type="button"
className={`tool-button automation-toggle-button chord-guide-toggle-button chord-guide-toggle-button-${index} ${chordGuide === button.value ? 'active' : ''}`}
onClick={() => onChordGuideChange(button.value)}
title={button.ariaLabel}
aria-label={button.ariaLabel}
aria-pressed={chordGuide === button.value}
>
{button.label}
</button>
))}
</div>
</div>
)}
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { KGProject } from '../../core/KGProject';
import { GlobalTrackType } from '../../core/global-track';
import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
import { getNextChordCandidateIndex, getNextChordGuideSelection, resolveChordGuideContext } from './chordGuideUtil';
describe('chordGuideUtil', () => {
it('cycles chord guide selection in the expected order', () => {
expect(getNextChordGuideSelection('N')).toBe('T');
expect(getNextChordGuideSelection('T')).toBe('S');
expect(getNextChordGuideSelection('S')).toBe('D');
expect(getNextChordGuideSelection('D')).toBe('N');
});
it('cycles candidate indices forward and backward with wraparound', () => {
expect(getNextChordCandidateIndex(0, 4, 1)).toBe(1);
expect(getNextChordCandidateIndex(3, 4, 1)).toBe(0);
expect(getNextChordCandidateIndex(0, 4, -1)).toBe(3);
expect(getNextChordCandidateIndex(2, 4, -1)).toBe(1);
});
it('leaves candidate index unchanged for empty or single-candidate lists', () => {
expect(getNextChordCandidateIndex(0, 0, 1)).toBe(0);
expect(getNextChordCandidateIndex(0, 1, 1)).toBe(0);
expect(getNextChordCandidateIndex(0, 1, -1)).toBe(0);
});
it('resolves ionian from the effective major key signature at the playhead beat', () => {
const project = new KGProject('test', 16, 0, 120, { numerator: 4, denominator: 4 }, 'C major', 'dorian');
const signatureTrack = project.getGlobalTracks().find((track) => track.getType() === GlobalTrackType.Signature);
signatureTrack?.setRegions([
new KGKeySignatureRegion('sig-0', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 2, 4),
new KGKeySignatureRegion('sig-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 2, 2, 4),
]);
expect(resolveChordGuideContext(project, 8)).toEqual({
keySignature: 'G major',
mode: 'ionian',
});
});
it('resolves aeolian from the effective minor key signature at the playhead beat', () => {
const project = new KGProject('test', 16, 0, 120, { numerator: 4, denominator: 4 }, 'C major', 'ionian');
const signatureTrack = project.getGlobalTracks().find((track) => track.getType() === GlobalTrackType.Signature);
signatureTrack?.setRegions([
new KGKeySignatureRegion('sig-0', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 1, 4),
new KGKeySignatureRegion('sig-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'A minor', 1, 2, 4),
]);
expect(resolveChordGuideContext(project, 4)).toEqual({
keySignature: 'A minor',
mode: 'aeolian',
});
});
});
@@ -0,0 +1,42 @@
import type { KeySignature, KGProject } from '../../core/KGProject';
import { getEffectiveKeySignatureAtBeat } from '../../util/globalTrackUtil';
import { getChordGuideModeFromKeySignature } from '../../util/scaleUtil';
export type ChordGuideFunction = 'N' | 'T' | 'S' | 'D';
export type ChordGuideMode = 'ionian' | 'aeolian';
export function getNextChordGuideSelection(current: ChordGuideFunction): ChordGuideFunction {
switch (current) {
case 'N':
return 'T';
case 'T':
return 'S';
case 'S':
return 'D';
default:
return 'N';
}
}
export function getNextChordCandidateIndex(
currentIndex: number,
candidateCount: number,
direction: 1 | -1
): number {
if (candidateCount <= 1) {
return currentIndex;
}
return (currentIndex + direction + candidateCount) % candidateCount;
}
export function resolveChordGuideContext(project: KGProject, beat: number): {
keySignature: KeySignature;
mode: ChordGuideMode;
} {
const keySignature = getEffectiveKeySignatureAtBeat(project, beat);
return {
keySignature,
mode: getChordGuideModeFromKeySignature(keySignature),
};
}