feat: added shortcut N for sheet music view

This commit is contained in:
Xiaohan-Tian
2026-05-18 19:12:08 -07:00
parent 4cf474be7e
commit 97dad696b2
7 changed files with 253 additions and 8 deletions
+2
View File
@@ -35,6 +35,8 @@ const storeState = {
storeState.activeRegionId = regionId; storeState.activeRegionId = regionId;
}), }),
pianoRollMode: 'midi-edit' as const, pianoRollMode: 'midi-edit' as const,
requestedSheetMusicViewEnabled: false,
pianoRollViewRequestVersion: 0,
openMidiPianoRoll: vi.fn(), openMidiPianoRoll: vi.fn(),
openSpectrogramViewer: vi.fn(), openSpectrogramViewer: vi.fn(),
openHybridMode: vi.fn(), openHybridMode: vi.fn(),
+4
View File
@@ -46,6 +46,8 @@ const MainContent: React.FC<MainContentProps> = ({
setShowPianoRoll, setShowPianoRoll,
setActiveRegionId, setActiveRegionId,
pianoRollMode, pianoRollMode,
requestedSheetMusicViewEnabled,
pianoRollViewRequestVersion,
openMidiPianoRoll, openMidiPianoRoll,
openSpectrogramViewer, openSpectrogramViewer,
openHybridMode, openHybridMode,
@@ -1052,6 +1054,8 @@ const MainContent: React.FC<MainContentProps> = ({
onClose={handlePianoRollClose} onClose={handlePianoRollClose}
regionId={activeRegionId} regionId={activeRegionId}
mode={pianoRollMode} mode={pianoRollMode}
requestedSheetMusicViewEnabled={requestedSheetMusicViewEnabled}
pianoRollViewRequestVersion={pianoRollViewRequestVersion}
audioRegion={(() => { audioRegion={(() => {
// spectrogram mode: audio region IS the activeRegionId // spectrogram mode: audio region IS the activeRegionId
// hybrid mode: audio region is hybridAudioRegionId // hybrid mode: audio region is hybridAudioRegionId
+41
View File
@@ -63,6 +63,8 @@ interface PianoRollProps {
initialPosition?: { x: number; y: number }; initialPosition?: { x: number; y: number };
initialSize?: { width: number; height: number }; initialSize?: { width: number; height: number };
mode?: 'midi-edit' | 'spectrogram' | 'hybrid'; mode?: 'midi-edit' | 'spectrogram' | 'hybrid';
requestedSheetMusicViewEnabled?: boolean;
pianoRollViewRequestVersion?: number;
audioRegion?: KGAudioRegion; audioRegion?: KGAudioRegion;
trackId?: string; trackId?: string;
projectName?: string; projectName?: string;
@@ -74,6 +76,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
initialPosition, initialPosition,
initialSize, initialSize,
mode = 'midi-edit', mode = 'midi-edit',
requestedSheetMusicViewEnabled = false,
pianoRollViewRequestVersion = 0,
audioRegion, audioRegion,
trackId, trackId,
projectName, projectName,
@@ -153,6 +157,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const pendingModeSwitchRequestRef = useRef<PendingModeSwitchRequest | null>(null); const pendingModeSwitchRequestRef = useRef<PendingModeSwitchRequest | null>(null);
const previousSheetMusicViewEnabledRef = useRef<boolean>(false); const previousSheetMusicViewEnabledRef = useRef<boolean>(false);
const previousActiveRegionIdRef = useRef<string | null>(null); const previousActiveRegionIdRef = useRef<string | null>(null);
const lastAppliedViewRequestVersionRef = useRef<number>(0);
// Ref for storing the setNoteUpdateCounter function // Ref for storing the setNoteUpdateCounter function
const triggerNoteUpdateRef = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null); const triggerNoteUpdateRef = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null);
@@ -268,6 +273,42 @@ const PianoRoll: React.FC<PianoRollProps> = ({
} }
}, []); // Empty dependency array means this runs once on mount }, []); // Empty dependency array means this runs once on mount
useEffect(() => {
if (isSpectrogram) {
return;
}
if (pianoRollViewRequestVersion === 0 || lastAppliedViewRequestVersionRef.current === pianoRollViewRequestVersion) {
return;
}
lastAppliedViewRequestVersionRef.current = pianoRollViewRequestVersion;
if (activeRegion) {
pendingModeSwitchRequestRef.current = createPendingModeSwitchRequest({
playheadBeat: playheadPosition,
regionStartBeat: activeRegion.getStartFromBeat(),
regionEndBeat: activeRegion.getStartFromBeat() + activeRegion.getLength(),
sourceSheetMusicViewEnabled: sheetMusicViewEnabled,
destinationSheetMusicViewEnabled: requestedSheetMusicViewEnabled,
destinationSheetMusicTrackScopeEnabled: requestedSheetMusicViewEnabled && sheetMusicTrackScopeEnabled,
});
} else {
pendingModeSwitchRequestRef.current = null;
}
setSheetMusicViewEnabled(requestedSheetMusicViewEnabled);
KGPianoRollState.instance().setSheetMusicViewEnabled(requestedSheetMusicViewEnabled);
}, [
activeRegion,
isSpectrogram,
pianoRollViewRequestVersion,
playheadPosition,
requestedSheetMusicViewEnabled,
sheetMusicTrackScopeEnabled,
sheetMusicViewEnabled,
]);
useEffect(() => { useEffect(() => {
let unsubscribe: (() => void) | undefined; let unsubscribe: (() => void) | undefined;
+99 -2
View File
@@ -2,12 +2,27 @@ import React from 'react';
import { fireEvent, render, waitFor } from '@testing-library/react'; import { fireEvent, render, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useGlobalKeyboardHandler } from './useGlobalKeyboardHandler'; import { useGlobalKeyboardHandler } from './useGlobalKeyboardHandler';
import { showAlert } from '../util/dialogUtil';
const regionEditUtilMocks = vi.hoisted(() => ({ const regionEditUtilMocks = vi.hoisted(() => ({
splitSelectedRegionAtPlayhead: vi.fn(), splitSelectedRegionAtPlayhead: vi.fn(),
mergeSelectedMidiRegions: vi.fn(), mergeSelectedMidiRegions: vi.fn(),
})); }));
const MockMidiRegion = vi.hoisted(() => class {
private readonly id: string;
constructor(id: string) {
this.id = id;
}
getId() {
return this.id;
}
});
let mockTracks: Array<{ getRegions: () => Array<{ getId: () => string }> }> = [];
const storeState = { const storeState = {
undo: vi.fn(), undo: vi.fn(),
redo: vi.fn(), redo: vi.fn(),
@@ -30,6 +45,7 @@ const storeState = {
setShowPianoRoll: vi.fn(), setShowPianoRoll: vi.fn(),
showPianoRoll: false, showPianoRoll: false,
openMidiPianoRoll: vi.fn(), openMidiPianoRoll: vi.fn(),
openMidiPianoRollWithSheetMusicView: vi.fn(),
openSpectrogramViewer: vi.fn(), openSpectrogramViewer: vi.fn(),
playheadPosition: 12, playheadPosition: 12,
refreshProjectState: vi.fn(), refreshProjectState: vi.fn(),
@@ -101,7 +117,7 @@ vi.mock('../core/KGCore', () => ({
KGCore: { KGCore: {
instance: () => ({ instance: () => ({
getCurrentProject: () => ({ getCurrentProject: () => ({
getTracks: () => [], getTracks: () => mockTracks,
}), }),
}), }),
}, },
@@ -116,7 +132,7 @@ vi.mock('../core/midi-input/KGMidiInput', () => ({
})); }));
vi.mock('../core/region/KGMidiRegion', () => ({ vi.mock('../core/region/KGMidiRegion', () => ({
KGMidiRegion: class {}, KGMidiRegion: MockMidiRegion,
})); }));
vi.mock('../core/track/KGAudioTrack', () => ({ vi.mock('../core/track/KGAudioTrack', () => ({
@@ -139,9 +155,18 @@ const HookHarness = () => {
describe('useGlobalKeyboardHandler region shortcuts', () => { describe('useGlobalKeyboardHandler region shortcuts', () => {
beforeEach(() => { beforeEach(() => {
mockTracks = [];
regionEditUtilMocks.splitSelectedRegionAtPlayhead.mockReset(); regionEditUtilMocks.splitSelectedRegionAtPlayhead.mockReset();
regionEditUtilMocks.mergeSelectedMidiRegions.mockReset(); regionEditUtilMocks.mergeSelectedMidiRegions.mockReset();
storeState.setStatus.mockClear(); storeState.setStatus.mockClear();
storeState.setShowPianoRoll.mockClear();
storeState.showPianoRoll = false;
storeState.activeRegionId = null;
storeState.selectedRegionIds = ['region-a', 'region-b'];
storeState.openMidiPianoRoll.mockClear();
storeState.openMidiPianoRollWithSheetMusicView.mockClear();
storeState.openSpectrogramViewer.mockClear();
vi.mocked(showAlert).mockClear();
}); });
it('triggers split on Ctrl+T', async () => { it('triggers split on Ctrl+T', async () => {
@@ -180,4 +205,76 @@ describe('useGlobalKeyboardHandler region shortcuts', () => {
expect(storeState.setStatus).toHaveBeenCalledWith('Merged 2 MIDI regions'); expect(storeState.setStatus).toHaveBeenCalledWith('Merged 2 MIDI regions');
}); });
}); });
it('opens a selected MIDI region in piano roll view on E', () => {
mockTracks = [
{
getRegions: () => [new MockMidiRegion('region-b')],
},
];
render(<HookHarness />);
fireEvent.keyDown(document.body, { key: 'e' });
expect(storeState.openMidiPianoRollWithSheetMusicView).toHaveBeenCalledWith('region-b', false);
expect(storeState.openSpectrogramViewer).not.toHaveBeenCalled();
});
it('opens a selected MIDI region in sheet music view on N', () => {
mockTracks = [
{
getRegions: () => [new MockMidiRegion('region-b')],
},
];
render(<HookHarness />);
fireEvent.keyDown(document.body, { key: 'n' });
expect(storeState.openMidiPianoRollWithSheetMusicView).toHaveBeenCalledWith('region-b', true);
expect(storeState.setShowPianoRoll).not.toHaveBeenCalled();
});
it('shows the editor alert on N when no region is selected', () => {
storeState.selectedRegionIds = [];
render(<HookHarness />);
fireEvent.keyDown(document.body, { key: 'n' });
expect(showAlert).toHaveBeenCalledWith('Please select a region to open the editor.');
expect(storeState.openMidiPianoRollWithSheetMusicView).not.toHaveBeenCalled();
});
it('does not open spectrogram on N for an audio region', () => {
mockTracks = [
{
getRegions: () => [
{
getId: () => 'region-b',
},
],
},
];
render(<HookHarness />);
fireEvent.keyDown(document.body, { key: 'n' });
expect(showAlert).toHaveBeenCalledWith('Sheet music view is only available for MIDI regions.');
expect(storeState.openSpectrogramViewer).not.toHaveBeenCalled();
expect(storeState.openMidiPianoRollWithSheetMusicView).not.toHaveBeenCalled();
});
it('does not close the editor when N is pressed while piano roll is already open', () => {
storeState.showPianoRoll = true;
mockTracks = [
{
getRegions: () => [new MockMidiRegion('region-b')],
},
];
render(<HookHarness />);
fireEvent.keyDown(document.body, { key: 'n' });
expect(storeState.setShowPianoRoll).not.toHaveBeenCalled();
expect(storeState.openMidiPianoRollWithSheetMusicView).toHaveBeenCalledWith('region-b', true);
});
}); });
+30 -3
View File
@@ -17,7 +17,7 @@ import { showAlert } from '../util/dialogUtil';
* Handles keyboard shortcuts defined in the configuration * Handles keyboard shortcuts defined in the configuration
*/ */
export const useGlobalKeyboardHandler = () => { export const useGlobalKeyboardHandler = () => {
const { undo, redo, setStatus, isPlaying, startPlaying, stopTransport, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName, isRecording, startRecording, stopRecording, activeRegionId, selectedRegionIds, setActiveRegionId, setShowPianoRoll, showPianoRoll, openMidiPianoRoll, openSpectrogramViewer, playheadPosition, refreshProjectState } = useProjectStore(); const { undo, redo, setStatus, isPlaying, startPlaying, stopTransport, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName, isRecording, startRecording, stopRecording, activeRegionId, selectedRegionIds, setActiveRegionId, setShowPianoRoll, showPianoRoll, openMidiPianoRollWithSheetMusicView, openSpectrogramViewer, playheadPosition, refreshProjectState } = useProjectStore();
const lastSelectedRegionId = selectedRegionIds[selectedRegionIds.length - 1] ?? null; const lastSelectedRegionId = selectedRegionIds[selectedRegionIds.length - 1] ?? null;
useEffect(() => { useEffect(() => {
@@ -256,13 +256,40 @@ export const useGlobalKeyboardHandler = () => {
} }
} }
if (foundMidi) { if (foundMidi) {
openMidiPianoRoll(candidateId); openMidiPianoRollWithSheetMusicView(candidateId, false);
} else if (foundAudio) { } else if (foundAudio) {
openSpectrogramViewer(candidateId); openSpectrogramViewer(candidateId);
} }
return; return;
} }
// Check for sheet music shortcut (N) — open piano roll for MIDI in sheet music view
if (event.key.toLowerCase() === 'n' && !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
event.preventDefault();
const candidateId = activeRegionId ?? lastSelectedRegionId;
if (!candidateId) {
void showAlert('Please select a region to open the editor.');
return;
}
const tracks = KGCore.instance().getCurrentProject().getTracks();
let foundMidi = false;
let foundAudio = false;
for (const track of tracks) {
const region = track.getRegions().find(r => r.getId() === candidateId);
if (region) {
if (region instanceof KGMidiRegion) foundMidi = true;
else foundAudio = true;
break;
}
}
if (foundMidi) {
openMidiPianoRollWithSheetMusicView(candidateId, true);
} else if (foundAudio) {
void showAlert('Sheet music view is only available for MIDI regions.');
}
return;
}
// Check for save shortcut // Check for save shortcut
if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) { if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) {
event.preventDefault(); event.preventDefault();
@@ -307,7 +334,7 @@ export const useGlobalKeyboardHandler = () => {
setActiveRegionId, setActiveRegionId,
setShowPianoRoll, setShowPianoRoll,
showPianoRoll, showPianoRoll,
openMidiPianoRoll, openMidiPianoRollWithSheetMusicView,
openSpectrogramViewer, openSpectrogramViewer,
playheadPosition, playheadPosition,
refreshProjectState, refreshProjectState,
+35
View File
@@ -3,6 +3,10 @@ import { act } from '@testing-library/react';
import { KGTrack } from '../core/track/KGTrack'; import { KGTrack } from '../core/track/KGTrack';
import { KGMidiTrack } from '../core/track/KGMidiTrack'; import { KGMidiTrack } from '../core/track/KGMidiTrack';
const pianoRollStateMocks = vi.hoisted(() => ({
setSheetMusicViewEnabled: vi.fn(),
}));
let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')]; let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
const mockProject = { const mockProject = {
getTimeSignature: () => ({ numerator: 4, denominator: 4 }), getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
@@ -76,10 +80,17 @@ vi.mock('../core/config/ConfigManager', () => ({
}, },
})); }));
vi.mock('../core/state/KGPianoRollState', () => ({
KGPianoRollState: {
instance: () => pianoRollStateMocks,
},
}));
describe('projectStore piano roll state', () => { describe('projectStore piano roll state', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.resetModules(); vi.resetModules();
pianoRollStateMocks.setSheetMusicViewEnabled.mockReset();
mockTracks = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')]; mockTracks = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
mockCore.startPlaying.mockReset(); mockCore.startPlaying.mockReset();
mockCore.startPlaying.mockResolvedValue(undefined); mockCore.startPlaying.mockResolvedValue(undefined);
@@ -118,10 +129,34 @@ describe('projectStore piano roll state', () => {
}); });
state = useProjectStore.getState(); state = useProjectStore.getState();
expect(pianoRollStateMocks.setSheetMusicViewEnabled).toHaveBeenCalledWith(false);
expect(state.showPianoRoll).toBe(true); expect(state.showPianoRoll).toBe(true);
expect(state.pianoRollMode).toBe('midi-edit'); expect(state.pianoRollMode).toBe('midi-edit');
expect(state.activeRegionId).toBe('midi-b'); expect(state.activeRegionId).toBe('midi-b');
expect(state.hybridAudioRegionId).toBeNull(); expect(state.hybridAudioRegionId).toBeNull();
expect(state.requestedSheetMusicViewEnabled).toBe(false);
expect(state.pianoRollViewRequestVersion).toBe(1);
});
it('opens a MIDI region in sheet music view when requested', async () => {
const { useProjectStore } = await import('./projectStore');
act(() => {
useProjectStore.getState().openHybridMode('midi-a', 'audio-a');
});
act(() => {
useProjectStore.getState().openMidiPianoRollWithSheetMusicView('midi-b', true);
});
const state = useProjectStore.getState();
expect(pianoRollStateMocks.setSheetMusicViewEnabled).toHaveBeenCalledWith(true);
expect(state.showPianoRoll).toBe(true);
expect(state.pianoRollMode).toBe('midi-edit');
expect(state.activeRegionId).toBe('midi-b');
expect(state.hybridAudioRegionId).toBeNull();
expect(state.requestedSheetMusicViewEnabled).toBe(true);
expect(state.pianoRollViewRequestVersion).toBe(1);
}); });
it('tracks playback preparation around startPlaying success', async () => { it('tracks playback preparation around startPlaying success', async () => {
+42 -3
View File
@@ -98,6 +98,8 @@ interface ProjectState {
activeRegionId: string | null; activeRegionId: string | null;
pianoRollMode: 'midi-edit' | 'spectrogram' | 'hybrid'; pianoRollMode: 'midi-edit' | 'spectrogram' | 'hybrid';
hybridAudioRegionId: string | null; hybridAudioRegionId: string | null;
requestedSheetMusicViewEnabled: boolean;
pianoRollViewRequestVersion: number;
automationRedrawVersion: number; automationRedrawVersion: number;
activeTrackAutomationTrackId: string | null; activeTrackAutomationTrackId: string | null;
activeTrackAutomationType: TrackAutomationType | null; activeTrackAutomationType: TrackAutomationType | null;
@@ -194,6 +196,7 @@ interface ProjectState {
setShowPianoRoll: (show: boolean) => void; setShowPianoRoll: (show: boolean) => void;
setActiveRegionId: (regionId: string | null) => void; setActiveRegionId: (regionId: string | null) => void;
openMidiPianoRoll: (regionId: string) => void; openMidiPianoRoll: (regionId: string) => void;
openMidiPianoRollWithSheetMusicView: (regionId: string, sheetMusicViewEnabled: boolean) => void;
openSpectrogramViewer: (regionId: string) => void; openSpectrogramViewer: (regionId: string) => void;
openHybridMode: (midiRegionId: string, audioRegionId: string) => void; openHybridMode: (midiRegionId: string, audioRegionId: string) => void;
bumpAutomationRedrawVersion: () => void; bumpAutomationRedrawVersion: () => void;
@@ -430,6 +433,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
activeRegionId: null, activeRegionId: null,
pianoRollMode: 'midi-edit' as const, pianoRollMode: 'midi-edit' as const,
hybridAudioRegionId: null, hybridAudioRegionId: null,
requestedSheetMusicViewEnabled: false,
pianoRollViewRequestVersion: 0,
automationRedrawVersion: 0, automationRedrawVersion: 0,
activeTrackAutomationTrackId: null, activeTrackAutomationTrackId: null,
activeTrackAutomationType: null, activeTrackAutomationType: null,
@@ -1560,15 +1565,47 @@ export const useProjectStore = create<ProjectState>((set, get) => {
}, },
openMidiPianoRoll: (regionId: string) => { openMidiPianoRoll: (regionId: string) => {
set({ showPianoRoll: true, activeRegionId: regionId, pianoRollMode: 'midi-edit', hybridAudioRegionId: null }); KGPianoRollState.instance().setSheetMusicViewEnabled(false);
set(state => ({
showPianoRoll: true,
activeRegionId: regionId,
pianoRollMode: 'midi-edit',
hybridAudioRegionId: null,
requestedSheetMusicViewEnabled: false,
pianoRollViewRequestVersion: state.pianoRollViewRequestVersion + 1,
}));
},
openMidiPianoRollWithSheetMusicView: (regionId: string, sheetMusicViewEnabled: boolean) => {
KGPianoRollState.instance().setSheetMusicViewEnabled(sheetMusicViewEnabled);
set(state => ({
showPianoRoll: true,
activeRegionId: regionId,
pianoRollMode: 'midi-edit',
hybridAudioRegionId: null,
requestedSheetMusicViewEnabled: sheetMusicViewEnabled,
pianoRollViewRequestVersion: state.pianoRollViewRequestVersion + 1,
}));
}, },
openSpectrogramViewer: (regionId: string) => { openSpectrogramViewer: (regionId: string) => {
set({ showPianoRoll: true, activeRegionId: regionId, pianoRollMode: 'spectrogram', hybridAudioRegionId: null }); set({
showPianoRoll: true,
activeRegionId: regionId,
pianoRollMode: 'spectrogram',
hybridAudioRegionId: null,
requestedSheetMusicViewEnabled: false,
});
}, },
openHybridMode: (midiRegionId: string, audioRegionId: string) => { openHybridMode: (midiRegionId: string, audioRegionId: string) => {
set({ showPianoRoll: true, activeRegionId: midiRegionId, hybridAudioRegionId: audioRegionId, pianoRollMode: 'hybrid' }); set({
showPianoRoll: true,
activeRegionId: midiRegionId,
hybridAudioRegionId: audioRegionId,
pianoRollMode: 'hybrid',
requestedSheetMusicViewEnabled: false,
});
}, },
bumpAutomationRedrawVersion: () => { bumpAutomationRedrawVersion: () => {
set(state => ({ automationRedrawVersion: state.automationRedrawVersion + 1 })); set(state => ({ automationRedrawVersion: state.automationRedrawVersion + 1 }));
@@ -1587,6 +1624,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
activeRegionId: null, activeRegionId: null,
hybridAudioRegionId: null, hybridAudioRegionId: null,
pianoRollMode: 'midi-edit', pianoRollMode: 'midi-edit',
requestedSheetMusicViewEnabled: false,
pianoRollViewRequestVersion: 0,
activeTrackAutomationTrackId: null, activeTrackAutomationTrackId: null,
activeTrackAutomationType: null, activeTrackAutomationType: null,
trackAutomationRedrawVersion: 0, trackAutomationRedrawVersion: 0,