feat: simplify chord guide feature; updated help doc; added hotkeys doc
This commit is contained in:
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { enforceDefaultHotkeysForAppConfig } from './ConfigManager';
|
||||
|
||||
const baseConfig = {
|
||||
general: {} as never,
|
||||
hotkeys: {
|
||||
main: {} as never,
|
||||
piano_roll: {
|
||||
switch: 'g',
|
||||
switch_voicing: 'shift+tab',
|
||||
select: 'q',
|
||||
pencil: 'w',
|
||||
hold_to_create_note: 'ctrl',
|
||||
snap_none: '1',
|
||||
snap_1_4: '2',
|
||||
snap_1_8: '3',
|
||||
snap_1_16: '4',
|
||||
qua_pos_1_4: '5',
|
||||
qua_pos_1_8: '6',
|
||||
qua_pos_1_16: '7',
|
||||
qua_len_1_4: '8',
|
||||
qua_len_1_8: '9',
|
||||
qua_len_1_16: '0',
|
||||
},
|
||||
},
|
||||
editor: {} as never,
|
||||
chatbox: {} as never,
|
||||
audio: {} as never,
|
||||
templates: {} as never,
|
||||
chord_guide: {} as never,
|
||||
};
|
||||
|
||||
describe('enforceDefaultHotkeysForAppConfig', () => {
|
||||
it('always replaces saved hotkeys with config defaults', () => {
|
||||
const result = enforceDefaultHotkeysForAppConfig(
|
||||
{
|
||||
...baseConfig,
|
||||
hotkeys: {
|
||||
...baseConfig.hotkeys,
|
||||
piano_roll: {
|
||||
...baseConfig.hotkeys.piano_roll,
|
||||
switch: 'tab',
|
||||
switch_voicing: 'f',
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
baseConfig as never
|
||||
);
|
||||
|
||||
expect(result.hotkeys.main).toBe(baseConfig.hotkeys.main);
|
||||
expect(result.hotkeys.piano_roll.switch).toBe('g');
|
||||
expect(result.hotkeys.piano_roll.switch_voicing).toBe('shift+tab');
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,7 @@ interface AppConfig {
|
||||
};
|
||||
piano_roll: {
|
||||
switch: string;
|
||||
switch_voicing: string;
|
||||
select: string;
|
||||
pencil: string;
|
||||
hold_to_create_note: string;
|
||||
@@ -106,6 +107,13 @@ interface AppConfig {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function enforceDefaultHotkeysForAppConfig(config: AppConfig, defaultConfig: AppConfig): AppConfig {
|
||||
return {
|
||||
...config,
|
||||
hotkeys: defaultConfig.hotkeys,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfigManager - Manages application configuration with IndexedDB persistence
|
||||
* Implements the singleton pattern for global access
|
||||
@@ -162,6 +170,7 @@ export class ConfigManager {
|
||||
|
||||
// Merge with default config (saved config overrides defaults)
|
||||
this.config = this.mergeConfigs(this.defaultConfig!, savedConfig);
|
||||
this.config = enforceDefaultHotkeysForAppConfig(this.config, this.defaultConfig!);
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('ConfigManager initialized successfully with config:', this.config);
|
||||
@@ -253,7 +262,8 @@ export class ConfigManager {
|
||||
merge_regions: 'ctrl+j'
|
||||
},
|
||||
piano_roll: {
|
||||
switch: 'tab',
|
||||
switch: 'g',
|
||||
switch_voicing: 'shift+tab',
|
||||
select: 'q',
|
||||
pencil: 'w',
|
||||
hold_to_create_note: 'ctrl',
|
||||
@@ -329,9 +339,10 @@ export class ConfigManager {
|
||||
try {
|
||||
const shouldSanitize = !this.isRunningOnLocalhost() &&
|
||||
!this.config.general.persist_api_keys_non_localhost;
|
||||
const configToPersist = shouldSanitize
|
||||
const baseConfigToPersist = shouldSanitize
|
||||
? this.getSanitizedConfigForStorage()
|
||||
: this.config;
|
||||
const configToPersist = this.removeHotkeysFromConfigForStorage(baseConfigToPersist);
|
||||
|
||||
await this.storage.save(
|
||||
ConfigManager.CONFIG_KEY,
|
||||
@@ -475,6 +486,11 @@ export class ConfigManager {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
private removeHotkeysFromConfigForStorage(config: AppConfig): AppConfig {
|
||||
const { hotkeys: _hotkeys, ...configWithoutHotkeys } = config;
|
||||
return configWithoutHotkeys as AppConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from an object using dot notation
|
||||
*/
|
||||
|
||||
@@ -49,7 +49,7 @@ vi.mock('../localLLMConfig', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
describe('processUserMessage /welcome', () => {
|
||||
describe('processUserMessage slash commands', () => {
|
||||
beforeEach(() => {
|
||||
configState.clear();
|
||||
configState.set('general.llm_provider', 'local_browser');
|
||||
@@ -132,6 +132,49 @@ describe('processUserMessage /welcome', () => {
|
||||
expect(message?.content).toContain('welcome_local_llm.md');
|
||||
});
|
||||
|
||||
it('fetches the hotkeys guide for /hotkeys', async () => {
|
||||
const result = await processUserMessage('/hotkeys');
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/hotkeys.md'));
|
||||
expect(result).toMatchObject({
|
||||
displayUserMessage: false,
|
||||
sendToLLM: false,
|
||||
finalMessageForLLM: null,
|
||||
metadata: { command: 'hotkeys' },
|
||||
});
|
||||
expect(result.pseudoAssistantResponse).toContain('chat/hotkeys.md');
|
||||
});
|
||||
|
||||
it('supports /hotkey as an alias of /hotkeys', async () => {
|
||||
const result = await processUserMessage('/hotkey');
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/hotkeys.md'));
|
||||
expect(result).toMatchObject({
|
||||
displayUserMessage: false,
|
||||
sendToLLM: false,
|
||||
finalMessageForLLM: null,
|
||||
metadata: { command: 'hotkeys' },
|
||||
});
|
||||
expect(result.pseudoAssistantResponse).toContain('chat/hotkeys.md');
|
||||
});
|
||||
|
||||
it('lists the new hotkeys commands for unknown slash commands', async () => {
|
||||
const result = await processUserMessage('/unknown foo');
|
||||
|
||||
expect(storeState.setStatus).toHaveBeenCalledWith(
|
||||
'Unknown command: /unknown. Available commands: /clear, /welcome, /help, /hotkeys, /hotkey'
|
||||
);
|
||||
expect(result.pseudoAssistantResponse).toBe(
|
||||
'Unknown command: /unknown foo.\nAvailable commands: /clear, /welcome, /help, /hotkeys, /hotkey'
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
displayUserMessage: false,
|
||||
sendToLLM: false,
|
||||
finalMessageForLLM: null,
|
||||
metadata: { command: 'unknown' },
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks local-browser messages when the runtime is hard unsupported', async () => {
|
||||
detectLocalLLMRuntimeSupportMock.mockReturnValue({
|
||||
supported: false,
|
||||
|
||||
@@ -148,9 +148,37 @@ export async function processUserMessage(originalMessage: string): Promise<UserM
|
||||
}
|
||||
}
|
||||
|
||||
case '/hotkeys':
|
||||
case '/hotkey': {
|
||||
try {
|
||||
const url = `${import.meta.env.BASE_URL}chat/hotkeys.md`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${resp.status}`);
|
||||
}
|
||||
const md = await resp.text();
|
||||
return {
|
||||
displayUserMessage: false,
|
||||
sendToLLM: false,
|
||||
finalMessageForLLM: null,
|
||||
pseudoAssistantResponse: md,
|
||||
metadata: { command: 'hotkeys' }
|
||||
};
|
||||
} catch (err) {
|
||||
const fallback = 'Hotkeys guide is currently unavailable.';
|
||||
return {
|
||||
displayUserMessage: false,
|
||||
sendToLLM: false,
|
||||
finalMessageForLLM: null,
|
||||
pseudoAssistantResponse: fallback,
|
||||
metadata: { command: 'hotkeys', error: String(err) }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
const { setStatus } = useProjectStore.getState();
|
||||
const help = 'Available commands: /clear, /welcome, /help';
|
||||
const help = 'Available commands: /clear, /welcome, /help, /hotkeys, /hotkey';
|
||||
setStatus(`Unknown command: ${command}. ${help}`);
|
||||
return {
|
||||
displayUserMessage: false,
|
||||
|
||||
@@ -17,6 +17,14 @@ export const getRootNoteFromKeySignature = (keySignature: KeySignature): string
|
||||
return match[1];
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the chord-guide mode from a key signature.
|
||||
* Chord guiding only supports major/minor quality and maps them to Ionian/Aeolian.
|
||||
*/
|
||||
export const getChordGuideModeFromKeySignature = (keySignature: KeySignature): 'ionian' | 'aeolian' => {
|
||||
return keySignature.endsWith(' minor') ? 'aeolian' : 'ionian';
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a note name (without octave) to pitch class (0-11)
|
||||
* @param noteName - Note name like "C", "C#", "Db", "F#"
|
||||
|
||||
Reference in New Issue
Block a user