diff --git a/src/components/MainContent.test.tsx b/src/components/MainContent.test.tsx index fc0237d..c374e5e 100644 --- a/src/components/MainContent.test.tsx +++ b/src/components/MainContent.test.tsx @@ -35,6 +35,8 @@ const storeState = { storeState.activeRegionId = regionId; }), pianoRollMode: 'midi-edit' as const, + requestedSheetMusicViewEnabled: false, + pianoRollViewRequestVersion: 0, openMidiPianoRoll: vi.fn(), openSpectrogramViewer: vi.fn(), openHybridMode: vi.fn(), diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index 3cb657b..ace8c85 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -46,6 +46,8 @@ const MainContent: React.FC = ({ setShowPianoRoll, setActiveRegionId, pianoRollMode, + requestedSheetMusicViewEnabled, + pianoRollViewRequestVersion, openMidiPianoRoll, openSpectrogramViewer, openHybridMode, @@ -1052,6 +1054,8 @@ const MainContent: React.FC = ({ onClose={handlePianoRollClose} regionId={activeRegionId} mode={pianoRollMode} + requestedSheetMusicViewEnabled={requestedSheetMusicViewEnabled} + pianoRollViewRequestVersion={pianoRollViewRequestVersion} audioRegion={(() => { // spectrogram mode: audio region IS the activeRegionId // hybrid mode: audio region is hybridAudioRegionId diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index 56cfd76..85170b3 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -63,6 +63,8 @@ interface PianoRollProps { initialPosition?: { x: number; y: number }; initialSize?: { width: number; height: number }; mode?: 'midi-edit' | 'spectrogram' | 'hybrid'; + requestedSheetMusicViewEnabled?: boolean; + pianoRollViewRequestVersion?: number; audioRegion?: KGAudioRegion; trackId?: string; projectName?: string; @@ -74,6 +76,8 @@ const PianoRoll: React.FC = ({ initialPosition, initialSize, mode = 'midi-edit', + requestedSheetMusicViewEnabled = false, + pianoRollViewRequestVersion = 0, audioRegion, trackId, projectName, @@ -153,6 +157,7 @@ const PianoRoll: React.FC = ({ const pendingModeSwitchRequestRef = useRef(null); const previousSheetMusicViewEnabledRef = useRef(false); const previousActiveRegionIdRef = useRef(null); + const lastAppliedViewRequestVersionRef = useRef(0); // Ref for storing the setNoteUpdateCounter function const triggerNoteUpdateRef = useRef> | null>(null); @@ -268,6 +273,42 @@ const PianoRoll: React.FC = ({ } }, []); // 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(() => { let unsubscribe: (() => void) | undefined; diff --git a/src/hooks/useGlobalKeyboardHandler.test.tsx b/src/hooks/useGlobalKeyboardHandler.test.tsx index 795b12a..35f4e40 100644 --- a/src/hooks/useGlobalKeyboardHandler.test.tsx +++ b/src/hooks/useGlobalKeyboardHandler.test.tsx @@ -2,12 +2,27 @@ import React from 'react'; import { fireEvent, render, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useGlobalKeyboardHandler } from './useGlobalKeyboardHandler'; +import { showAlert } from '../util/dialogUtil'; const regionEditUtilMocks = vi.hoisted(() => ({ splitSelectedRegionAtPlayhead: 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 = { undo: vi.fn(), redo: vi.fn(), @@ -30,6 +45,7 @@ const storeState = { setShowPianoRoll: vi.fn(), showPianoRoll: false, openMidiPianoRoll: vi.fn(), + openMidiPianoRollWithSheetMusicView: vi.fn(), openSpectrogramViewer: vi.fn(), playheadPosition: 12, refreshProjectState: vi.fn(), @@ -101,7 +117,7 @@ vi.mock('../core/KGCore', () => ({ KGCore: { instance: () => ({ getCurrentProject: () => ({ - getTracks: () => [], + getTracks: () => mockTracks, }), }), }, @@ -116,7 +132,7 @@ vi.mock('../core/midi-input/KGMidiInput', () => ({ })); vi.mock('../core/region/KGMidiRegion', () => ({ - KGMidiRegion: class {}, + KGMidiRegion: MockMidiRegion, })); vi.mock('../core/track/KGAudioTrack', () => ({ @@ -139,9 +155,18 @@ const HookHarness = () => { describe('useGlobalKeyboardHandler region shortcuts', () => { beforeEach(() => { + mockTracks = []; regionEditUtilMocks.splitSelectedRegionAtPlayhead.mockReset(); regionEditUtilMocks.mergeSelectedMidiRegions.mockReset(); 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 () => { @@ -180,4 +205,76 @@ describe('useGlobalKeyboardHandler region shortcuts', () => { 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(); + 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(); + 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(); + 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(); + 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(); + fireEvent.keyDown(document.body, { key: 'n' }); + + expect(storeState.setShowPianoRoll).not.toHaveBeenCalled(); + expect(storeState.openMidiPianoRollWithSheetMusicView).toHaveBeenCalledWith('region-b', true); + }); }); diff --git a/src/hooks/useGlobalKeyboardHandler.ts b/src/hooks/useGlobalKeyboardHandler.ts index 589d551..6497542 100644 --- a/src/hooks/useGlobalKeyboardHandler.ts +++ b/src/hooks/useGlobalKeyboardHandler.ts @@ -17,7 +17,7 @@ import { showAlert } from '../util/dialogUtil'; * Handles keyboard shortcuts defined in the configuration */ 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; useEffect(() => { @@ -256,13 +256,40 @@ export const useGlobalKeyboardHandler = () => { } } if (foundMidi) { - openMidiPianoRoll(candidateId); + openMidiPianoRollWithSheetMusicView(candidateId, false); } else if (foundAudio) { openSpectrogramViewer(candidateId); } 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 if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) { event.preventDefault(); @@ -307,7 +334,7 @@ export const useGlobalKeyboardHandler = () => { setActiveRegionId, setShowPianoRoll, showPianoRoll, - openMidiPianoRoll, + openMidiPianoRollWithSheetMusicView, openSpectrogramViewer, playheadPosition, refreshProjectState, diff --git a/src/stores/projectStore.test.ts b/src/stores/projectStore.test.ts index 66b1478..9788284 100644 --- a/src/stores/projectStore.test.ts +++ b/src/stores/projectStore.test.ts @@ -3,6 +3,10 @@ import { act } from '@testing-library/react'; import { KGTrack } from '../core/track/KGTrack'; import { KGMidiTrack } from '../core/track/KGMidiTrack'; +const pianoRollStateMocks = vi.hoisted(() => ({ + setSheetMusicViewEnabled: vi.fn(), +})); + let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')]; const mockProject = { 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', () => { beforeEach(() => { vi.useFakeTimers(); vi.resetModules(); + pianoRollStateMocks.setSheetMusicViewEnabled.mockReset(); mockTracks = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')]; mockCore.startPlaying.mockReset(); mockCore.startPlaying.mockResolvedValue(undefined); @@ -118,10 +129,34 @@ describe('projectStore piano roll state', () => { }); state = useProjectStore.getState(); + expect(pianoRollStateMocks.setSheetMusicViewEnabled).toHaveBeenCalledWith(false); 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(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 () => { diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index eabd77a..eafac14 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -98,6 +98,8 @@ interface ProjectState { activeRegionId: string | null; pianoRollMode: 'midi-edit' | 'spectrogram' | 'hybrid'; hybridAudioRegionId: string | null; + requestedSheetMusicViewEnabled: boolean; + pianoRollViewRequestVersion: number; automationRedrawVersion: number; activeTrackAutomationTrackId: string | null; activeTrackAutomationType: TrackAutomationType | null; @@ -194,6 +196,7 @@ interface ProjectState { setShowPianoRoll: (show: boolean) => void; setActiveRegionId: (regionId: string | null) => void; openMidiPianoRoll: (regionId: string) => void; + openMidiPianoRollWithSheetMusicView: (regionId: string, sheetMusicViewEnabled: boolean) => void; openSpectrogramViewer: (regionId: string) => void; openHybridMode: (midiRegionId: string, audioRegionId: string) => void; bumpAutomationRedrawVersion: () => void; @@ -430,6 +433,8 @@ export const useProjectStore = create((set, get) => { activeRegionId: null, pianoRollMode: 'midi-edit' as const, hybridAudioRegionId: null, + requestedSheetMusicViewEnabled: false, + pianoRollViewRequestVersion: 0, automationRedrawVersion: 0, activeTrackAutomationTrackId: null, activeTrackAutomationType: null, @@ -1560,15 +1565,47 @@ export const useProjectStore = create((set, get) => { }, 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) => { - 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) => { - set({ showPianoRoll: true, activeRegionId: midiRegionId, hybridAudioRegionId: audioRegionId, pianoRollMode: 'hybrid' }); + set({ + showPianoRoll: true, + activeRegionId: midiRegionId, + hybridAudioRegionId: audioRegionId, + pianoRollMode: 'hybrid', + requestedSheetMusicViewEnabled: false, + }); }, bumpAutomationRedrawVersion: () => { set(state => ({ automationRedrawVersion: state.automationRedrawVersion + 1 })); @@ -1587,6 +1624,8 @@ export const useProjectStore = create((set, get) => { activeRegionId: null, hybridAudioRegionId: null, pianoRollMode: 'midi-edit', + requestedSheetMusicViewEnabled: false, + pianoRollViewRequestVersion: 0, activeTrackAutomationTrackId: null, activeTrackAutomationType: null, trackAutomationRedrawVersion: 0,