diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 2bd38c4..ee2e83f 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,15 +2,15 @@ "version": "2.0.0", "tasks": [ { - "type": "npm", - "script": "dev", + "type": "shell", "label": "npm: dev", "detail": "vite", "isBackground": true, + "command": "source ~/.nvm/nvm.sh && nvm use 20 && npm run dev", "options": { "shell": { "executable": "/bin/zsh", - "args": ["-c", "source ~/.nvm/nvm.sh && nvm use 20 && npm run dev"] + "args": ["-c"] } }, "problemMatcher": { @@ -25,11 +25,11 @@ } }, { - "type": "npm", - "script": "dev", + "type": "shell", "label": "npm: dev (Windows)", "detail": "vite", "isBackground": true, + "command": "npm run dev", "options": { "shell": { "executable": "cmd.exe", @@ -48,4 +48,4 @@ } } ] -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index 7a0f770..b15f8ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "K.G.Studio", - "version": "0.16.0-build.20260510", + "version": "0.17.3-build.20260515", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "K.G.Studio", - "version": "0.16.0-build.20260510", + "version": "0.17.3-build.20260515", "dependencies": { "@breezystack/lamejs": "^1.2.7", "class-transformer": "^0.5.1", diff --git a/public/config.json b/public/config.json index 88226c1..3d55123 100644 --- a/public/config.json +++ b/public/config.json @@ -27,8 +27,12 @@ "model": "" }, "local_browser": { - "context_length": 32768 + "context_length": 32768, + "model_url": "https://huggingface.co/notabilia/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.task" }, + "uvr5_web_runtime": { + "mdx_net_model_url": "https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx" + }, "soundfont": { "base_url": "https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/" }, @@ -49,7 +53,9 @@ "copy": "ctrl+c", "cut": "ctrl+x", "paste": "ctrl+v", - "save": "ctrl+s" + "save": "ctrl+s", + "split_region": "ctrl+t", + "merge_regions": "ctrl+j" }, "piano_roll": { "switch": "tab", @@ -76,6 +82,7 @@ "default_open": true }, "audio": { + "bounce_starts_from_beat_1": true, "enable_audio_capture_for_screen_sharing": false, "input_device_id": "default", "lookahead_time": 0.05, diff --git a/src/agent/llm/LocalBrowserLLMProvider.ts b/src/agent/llm/LocalBrowserLLMProvider.ts index 57970f8..46de966 100644 --- a/src/agent/llm/LocalBrowserLLMProvider.ts +++ b/src/agent/llm/LocalBrowserLLMProvider.ts @@ -12,7 +12,7 @@ import { import { LOCAL_LLM_DEFAULT_CONTEXT_LENGTH, LOCAL_LLM_MODEL_FILENAME, - LOCAL_LLM_MODEL_URL, + LOCAL_LLM_DEFAULT_MODEL_URL, normalizeLocalLLMContextLength, } from '../../util/localLLMConfig'; import { LocalLLMModelCache } from '../../util/localLLMModelCache'; @@ -67,12 +67,13 @@ export class LocalBrowserLLMProvider implements LLMProvider { } const maxTokens = this.getConfiguredContextLength(); + const modelUrl = this.getConfiguredModelUrl(); console.log(`[localLLM] Initializing with max context length: ${maxTokens} tokens`); const [{ FilesetResolver, LlmInference }, modelLoad] = await Promise.all([ this.getMediaPipeModule(), LocalLLMModelCache.loadModelReaderWithCache( - LOCAL_LLM_MODEL_URL, + modelUrl, LOCAL_LLM_MODEL_FILENAME, progress => { LocalLLMModelManager.notifyLoadProgress(progress.receivedBytes, progress.totalBytes, progress.fromCache); @@ -132,6 +133,16 @@ export class LocalBrowserLLMProvider implements LLMProvider { } } + private getConfiguredModelUrl(): string { + try { + const configManager = ConfigManager.instance(); + const configured = configManager.get('general.local_browser.model_url'); + return typeof configured === 'string' && configured.trim() ? configured : LOCAL_LLM_DEFAULT_MODEL_URL; + } catch { + return LOCAL_LLM_DEFAULT_MODEL_URL; + } + } + private applyTemplate(message: { role: 'user' | 'model'; text: string }): string { const template = PROMPT_TEMPLATE[message.role]; return `${template.pre}${message.text}${template.post}`; diff --git a/src/components/KGOnePanel.test.tsx b/src/components/KGOnePanel.test.tsx index f63f5bf..a4432b5 100644 --- a/src/components/KGOnePanel.test.tsx +++ b/src/components/KGOnePanel.test.tsx @@ -5,6 +5,10 @@ import KGOnePanel from './KGOnePanel'; import { KGAudioRegion } from '../core/region/KGAudioRegion'; import { KGAudioTrack } from '../core/track/KGAudioTrack'; +const { mockLocalSeparatorDownload } = vi.hoisted(() => ({ + mockLocalSeparatorDownload: vi.fn(async (_url?: string, _filename?: string, _onProgress?: unknown) => undefined), +})); + let kgoneEnabled = false; let selectedRegionIds: string[] = []; let localModelCached = false; @@ -31,6 +35,7 @@ vi.mock('../core/config/ConfigManager', () => ({ get: (key: string) => { if (key === 'general.kgone.enabled') return kgoneEnabled; if (key === 'general.kgone.base_url') return 'http://127.0.0.1:8000'; + if (key === 'general.uvr5_web_runtime.mdx_net_model_url') return 'https://example.com/custom-uvr5.onnx'; return undefined; }, }), @@ -79,8 +84,9 @@ vi.mock('../util/audioUtil', () => ({ vi.mock('../util/localSeparatorModelCache', () => ({ LocalSeparatorModelCache: { exists: vi.fn(async () => localModelCached), - download: vi.fn(async () => { + download: vi.fn(async (url: string, filename: string, onProgress: (progress: unknown) => void) => { localModelCached = true; + return mockLocalSeparatorDownload(url, filename, onProgress); }), delete: vi.fn(async () => { localModelCached = false; @@ -124,6 +130,7 @@ describe('KGOnePanel local separator mode', () => { { name: 'Instrumental', blob: new Blob(['instrumental'], { type: 'audio/wav' }) }, { name: 'Vocals', blob: new Blob(['vocals'], { type: 'audio/wav' }) }, ]; + mockLocalSeparatorDownload.mockClear(); mockRefreshProjectState.mockReset(); mockExecuteCommand.mockReset(); }); @@ -155,6 +162,20 @@ describe('KGOnePanel local separator mode', () => { expect(screen.getByLabelText('MDX overlap')).toBeInTheDocument(); }); + it('uses the configured UVR5 model URL when downloading the local model', async () => { + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Download Model' })); + + await waitFor(() => { + expect(mockLocalSeparatorDownload).toHaveBeenCalledWith( + 'https://example.com/custom-uvr5.onnx', + 'UVR-MDX-NET-Inst_HQ_3.onnx', + expect.any(Function), + ); + }); + }); + it('prompts for an audio region when the model is cached but nothing is selected', async () => { localModelCached = true; diff --git a/src/components/KGOnePanel.tsx b/src/components/KGOnePanel.tsx index 4c26bdb..5272efb 100644 --- a/src/components/KGOnePanel.tsx +++ b/src/components/KGOnePanel.tsx @@ -18,7 +18,7 @@ import { showAlert } from '../util/dialogUtil'; import { LOCAL_SEPARATOR_MODEL_CONFIG, LOCAL_SEPARATOR_MODEL_FILENAME, - LOCAL_SEPARATOR_MODEL_URL, + LOCAL_SEPARATOR_DEFAULT_MODEL_URL, } from '../util/localSeparatorConfig'; import { LocalSeparatorModelCache } from '../util/localSeparatorModelCache'; import { runLocalSeparator } from '../util/localSeparatorRunner'; @@ -959,6 +959,13 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { return null; }, [selectedRegionIds]); + const getConfiguredLocalSeparatorModelUrl = useCallback(() => { + const configured = ConfigManager.instance().get('general.uvr5_web_runtime.mdx_net_model_url'); + return typeof configured === 'string' && configured.trim() + ? configured + : LOCAL_SEPARATOR_DEFAULT_MODEL_URL; + }, []); + const isGenerating = genStatus !== 'idle' && genStatus !== 'done' && genStatus !== 'error'; const handleDownloadLocalModel = useCallback(async () => { @@ -969,7 +976,7 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { try { await LocalSeparatorModelCache.download( - LOCAL_SEPARATOR_MODEL_URL, + getConfiguredLocalSeparatorModelUrl(), LOCAL_SEPARATOR_MODEL_FILENAME, progress => { const receivedMb = (progress.receivedBytes / (1024 * 1024)).toFixed(1); @@ -992,7 +999,7 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { } finally { setIsDownloadingLocalModel(false); } - }, [refreshLocalModelCacheState]); + }, [getConfiguredLocalSeparatorModelUrl, refreshLocalModelCacheState]); const handleDeleteLocalModel = useCallback(async () => { setIsDeletingLocalModel(true); diff --git a/src/components/MainContent.test.tsx b/src/components/MainContent.test.tsx index fc0237d..5d24208 100644 --- a/src/components/MainContent.test.tsx +++ b/src/components/MainContent.test.tsx @@ -3,13 +3,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen } from '@testing-library/react'; import MainContent from './MainContent'; import { KGMidiRegion } from '../core/region/KGMidiRegion'; +import { KGAudioRegion } from '../core/region/KGAudioRegion'; +import { KGAudioTrack } from '../core/track/KGAudioTrack'; import { createMockMidiTrack } from '../test/utils/mock-data'; const midiRegion = new KGMidiRegion('region-1', '1', 0, 'Region 1', 0, 4); -const track = createMockMidiTrack({ id: 1, regions: [midiRegion] }); +const anotherMidiRegion = new KGMidiRegion('region-2', '1', 0, 'Region 2', 8, 4); +const audioRegion = new KGAudioRegion('audio-1', '2', 1, 'Audio 1', 4, 4); +const midiTrack = createMockMidiTrack({ id: 1, regions: [midiRegion, anotherMidiRegion] }); +const audioTrack = new KGAudioTrack('Audio Track', 2); +audioTrack.setTrackIndex(1); +audioTrack.setRegions([audioRegion]); const storeState = { - tracks: [track], + tracks: [midiTrack, audioTrack], maxBars: 8, barWidthMultiplier: 1, reorderTracks: vi.fn(), @@ -35,6 +42,8 @@ const storeState = { storeState.activeRegionId = regionId; }), pianoRollMode: 'midi-edit' as const, + requestedSheetMusicViewEnabled: false, + pianoRollViewRequestVersion: 0, openMidiPianoRoll: vi.fn(), openSpectrogramViewer: vi.fn(), openHybridMode: vi.fn(), @@ -69,13 +78,16 @@ vi.mock('../stores/projectStore', () => ({ vi.mock('../core/KGCore', () => ({ KGCore: { instance: () => ({ - addSelectedItems: (items: KGMidiRegion[]) => { + addSelectedItems: (items: Array<{ getId(): string }>) => { storeState.selectedRegionIds = items.map(item => item.getId()); }, clearSelectedItems: () => { storeState.selectedRegionIds = []; }, executeCommand: vi.fn(), + getCurrentProject: () => ({ + getTracks: () => storeState.tracks, + }), }), }, })); @@ -92,14 +104,26 @@ vi.mock('./track/TrackInfoPanel', () => ({ vi.mock('./track/TrackGridPanel', () => ({ default: ({ onRegionClick }: { onRegionClick?: RegionClickHandler }) => ( - + <> + + + + ), })); vi.mock('./piano-roll/PianoRoll', () => ({ - default: () =>
, + default: ({ onClose }: { onClose?: () => void }) => ( +
+ +
+ ), })); describe('MainContent', () => { @@ -118,11 +142,65 @@ describe('MainContent', () => { it('updates activeRegionId when selecting a region with piano roll closed', () => { render(); - fireEvent.click(screen.getByRole('button', { name: 'select-region' })); + fireEvent.click(screen.getByRole('button', { name: 'select-midi-region' })); expect(storeState.activeRegionId).toBe('region-1'); expect(storeState.setActiveRegionId).toHaveBeenCalledWith('region-1'); expect(storeState.showPianoRoll).toBe(false); expect(storeState.openMidiPianoRoll).not.toHaveBeenCalled(); }); + + it('keeps piano roll open and preserves activeRegionId when deselecting all regions', () => { + storeState.showPianoRoll = true; + storeState.activeRegionId = 'region-1'; + storeState.selectedRegionIds = ['region-1']; + + const { container } = render(); + + fireEvent.click(container.firstChild as HTMLElement); + + expect(storeState.selectedRegionIds).toEqual([]); + expect(storeState.showPianoRoll).toBe(true); + expect(storeState.activeRegionId).toBe('region-1'); + expect(storeState.setShowPianoRoll).not.toHaveBeenCalledWith(false); + expect(storeState.setActiveRegionId).not.toHaveBeenCalledWith(null); + }); + + it('auto-switches the open piano roll when selecting another MIDI region', () => { + storeState.showPianoRoll = true; + storeState.activeRegionId = 'region-1'; + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'select-second-midi-region' })); + + expect(storeState.activeRegionId).toBe('region-2'); + expect(storeState.openMidiPianoRoll).toHaveBeenCalledWith('region-2'); + }); + + it('preserves current cross-type behavior when selecting an audio region with the editor open', () => { + storeState.showPianoRoll = true; + storeState.activeRegionId = 'region-1'; + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'select-audio-region' })); + + expect(storeState.activeRegionId).toBe('audio-1'); + expect(storeState.openSpectrogramViewer).toHaveBeenCalledWith('audio-1'); + }); + + it('explicit close still clears piano roll visibility and active region', () => { + storeState.showPianoRoll = true; + storeState.activeRegionId = 'region-1'; + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'close-piano-roll' })); + + expect(storeState.showPianoRoll).toBe(false); + expect(storeState.activeRegionId).toBeNull(); + expect(storeState.setShowPianoRoll).toHaveBeenCalledWith(false); + expect(storeState.setActiveRegionId).toHaveBeenCalledWith(null); + }); }); diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index 3cb657b..ab677ec 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, @@ -334,6 +336,21 @@ const MainContent: React.FC = ({ setRegions(updatedRegions); }, [tracks, timeSignature]); + useEffect(() => { + if (!showPianoRoll || !activeRegionId) { + return; + } + + const activeRegionStillExists = tracks.some(track => + track.getRegions().some(region => region.getId() === activeRegionId) + ); + + if (!activeRegionStillExists) { + setShowPianoRoll(false); + setActiveRegionId(null); + } + }, [tracks, showPianoRoll, activeRegionId, setShowPianoRoll, setActiveRegionId]); + // Apply auto-selection for newly created/imported regions after the regions state commits. useEffect(() => { const pendingRegionId = pendingAutoSelectionRegionIdRef.current; @@ -587,7 +604,9 @@ const MainContent: React.FC = ({ : null; setSelectedRegionId(lastSelectedRegionId); - setActiveRegionId(lastSelectedRegionId); + if (lastSelectedRegionId) { + setActiveRegionId(lastSelectedRegionId); + } if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Selected regions: ${selectedRegions.map(selectedRegion => selectedRegion.getId()).join(', ')}`); @@ -598,7 +617,6 @@ const MainContent: React.FC = ({ } if (!lastSelectedRegionId) { - setShowPianoRoll(false); return; } @@ -1052,6 +1070,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/Toolbar.css b/src/components/Toolbar.css index 258eebb..aac08f2 100644 --- a/src/components/Toolbar.css +++ b/src/components/Toolbar.css @@ -7,6 +7,8 @@ height: 50px; padding: 0 10px; border-bottom: 1px solid #3a3a3a; + position: relative; + z-index: 2000; } .toolbar-left, diff --git a/src/components/Toolbar.test.tsx b/src/components/Toolbar.test.tsx index fdbdbdd..871d847 100644 --- a/src/components/Toolbar.test.tsx +++ b/src/components/Toolbar.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import Toolbar from './Toolbar'; @@ -52,13 +52,13 @@ const storeState = { setShowPianoRoll: vi.fn(), activeRegionId: null, setActiveRegionId: vi.fn(), - selectedRegionIds: [], + selectedRegionIds: [] as string[], selectedTrackId: null, playheadPosition: 0, refreshProjectState: vi.fn(), requestMainContentScroll: vi.fn(), requestPianoRollScroll: vi.fn(), - tracks: [], + tracks: [] as unknown[], }; type StoreState = typeof storeState; @@ -108,6 +108,14 @@ vi.mock('../core/state/KGMainContentState', () => ({ KGMainContentState: {} })); vi.mock('../util/regionDeleteUtil', () => ({ regionDeleteManager: { deleteSelectedRegions: vi.fn(() => false) } })); vi.mock('../core/commands/region/SplitRegionCommand', () => ({ SplitRegionCommand: class {} })); vi.mock('../core/commands/region/MergeMidiRegionsCommand', () => ({ MergeMidiRegionsCommand: class {} })); +const regionEditUtilMocks = vi.hoisted(() => ({ + splitSelectedRegionAtPlayheadMock: vi.fn(), + mergeSelectedMidiRegionsMock: vi.fn(), +})); +vi.mock('../util/regionEditUtil', () => ({ + splitSelectedRegionAtPlayhead: regionEditUtilMocks.splitSelectedRegionAtPlayheadMock, + mergeSelectedMidiRegions: regionEditUtilMocks.mergeSelectedMidiRegionsMock, +})); vi.mock('../util/copyPasteUtil', () => ({ handleCopyOperation: vi.fn(() => false), handlePasteOperation: vi.fn(() => false), @@ -135,6 +143,8 @@ vi.mock('../util/dialogUtil', () => ({ describe('Toolbar settings side-panel behavior', () => { beforeEach(() => { + regionEditUtilMocks.splitSelectedRegionAtPlayheadMock.mockReset(); + regionEditUtilMocks.mergeSelectedMidiRegionsMock.mockReset(); storeState.toggleChatBox.mockClear(); storeState.toggleKGOnePanel.mockClear(); storeState.toggleEventListPanel.mockClear(); @@ -162,4 +172,25 @@ describe('Toolbar settings side-panel behavior', () => { expect(storeState.activateSidePanel).toHaveBeenCalledWith('eventList'); expect(storeState.toggleEventListPanel).not.toHaveBeenCalled(); }); + + it('routes the split toolbar button through the shared split helper', async () => { + storeState.selectedRegionIds = ['region-1']; + storeState.playheadPosition = 12; + regionEditUtilMocks.splitSelectedRegionAtPlayheadMock.mockResolvedValue('Split 1 note at beat 12.00'); + + render(); + fireEvent.click(screen.getByTitle('Split Region at Playhead')); + + await waitFor(() => { + expect(regionEditUtilMocks.splitSelectedRegionAtPlayheadMock).toHaveBeenCalledWith({ + selectedRegionIds: ['region-1'], + playheadPosition: 12, + refreshProjectState: storeState.refreshProjectState, + }); + }); + + await waitFor(() => { + expect(storeState.setStatus).toHaveBeenCalledWith('Split 1 note at beat 12.00'); + }); + }); }); diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index da6941f..19f4c66 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -22,8 +22,6 @@ import { plainToInstance } from 'class-transformer'; import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles, FaListUl } from 'react-icons/fa6'; import { KGMainContentState } from '../core/state/KGMainContentState'; import { regionDeleteManager } from '../util/regionDeleteUtil'; -import { SplitRegionCommand } from '../core/commands/region/SplitRegionCommand'; -import { MergeMidiRegionsCommand } from '../core/commands/region/MergeMidiRegionsCommand'; import { handleCopyOperation, handlePasteOperation } from '../util/copyPasteUtil'; import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil'; import { KEY_SIGNATURE_MAP } from '../constants/coreConstants'; @@ -35,7 +33,7 @@ import OpenProjectModal from './common/OpenProjectModal'; import { clearChatHistoryAndUI } from '../util/chatUtil'; import PianoIcon from './common/icons/PianoIcon'; import MetronomeIcon from './common/icons/MetronomeIcon'; -import { ConfigManager } from '../core/config/ConfigManager'; +import { mergeSelectedMidiRegions, splitSelectedRegionAtPlayhead } from '../util/regionEditUtil'; import { showAlert, showChoice, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil'; const Toolbar: React.FC = () => { @@ -791,43 +789,19 @@ const Toolbar: React.FC = () => { console.log("Split button clicked"); } - if (selectedRegionIds.length === 0) { - await showAlert("Please select a region to split."); - return; - } - if (selectedRegionIds.length > 1) { - await showAlert("Please select exactly one region to split."); + const status = await splitSelectedRegionAtPlayhead({ + selectedRegionIds, + playheadPosition, + refreshProjectState, + }); + if (!status) { return; } - const regionId = lastSelectedRegionId; - const tracks = KGCore.instance().getCurrentProject().getTracks(); - let targetRegion = null; - for (const track of tracks) { - const found = track.getRegions().find(r => r.getId() === regionId); - if (found) { targetRegion = found; break; } - } - - if (!targetRegion) { - await showAlert("Selected region not found."); - return; - } - - const regionStart = targetRegion.getStartFromBeat(); - const regionEnd = regionStart + targetRegion.getLength(); - - if (playheadPosition <= regionStart || playheadPosition >= regionEnd) { - await showAlert("The playhead is not inside the selected region. Move the playhead inside the region before splitting."); - return; - } - - const command = new SplitRegionCommand(regionId, playheadPosition); - KGCore.instance().executeCommand(command); - refreshProjectState(); - setStatus(`Split region at beat ${playheadPosition.toFixed(2)}`); + setStatus(status); if (DEBUG_MODE.TOOLBAR) { - console.log(`Split region ${regionId} at beat ${playheadPosition}`); + console.log(`Split selected region at beat ${playheadPosition}`); } }; @@ -836,92 +810,15 @@ const Toolbar: React.FC = () => { console.log('Merge button clicked'); } - if (selectedRegionIds.length < 2) { - await showAlert('Please select at least two MIDI regions on the same track to merge.'); - return; - } - - const tracks = KGCore.instance().getCurrentProject().getTracks(); - const selectedRegionIdSet = new Set(selectedRegionIds); - const selectedMidiRegions: KGMidiRegion[] = []; - let targetTrackId: string | null = null; - - for (const track of tracks) { - for (const region of track.getRegions()) { - if (!selectedRegionIdSet.has(region.getId())) { - continue; - } - - if (!(region instanceof KGMidiRegion)) { - await showAlert('Only MIDI regions can be merged. Please adjust your selection and try again.'); - return; - } - - const regionTrackId = track.getId().toString(); - if (targetTrackId && targetTrackId !== regionTrackId) { - await showAlert('Please select only MIDI regions from a single track before merging.'); - return; - } - - targetTrackId = regionTrackId; - selectedMidiRegions.push(region); - } - } - - if (selectedMidiRegions.length !== selectedRegionIds.length || !targetTrackId) { - await showAlert('Some selected regions could not be found. Please reselect the MIDI regions and try again.'); - return; - } - - const sortedSelectedRegions = [...selectedMidiRegions].sort((a, b) => { - const startDelta = a.getStartFromBeat() - b.getStartFromBeat(); - if (startDelta !== 0) return startDelta; - return a.getLength() - b.getLength(); + const status = await mergeSelectedMidiRegions({ + selectedRegionIds, + refreshProjectState, }); - - let regionIdsToMerge = selectedRegionIds; - const firstSelectedRegion = sortedSelectedRegions[0]; - const lastSelectedRegion = sortedSelectedRegions[sortedSelectedRegions.length - 1]; - const spanStart = firstSelectedRegion.getStartFromBeat(); - const spanEnd = lastSelectedRegion.getStartFromBeat() + lastSelectedRegion.getLength(); - - const targetTrack = tracks.find(track => track.getId().toString() === targetTrackId); - const inBetweenRegions = targetTrack - ?.getRegions() - .filter(region => ( - region instanceof KGMidiRegion && - !selectedRegionIdSet.has(region.getId()) && - region.getStartFromBeat() >= spanStart && - region.getStartFromBeat() <= spanEnd - )) ?? []; - - if (inBetweenRegions.length > 0) { - const shouldIncludeInBetweenRegions = await showConfirm( - 'There are additional MIDI regions between the first and last selected regions on this track. Would you like KGStudio to merge those as well?', - { - confirmLabel: 'Merge All In Between', - cancelLabel: 'Stop', - } - ); - - if (!shouldIncludeInBetweenRegions) { - return; - } - - regionIdsToMerge = Array.from(new Set([ - ...selectedRegionIds, - ...inBetweenRegions.map(region => region.getId()), - ])); + if (!status) { + return; } - try { - const command = new MergeMidiRegionsCommand(regionIdsToMerge); - KGCore.instance().executeCommand(command, { rethrow: true }); - refreshProjectState(); - setStatus(`Merged ${regionIdsToMerge.length} MIDI regions`); - } catch (error) { - await showAlert(error instanceof Error ? error.message : 'Unable to merge the selected MIDI regions.'); - } + setStatus(status); }; // Handle undo button click diff --git a/src/components/interfaces.ts b/src/components/interfaces.ts index 5451700..4f7367c 100644 --- a/src/components/interfaces.ts +++ b/src/components/interfaces.ts @@ -18,6 +18,11 @@ export interface RegionUI { name: string; } +export interface RegionPreviewContentStyle { + left: string; + width: string; +} + export interface RegionClickOptions { shiftKey: boolean; } diff --git a/src/components/piano-roll/PianoKeys.test.tsx b/src/components/piano-roll/PianoKeys.test.tsx new file mode 100644 index 0000000..bc86fb4 --- /dev/null +++ b/src/components/piano-roll/PianoKeys.test.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import PianoKeys from './PianoKeys'; +import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data'; + +type TestLiveNoteActivityListener = (...args: [{ pitch: number; isNoteOn: boolean }]) => void; + +const storeState = { + tracks: [createMockMidiTrack({ id: 1 })], + playheadPosition: 0, + isPlaying: false, +}; + +const audioInterfaceMock = { + getIsInitialized: vi.fn(), + getIsAudioContextStarted: vi.fn(), + startAudioContext: vi.fn(), + triggerNoteAttack: vi.fn(), + releaseNote: vi.fn(), +}; + +let liveNoteActivityListener: TestLiveNoteActivityListener | null = null; +const midiInputMock = { + addLiveNoteActivityListener: vi.fn((listener: TestLiveNoteActivityListener) => { + liveNoteActivityListener = listener; + }), + removeLiveNoteActivityListener: vi.fn((listener: TestLiveNoteActivityListener) => { + if (liveNoteActivityListener === listener) { + liveNoteActivityListener = null; + } + }), +}; + +vi.mock('../../stores/projectStore', () => ({ + useProjectStore: (selector: (...args: [typeof storeState]) => unknown) => selector(storeState), +})); + +vi.mock('../../core/audio-interface/KGAudioInterface', () => ({ + KGAudioInterface: { + instance: () => audioInterfaceMock, + }, +})); + +vi.mock('../../core/midi-input/KGMidiInput', () => ({ + KGMidiInput: { + instance: () => midiInputMock, + }, +})); + +describe('PianoKeys', () => { + const activeRegion = createMockMidiRegion({ + trackId: '1', + notes: [createMockMidiNote({ id: 'note-c4', pitch: 60, startBeat: 0, endBeat: 2 })], + }); + + beforeEach(() => { + storeState.tracks = [createMockMidiTrack({ id: 1 })]; + storeState.playheadPosition = 0; + storeState.isPlaying = false; + liveNoteActivityListener = null; + vi.clearAllMocks(); + audioInterfaceMock.getIsInitialized.mockReturnValue(true); + audioInterfaceMock.getIsAudioContextStarted.mockReturnValue(true); + audioInterfaceMock.startAudioContext.mockResolvedValue(undefined); + }); + + it('shows dot and background feedback for mouse preview while held', () => { + const { container } = render(); + const key = container.querySelector('[data-note="C4"]') as HTMLElement; + + fireEvent.mouseDown(key); + + expect(key.className).toContain('visual-active'); + expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument(); + + fireEvent.mouseUp(key); + + expect(key.className).not.toContain('visual-active'); + expect(screen.queryByTestId('piano-key-dot-C4')).not.toBeInTheDocument(); + }); + + it('shows MIDI activity dot without background feedback', () => { + const { container } = render(); + const key = container.querySelector('[data-note="C4"]') as HTMLElement; + expect(midiInputMock.addLiveNoteActivityListener).toHaveBeenCalledTimes(1); + + act(() => { + liveNoteActivityListener?.({ pitch: 60, isNoteOn: true }); + }); + + expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument(); + expect(key.className).not.toContain('visual-active'); + + act(() => { + liveNoteActivityListener?.({ pitch: 60, isNoteOn: false }); + }); + + expect(screen.queryByTestId('piano-key-dot-C4')).not.toBeInTheDocument(); + }); + + it('shows playback background feedback without dot for sounding notes in the active region', () => { + storeState.isPlaying = true; + storeState.playheadPosition = 1; + + const { container } = render(); + const key = container.querySelector('[data-note="C4"]') as HTMLElement; + + expect(key.className).toContain('playback-active'); + expect(key.className).toContain('visual-active'); + expect(screen.queryByTestId('piano-key-dot-C4')).not.toBeInTheDocument(); + }); + + it('preserves source-specific feedback while mouse, MIDI, and playback overlap', () => { + storeState.isPlaying = true; + storeState.playheadPosition = 1; + + const { container, rerender } = render(); + const key = container.querySelector('[data-note="C4"]') as HTMLElement; + + fireEvent.mouseDown(key); + act(() => { + liveNoteActivityListener?.({ pitch: 60, isNoteOn: true }); + }); + + expect(key.className).toContain('visual-active'); + expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument(); + + storeState.isPlaying = false; + rerender(); + + expect(key.className).toContain('visual-active'); + expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument(); + + fireEvent.mouseUp(key); + + expect(key.className).not.toContain('visual-active'); + expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument(); + + act(() => { + liveNoteActivityListener?.({ pitch: 60, isNoteOn: false }); + }); + + expect(screen.queryByTestId('piano-key-dot-C4')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/piano-roll/PianoKeys.tsx b/src/components/piano-roll/PianoKeys.tsx index e62519a..a4e8fe4 100644 --- a/src/components/piano-roll/PianoKeys.tsx +++ b/src/components/piano-roll/PianoKeys.tsx @@ -1,30 +1,94 @@ -import React, { useState, useRef } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface'; -import { noteNameToPitch, midiPercussionKeyMap, pitchToNoteNameString } from '../../util/midiUtil'; +import { noteNameToPitch, midiPercussionKeyMap } from '../../util/midiUtil'; import { useProjectStore } from '../../stores/projectStore'; import { KGMidiTrack } from '../../core/track/KGMidiTrack'; +import { KGMidiInput, type LiveMidiNoteActivityEvent } from '../../core/midi-input/KGMidiInput'; interface PianoKeysProps { activeRegion: KGMidiRegion | null; } +function incrementPitchCount(source: Map, pitch: number): Map { + const next = new Map(source); + next.set(pitch, (next.get(pitch) ?? 0) + 1); + return next; +} + +function decrementPitchCount(source: Map, pitch: number): Map { + const next = new Map(source); + const current = next.get(pitch) ?? 0; + + if (current <= 1) { + next.delete(pitch); + } else { + next.set(pitch, current - 1); + } + + return next; +} + const PianoKeys: React.FC = ({ activeRegion }) => { - const [pressedKeys, setPressedKeys] = useState>(new Set()); - const pressedKeysRef = useRef>(new Set()); - const { tracks } = useProjectStore(); + const [mouseActivePitches, setMouseActivePitches] = useState>(new Map()); + const [midiActivePitches, setMidiActivePitches] = useState>(new Map()); + const mouseActivePitchesRef = useRef>(new Map()); + const tracks = useProjectStore(state => state.tracks); + const playheadPosition = useProjectStore(state => state.playheadPosition); + const isPlaying = useProjectStore(state => state.isPlaying); // Check if current active region belongs to a drum track - const isDrumTrack = React.useMemo(() => { + const isDrumTrack = useMemo(() => { if (!activeRegion) return false; const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId()); return track instanceof KGMidiTrack && track.getInstrument() === 'standard'; }, [activeRegion, tracks]); + const playbackActivePitches = useMemo(() => { + if (!activeRegion || !isPlaying) { + return new Set(); + } + + const activePitches = new Set(); + const absolutePlayhead = playheadPosition; + const regionStartBeat = activeRegion.getStartFromBeat(); + + activeRegion.getNotes().forEach(note => { + const startBeat = regionStartBeat + note.getStartBeat(); + const endBeat = regionStartBeat + note.getEndBeat(); + + if (absolutePlayhead >= startBeat && absolutePlayhead < endBeat) { + activePitches.add(note.getPitch()); + } + }); + + return activePitches; + }, [activeRegion, isPlaying, playheadPosition]); + + useEffect(() => { + const midiInput = KGMidiInput.instance(); + + const handleLiveNoteActivity = (event: LiveMidiNoteActivityEvent) => { + setMidiActivePitches(current => ( + event.isNoteOn + ? incrementPitchCount(current, event.pitch) + : decrementPitchCount(current, event.pitch) + )); + }; + + midiInput.addLiveNoteActivityListener(handleLiveNoteActivity); + + return () => { + midiInput.removeLiveNoteActivityListener(handleLiveNoteActivity); + }; + }, []); + // Handle mouse down on piano key const handleKeyMouseDown = (keyId: string) => { + const pitch = noteNameToPitch(keyId); + // Prevent double pressing the same key - if (pressedKeysRef.current.has(keyId)) { + if ((mouseActivePitchesRef.current.get(pitch) ?? 0) > 0) { return; } @@ -37,9 +101,6 @@ const PianoKeys: React.FC = ({ activeRegion }) => { const trackId = activeRegion.getTrackId(); try { - // Convert note name to pitch (keyId is always a note name like "C4") - const pitch = noteNameToPitch(keyId); - // Get audio interface and start playing the note const audioInterface = KGAudioInterface.instance(); if (audioInterface.getIsInitialized()) { @@ -54,11 +115,9 @@ const PianoKeys: React.FC = ({ activeRegion }) => { if (audioInterface.getIsAudioContextStarted()) { audioInterface.triggerNoteAttack(trackId, pitch, 127); - // Update pressed keys state - const newPressedKeys = new Set(pressedKeysRef.current); - newPressedKeys.add(keyId); - pressedKeysRef.current = newPressedKeys; - setPressedKeys(newPressedKeys); + const nextMouseActivePitches = incrementPitchCount(mouseActivePitchesRef.current, pitch); + mouseActivePitchesRef.current = nextMouseActivePitches; + setMouseActivePitches(nextMouseActivePitches); console.log(`Started playing piano key: ${keyId} (pitch ${pitch})`); } @@ -70,8 +129,10 @@ const PianoKeys: React.FC = ({ activeRegion }) => { // Handle mouse up on piano key const handleKeyMouseUp = (keyId: string) => { + const pitch = noteNameToPitch(keyId); + // Only release if key was actually pressed - if (!pressedKeysRef.current.has(keyId)) { + if ((mouseActivePitchesRef.current.get(pitch) ?? 0) === 0) { return; } @@ -83,19 +144,14 @@ const PianoKeys: React.FC = ({ activeRegion }) => { const trackId = activeRegion.getTrackId(); try { - // Convert note name to pitch (keyId is always a note name like "C4") - const pitch = noteNameToPitch(keyId); - // Get audio interface and stop playing the note const audioInterface = KGAudioInterface.instance(); if (audioInterface.getIsInitialized() && audioInterface.getIsAudioContextStarted()) { audioInterface.releaseNote(trackId, pitch); - // Update pressed keys state - const newPressedKeys = new Set(pressedKeysRef.current); - newPressedKeys.delete(keyId); - pressedKeysRef.current = newPressedKeys; - setPressedKeys(newPressedKeys); + const nextMouseActivePitches = decrementPitchCount(mouseActivePitchesRef.current, pitch); + mouseActivePitchesRef.current = nextMouseActivePitches; + setMouseActivePitches(nextMouseActivePitches); console.log(`Stopped playing piano key: ${keyId} (pitch ${pitch})`); } @@ -123,14 +179,25 @@ const PianoKeys: React.FC = ({ activeRegion }) => { const note = notes[i]; const isSharp = note.includes('#'); const keyId = `${note}${octave}`; - const isPressed = pressedKeys.has(keyId); - const keyClass = `piano-key ${isSharp ? 'sharp' : 'natural'} ${isPressed ? 'pressed' : ''}`; + const pitch = noteNameToPitch(keyId); + const isMouseActive = (mouseActivePitches.get(pitch) ?? 0) > 0; + const isMidiActive = (midiActivePitches.get(pitch) ?? 0) > 0; + const isPlaybackActive = playbackActivePitches.has(pitch); + const showIndicator = isMouseActive || isMidiActive; + const hasBackgroundFeedback = isMouseActive || isPlaybackActive; + const keyClass = [ + 'piano-key', + isSharp ? 'sharp' : 'natural', + isMouseActive ? 'mouse-active' : '', + isMidiActive ? 'midi-active' : '', + isPlaybackActive ? 'playback-active' : '', + hasBackgroundFeedback ? 'visual-active' : '', + ].filter(Boolean).join(' '); const isC = note === 'C'; // For drum tracks, show drum labels when available let labelContent = null; if (isDrumTrack) { - const pitch = noteNameToPitch(keyId); const drumInfo = midiPercussionKeyMap[pitch]; if (drumInfo) { labelContent = {drumInfo.shortName}; @@ -153,6 +220,7 @@ const PianoKeys: React.FC = ({ activeRegion }) => { }} > {labelContent} + {showIndicator ? : null}
); } @@ -175,4 +243,4 @@ const PianoKeys: React.FC = ({ activeRegion }) => { ); }; -export default PianoKeys; \ No newline at end of file +export default PianoKeys; diff --git a/src/components/piano-roll/PianoRoll.css b/src/components/piano-roll/PianoRoll.css index 56c8d46..794960d 100644 --- a/src/components/piano-roll/PianoRoll.css +++ b/src/components/piano-roll/PianoRoll.css @@ -572,6 +572,8 @@ display: flex; align-items: center; border-bottom: 1px solid #3a3a3a; + position: relative; + transition: background-color 0.08s ease; } .piano-key.natural { @@ -584,16 +586,47 @@ color: #e0e0e0; } +.piano-key.natural.visual-active { + background-color: #b8b8b8; +} + +.piano-key.sharp.visual-active { + background-color: #5a5a5a; +} + .key-label { font-size: 10px; padding-left: 5px; + padding-right: 18px; + position: relative; + z-index: 1; +} + +.piano-key-activity-dot { + position: absolute; + right: 6px; + top: 50%; + width: 8px; + height: 8px; + margin-top: -4px; + border-radius: 999px; + background-color: #000; + border: 1px solid #000; +} + +.piano-key.sharp .piano-key-activity-dot { + background-color: #fff; + border-color: #fff; } .piano-grid { width: 100%; height: 100%; position: relative; - background-size: var(--region-grid-beat-width) var(--region-piano-key-height), 100% 100%; + background-size: + var(--region-grid-bar-width) var(--region-piano-key-height), + var(--region-grid-beat-width) var(--region-piano-key-height), + 100% 100%; /* background-image is now set dynamically via React inline styles in PianoGrid component */ } diff --git a/src/components/piano-roll/PianoRoll.test.ts b/src/components/piano-roll/PianoRoll.test.ts index c9c9c31..1a4b761 100644 --- a/src/components/piano-roll/PianoRoll.test.ts +++ b/src/components/piano-roll/PianoRoll.test.ts @@ -33,6 +33,7 @@ vi.mock('../../stores/projectStore', () => ({ import { createPendingModeSwitchRequest, + getRegionStartScrollLeft, getRegionPlayheadRelation, getScrollLeftForViewportRequest, } from './PianoRoll'; @@ -57,6 +58,13 @@ describe('PianoRoll viewport switch helpers', () => { expect(getRegionPlayheadRelation(25, 16, 24)).toBe('after'); }); + it('uses the zoomed beat width when scrolling to a different region in piano-roll view', () => { + document.documentElement.style.setProperty('--region-grid-beat-width', '80px'); + document.documentElement.style.setProperty('--region-grid-bar-width', 'calc(var(--region-grid-beat-width) * var(--time-signature-numerator))'); + + expect(getRegionStartScrollLeft(16)).toBe(1280); + }); + it('centers an in-region playhead when switching to region-scope sheet view', () => { const request = createPendingModeSwitchRequest({ playheadBeat: 20, diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index 56cfd76..141fe35 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, @@ -92,7 +96,7 @@ const PianoRoll: React.FC = ({ useState(3); // Piano roll zoom (1x–8x); updates --region-grid-beat-width CSS variable - const [pianoRollZoom, setPianoRollZoom] = useState(1); + const [pianoRollZoom, setPianoRollZoom] = useState(() => KGPianoRollState.instance().getPianoRollZoom()); const [automationEnabled, setAutomationEnabled] = useState(false); const [automationType, setAutomationType] = useState('pitch-bend'); const [sheetMusicViewEnabled, setSheetMusicViewEnabled] = useState(false); @@ -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; @@ -744,6 +785,8 @@ const PianoRoll: React.FC = ({ } } + KGPianoRollState.instance().setPianoRollZoom(nextZoom); + KGCore.instance().getCurrentProject().setPianoRollZoom(nextZoom); setPianoRollZoom(nextZoom); }, [pianoRollZoom]); @@ -986,7 +1029,7 @@ const PianoRoll: React.FC = ({ }; }, [pianoRollZoom]); - // Scroll horizontally to the active region's starting bar + // Scroll horizontally to the active region's starting position useEffect(() => { if (!pianoRollNoteScrollRef.current || !activeRegion) { previousActiveRegionIdRef.current = activeRegion?.getId() ?? null; @@ -1002,27 +1045,17 @@ const PianoRoll: React.FC = ({ // Get the starting beat of the region const startBeat = activeRegion.getStartFromBeat(); - // Get the time signature to calculate beats per bar - const beatsPerBar = timeSignature.numerator; - - // Calculate the bar number (0-indexed) - const barNumber = Math.floor(startBeat / beatsPerBar); - if (DEBUG_MODE.PIANO_ROLL) { - console.log(`Scrolling to region's starting bar: ${barNumber + 1} (startBeat: ${startBeat}, beatsPerBar: ${beatsPerBar})`); + console.log(`Scrolling to region's starting position: startBeat=${startBeat}`); } - // Calculate the pixel position (each bar is --region-grid-bar-width wide, which is 160px by default) - const barWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-bar-width')) || 160; - - // Calculate the scroll position to scroll to the starting bar - const scrollPosition = barNumber * barWidth; + const scrollPosition = getRegionStartScrollLeft(startBeat); // Scroll to the calculated position pianoRollNoteScrollRef.current.scrollLeft = Math.max(0, scrollPosition); previousActiveRegionIdRef.current = activeRegion.getId(); } - }, [activeRegion, timeSignature]); + }, [activeRegion]); useLayoutEffect(() => { const request = pendingModeSwitchRequestRef.current; @@ -1548,3 +1581,11 @@ export function getScrollLeftForViewportRequest({ container, }); } + +export function getRegionStartScrollLeft(startBeat: number): number { + const beatWidth = parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width') + ) || TOOLBAR_CONSTANTS.BASE_BAR_WIDTH; + + return Math.max(0, startBeat * beatWidth); +} diff --git a/src/components/piano-roll/PianoRollContent.tsx b/src/components/piano-roll/PianoRollContent.tsx index 957f4f1..40916c0 100644 --- a/src/components/piano-roll/PianoRollContent.tsx +++ b/src/components/piano-roll/PianoRollContent.tsx @@ -239,8 +239,8 @@ const PianoRollContent: React.FC = ({ const noteId = note.getId(); - // Check if this note is being resized or dragged and has a temporary style - if ((resizingNoteId === noteId || draggingNoteId === noteId) && tempNoteStyles[noteId]) { + // Use preview geometry whenever this note has an active temporary style. + if (tempNoteStyles[noteId]) { // Use the temporary style for position and size const tempStyle = tempNoteStyles[noteId]; @@ -351,7 +351,6 @@ const PianoRollContent: React.FC = ({ keySignature={sheetKeySignature} instrument={sheetInstrument} quantization={sheetQuantization} - noteScrollRef={noteScrollRef} onMetricsChange={onSheetMeasureMetricsChange ?? NOOP_SHEET_METRICS_CHANGE} /> ) : ( diff --git a/src/components/piano-roll/PianoRollToolbar.test.tsx b/src/components/piano-roll/PianoRollToolbar.test.tsx index 8e16cf5..7e50a4f 100644 --- a/src/components/piano-roll/PianoRollToolbar.test.tsx +++ b/src/components/piano-roll/PianoRollToolbar.test.tsx @@ -130,10 +130,23 @@ describe('PianoRollToolbar', () => { expect(screen.getByRole('button', { name: 'Sheet Music View' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /16,48/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Show Entire Track' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '1x' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Pointer Tool' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Pitch Bend/i })).not.toBeInTheDocument(); }); + it('shows the zoom button outside sheet mode', () => { + render( + + ); + + expect(screen.getByRole('button', { name: '1x' })).toBeInTheDocument(); + }); + it('toggles the full-track sheet scope button', () => { const onSheetMusicTrackScopeToggle = vi.fn(); diff --git a/src/components/piano-roll/PianoRollToolbar.tsx b/src/components/piano-roll/PianoRollToolbar.tsx index b9b6df0..3e8d8c5 100644 --- a/src/components/piano-roll/PianoRollToolbar.tsx +++ b/src/components/piano-roll/PianoRollToolbar.tsx @@ -256,28 +256,30 @@ const PianoRollToolbar: React.FC = ({ )} -
- - {showZoomSlider && ( -
- onZoomChange(parseInt(e.target.value))} - /> - {zoom}x -
- )} -
+ {!sheetMusicViewEnabled && ( +
+ + {showZoomSlider && ( +
+ onZoomChange(parseInt(e.target.value))} + /> + {zoom}x +
+ )} +
+ )} ); diff --git a/src/components/piano-roll/PianoRollZoomPersistence.test.tsx b/src/components/piano-roll/PianoRollZoomPersistence.test.tsx new file mode 100644 index 0000000..f9df01b --- /dev/null +++ b/src/components/piano-roll/PianoRollZoomPersistence.test.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import { describe, expect, it, beforeEach, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import PianoRoll from './PianoRoll'; +import { createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data'; + +const pianoRollState = { + zoom: 1, + getCurrentSnap: vi.fn(() => 'NO SNAP'), + getActiveTool: vi.fn(() => 'pointer'), + getAutomationViewEnabled: vi.fn(() => false), + getCurrentAutomationType: vi.fn(() => 'pitch-bend'), + getPianoRollZoom: vi.fn(() => pianoRollState.zoom), + setPianoRollZoom: vi.fn((zoom: number) => { + pianoRollState.zoom = zoom; + }), + getSheetMusicViewEnabled: vi.fn(() => false), + getSheetMusicTrackScopeEnabled: vi.fn(() => false), + getSheetQuantization: vi.fn(() => '16,48'), + setSheetMusicViewEnabled: vi.fn(), + setActiveTool: vi.fn(), + setAutomationViewEnabled: vi.fn(), + setCurrentAutomationType: vi.fn(), + setSheetQuantization: vi.fn(), + setSheetMusicTrackScopeEnabled: vi.fn(), + setCurrentSnap: vi.fn(), + setCurrentSuitableChords: vi.fn(), + setCurrentSuitableChordsPitchClasses: vi.fn(), +}; + +const mockProject = { + setPianoRollZoom: vi.fn(), +}; + +const region = createMockMidiRegion({ id: 'region-1', trackId: '1', trackIndex: 0 }); +const track = createMockMidiTrack({ id: 1, regions: [region] }); + +const storeState = { + maxBars: 8, + tracks: [track], + updateTrack: vi.fn(), + timeSignature: { numerator: 4, denominator: 4 }, + showChatBox: false, + showKGOnePanel: false, + showEventListPanel: false, + showInstrumentSelection: false, + keySignature: 'C major', + selectedMode: 'ionian', + setSelectedMode: vi.fn(), + playheadPosition: 0, + isPlaying: false, + autoScrollEnabled: false, + bpm: 120, + pianoRollScrollRequest: null, + selectedNoteIds: [], + automationRedrawVersion: 0, +}; + +let latestToolbarProps: { zoom: number; onZoomChange: (value: number) => void } | null = null; + +vi.mock('../../stores/projectStore', () => ({ + useProjectStore: Object.assign( + (selector?: (state: typeof storeState) => unknown) => selector ? selector(storeState) : storeState, + { + getState: () => ({ setAutoScrollEnabled: vi.fn() }), + setState: vi.fn(), + } + ), +})); + +vi.mock('../../core/state/KGPianoRollState', () => ({ + KGPianoRollState: { + instance: () => pianoRollState, + SNAP_OPTIONS: ['NO SNAP'], + QUANT_POS_OPTIONS: ['1/8'], + QUANT_LEN_OPTIONS: ['1/8'], + }, +})); + +vi.mock('../../core/KGCore', () => ({ + KGCore: { + FUNCTIONAL_CHORDS_DATA: { ionian: { name: 'Ionian' } }, + instance: () => ({ + getCurrentProject: () => mockProject, + getSelectedItems: () => [], + executeCommand: vi.fn(), + }), + }, +})); + +vi.mock('../../core/config/ConfigManager', () => ({ + ConfigManager: { + instance: () => ({ + getIsInitialized: () => true, + get: vi.fn(), + addChangeListener: () => () => undefined, + }), + }, +})); + +vi.mock('../../util/scaleUtil', () => ({ + getSuitableChords: vi.fn(() => ({})), + noteNameToPitchClass: vi.fn(), +})); + +vi.mock('../../util/dialogUtil', () => ({ + showAlert: vi.fn(), + showPrompt: vi.fn(), +})); + +vi.mock('./PianoRollHeader', () => ({ default: () =>
})); +vi.mock('./NoteAttributeBar', () => ({ default: () =>
})); +vi.mock('./PianoRollContent', () => ({ default: () =>
})); +vi.mock('./PianoRollToolbar', () => ({ + default: (props: { zoom: number; onZoomChange: (value: number) => void }) => { + latestToolbarProps = props; + return
{props.zoom}x
; + }, +})); + +describe('PianoRoll zoom persistence', () => { + beforeEach(() => { + latestToolbarProps = null; + pianoRollState.zoom = 1; + mockProject.setPianoRollZoom.mockReset(); + pianoRollState.getPianoRollZoom.mockClear(); + pianoRollState.setPianoRollZoom.mockClear(); + document.documentElement.style.setProperty('--region-piano-key-width', '60px'); + document.documentElement.style.setProperty('--region-grid-beat-width', '40px'); + }); + + it('restores the previous zoom after closing and reopening the piano roll', () => { + const firstRender = render( + + ); + + expect(latestToolbarProps?.zoom).toBe(1); + + latestToolbarProps?.onZoomChange(3); + + expect(pianoRollState.setPianoRollZoom).toHaveBeenCalledWith(3); + expect(mockProject.setPianoRollZoom).toHaveBeenCalledWith(3); + + firstRender.unmount(); + + render( + + ); + + expect(latestToolbarProps?.zoom).toBe(3); + }); +}); diff --git a/src/components/piano-roll/SheetMusicView.test.tsx b/src/components/piano-roll/SheetMusicView.test.tsx new file mode 100644 index 0000000..789073a --- /dev/null +++ b/src/components/piano-roll/SheetMusicView.test.tsx @@ -0,0 +1,302 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import SheetMusicView from './SheetMusicView'; +import { getSheetPlayheadPixel, parseSheetQuantization } from './sheetNotation'; +import type { SheetMeasureMetric } from './sheetNotationTypes'; +import { createMockMidiNote, createMockMidiRegion } from '../../test/utils/mock-data'; + +const setPlayheadPosition = vi.fn(); +const requestMainContentScroll = vi.fn(); + +vi.mock('../../stores/projectStore', () => ({ + useProjectStore: (selector: (state: { + playheadPosition: number; + setPlayheadPosition: typeof setPlayheadPosition; + requestMainContentScroll: typeof requestMainContentScroll; + }) => unknown) => selector({ + playheadPosition: 0, + setPlayheadPosition, + requestMainContentScroll, + }), +})); + +vi.mock('../common', () => ({ + Playhead: ({ pixelPositionOverride }: { pixelPositionOverride?: number }) => ( +
+ ), +})); + +vi.mock('vexflow', () => { + class MockRenderer { + static Backends = { SVG: 'svg' }; + + private readonly host: HTMLElement; + + constructor(host: HTMLElement) { + this.host = host; + } + + resize() {} + + getContext() { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + this.host.appendChild(svg); + return {}; + } + } + + class MockStave { + constructor( + _x: number, + _y: number, + _width: number + ) {} + + setBegBarType() { + return this; + } + + setEndBarType() { + return this; + } + + addClef() { + return this; + } + + addKeySignature() { + return this; + } + + addTimeSignature() { + return this; + } + + setContext() { + return this; + } + + draw() { + return this; + } + } + + class MockStaveNote { + constructor(_options: unknown) {} + + isRest() { + return false; + } + + getTieLeftX() { + return 0; + } + + getTieRightX() { + return 0; + } + + getYs() { + return [0]; + } + } + + class MockVoice { + constructor(_options: unknown) {} + + setStrict() { + return this; + } + + addTickables() { + return this; + } + + draw() { + return this; + } + } + + class MockFormatter { + joinVoices() { + return this; + } + + formatToStave() { + return this; + } + } + + class MockBeam { + static generateBeams() { + return []; + } + + setContext() { + return this; + } + + draw() { + return this; + } + } + + return { + Accidental: { applyAccidentals: vi.fn() }, + BarlineType: { SINGLE: 1, NONE: 0 }, + Beam: MockBeam, + Dot: { buildAndAttach: vi.fn() }, + Formatter: MockFormatter, + Renderer: MockRenderer, + Stave: MockStave, + StaveNote: MockStaveNote, + Voice: MockVoice, + }; +}); + +describe('SheetMusicView', () => { + const quantization = parseSheetQuantization('16,48'); + const onMetricsChange = vi.fn(); + + const getLatestMetrics = (): SheetMeasureMetric[] => { + const latestCall = onMetricsChange.mock.calls.at(-1); + expect(latestCall).toBeDefined(); + return latestCall?.[0] as SheetMeasureMetric[]; + }; + + beforeEach(() => { + setPlayheadPosition.mockClear(); + requestMainContentScroll.mockClear(); + onMetricsChange.mockClear(); + }); + + it('maps header clicks in region scope without adding scroll offset', () => { + const activeRegion = createMockMidiRegion({ + startFromBeat: 16, + length: 8, + notes: [], + }); + + render( + + ); + + const header = document.querySelector('.sheet-music-header') as HTMLDivElement; + expect(header).not.toBeNull(); + const metrics = getLatestMetrics(); + const expectedLocalBeat = 6; + const headerPixel = getSheetPlayheadPixel(expectedLocalBeat, metrics); + + Object.defineProperty(header, 'getBoundingClientRect', { + value: () => ({ + left: 100, + top: 0, + right: 540, + bottom: 20, + width: 440, + height: 20, + x: 100, + y: 0, + toJSON: () => ({}), + }), + }); + + fireEvent.click(header, { clientX: 100 + headerPixel }); + + expect(setPlayheadPosition).toHaveBeenCalledTimes(1); + expect(requestMainContentScroll).toHaveBeenCalledTimes(1); + expect(setPlayheadPosition).toHaveBeenCalledWith(22); + expect(requestMainContentScroll).toHaveBeenCalledWith(22); + }); + + it('maps header clicks in track scope to absolute beats', () => { + const activeRegion = createMockMidiRegion({ + startFromBeat: 16, + length: 8, + notes: [ + createMockMidiNote({ id: 'note-1', startBeat: 0, endBeat: 1, pitch: 60 }), + ], + }); + + const anotherRegion = createMockMidiRegion({ + id: 'region-2', + startFromBeat: 24, + length: 4, + notes: [ + createMockMidiNote({ id: 'note-2', startBeat: 0, endBeat: 1, pitch: 67 }), + ], + }); + + render( + + ); + + const header = document.querySelector('.sheet-music-header') as HTMLDivElement; + expect(header).not.toBeNull(); + const metrics = getLatestMetrics(); + const expectedBeat = 3; + const headerPixel = getSheetPlayheadPixel(expectedBeat, metrics); + + Object.defineProperty(header, 'getBoundingClientRect', { + value: () => ({ + left: 100, + top: 0, + right: 300, + bottom: 20, + width: 200, + height: 20, + x: 100, + y: 0, + toJSON: () => ({}), + }), + }); + + fireEvent.click(header, { clientX: 100 + headerPixel }); + + expect(setPlayheadPosition).toHaveBeenCalledTimes(1); + expect(requestMainContentScroll).toHaveBeenCalledTimes(1); + expect(setPlayheadPosition).toHaveBeenCalledWith(3); + expect(requestMainContentScroll).toHaveBeenCalledWith(3); + }); + + it('renders the playhead container', () => { + const activeRegion = createMockMidiRegion(); + + render( + + ); + + expect(screen.getByTestId('playhead')).toBeInTheDocument(); + }); +}); diff --git a/src/components/piano-roll/SheetMusicView.tsx b/src/components/piano-roll/SheetMusicView.tsx index 3620b0c..8329ba0 100644 --- a/src/components/piano-roll/SheetMusicView.tsx +++ b/src/components/piano-roll/SheetMusicView.tsx @@ -26,7 +26,6 @@ interface SheetMusicViewProps { keySignature: KeySignature; instrument: InstrumentType; quantization: SheetQuantization; - noteScrollRef: React.MutableRefObject; onMetricsChange: (metrics: SheetMeasureMetric[]) => void; } @@ -61,7 +60,6 @@ const SheetMusicView: React.FC = ({ keySignature, instrument, quantization, - noteScrollRef, onMetricsChange, }) => { const setPlayheadPosition = useProjectStore(state => state.setPlayheadPosition); @@ -245,7 +243,7 @@ const SheetMusicView: React.FC = ({ } const rect = headerRef.current.getBoundingClientRect(); - const relativeX = event.clientX - rect.left + (noteScrollRef.current?.scrollLeft ?? 0); + const relativeX = event.clientX - rect.left; const metric = metrics.find(candidate => ( relativeX >= candidate.leftPx && relativeX <= candidate.leftPx + candidate.widthPx )); @@ -365,7 +363,6 @@ const arePropsEqual = (previous: SheetMusicViewProps, next: SheetMusicViewProps) previous.quantization.raw === next.quantization.raw && previous.timeSignature.numerator === next.timeSignature.numerator && previous.timeSignature.denominator === next.timeSignature.denominator && - previous.noteScrollRef === next.noteScrollRef && previous.onMetricsChange === next.onMetricsChange ); }; diff --git a/src/components/settings/sections/BehaviorSettings.tsx b/src/components/settings/sections/BehaviorSettings.tsx index 88f6d05..4688346 100644 --- a/src/components/settings/sections/BehaviorSettings.tsx +++ b/src/components/settings/sections/BehaviorSettings.tsx @@ -14,6 +14,7 @@ const BehaviorSettings: React.FC = () => { const [midiAutomationInterpolationIntervalMs, setMidiAutomationInterpolationIntervalMs] = useState(10); const [playbackDelay, setPlaybackDelay] = useState('200'); const [recordingOffset, setRecordingOffset] = useState('0'); + const [bounceStartsFromBeat1, setBounceStartsFromBeat1] = useState(true); const [enableAudioCapture, setEnableAudioCapture] = useState(false); const [lookaheadValidationErrors, setLookaheadValidationErrors] = useState([]); const [playbackDelayValidationErrors, setPlaybackDelayValidationErrors] = useState([]); @@ -42,6 +43,7 @@ const BehaviorSettings: React.FC = () => { setPlaybackDelay(((playbackDelaySeconds * 1000).toFixed(0))); const recordingOffsetSeconds = (configManager.get('audio.recording_offset') as number) ?? 0; setRecordingOffset(((recordingOffsetSeconds * 1000).toFixed(0))); + setBounceStartsFromBeat1((configManager.get('audio.bounce_starts_from_beat_1') as boolean) ?? true); setEnableAudioCapture((configManager.get('audio.enable_audio_capture_for_screen_sharing') as boolean) ?? false); }; @@ -158,6 +160,12 @@ const BehaviorSettings: React.FC = () => { await configManager.set('audio.enable_audio_capture_for_screen_sharing', boolValue); }; + const handleBounceStartsFromBeat1Change = async (value: string) => { + const boolValue = value === 'yes'; + setBounceStartsFromBeat1(boolValue); + await configManager.set('audio.bounce_starts_from_beat_1', boolValue); + }; + return (
@@ -325,6 +333,23 @@ const BehaviorSettings: React.FC = () => {
+
+ + +
+ Yes includes leading silence from the start of the song up to the first rendered region when bouncing WAV/MP3. No trims that leading silence and starts bounce at the first rendered note or audio region. +
+
+
+
+ + handleLocalModelUrlChange(e.target.value)} + /> + +
+ {!localModelState.isCached && !localModelState.isDownloading && localModelState.runtimeSupport.supported && (
The local model downloads automatically the next time you chat with `Local LLM (Browser)`. @@ -366,6 +438,47 @@ const GeneralSettings: React.FC = () => {
+
+

UVR5 Web Runtime

+ +
+ + handleUvr5ModelUrlChange(e.target.value)} + /> + +
+ +
+ +
+
+

OpenAI

diff --git a/src/components/track/Region.css b/src/components/track/Region.css index e112248..1de1aeb 100644 --- a/src/components/track/Region.css +++ b/src/components/track/Region.css @@ -73,12 +73,26 @@ background-color: #87CEFA; /* Light blue */ width: 100%; position: relative; /* Allow overlayed controls */ + overflow: hidden; } .region-content.audio-region-content { background-color: #90EE90; /* Light green for audio regions */ } +.region-preview-content { + position: absolute; + inset: 0 auto 0 0; + width: 100%; + height: 100%; +} + +.region-preview-content canvas { + display: block; + width: 100%; + height: 100%; +} + /* Audio region overrides */ .track-region.audio-region { background-color: #3a6b4a; diff --git a/src/components/track/RegionItem.test.tsx b/src/components/track/RegionItem.test.tsx index 5076ff0..7164275 100644 --- a/src/components/track/RegionItem.test.tsx +++ b/src/components/track/RegionItem.test.tsx @@ -147,4 +147,35 @@ describe('RegionItem', () => { expect(context.stroke).toHaveBeenCalled(); rectSpy.mockRestore(); }); + + it('applies preview content clipping styles when provided', () => { + const { container } = renderRegion({ + previewContentStyle: { + left: '-40px', + width: '120px', + }, + }); + + const previewContent = container.querySelector('.region-preview-content'); + + expect(previewContent).toBeTruthy(); + expect(previewContent).toHaveAttribute('data-preview-content-active', 'true'); + expect(previewContent).toHaveStyle({ + left: '-40px', + width: '120px', + }); + }); + + it('uses the default preview content wrapper sizing for normal regions', () => { + const { container } = renderRegion(); + + const previewContent = container.querySelector('.region-preview-content'); + + expect(previewContent).toBeTruthy(); + expect(previewContent).toHaveAttribute('data-preview-content-active', 'false'); + expect(previewContent).not.toHaveStyle({ + left: '-40px', + width: '120px', + }); + }); }); diff --git a/src/components/track/RegionItem.tsx b/src/components/track/RegionItem.tsx index ed5a17a..f9eef8b 100644 --- a/src/components/track/RegionItem.tsx +++ b/src/components/track/RegionItem.tsx @@ -9,6 +9,7 @@ import { KGAudioRegion } from '../../core/region/KGAudioRegion'; import { useProjectStore } from '../../stores/projectStore'; import { KGMainContentState } from '../../core/state/KGMainContentState'; import type { AudioRecordingPeak } from '../../core/audio-interface/KGAudioRecorder'; +import type { RegionPreviewContentStyle } from '../interfaces'; const DRAG_START_THRESHOLD_PX = 4; @@ -46,6 +47,7 @@ interface RegionItemProps { previewWaveformPeaks?: AudioRecordingPeak[]; isPreview?: boolean; isAudioRegion?: boolean; + previewContentStyle?: RegionPreviewContentStyle; } const RegionItem: React.FC = ({ @@ -73,6 +75,7 @@ const RegionItem: React.FC = ({ previewWaveformPeaks, isPreview = false, isAudioRegion = false, + previewContentStyle, }) => { // Get selection state and time signature from store const { selectedRegionIds, timeSignature, bpm } = useProjectStore(); @@ -98,24 +101,26 @@ const RegionItem: React.FC = ({ // Canvas ref for note visualization const canvasRef = useRef(null); - const regionContentRef = useRef(null); + const previewContentRef = useRef(null); // Function to render notes on canvas const renderNotesOnCanvas = () => { - if (!canvasRef.current || !regionContentRef.current || !midiRegion) return; + if (!canvasRef.current || !previewContentRef.current || !midiRegion) return; const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); if (!ctx) return; // Get the current dimensions of the region content - const contentRect = regionContentRef.current.getBoundingClientRect(); - const width = contentRect.width; - const height = contentRect.height; + const contentRect = previewContentRef.current.getBoundingClientRect(); + const width = Math.max(1, Math.round(contentRect.width)); + const height = Math.max(1, Math.round(contentRect.height)); // Set canvas size to match the region content canvas.width = width; canvas.height = height; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; // Clear the canvas ctx.clearRect(0, 0, width, height); @@ -242,18 +247,20 @@ const RegionItem: React.FC = ({ // Function to render audio waveform on canvas const renderWaveformOnCanvas = () => { - if (!canvasRef.current || !regionContentRef.current || !audioBuffer) return; + if (!canvasRef.current || !previewContentRef.current || !audioBuffer) return; const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); if (!ctx) return; - const contentRect = regionContentRef.current.getBoundingClientRect(); - const width = contentRect.width; - const height = contentRect.height; + const contentRect = previewContentRef.current.getBoundingClientRect(); + const width = Math.max(1, Math.round(contentRect.width)); + const height = Math.max(1, Math.round(contentRect.height)); canvas.width = width; canvas.height = height; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; ctx.clearRect(0, 0, width, height); @@ -311,18 +318,20 @@ const RegionItem: React.FC = ({ }; const renderPreviewWaveformOnCanvas = () => { - if (!canvasRef.current || !regionContentRef.current || !previewWaveformPeaks || previewWaveformPeaks.length === 0) return; + if (!canvasRef.current || !previewContentRef.current || !previewWaveformPeaks || previewWaveformPeaks.length === 0) return; const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); if (!ctx) return; - const contentRect = regionContentRef.current.getBoundingClientRect(); - const width = contentRect.width; - const height = contentRect.height; + const contentRect = previewContentRef.current.getBoundingClientRect(); + const width = Math.max(1, Math.round(contentRect.width)); + const height = Math.max(1, Math.round(contentRect.height)); canvas.width = width; canvas.height = height; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; ctx.clearRect(0, 0, width, height); const centerY = height / 2; @@ -374,11 +383,11 @@ const RegionItem: React.FC = ({ } else { renderNotesOnCanvas(); } - }, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm, id, noteUpdateTrigger, barNumber, length]); + }, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm, id, noteUpdateTrigger, barNumber, length, previewContentStyle?.width]); // Re-render canvas when region content size changes useEffect(() => { - if (!regionContentRef.current) return; + if (!previewContentRef.current) return; const resizeObserver = new ResizeObserver(() => { if (previewWaveformPeaks && previewWaveformPeaks.length > 0) { @@ -390,14 +399,14 @@ const RegionItem: React.FC = ({ } }); - resizeObserver.observe(regionContentRef.current); + resizeObserver.observe(previewContentRef.current); return () => { - if (regionContentRef.current) { - resizeObserver.unobserve(regionContentRef.current); + if (previewContentRef.current) { + resizeObserver.unobserve(previewContentRef.current); } }; - }, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm]); + }, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm, previewContentStyle?.width]); // Handle mouse movement to detect edge proximity const handleMouseMove = (e: React.MouseEvent) => { @@ -668,7 +677,7 @@ const RegionItem: React.FC = ({
{name}
-
+
{!isPreview &&
{!audioRegion && (
} - +
+ +
); diff --git a/src/components/track/TrackGridItem.test.tsx b/src/components/track/TrackGridItem.test.tsx index b92c91f..288d7aa 100644 --- a/src/components/track/TrackGridItem.test.tsx +++ b/src/components/track/TrackGridItem.test.tsx @@ -1,41 +1,71 @@ -import React from 'react'; -import { beforeAll, describe, expect, it, vi } from 'vitest'; -import { render } from '@testing-library/react'; +import React, { useState } from 'react'; +import { act, render } from '@testing-library/react'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import TrackGridItem from './TrackGridItem'; import { KGAudioTrack } from '../../core/track/KGAudioTrack'; +import { KGMainContentState } from '../../core/state/KGMainContentState'; +import { createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data'; +import type { RegionPreviewContentStyle } from '../interfaces'; + +const storeState = { + selectedRegionIds: [] as string[], + activeTrackAutomationTrackId: null as string | null, + activeTrackAutomationType: null, + trackAutomationRedrawVersion: 0, + recordingMode: 'audio' as 'audio' | 'midi' | null, + recordingTargetTrackIndex: 0, + recordingCommitStartBeatAbsolute: 4, + recordingAudioPreviewCurrentBeat: 8, + recordingAudioPreviewPeaks: [{ min: -0.5, max: 0.5 }], + recordingAudioPreviewFileName: 'Recording' as string | null, + timeSignature: { numerator: 4, denominator: 4 }, +}; + +const regionItemProps = new Map>(); vi.mock('../../stores/projectStore', () => ({ - useProjectStore: (selector?: (state: { - selectedRegionIds: string[]; - activeTrackAutomationTrackId: string | null; - activeTrackAutomationType: null; - trackAutomationRedrawVersion: number; - recordingMode: 'audio' | 'midi' | null; - recordingTargetTrackIndex: number | null; - recordingCommitStartBeatAbsolute: number; - recordingAudioPreviewCurrentBeat: number; - recordingAudioPreviewPeaks: Array<{ min: number; max: number }>; - recordingAudioPreviewFileName: string | null; - timeSignature: { numerator: number; denominator: number }; - }) => unknown) => { - const state = { - selectedRegionIds: [], - activeTrackAutomationTrackId: null, - activeTrackAutomationType: null, - trackAutomationRedrawVersion: 0, - recordingMode: 'audio' as const, - recordingTargetTrackIndex: 0, - recordingCommitStartBeatAbsolute: 4, - recordingAudioPreviewCurrentBeat: 8, - recordingAudioPreviewPeaks: [{ min: -0.5, max: 0.5 }], - recordingAudioPreviewFileName: 'Recording', - timeSignature: { numerator: 4, denominator: 4 }, - }; - return selector ? selector(state) : state; + useProjectStore: (selector?: (state: typeof storeState) => unknown) => ( + selector ? selector(storeState) : storeState + ), +})); + +vi.mock('./RegionItem', () => ({ + default: (props: Record) => { + regionItemProps.set(props.id as string, props); + return ( +
+ ); }, })); -describe('TrackGridItem recording preview', () => { +vi.mock('./TrackAutomationLane', () => ({ + default: () => null, +})); + +vi.mock('../../core/audio-interface/KGAudioInterface', () => ({ + KGAudioInterface: { + instance: () => ({ + getAudioBuffer: () => undefined, + }), + }, +})); + +describe('TrackGridItem preview behavior', () => { + const getRegionItem = (regionId: string) => regionItemProps.get(regionId) as { + style: React.CSSProperties; + previewContentStyle?: RegionPreviewContentStyle; + onResizeStart?: (regionId: string, resizeAction: 'start' | 'end', initialX: number) => void; + onResize?: (regionId: string, resizeAction: 'start' | 'end', deltaX: number) => void; + onResizeEnd?: (regionId: string, resizeAction: 'start' | 'end') => void; + onDragStart?: (regionId: string, initialX: number, initialY: number) => void; + onDrag?: (regionId: string, deltaX: number, deltaY: number) => void; + onDragEnd?: (regionId: string) => void; + }; + beforeAll(() => { Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { value: vi.fn(() => ({ @@ -57,11 +87,102 @@ describe('TrackGridItem recording preview', () => { vi.stubGlobal('ResizeObserver', ResizeObserverMock); }); + beforeEach(() => { + regionItemProps.clear(); + storeState.selectedRegionIds = []; + storeState.activeTrackAutomationTrackId = null; + storeState.activeTrackAutomationType = null; + storeState.trackAutomationRedrawVersion = 0; + storeState.recordingMode = 'audio'; + storeState.recordingTargetTrackIndex = 0; + storeState.recordingCommitStartBeatAbsolute = 4; + storeState.recordingAudioPreviewCurrentBeat = 8; + storeState.recordingAudioPreviewPeaks = [{ min: -0.5, max: 0.5 }]; + storeState.recordingAudioPreviewFileName = 'Recording'; + storeState.timeSignature = { numerator: 4, denominator: 4 }; + KGMainContentState.instance().setActiveTool('pointer'); + KGMainContentState.instance().setSnapping(true); + }); + + const createGridContainerRef = () => { + const gridElement = document.createElement('div'); + Object.defineProperty(gridElement, 'clientWidth', { configurable: true, value: 800 }); + Object.defineProperty(gridElement, 'clientHeight', { configurable: true, value: 240 }); + return { current: gridElement }; + }; + + const renderSharedPreviewHarness = ( + selectedRegionIds: string[] = [], + regionOverrides: Array<{ id: string; trackId: string; trackIndex: number; barNumber: number; length: number; name: string }> = [ + { id: 'region-a', trackId: '1', trackIndex: 0, barNumber: 1, length: 1, name: 'Region A' }, + { id: 'region-b', trackId: '2', trackIndex: 1, barNumber: 3, length: 2, name: 'Region B' }, + ], + ) => { + storeState.selectedRegionIds = selectedRegionIds; + + const regionAData = regionOverrides.find(region => region.id === 'region-a')!; + const regionBData = regionOverrides.find(region => region.id === 'region-b')!; + const regionA = createMockMidiRegion({ id: 'region-a', trackId: '1', trackIndex: 0, startFromBeat: (regionAData.barNumber - 1) * 4, length: regionAData.length * 4 }); + const regionB = createMockMidiRegion({ id: 'region-b', trackId: '2', trackIndex: 1, startFromBeat: (regionBData.barNumber - 1) * 4, length: regionBData.length * 4 }); + const trackA = createMockMidiTrack({ id: 1, regions: [regionA] }); + const trackB = createMockMidiTrack({ id: 2, regions: [regionB] }); + trackA.setTrackIndex(0); + trackB.setTrackIndex(1); + + const gridContainerRef = createGridContainerRef(); + const baseProps = { + isDragging: false, + isDragOver: false, + regions: regionOverrides, + maxBars: 8, + selectedRegionId: null, + gridContainerRef, + onDoubleClick: vi.fn(), + onRegionResize: vi.fn(), + onRegionResizeEnd: vi.fn(), + onRegionDrag: vi.fn(), + onRegionDragEnd: vi.fn(), + allTracks: [trackA, trackB], + }; + + const SharedPreviewHarness = () => { + const [previewRegionStyles, setPreviewRegionStyles] = useState>({}); + const [previewRegionContentStyles, setPreviewRegionContentStyles] = useState>({}); + + return ( + <> + + + + ); + }; + + render(); + + return baseProps; + }; + it('renders a non-interactive preview region on the recording audio track', () => { const track = new KGAudioTrack('Audio Track', 1); track.setTrackIndex(0); - const view = render( + render( { regions={[]} maxBars={8} selectedRegionId={null} - gridContainerRef={{ current: document.createElement('div') }} + gridContainerRef={createGridContainerRef()} onDoubleClick={vi.fn()} /> ); - const previewRegion = view.container.querySelector('[data-preview-region="true"]'); - expect(previewRegion).toBeTruthy(); + expect(regionItemProps.get('audio-recording-preview')).toBeTruthy(); + }); + + it('previews end resize for all selected regions across track rows', () => { + renderSharedPreviewHarness(['region-a', 'region-b']); + + act(() => { + getRegionItem('region-a').onResizeStart?.('region-a', 'end', 0); + getRegionItem('region-a').onResize?.('region-a', 'end', 40); + }); + + expect(getRegionItem('region-a').style).toEqual({ + left: '0px', + width: '140px', + position: 'absolute', + }); + expect(getRegionItem('region-a').previewContentStyle).toEqual({ + left: '0px', + width: '100px', + }); + expect(getRegionItem('region-b').style).toEqual({ + left: '200px', + width: '240px', + position: 'absolute', + }); + expect(getRegionItem('region-b').previewContentStyle).toEqual({ + left: '0px', + width: '200px', + }); + }); + + it('previews start resize for all selected regions across track rows', () => { + renderSharedPreviewHarness( + ['region-a', 'region-b'], + [ + { id: 'region-a', trackId: '1', trackIndex: 0, barNumber: 2, length: 2, name: 'Region A' }, + { id: 'region-b', trackId: '2', trackIndex: 1, barNumber: 4, length: 3, name: 'Region B' }, + ], + ); + + act(() => { + getRegionItem('region-a').onResizeStart?.('region-a', 'start', 0); + getRegionItem('region-a').onResize?.('region-a', 'start', 40); + }); + + expect(getRegionItem('region-a').style).toEqual({ + left: '140px', + width: '160px', + position: 'absolute', + }); + expect(getRegionItem('region-a').previewContentStyle).toEqual({ + left: '-40px', + width: '200px', + }); + expect(getRegionItem('region-b').style).toEqual({ + left: '340px', + width: '260px', + position: 'absolute', + }); + expect(getRegionItem('region-b').previewContentStyle).toEqual({ + left: '-40px', + width: '300px', + }); + }); + + it('keeps preview content fixed while shrinking from the end', () => { + renderSharedPreviewHarness( + ['region-a', 'region-b'], + [ + { id: 'region-a', trackId: '1', trackIndex: 0, barNumber: 1, length: 2, name: 'Region A' }, + { id: 'region-b', trackId: '2', trackIndex: 1, barNumber: 3, length: 3, name: 'Region B' }, + ], + ); + + act(() => { + getRegionItem('region-a').onResizeStart?.('region-a', 'end', 0); + getRegionItem('region-a').onResize?.('region-a', 'end', -40); + }); + + expect(getRegionItem('region-a').style).toEqual({ + left: '0px', + width: '160px', + position: 'absolute', + }); + expect(getRegionItem('region-a').previewContentStyle).toEqual({ + left: '0px', + width: '200px', + }); + expect(getRegionItem('region-b').style).toEqual({ + left: '200px', + width: '260px', + position: 'absolute', + }); + expect(getRegionItem('region-b').previewContentStyle).toEqual({ + left: '0px', + width: '300px', + }); + }); + + it('shifts preview content right when extending from the start', () => { + renderSharedPreviewHarness( + ['region-a', 'region-b'], + [ + { id: 'region-a', trackId: '1', trackIndex: 0, barNumber: 2, length: 2, name: 'Region A' }, + { id: 'region-b', trackId: '2', trackIndex: 1, barNumber: 4, length: 3, name: 'Region B' }, + ], + ); + + act(() => { + getRegionItem('region-a').onResizeStart?.('region-a', 'start', 0); + getRegionItem('region-a').onResize?.('region-a', 'start', -40); + }); + + expect(getRegionItem('region-a').style).toEqual({ + left: '60px', + width: '240px', + position: 'absolute', + }); + expect(getRegionItem('region-a').previewContentStyle).toEqual({ + left: '40px', + width: '200px', + }); + expect(getRegionItem('region-b').style).toEqual({ + left: '260px', + width: '340px', + position: 'absolute', + }); + expect(getRegionItem('region-b').previewContentStyle).toEqual({ + left: '40px', + width: '300px', + }); + }); + + it('previews drag movement for all selected regions across track rows', () => { + renderSharedPreviewHarness(['region-a', 'region-b']); + + act(() => { + getRegionItem('region-a').onDragStart?.('region-a', 0, 0); + getRegionItem('region-a').onDrag?.('region-a', 50, 60); + }); + + expect(getRegionItem('region-a').style).toEqual({ + left: '50px', + width: '100px', + position: 'absolute', + zIndex: 100, + transform: 'translateY(0px)', + }); + expect(getRegionItem('region-b').style).toEqual({ + left: '250px', + width: '200px', + position: 'absolute', + zIndex: 100, + transform: 'translateY(0px)', + }); + }); + + it('previews only the grabbed region when it is outside the current selection', () => { + renderSharedPreviewHarness(['region-a', 'region-b']); + + const regionC = { + id: 'region-c', + trackId: '1', + trackIndex: 0, + barNumber: 5, + length: 1, + name: 'Region C', + }; + + const track = createMockMidiTrack({ id: 1, regions: [createMockMidiRegion({ id: 'region-c', trackId: '1', trackIndex: 0, startFromBeat: 16, length: 4 })] }); + track.setTrackIndex(0); + const gridContainerRef = createGridContainerRef(); + + render( + + ); + + act(() => { + getRegionItem('region-c').onDragStart?.('region-c', 0, 0); + getRegionItem('region-c').onDrag?.('region-c', 50, 60); + }); + + expect(getRegionItem('region-c').style).toEqual({ + left: '450px', + width: '100px', + position: 'absolute', + zIndex: 100, + transform: 'translateY(60px)', + }); + }); + + it('clears preview styles for the full cohort after drag and resize end', () => { + const { onRegionResizeEnd, onRegionDragEnd } = renderSharedPreviewHarness(['region-a', 'region-b']); + + act(() => { + getRegionItem('region-a').onResizeStart?.('region-a', 'end', 0); + getRegionItem('region-a').onResize?.('region-a', 'end', 40); + getRegionItem('region-a').onResizeEnd?.('region-a', 'end'); + }); + + expect(getRegionItem('region-a').style).toEqual({ + left: '0px', + width: '100px', + position: 'absolute', + }); + expect(getRegionItem('region-a').previewContentStyle).toBeUndefined(); + expect(getRegionItem('region-b').style).toEqual({ + left: '200px', + width: '200px', + position: 'absolute', + }); + expect(getRegionItem('region-b').previewContentStyle).toBeUndefined(); + expect(onRegionResizeEnd).toHaveBeenCalledWith('region-a', 1, 1); + + act(() => { + getRegionItem('region-a').onDragStart?.('region-a', 0, 0); + getRegionItem('region-a').onDrag?.('region-a', 50, 60); + getRegionItem('region-a').onDragEnd?.('region-a'); + }); + + expect(getRegionItem('region-a').style).toEqual({ + left: '0px', + width: '100px', + position: 'absolute', + }); + expect(getRegionItem('region-a').previewContentStyle).toBeUndefined(); + expect(getRegionItem('region-b').style).toEqual({ + left: '200px', + width: '200px', + position: 'absolute', + }); + expect(getRegionItem('region-b').previewContentStyle).toBeUndefined(); + expect(onRegionDragEnd).toHaveBeenCalledWith('region-a', 2, 0); }); }); diff --git a/src/components/track/TrackGridItem.tsx b/src/components/track/TrackGridItem.tsx index f436272..0641618 100644 --- a/src/components/track/TrackGridItem.tsx +++ b/src/components/track/TrackGridItem.tsx @@ -5,12 +5,29 @@ import { KGAudioRegion } from '../../core/region/KGAudioRegion'; import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface'; import RegionItem from './RegionItem'; import TrackAutomationLane from './TrackAutomationLane'; -import type { RegionClickOptions, RegionUI, ResizeAction } from '../interfaces'; +import type { RegionClickOptions, RegionPreviewContentStyle, RegionUI, ResizeAction } from '../interfaces'; import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants'; import { KGMainContentState } from '../../core/state/KGMainContentState'; import { isModifierKeyPressed } from '../../util/osUtil'; import { useProjectStore } from '../../stores/projectStore'; +interface RegionResizePreviewBaseline { + regionId: string; + originalBarNumber: number; + originalLength: number; + originalLeft: number; + originalWidth: number; + originalContentWidth: number; +} + +interface RegionDragPreviewBaseline { + regionId: string; + originalBarNumber: number; + originalTrackIndex: number; + originalLeft: number; + originalWidth: number; +} + interface TrackGridItemProps { track: KGTrack; index: number; @@ -35,6 +52,10 @@ interface TrackGridItemProps { onOpenHybrid?: (regionId: string) => void; allTracks?: KGTrack[]; // Added to access all tracks for drag operations onKGOneClipDrop?: (e: React.DragEvent, trackIndex: number) => void; + previewRegionStyles?: Record; + setPreviewRegionStyles?: React.Dispatch>>; + previewRegionContentStyles?: Record; + setPreviewRegionContentStyles?: React.Dispatch>>; } const TrackGridItem: React.FC = ({ @@ -61,6 +82,10 @@ const TrackGridItem: React.FC = ({ onOpenHybrid, allTracks, onKGOneClipDrop, + previewRegionStyles, + setPreviewRegionStyles, + previewRegionContentStyles, + setPreviewRegionContentStyles, }) => { const selectedRegionIds = useProjectStore(state => state.selectedRegionIds); const activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId); @@ -76,7 +101,8 @@ const TrackGridItem: React.FC = ({ const [containerWidth, setContainerWidth] = useState(0); const [resizingRegion, setResizingRegion] = useState(null); const [draggingRegion, setDraggingRegion] = useState(null); - const [tempRegionStyles, setTempRegionStyles] = useState>({}); + const [localTempRegionStyles, setLocalTempRegionStyles] = useState>({}); + const [localPreviewRegionContentStyles, setLocalPreviewRegionContentStyles] = useState>({}); const [isModifierPressed, setIsModifierPressed] = useState(false); // Refs for resize operations @@ -86,14 +112,71 @@ const TrackGridItem: React.FC = ({ const currentResizeRegion = useRef(null); const initialBarNumberRef = useRef(null); const initialLengthRef = useRef(null); + const resizePreviewBaselinesRef = useRef([]); + const resizePreviewRegionIdsRef = useRef([]); // Refs for drag operations const currentDragLeft = useRef(null); const currentDragTop = useRef(null); const currentDragRegion = useRef(null); + const dragPreviewBaselinesRef = useRef([]); + const dragPreviewRegionIdsRef = useRef([]); const trackElementRef = useRef(null); const isBulkRegionEdit = (regionId: string) => selectedRegionIds.length > 1 && selectedRegionIds.includes(regionId); + const tempRegionStyles = previewRegionStyles ?? localTempRegionStyles; + const setTempRegionStyles = setPreviewRegionStyles ?? setLocalTempRegionStyles; + const tempPreviewRegionContentStyles = previewRegionContentStyles ?? localPreviewRegionContentStyles; + const setTempPreviewRegionContentStyles = setPreviewRegionContentStyles ?? setLocalPreviewRegionContentStyles; + + const getPreviewRegionIds = (regionId: string) => ( + selectedRegionIds.length > 1 && selectedRegionIds.includes(regionId) + ? selectedRegionIds + : [regionId] + ); + + const clearTempRegionStyles = (regionIds?: string[]) => { + if (!regionIds || regionIds.length === 0) { + setTempRegionStyles({}); + return; + } + + setTempRegionStyles(prev => { + const updated = { ...prev }; + regionIds.forEach(id => { + delete updated[id]; + }); + return updated; + }); + }; + + const clearTempPreviewRegionContentStyles = (regionIds?: string[]) => { + if (!regionIds || regionIds.length === 0) { + setTempPreviewRegionContentStyles({}); + return; + } + + setTempPreviewRegionContentStyles(prev => { + const updated = { ...prev }; + regionIds.forEach(id => { + delete updated[id]; + }); + return updated; + }); + }; + + const getMeasuredRegionContentWidth = (regionId: string, fallbackWidth: number) => { + const regionElement = Array.from(document.querySelectorAll('[data-region-id]')) + .find(element => element.getAttribute('data-region-id') === regionId); + const regionContentElement = regionElement?.querySelector('.region-content'); + const measuredWidth = regionContentElement?.getBoundingClientRect().width; + + if (!measuredWidth || Number.isNaN(measuredWidth)) { + return fallbackWidth; + } + + return measuredWidth; + }; // Update container width when the grid container changes size useEffect(() => { @@ -144,7 +227,7 @@ const TrackGridItem: React.FC = ({ // Calculate region position and style const getRegionStyle = (region: RegionUI) => { // Check if there's a temporary style for this region during resize or drag - if ((resizingRegion === region.id || draggingRegion === region.id) && tempRegionStyles[region.id]) { + if (tempRegionStyles[region.id]) { return tempRegionStyles[region.id]; } @@ -195,17 +278,42 @@ const TrackGridItem: React.FC = ({ // Store the initial width and left position currentResizeWidth.current = region.length * barWidth; currentResizeLeft.current = (region.barNumber - 1) * barWidth; - - // Set initial style to current position/size - const initialStyle = { - left: `${currentResizeLeft.current}px`, - width: `${currentResizeWidth.current}px`, - position: 'absolute' as const, // Fixed: Use const assertion - }; - + + const previewRegionIds = getPreviewRegionIds(regionId); + resizePreviewBaselinesRef.current = previewRegionIds + .map(id => regions.find(candidate => candidate.id === id)) + .filter((candidate): candidate is RegionUI => candidate !== undefined) + .map(candidate => ({ + regionId: candidate.id, + originalBarNumber: candidate.barNumber, + originalLength: candidate.length, + originalLeft: (candidate.barNumber - 1) * barWidth, + originalWidth: candidate.length * barWidth, + originalContentWidth: getMeasuredRegionContentWidth(candidate.id, candidate.length * barWidth), + })); + resizePreviewRegionIdsRef.current = resizePreviewBaselinesRef.current.map(baseline => baseline.regionId); + setTempRegionStyles(prev => ({ ...prev, - [regionId]: initialStyle + ...Object.fromEntries(resizePreviewBaselinesRef.current.map(baseline => [ + baseline.regionId, + { + left: `${baseline.originalLeft}px`, + width: `${baseline.originalWidth}px`, + position: 'absolute' as const, + }, + ])), + })); + + setTempPreviewRegionContentStyles(prev => ({ + ...prev, + ...Object.fromEntries(resizePreviewBaselinesRef.current.map(baseline => [ + baseline.regionId, + { + left: '0px', + width: `${baseline.originalContentWidth}px`, + }, + ])), })); }; @@ -254,16 +362,40 @@ const TrackGridItem: React.FC = ({ console.log(`RESIZE: regionId=${regionId}, action=${resizeAction}, deltaX=${deltaX}, newBarNumber=${newBarNumber}, newLength=${newLength}`); } - // Update the temporary style for this region - const newStyle = { - left: `${newLeft}px`, - width: `${newWidth}px`, - position: 'absolute' as const, // Fixed: Use const assertion - }; - + const leftDelta = newLeft - originalLeft; + const widthDelta = newWidth - originalWidth; + const previewBaselines = resizePreviewBaselinesRef.current.length > 0 + ? resizePreviewBaselinesRef.current + : [{ + regionId, + originalBarNumber: region.barNumber, + originalLength: region.length, + originalLeft, + originalWidth, + originalContentWidth: getMeasuredRegionContentWidth(regionId, originalWidth), + }]; + setTempRegionStyles(prev => ({ ...prev, - [regionId]: newStyle + ...Object.fromEntries(previewBaselines.map(baseline => [ + baseline.regionId, + { + left: `${resizeAction === 'start' ? baseline.originalLeft + leftDelta : baseline.originalLeft}px`, + width: `${baseline.originalWidth + widthDelta}px`, + position: 'absolute' as const, + }, + ])), + })); + + setTempPreviewRegionContentStyles(prev => ({ + ...prev, + ...Object.fromEntries(previewBaselines.map(baseline => [ + baseline.regionId, + { + left: `${resizeAction === 'start' ? -(newLeft - originalLeft) : 0}px`, + width: `${baseline.originalContentWidth}px`, + }, + ])), })); // Notify parent about resize @@ -328,16 +460,15 @@ const TrackGridItem: React.FC = ({ // Clear resizing state setResizingRegion(null); - setTempRegionStyles(prev => { - const updated = { ...prev }; - delete updated[regionId]; - return updated; - }); + clearTempRegionStyles(resizePreviewRegionIdsRef.current); + clearTempPreviewRegionContentStyles(resizePreviewRegionIdsRef.current); currentResizeWidth.current = null; currentResizeLeft.current = null; currentResizeRegion.current = null; initialBarNumberRef.current = null; initialLengthRef.current = null; + resizePreviewBaselinesRef.current = []; + resizePreviewRegionIdsRef.current = []; // Notify parent about resize end with rounded values if (onRegionResizeEnd) { @@ -378,18 +509,32 @@ const TrackGridItem: React.FC = ({ // Store the initial position currentDragLeft.current = left; currentDragTop.current = 0; // Initially at the top of the current track - - // Set initial style - const initialStyle = { - left: `${left}px`, - width: `${width}px`, - position: 'absolute' as const, // Fixed: Use const assertion - zIndex: 100, // Bring to front during drag - }; - + + const previewRegionIds = getPreviewRegionIds(regionId); + dragPreviewBaselinesRef.current = previewRegionIds + .map(id => regions.find(candidate => candidate.id === id)) + .filter((candidate): candidate is RegionUI => candidate !== undefined) + .map(candidate => ({ + regionId: candidate.id, + originalBarNumber: candidate.barNumber, + originalTrackIndex: candidate.trackIndex, + originalLeft: (candidate.barNumber - 1) * barWidth, + originalWidth: candidate.length * barWidth, + })); + dragPreviewRegionIdsRef.current = dragPreviewBaselinesRef.current.map(baseline => baseline.regionId); + setTempRegionStyles(prev => ({ ...prev, - [regionId]: initialStyle + ...Object.fromEntries(dragPreviewBaselinesRef.current.map(baseline => [ + baseline.regionId, + { + left: `${baseline.originalLeft}px`, + width: `${baseline.originalWidth}px`, + position: 'absolute' as const, + zIndex: 100, + transform: 'translateY(0px)', + }, + ])), })); }; @@ -425,18 +570,29 @@ const TrackGridItem: React.FC = ({ console.log(`DRAG: regionId=${regionId}, deltaX=${deltaX}, deltaY=${deltaY}, newBarNumber=${newBarNumber}`); } - // Update the temporary style for this region - const newStyle = { - left: `${newLeft}px`, - width: `${region.length * barWidth}px`, - position: 'absolute' as const, - zIndex: 100, // Keep on top during drag - transform: `translateY(${appliedDeltaY}px)`, - }; - + const leftDelta = newLeft - initialLeft; + const previewBaselines = dragPreviewBaselinesRef.current.length > 0 + ? dragPreviewBaselinesRef.current + : [{ + regionId, + originalBarNumber: region.barNumber, + originalTrackIndex: region.trackIndex, + originalLeft: initialLeft, + originalWidth: region.length * barWidth, + }]; + setTempRegionStyles(prev => ({ ...prev, - [regionId]: newStyle + ...Object.fromEntries(previewBaselines.map(baseline => [ + baseline.regionId, + { + left: `${baseline.originalLeft + leftDelta}px`, + width: `${baseline.originalWidth}px`, + position: 'absolute' as const, + zIndex: 100, + transform: `translateY(${appliedDeltaY}px)`, + }, + ])), })); // We'll calculate the track index on drag end, but still notify parent about the drag @@ -506,14 +662,13 @@ const TrackGridItem: React.FC = ({ // Clear dragging state setDraggingRegion(null); - setTempRegionStyles(prev => { - const updated = { ...prev }; - delete updated[regionId]; - return updated; - }); + clearTempRegionStyles(dragPreviewRegionIdsRef.current); + clearTempPreviewRegionContentStyles(dragPreviewRegionIdsRef.current); currentDragLeft.current = null; currentDragTop.current = null; currentDragRegion.current = null; + dragPreviewBaselinesRef.current = []; + dragPreviewRegionIdsRef.current = []; if (finalBarNumber === region.barNumber && finalTrackIndex === region.trackIndex) { if (DEBUG_MODE.TRACK_GRID_ITEM) { @@ -572,7 +727,7 @@ const TrackGridItem: React.FC = ({ }} onClick={(e) => { if (!isAutomationActive) { - onClick && onClick(e, index); + onClick?.(e, index); } }} ref={trackElementRef} @@ -640,6 +795,7 @@ const TrackGridItem: React.FC = ({ midiRegion={midiRegion} audioRegion={audioRegion} audioBuffer={audioBuffer} + previewContentStyle={tempPreviewRegionContentStyles[region.id]} /> ); })} diff --git a/src/components/track/TrackGridPanel.tsx b/src/components/track/TrackGridPanel.tsx index a14020f..87705c7 100644 --- a/src/components/track/TrackGridPanel.tsx +++ b/src/components/track/TrackGridPanel.tsx @@ -4,7 +4,7 @@ import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import TrackGridItem from './TrackGridItem'; import { Playhead, FileImportModal } from '../common'; import SelectionBox from '../piano-roll/SelectionBox'; -import type { RegionClickOptions, RegionUI } from '../interfaces'; +import type { RegionClickOptions, RegionPreviewContentStyle, RegionUI } from '../interfaces'; import { DEBUG_MODE, PIANO_ROLL_CONSTANTS, REGION_CONSTANTS } from '../../constants'; import { KGMainContentState } from '../../core/state/KGMainContentState'; import { isModifierKeyPressed } from '../../util/osUtil'; @@ -66,6 +66,8 @@ const TrackGridPanel: React.FC = ({ const refreshProjectState = useProjectStore(state => state.refreshProjectState); const gridContainerRef = useRef(null); const [showAudioImportModal, setShowAudioImportModal] = useState(false); + const [previewRegionStyles, setPreviewRegionStyles] = useState>({}); + const [previewRegionContentStyles, setPreviewRegionContentStyles] = useState>({}); const pendingAudioImportRef = useRef<{ barNumber: number; trackIndex: number } | null>(null); const isLassoSelectingRef = useRef(false); const isLassoShiftPressedRef = useRef(false); @@ -907,6 +909,10 @@ const TrackGridPanel: React.FC = ({ onOpenHybrid={onOpenHybrid} allTracks={tracks} onKGOneClipDrop={handleExternalDrop} + previewRegionStyles={previewRegionStyles} + setPreviewRegionStyles={setPreviewRegionStyles} + previewRegionContentStyles={previewRegionContentStyles} + setPreviewRegionContentStyles={setPreviewRegionContentStyles} /> ))} diff --git a/src/components/track/TrackInfoItem.tsx b/src/components/track/TrackInfoItem.tsx index 7722b67..244ac72 100644 --- a/src/components/track/TrackInfoItem.tsx +++ b/src/components/track/TrackInfoItem.tsx @@ -96,8 +96,8 @@ const TrackInfoItem: React.FC = ({ const volumeInputRef = useRef(null); // Local flag to track slider interaction; not used for rendering const isAdjustingVolumeRef = useRef(false); - const [muted, setMuted] = useState(false); - const [solo, setSolo] = useState(false); + const [muted, setMuted] = useState(track.getMuted()); + const [solo, setSolo] = useState(track.getSolo()); // Close dropdown when clicking outside useEffect(() => { @@ -135,11 +135,10 @@ const TrackInfoItem: React.FC = ({ setVolume(track.getVolume()); }, [allTracks, track]); - // Sync mute/solo UI with audio interface state on track/project changes + // Sync mute/solo UI with the track model on track/project changes useEffect(() => { - const audioInterface = KGAudioInterface.instance(); - setMuted(audioInterface.getTrackMuted(track.getId().toString())); - setSolo(audioInterface.getTrackSolo(track.getId().toString())); + setMuted(track.getMuted()); + setSolo(track.getSolo()); }, [allTracks, track]); // Handle track name edit within the component @@ -252,22 +251,20 @@ const TrackInfoItem: React.FC = ({ e.stopPropagation(); const next = !muted; setMuted(next); - try { - KGAudioInterface.instance().setTrackMute(track.getId().toString(), next); - } catch (err) { + useProjectStore.getState().updateTrackProperties(track.getId(), { muted: next }).catch(err => { + setMuted(track.getMuted()); console.error('Failed to toggle mute:', err); - } + }); }; const handleToggleSolo = (e: React.MouseEvent) => { e.stopPropagation(); const next = !solo; setSolo(next); - try { - KGAudioInterface.instance().setTrackSolo(track.getId().toString(), next); - } catch (err) { + useProjectStore.getState().updateTrackProperties(track.getId(), { solo: next }).catch(err => { + setSolo(track.getSolo()); console.error('Failed to toggle solo:', err); - } + }); }; // Handle track click diff --git a/src/constants/coreConstants.ts b/src/constants/coreConstants.ts index 33f0f40..a1d36b4 100644 --- a/src/constants/coreConstants.ts +++ b/src/constants/coreConstants.ts @@ -104,7 +104,7 @@ export const OPFS_CONSTANTS = { export const CONFIG_UPGRADER_CONSTANTS = { VERSION_KEY: '__config_version', - CURRENT_VERSION: 3, + CURRENT_VERSION: 4, }; export const URL_CONSTANTS = { diff --git a/src/core/KGProject.ts b/src/core/KGProject.ts index f73f921..6f7bf12 100644 --- a/src/core/KGProject.ts +++ b/src/core/KGProject.ts @@ -49,11 +49,15 @@ export class KGProject { @WithDefault(1) private barWidthMultiplier: number = 1; + @Expose() + @WithDefault(1) + private pianoRollZoom: number = 1; + @Expose() @WithDefault(0) private projectStructureVersion: number = 0; - public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 10; + public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 12; @Expose() @Type(() => KGTrack, { @@ -69,7 +73,7 @@ export class KGProject { private tracks: KGTrack[] = []; // Constructor - constructor(name: string = RESERVED_PROJECT_NAME, maxBars: number = 32, currentBars: number = 0, bpm: number = 125, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", isLooping: boolean = false, loopingRange: [number, number] = [0, 0], barWidthMultiplier: number = 1, tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION) { + constructor(name: string = RESERVED_PROJECT_NAME, maxBars: number = 32, currentBars: number = 0, bpm: number = 125, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", isLooping: boolean = false, loopingRange: [number, number] = [0, 0], barWidthMultiplier: number = 1, tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION, pianoRollZoom: number = 1) { this.name = name; this.maxBars = maxBars; this.currentBars = currentBars; @@ -82,6 +86,7 @@ export class KGProject { this.barWidthMultiplier = barWidthMultiplier; this.tracks = tracks; this.projectStructureVersion = projectStructureVersion; + this.pianoRollZoom = pianoRollZoom; } // Getters @@ -181,4 +186,12 @@ export class KGProject { public setBarWidthMultiplier(barWidthMultiplier: number): void { this.barWidthMultiplier = barWidthMultiplier; } + + public getPianoRollZoom(): number { + return this.pianoRollZoom; + } + + public setPianoRollZoom(pianoRollZoom: number): void { + this.pianoRollZoom = pianoRollZoom; + } } diff --git a/src/core/audio-interface/KGAudioInterface.ts b/src/core/audio-interface/KGAudioInterface.ts index bf785ea..180f88c 100644 --- a/src/core/audio-interface/KGAudioInterface.ts +++ b/src/core/audio-interface/KGAudioInterface.ts @@ -242,11 +242,13 @@ export class KGAudioInterface { console.log(`Creating audio bus for track ${trackId} with instrument ${instrumentType}`); // Create new audio bus - // Initialize with track's stored volume if available + // Initialize with track's stored mix state if available const project = KGCore.instance().getCurrentProject(); const track = project.getTracks().find(t => t.getId().toString() === trackId); const initialVolume = track ? track.getVolume() : AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME; - const audioBus = await KGAudioBus.create(instrumentType, initialVolume, 0); + const initialMuted = track ? track.getMuted() : false; + const initialSolo = track ? track.getSolo() : false; + const audioBus = await KGAudioBus.create(instrumentType, initialVolume, 0, initialMuted, initialSolo); // Connect to master gain if available, otherwise to destination if (this.masterGain) { @@ -299,7 +301,11 @@ export class KGAudioInterface { try { console.log(`Creating audio player bus for track ${trackId}`); - const playerBus = await KGAudioPlayerBus.create(volume, 0); + const project = KGCore.instance().getCurrentProject(); + const track = project.getTracks().find(t => t.getId().toString() === trackId); + const initialMuted = track ? track.getMuted() : false; + const initialSolo = track ? track.getSolo() : false; + const playerBus = await KGAudioPlayerBus.create(volume, 0, initialMuted, initialSolo); if (this.masterGain) { playerBus.connect(this.masterGain); diff --git a/src/core/audio-interface/KGOfflineRenderer.test.ts b/src/core/audio-interface/KGOfflineRenderer.test.ts index c184320..358c880 100644 --- a/src/core/audio-interface/KGOfflineRenderer.test.ts +++ b/src/core/audio-interface/KGOfflineRenderer.test.ts @@ -1,7 +1,37 @@ -import { describe, it, expect } from 'vitest'; -import { applyOfflinePitchBendAutomation, encodeWav, getOfflineTrackGain, getOfflineTrackVolumeDb } from './KGOfflineRenderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack, createMockProject } from '../../test/utils/mock-data'; import { bakeMidiAutomationPointsInWindow } from '../../util/midiAutomationUtil'; +const { offlineMock, configGetMock } = vi.hoisted(() => ({ + offlineMock: vi.fn(), + configGetMock: vi.fn(), +})); + +vi.mock('tone', () => ({ + Offline: offlineMock, +})); + +vi.mock('./KGAudioInterface', () => ({ + KGAudioInterface: { + instance: vi.fn(() => ({ + getTrackVolume: vi.fn().mockReturnValue(0), + getTrackMuted: vi.fn().mockReturnValue(false), + getTrackSolo: vi.fn().mockReturnValue(false), + getAudioBuffer: vi.fn().mockReturnValue(null), + })), + }, +})); + +vi.mock('../config/ConfigManager', () => ({ + ConfigManager: { + instance: vi.fn(() => ({ + get: configGetMock, + })), + }, +})); + +import { KGOfflineRenderer, applyOfflinePitchBendAutomation, encodeWav, getOfflineTrackGain, getOfflineTrackVolumeDb } from './KGOfflineRenderer'; + /** * Create a minimal AudioBuffer-like object for testing. * In the jsdom test environment, AudioBuffer is not available, @@ -228,3 +258,77 @@ describe('offline pitch bend automation', () => { expect(calls[0][1]).toBe(0.26); }); }); + +describe('renderToBuffer bounce range', () => { + beforeEach(() => { + vi.clearAllMocks(); + configGetMock.mockImplementation((key: string) => { + if (key === 'audio.bounce_starts_from_beat_1') return true; + if (key === 'audio.midi_automation_interpolation_interval_ms') return 10; + return null; + }); + offlineMock.mockResolvedValue({ + duration: 0, + numberOfChannels: 2, + get: vi.fn(), + }); + ;(KGOfflineRenderer as unknown as { _instance: KGOfflineRenderer | null })._instance = null; + }); + + it('starts non-looping bounce at beat 0 when configured to include leading silence', async () => { + const region = createMockMidiRegion({ + startFromBeat: 8, + notes: [createMockMidiNote({ startBeat: 0, endBeat: 4 })], + }); + const track = createMockMidiTrack({ id: 1, regions: [region] }); + const project = createMockProject({ bpm: 120, tracks: [track] }); + + await KGOfflineRenderer.instance().renderToBuffer(project, { tailSeconds: 0 }); + + expect(offlineMock).toHaveBeenCalledWith(expect.any(Function), 6, 2, 44100); + }); + + it('starts non-looping bounce at first content when the setting is disabled', async () => { + configGetMock.mockImplementation((key: string) => { + if (key === 'audio.bounce_starts_from_beat_1') return false; + if (key === 'audio.midi_automation_interpolation_interval_ms') return 10; + return null; + }); + + const region = createMockMidiRegion({ + startFromBeat: 8, + notes: [createMockMidiNote({ startBeat: 0, endBeat: 4 })], + }); + const track = createMockMidiTrack({ id: 1, regions: [region] }); + const project = createMockProject({ bpm: 120, tracks: [track] }); + + await KGOfflineRenderer.instance().renderToBuffer(project, { tailSeconds: 0 }); + + expect(offlineMock).toHaveBeenCalledWith(expect.any(Function), 2, 2, 44100); + }); + + it('keeps looping bounce bounds regardless of the beat-1 setting', async () => { + configGetMock.mockImplementation((key: string) => { + if (key === 'audio.bounce_starts_from_beat_1') return false; + if (key === 'audio.midi_automation_interpolation_interval_ms') return 10; + return null; + }); + + const project = createMockProject({ bpm: 120, tracks: [] }); + project.setIsLooping(true); + project.setLoopingRange([2, 5]); + + await KGOfflineRenderer.instance().renderToBuffer(project, { tailSeconds: 0 }); + + expect(offlineMock).toHaveBeenCalledWith(expect.any(Function), 8, 2, 44100); + }); + + it('falls back to the full project length when there is no renderable content', async () => { + const project = createMockProject({ bpm: 120, tracks: [] }); + project.setMaxBars(16); + + await KGOfflineRenderer.instance().renderToBuffer(project, { tailSeconds: 0 }); + + expect(offlineMock).toHaveBeenCalledWith(expect.any(Function), 32, 2, 44100); + }); +}); diff --git a/src/core/audio-interface/KGOfflineRenderer.ts b/src/core/audio-interface/KGOfflineRenderer.ts index 5f57326..2cdc143 100644 --- a/src/core/audio-interface/KGOfflineRenderer.ts +++ b/src/core/audio-interface/KGOfflineRenderer.ts @@ -99,6 +99,7 @@ export class KGOfflineRenderer { let renderStartBeat = 0; let renderEndBeat: number; + const bounceStartsFromBeat1 = (ConfigManager.instance().get('audio.bounce_starts_from_beat_1') as boolean) ?? true; const isLooping = project.getIsLooping(); // Looping range is determined up-front; non-looping range is computed @@ -272,7 +273,7 @@ export class KGOfflineRenderer { } if (contentEnd > 0) { - renderStartBeat = contentStart; + renderStartBeat = bounceStartsFromBeat1 ? 0 : contentStart; renderEndBeat = contentEnd; } // else: no content found, keep the full project range as fallback diff --git a/src/core/commands/index.ts b/src/core/commands/index.ts index cc321f9..cbd015d 100644 --- a/src/core/commands/index.ts +++ b/src/core/commands/index.ts @@ -41,6 +41,7 @@ export { DeleteNotesCommand, DeleteNoteCommand } from './note/DeleteNotesCommand export { ResizeNotesCommand } from './note/ResizeNotesCommand'; export { MoveNotesCommand } from './note/MoveNotesCommand'; export { PasteNotesCommand } from './note/PasteNotesCommand'; +export { SplitSelectedNotesCommand } from './note/SplitSelectedNotesCommand'; export { UpdateNotePropertiesCommand } from './note/UpdateNotePropertiesCommand'; export { UpdatePitchBendPropertiesCommand } from './note/UpdatePitchBendPropertiesCommand'; export { UpdateControllerEventPropertiesCommand } from './note/UpdateControllerEventPropertiesCommand'; diff --git a/src/core/commands/note/SplitSelectedNotesCommand.test.ts b/src/core/commands/note/SplitSelectedNotesCommand.test.ts new file mode 100644 index 0000000..624cd00 --- /dev/null +++ b/src/core/commands/note/SplitSelectedNotesCommand.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Selectable } from '../../../components/interfaces'; +import { KGCore } from '../../KGCore'; +import { KGMidiNote } from '../../midi/KGMidiNote'; +import { SplitSelectedNotesCommand } from './SplitSelectedNotesCommand'; +import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack, createMockProject } from '../../../test/utils/mock-data'; + +vi.mock('../../KGCore', () => ({ + KGCore: { + instance: vi.fn(), + } +})); + +interface MockCore { + getCurrentProject: ReturnType; + getSelectedItems: ReturnType; + clearSelectedItems: ReturnType; + addSelectedItems: ReturnType; +} + +describe('SplitSelectedNotesCommand', () => { + let mockCore: MockCore; + + beforeEach(() => { + vi.clearAllMocks(); + mockCore = { + getCurrentProject: vi.fn(), + getSelectedItems: vi.fn(), + clearSelectedItems: vi.fn(), + addSelectedItems: vi.fn(), + }; + + vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore); + }); + + it('splits one selected note into two halves and selects both results', () => { + const splitNote = createMockMidiNote({ id: 'note-a', startBeat: 1, endBeat: 5, pitch: 64, velocity: 90 }); + splitNote.select(); + const region = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + trackIndex: 0, + notes: [splitNote], + }); + const track = createMockMidiTrack({ id: 1, regions: [region] }); + mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] })); + mockCore.getSelectedItems.mockReturnValue([splitNote]); + + const command = new SplitSelectedNotesCommand(region.getId(), [splitNote.getId()], 3); + + command.execute(); + + const notes = region.getNotes(); + expect(notes).toHaveLength(2); + expect(notes.map(note => [note.getStartBeat(), note.getEndBeat(), note.getPitch(), note.getVelocity()])).toEqual([ + [1, 3, 64, 90], + [3, 5, 64, 90], + ]); + expect(notes.every(note => note.isSelected())).toBe(true); + expect(command.getSplitCount()).toBe(1); + expect(mockCore.clearSelectedItems).toHaveBeenCalledTimes(1); + expect(mockCore.addSelectedItems).toHaveBeenCalledTimes(1); + expect((mockCore.addSelectedItems.mock.calls[0][0] as Selectable[])).toHaveLength(2); + }); + + it('splits multiple selected notes in one undoable operation and leaves uncrossed selected notes unchanged', () => { + const noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 4, pitch: 60, velocity: 70 }); + const noteB = createMockMidiNote({ id: 'note-b', startBeat: 1, endBeat: 2, pitch: 62, velocity: 75 }); + const noteC = createMockMidiNote({ id: 'note-c', startBeat: 2, endBeat: 5, pitch: 65, velocity: 80 }); + [noteA, noteB, noteC].forEach(note => note.select()); + const region = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + trackIndex: 0, + notes: [noteA, noteB, noteC], + }); + const track = createMockMidiTrack({ id: 1, regions: [region] }); + mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] })); + mockCore.getSelectedItems.mockReturnValue([noteA, noteB, noteC]); + + const command = new SplitSelectedNotesCommand(region.getId(), [noteA.getId(), noteB.getId(), noteC.getId()], 3); + + command.execute(); + + const notes = region.getNotes(); + expect(notes).toHaveLength(5); + expect(notes.map(note => [note.getStartBeat(), note.getEndBeat(), note.getPitch()])).toEqual([ + [0, 3, 60], + [3, 4, 60], + [1, 2, 62], + [2, 3, 65], + [3, 5, 65], + ]); + expect(command.getSplitCount()).toBe(2); + expect(command.getUnchangedSelectedNoteIds()).toEqual(['note-b']); + + command.undo(); + expect(region.getNotes()).toEqual([noteA, noteB, noteC]); + expect(mockCore.clearSelectedItems).toHaveBeenCalledTimes(2); + expect(mockCore.addSelectedItems).toHaveBeenLastCalledWith([noteA, noteB, noteC]); + + command.execute(); + expect(region.getNotes().map(note => [note.getStartBeat(), note.getEndBeat(), note.getPitch()])).toEqual([ + [0, 3, 60], + [3, 4, 60], + [1, 2, 62], + [2, 3, 65], + [3, 5, 65], + ]); + }); + + it('throws when no selected note crosses the playhead', () => { + const noteA = new KGMidiNote('note-a', 0, 1, 60, 100); + const noteB = new KGMidiNote('note-b', 4, 5, 62, 100); + const region = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + trackIndex: 0, + notes: [noteA, noteB], + }); + const track = createMockMidiTrack({ id: 1, regions: [region] }); + mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] })); + mockCore.getSelectedItems.mockReturnValue([noteA, noteB]); + + const command = new SplitSelectedNotesCommand(region.getId(), [noteA.getId(), noteB.getId()], 3); + + expect(() => command.execute()).toThrow( + 'The playhead is not inside any selected note. Move the playhead inside a selected note before splitting.' + ); + }); +}); diff --git a/src/core/commands/note/SplitSelectedNotesCommand.ts b/src/core/commands/note/SplitSelectedNotesCommand.ts new file mode 100644 index 0000000..3de7260 --- /dev/null +++ b/src/core/commands/note/SplitSelectedNotesCommand.ts @@ -0,0 +1,164 @@ +import type { Selectable } from '../../../components/interfaces'; +import { generateUniqueId } from '../../../util/miscUtil'; +import { KGCore } from '../../KGCore'; +import { KGMidiNote } from '../../midi/KGMidiNote'; +import { KGMidiRegion } from '../../region/KGMidiRegion'; +import { KGCommand } from '../KGCommand'; + +interface SplitNoteRecord { + originalNoteId: string; + leftNoteId: string; + rightNoteId: string; + startBeat: number; + endBeat: number; + pitch: number; + velocity: number; +} + +export class SplitSelectedNotesCommand extends KGCommand { + private readonly regionId: string; + private readonly selectedNoteIds: string[]; + private readonly splitAtBeat: number; + + private targetRegion: KGMidiRegion | null = null; + private originalNotes: KGMidiNote[] = []; + private originalSelectedItems: Selectable[] = []; + private splitNoteRecords: SplitNoteRecord[] = []; + private unchangedSelectedNoteIds: string[] = []; + + constructor(regionId: string, selectedNoteIds: string[], splitAtBeat: number) { + super(); + this.regionId = regionId; + this.selectedNoteIds = [...selectedNoteIds]; + this.splitAtBeat = splitAtBeat; + } + + execute(): void { + const core = KGCore.instance(); + const targetRegion = this.resolveTargetRegion(); + const noteIdSet = new Set(this.selectedNoteIds); + const currentNotes = targetRegion.getNotes(); + const selectedNotes = currentNotes.filter(note => noteIdSet.has(note.getId())); + + if (selectedNotes.length === 0) { + throw new Error('No selected notes were found in the active MIDI region.'); + } + + if (this.originalSelectedItems.length === 0) { + this.originalSelectedItems = [...core.getSelectedItems()]; + } + + if (this.originalNotes.length === 0) { + this.originalNotes = [...currentNotes]; + } + + if (this.splitNoteRecords.length === 0) { + this.splitNoteRecords = selectedNotes + .filter(note => note.getStartBeat() < this.splitAtBeat && this.splitAtBeat < note.getEndBeat()) + .map(note => ({ + originalNoteId: note.getId(), + leftNoteId: generateUniqueId('KGMidiNote'), + rightNoteId: generateUniqueId('KGMidiNote'), + startBeat: note.getStartBeat(), + endBeat: note.getEndBeat(), + pitch: note.getPitch(), + velocity: note.getVelocity(), + })); + } + + if (this.splitNoteRecords.length === 0) { + throw new Error('The playhead is not inside any selected note. Move the playhead inside a selected note before splitting.'); + } + + const splitRecordByOriginalId = new Map( + this.splitNoteRecords.map(record => [record.originalNoteId, record]) + ); + this.unchangedSelectedNoteIds = selectedNotes + .filter(note => !splitRecordByOriginalId.has(note.getId())) + .map(note => note.getId()); + + const nextNotes: KGMidiNote[] = []; + const nextSelectedNotes: KGMidiNote[] = []; + + for (const note of currentNotes) { + const splitRecord = splitRecordByOriginalId.get(note.getId()); + if (!splitRecord) { + note.deselect(); + nextNotes.push(note); + + if (noteIdSet.has(note.getId())) { + note.select(); + nextSelectedNotes.push(note); + } + continue; + } + + const leftNote = new KGMidiNote( + splitRecord.leftNoteId, + splitRecord.startBeat, + this.splitAtBeat, + splitRecord.pitch, + splitRecord.velocity + ); + const rightNote = new KGMidiNote( + splitRecord.rightNoteId, + this.splitAtBeat, + splitRecord.endBeat, + splitRecord.pitch, + splitRecord.velocity + ); + + leftNote.select(); + rightNote.select(); + nextNotes.push(leftNote, rightNote); + nextSelectedNotes.push(leftNote, rightNote); + } + + targetRegion.setNotes(nextNotes); + core.clearSelectedItems(); + core.addSelectedItems(nextSelectedNotes); + } + + undo(): void { + if (!this.targetRegion) { + throw new Error('Cannot undo: split was never executed'); + } + + const core = KGCore.instance(); + this.targetRegion.setNotes([...this.originalNotes]); + this.originalNotes.forEach(note => note.deselect()); + this.originalSelectedItems.forEach(item => item.select()); + core.clearSelectedItems(); + core.addSelectedItems(this.originalSelectedItems); + } + + getDescription(): string { + const splitCount = this.splitNoteRecords.length || this.selectedNoteIds.length; + return splitCount === 1 ? 'Split note' : `Split ${splitCount} notes`; + } + + public getSplitCount(): number { + return this.splitNoteRecords.length; + } + + public getUnchangedSelectedNoteIds(): string[] { + return [...this.unchangedSelectedNoteIds]; + } + + private resolveTargetRegion(): KGMidiRegion { + if (this.targetRegion) { + return this.targetRegion; + } + + const tracks = KGCore.instance().getCurrentProject().getTracks(); + for (const track of tracks) { + const region = track.getRegions().find(candidate => candidate.getId() === this.regionId); + if (region instanceof KGMidiRegion) { + this.targetRegion = region; + return region; + } + } + + throw new Error(`MIDI region with ID ${this.regionId} not found`); + } +} diff --git a/src/core/commands/track/UpdateTrackCommand.test.ts b/src/core/commands/track/UpdateTrackCommand.test.ts new file mode 100644 index 0000000..5a97edc --- /dev/null +++ b/src/core/commands/track/UpdateTrackCommand.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { KGCore } from '../../KGCore'; +import { KGProject } from '../../KGProject'; +import { KGAudioInterface } from '../../audio-interface/KGAudioInterface'; +import { KGTrack } from '../../track/KGTrack'; +import { UpdateTrackCommand } from './UpdateTrackCommand'; + +vi.mock('../../KGCore', () => ({ + KGCore: { + instance: vi.fn(), + }, +})); + +vi.mock('../../audio-interface/KGAudioInterface', () => ({ + KGAudioInterface: { + instance: vi.fn(), + }, +})); + +describe('UpdateTrackCommand', () => { + let track: KGTrack; + let project: KGProject; + const mockCore = { + getCurrentProject: vi.fn(), + }; + const mockAudioInterface = { + setTrackVolume: vi.fn(), + setTrackInstrument: vi.fn(), + setTrackMute: vi.fn(), + setTrackSolo: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + track = new KGTrack('Track 1', 1); + project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, 1, [track], 11); + mockCore.getCurrentProject.mockReturnValue(project); + vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore); + vi.mocked(KGAudioInterface.instance).mockReturnValue(mockAudioInterface as unknown as KGAudioInterface); + }); + + it('updates muted state and propagates to the audio interface', () => { + const command = new UpdateTrackCommand(1, { muted: true }); + + command.execute(); + + expect(track.getMuted()).toBe(true); + expect(mockAudioInterface.setTrackMute).toHaveBeenCalledWith('1', true); + expect(command.getChangedProperties()).toEqual(new Set(['muted'])); + }); + + it('updates solo state and propagates to the audio interface', () => { + const command = new UpdateTrackCommand(1, { solo: true }); + + command.execute(); + + expect(track.getSolo()).toBe(true); + expect(mockAudioInterface.setTrackSolo).toHaveBeenCalledWith('1', true); + expect(command.getChangedProperties()).toEqual(new Set(['solo'])); + }); + + it('restores muted and solo state on undo', () => { + track.setMuted(true); + track.setSolo(true); + const command = new UpdateTrackCommand(1, { muted: false, solo: false }); + + command.execute(); + command.undo(); + + expect(track.getMuted()).toBe(true); + expect(track.getSolo()).toBe(true); + expect(mockAudioInterface.setTrackMute).toHaveBeenLastCalledWith('1', true); + expect(mockAudioInterface.setTrackSolo).toHaveBeenLastCalledWith('1', true); + }); + + it('treats unchanged mute and solo values as no-ops', () => { + const command = new UpdateTrackCommand(1, { muted: false, solo: false }); + + command.execute(); + + expect(track.getMuted()).toBe(false); + expect(track.getSolo()).toBe(false); + expect(mockAudioInterface.setTrackMute).not.toHaveBeenCalled(); + expect(mockAudioInterface.setTrackSolo).not.toHaveBeenCalled(); + expect(command.getChangedProperties()).toEqual(new Set()); + }); +}); diff --git a/src/core/commands/track/UpdateTrackCommand.ts b/src/core/commands/track/UpdateTrackCommand.ts index 0a3cc84..df61e67 100644 --- a/src/core/commands/track/UpdateTrackCommand.ts +++ b/src/core/commands/track/UpdateTrackCommand.ts @@ -12,6 +12,8 @@ export interface TrackUpdateProperties { instrument?: InstrumentType; // Only applies to MIDI tracks type?: TrackType; volume?: number; + muted?: boolean; + solo?: boolean; } /** @@ -47,6 +49,8 @@ export class UpdateTrackCommand extends KGCommand { name: this.targetTrack.getName(), type: this.targetTrack.getType(), volume: this.targetTrack.getVolume(), + muted: this.targetTrack.getMuted(), + solo: this.targetTrack.getSolo(), }; // Store original instrument if it's a MIDI track @@ -103,6 +107,32 @@ export class UpdateTrackCommand extends KGCommand { updatedProperties.push(`volume: ${originalVolume} → ${newVolume}`); } + if (this.newProperties.muted !== undefined && this.newProperties.muted !== this.originalProperties.muted) { + const newMuted = this.newProperties.muted; + const originalMuted = this.originalProperties.muted; + + this.targetTrack.setMuted(newMuted); + + const audioInterface = KGAudioInterface.instance(); + audioInterface.setTrackMute(this.trackId.toString(), newMuted); + + this.changedProperties.add('muted'); + updatedProperties.push(`muted: ${originalMuted} → ${newMuted}`); + } + + if (this.newProperties.solo !== undefined && this.newProperties.solo !== this.originalProperties.solo) { + const newSolo = this.newProperties.solo; + const originalSolo = this.originalProperties.solo; + + this.targetTrack.setSolo(newSolo); + + const audioInterface = KGAudioInterface.instance(); + audioInterface.setTrackSolo(this.trackId.toString(), newSolo); + + this.changedProperties.add('solo'); + updatedProperties.push(`solo: ${originalSolo} → ${newSolo}`); + } + if (updatedProperties.length > 0) { console.log(`Updated track ${this.trackId}: ${updatedProperties.join(', ')}`); } else { @@ -156,6 +186,24 @@ export class UpdateTrackCommand extends KGCommand { restoredProperties.push(`volume: ${this.originalProperties.volume}`); } + if (this.changedProperties.has('muted') && this.originalProperties.muted !== undefined) { + this.targetTrack.setMuted(this.originalProperties.muted); + + const audioInterface = KGAudioInterface.instance(); + audioInterface.setTrackMute(this.trackId.toString(), this.originalProperties.muted); + + restoredProperties.push(`muted: ${this.originalProperties.muted}`); + } + + if (this.changedProperties.has('solo') && this.originalProperties.solo !== undefined) { + this.targetTrack.setSolo(this.originalProperties.solo); + + const audioInterface = KGAudioInterface.instance(); + audioInterface.setTrackSolo(this.trackId.toString(), this.originalProperties.solo); + + restoredProperties.push(`solo: ${this.originalProperties.solo}`); + } + console.log(`Restored track ${this.trackId}: ${restoredProperties.join(', ')}`); } @@ -175,6 +223,12 @@ export class UpdateTrackCommand extends KGCommand { if (this.newProperties.volume !== undefined) { updatedProps.push('volume'); } + if (this.newProperties.muted !== undefined) { + updatedProps.push('muted'); + } + if (this.newProperties.solo !== undefined) { + updatedProps.push('solo'); + } if (updatedProps.length === 1) { return `Update track "${trackName}" ${updatedProps[0]}`; @@ -219,4 +273,4 @@ export class UpdateTrackCommand extends KGCommand { public getChangedProperties(): Set { return new Set(this.changedProperties); } -} \ No newline at end of file +} diff --git a/src/core/config-upgrader/KGConfigUpgrader.ts b/src/core/config-upgrader/KGConfigUpgrader.ts index 21e08d7..96acc28 100644 --- a/src/core/config-upgrader/KGConfigUpgrader.ts +++ b/src/core/config-upgrader/KGConfigUpgrader.ts @@ -3,6 +3,7 @@ import { CONFIG_UPGRADER_CONSTANTS } from '../../constants/coreConstants'; import { upgradeConfigToV1 } from './upgradeConfigToV1'; import { upgradeConfigToV2 } from './upgradeConfigToV2'; import { upgradeConfigToV3 } from './upgradeConfigToV3'; +import { upgradeConfigToV4 } from './upgradeConfigToV4'; /** * KGConfigUpgrader — Orchestrates app-level migrations (e.g., storage backend changes). @@ -43,6 +44,10 @@ export class KGConfigUpgrader { await upgradeConfigToV3(); break; } + case 4: { + await upgradeConfigToV4(); + break; + } default: { throw new Error(`No config upgrader found for version ${nextVersion}`); } diff --git a/src/core/config-upgrader/upgradeConfigToV4.test.ts b/src/core/config-upgrader/upgradeConfigToV4.test.ts new file mode 100644 index 0000000..bfbce5c --- /dev/null +++ b/src/core/config-upgrader/upgradeConfigToV4.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const getRawMock = vi.fn(); +const saveRawMock = vi.fn(); + +vi.mock('../io/KGConfigStorage', () => ({ + KGConfigStorage: { + getInstance: vi.fn(() => ({ + getRaw: getRawMock, + saveRaw: saveRawMock, + })), + }, +})); + +import { upgradeConfigToV4 } from './upgradeConfigToV4'; + +describe('upgradeConfigToV4', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates the audio config and defaults bounce_starts_from_beat_1 to true when audio is missing', async () => { + const config: Record = { + general: {}, + }; + getRawMock.mockResolvedValue(config); + + await upgradeConfigToV4(); + + expect(config.audio).toEqual({ bounce_starts_from_beat_1: true }); + expect(saveRawMock).toHaveBeenCalledWith('userConfig', config); + }); + + it('defaults bounce_starts_from_beat_1 to true when the key is missing', async () => { + const config: Record = { + audio: { + playback_delay: 0.2, + }, + }; + getRawMock.mockResolvedValue(config); + + await upgradeConfigToV4(); + + expect(config.audio).toEqual({ + playback_delay: 0.2, + bounce_starts_from_beat_1: true, + }); + expect(saveRawMock).toHaveBeenCalledWith('userConfig', config); + }); + + it('preserves an explicit false value', async () => { + const config: Record = { + audio: { + bounce_starts_from_beat_1: false, + }, + }; + getRawMock.mockResolvedValue(config); + + await upgradeConfigToV4(); + + expect(saveRawMock).not.toHaveBeenCalled(); + expect((config.audio as Record).bounce_starts_from_beat_1).toBe(false); + }); +}); diff --git a/src/core/config-upgrader/upgradeConfigToV4.ts b/src/core/config-upgrader/upgradeConfigToV4.ts new file mode 100644 index 0000000..c592dea --- /dev/null +++ b/src/core/config-upgrader/upgradeConfigToV4.ts @@ -0,0 +1,30 @@ +import { KGConfigStorage } from '../io/KGConfigStorage'; + +const CONFIG_KEY = 'userConfig'; + +export async function upgradeConfigToV4(): Promise { + const storage = KGConfigStorage.getInstance(); + const rawConfig = await storage.getRaw(CONFIG_KEY); + if (!rawConfig || typeof rawConfig !== 'object') { + return; + } + + const config = rawConfig as Record; + const audio = config.audio; + + if (!audio || typeof audio !== 'object') { + config.audio = { + bounce_starts_from_beat_1: true, + }; + await storage.saveRaw(CONFIG_KEY, config); + return; + } + + const audioRecord = audio as Record; + if ('bounce_starts_from_beat_1' in audioRecord) { + return; + } + + audioRecord.bounce_starts_from_beat_1 = true; + await storage.saveRaw(CONFIG_KEY, config); +} diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index 0b6a102..9b08356 100644 --- a/src/core/config/ConfigManager.ts +++ b/src/core/config/ConfigManager.ts @@ -10,6 +10,10 @@ interface AppConfig { persist_api_keys_non_localhost: boolean; local_browser: { context_length: 32768 | 65536 | 131072; + model_url: string; + }; + uvr5_web_runtime: { + mdx_net_model_url: string; }; openai: { api_key: string; @@ -47,6 +51,7 @@ interface AppConfig { hold_to_create_region: string; play: string; loop: string; + record: string; undo: string; redo: string; select_all: string; @@ -54,6 +59,8 @@ interface AppConfig { cut: string; paste: string; save: string; + split_region: string; + merge_regions: string; }; piano_roll: { switch: string; @@ -80,6 +87,7 @@ interface AppConfig { default_open: boolean; }; audio: { + bounce_starts_from_beat_1: boolean; enable_audio_capture_for_screen_sharing: boolean; input_device_id: string; lookahead_time: number; @@ -212,7 +220,11 @@ export class ConfigManager { model: '' }, local_browser: { - context_length: 32768 + context_length: 32768, + model_url: 'https://huggingface.co/notabilia/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.task' + }, + uvr5_web_runtime: { + mdx_net_model_url: 'https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx' }, soundfont: { base_url: 'https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/' @@ -227,13 +239,16 @@ export class ConfigManager { hold_to_create_region: 'ctrl', play: 'space', loop: 'c', + record: 'r', undo: 'ctrl+z', redo: 'ctrl+shift+z', select_all: 'ctrl+a', copy: 'ctrl+c', cut: 'ctrl+x', paste: 'ctrl+v', - save: 'ctrl+s' + save: 'ctrl+s', + split_region: 'ctrl+t', + merge_regions: 'ctrl+j' }, piano_roll: { switch: 'tab', @@ -260,6 +275,7 @@ export class ConfigManager { default_open: true }, audio: { + bounce_starts_from_beat_1: true, enable_audio_capture_for_screen_sharing: false, input_device_id: 'default', lookahead_time: 0.05, diff --git a/src/core/io/KGProjectStorage.test.ts b/src/core/io/KGProjectStorage.test.ts index 85b382f..0e722a9 100644 --- a/src/core/io/KGProjectStorage.test.ts +++ b/src/core/io/KGProjectStorage.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage'; import { KGProject } from '../KGProject'; +import { KGTrack } from '../track/KGTrack'; // --- OPFS mock infrastructure --- @@ -104,6 +105,13 @@ describe('KGProjectStorage', () => { return new KGProject(name, 16, 0, 120); } + it('defaults both zoom levels to 1 on a fresh project', () => { + const project = new KGProject(); + + expect(project.getBarWidthMultiplier()).toBe(1); + expect(project.getPianoRollZoom()).toBe(1); + }); + it('initializes and creates the projects directory', async () => { // The projects directory should exist after init const projects = await mockRoot.getDirectoryHandle('projects'); @@ -121,6 +129,36 @@ describe('KGProjectStorage', () => { expect(loaded!.getBpm()).toBe(120); }); + it('preserves track mute and solo state when saving and loading', async () => { + const track = new KGTrack('Track 1', 1); + track.setMuted(true); + track.setSolo(true); + const project = new KGProject('My Song', 16, 0, 120, undefined, undefined, undefined, undefined, undefined, 1, [track], 11); + + await storage.save('My Song', project); + + const loaded = await storage.load('My Song'); + + expect(loaded).not.toBeNull(); + expect(loaded!.getTracks()).toHaveLength(1); + expect(loaded!.getTracks()[0].getMuted()).toBe(true); + expect(loaded!.getTracks()[0].getSolo()).toBe(true); + }); + + it('preserves both main-grid and piano-roll zoom levels when saving and loading', async () => { + const project = createTestProject('Zoom Song'); + project.setBarWidthMultiplier(3); + project.setPianoRollZoom(5); + + await storage.save('Zoom Song', project); + + const loaded = await storage.load('Zoom Song'); + + expect(loaded).not.toBeNull(); + expect(loaded!.getBarWidthMultiplier()).toBe(3); + expect(loaded!.getPianoRollZoom()).toBe(5); + }); + it('creates meta.json and media/ directory on save', async () => { const project = createTestProject('My Song'); await storage.save('My Song', project); diff --git a/src/core/io/LocalSeparatorModelCache.test.ts b/src/core/io/LocalSeparatorModelCache.test.ts index 8e96f03..78d5f08 100644 --- a/src/core/io/LocalSeparatorModelCache.test.ts +++ b/src/core/io/LocalSeparatorModelCache.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { LocalSeparatorModelCache } from '../../util/localSeparatorModelCache'; +import { + LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES, + LOCAL_SEPARATOR_MODEL_FILENAME, +} from '../../util/localSeparatorConfig'; class MockWritableFileStream { private readonly handle: MockFileSystemFileHandle; @@ -109,47 +113,81 @@ vi.stubGlobal('navigator', { }); describe('LocalSeparatorModelCache', () => { + const makeModelBytes = (fill: number): Uint8Array => new Uint8Array(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES).fill(fill); + beforeEach(() => { mockRoot.clear(); vi.restoreAllMocks(); }); it('downloads and stores a model in OPFS cache', async () => { + const bytes = makeModelBytes(1); + vi.stubGlobal('fetch', vi.fn(async () => new Response(bytes, { + status: 200, + headers: { 'Content-Length': String(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES) }, + }))); + + await LocalSeparatorModelCache.download('https://example.com/model.onnx'); + + expect(await LocalSeparatorModelCache.exists()).toBe(true); + const buffer = await LocalSeparatorModelCache.getArrayBuffer(); + expect(buffer.byteLength).toBe(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES); + expect(new Uint8Array(buffer)[0]).toBe(1); + }); + + it('replaces a broken cached file on redownload', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(makeModelBytes(1), { + status: 200, + headers: { 'Content-Length': String(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES) }, + }))); + await LocalSeparatorModelCache.download('https://example.com/model.onnx'); + + vi.stubGlobal('fetch', vi.fn(async () => new Response(makeModelBytes(9), { + status: 200, + headers: { 'Content-Length': String(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES) }, + }))); + await LocalSeparatorModelCache.download('https://example.com/model.onnx'); + + const buffer = await LocalSeparatorModelCache.getArrayBuffer(); + expect(buffer.byteLength).toBe(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES); + expect(new Uint8Array(buffer)[0]).toBe(9); + }); + + it('deletes the cached model file', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(makeModelBytes(2), { + status: 200, + headers: { 'Content-Length': String(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES) }, + }))); + await LocalSeparatorModelCache.download('https://example.com/model.onnx'); + + await LocalSeparatorModelCache.delete(); + + expect(await LocalSeparatorModelCache.exists()).toBe(false); + }); + + it('rejects and deletes a cached file when the size is wrong', async () => { + const dir = await navigator.storage.getDirectory(); + const modelsDir = await dir.getDirectoryHandle('models', { create: true }); + const fileHandle = await modelsDir.getFileHandle(LOCAL_SEPARATOR_MODEL_FILENAME, { create: true }); + const fileWritable = await fileHandle.createWritable(); + await fileWritable.write(new Uint8Array([1, 2, 3])); + await fileWritable.close(); + + const sizeHandle = await modelsDir.getFileHandle(`${LOCAL_SEPARATOR_MODEL_FILENAME}.size`, { create: true }); + const sizeWritable = await sizeHandle.createWritable(); + await sizeWritable.write(String(3)); + await sizeWritable.close(); + + expect(await LocalSeparatorModelCache.exists()).toBe(false); + }); + + it('fails a download when the final size does not match the expected model size', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), { status: 200, headers: { 'Content-Length': '3' }, }))); - await LocalSeparatorModelCache.download('https://example.com/model.onnx', 'model.onnx'); - - expect(await LocalSeparatorModelCache.exists('model.onnx')).toBe(true); - const buffer = await LocalSeparatorModelCache.getArrayBuffer('model.onnx'); - expect(Array.from(new Uint8Array(buffer))).toEqual([1, 2, 3]); - }); - - it('replaces a broken cached file on redownload', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array([1]), { - status: 200, - headers: { 'Content-Length': '1' }, - }))); - await LocalSeparatorModelCache.download('https://example.com/model.onnx', 'model.onnx'); - - vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array([9, 8, 7, 6]), { - status: 200, - headers: { 'Content-Length': '4' }, - }))); - await LocalSeparatorModelCache.download('https://example.com/model.onnx', 'model.onnx'); - - const buffer = await LocalSeparatorModelCache.getArrayBuffer('model.onnx'); - expect(Array.from(new Uint8Array(buffer))).toEqual([9, 8, 7, 6]); - }); - - it('deletes the cached model file', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array([1, 2]), { status: 200 }))); - await LocalSeparatorModelCache.download('https://example.com/model.onnx', 'model.onnx'); - - await LocalSeparatorModelCache.delete('model.onnx'); - - expect(await LocalSeparatorModelCache.exists('model.onnx')).toBe(false); + await expect(LocalSeparatorModelCache.download('https://example.com/model.onnx')).rejects.toThrow(/size mismatch/i); + expect(await LocalSeparatorModelCache.exists()).toBe(false); }); }); diff --git a/src/core/midi-input/KGMidiInput.test.ts b/src/core/midi-input/KGMidiInput.test.ts index 9e53747..396f04c 100644 --- a/src/core/midi-input/KGMidiInput.test.ts +++ b/src/core/midi-input/KGMidiInput.test.ts @@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { KGAudioTrack } from '../track/KGAudioTrack'; import { KGMidiTrack } from '../track/KGMidiTrack'; +type TestMidiEvent = { data: Uint8Array }; +type TestLiveNoteActivityListener = (...args: [{ pitch: number; isNoteOn: boolean }]) => void; + const { getStateMock, audioInterfaceMock } = vi.hoisted(() => ({ getStateMock: vi.fn(), audioInterfaceMock: { @@ -45,19 +48,40 @@ describe('KGMidiInput pitch bend', () => { it('routes live MIDI note on/off through the live monitoring path', () => { const midiInput = KGMidiInput.instance() as unknown as { - handleMIDIMessage: (event: { data: Uint8Array }) => void; + handleMIDIMessage: (...args: [TestMidiEvent]) => void; + addLiveNoteActivityListener: (...args: [TestLiveNoteActivityListener]) => void; }; + const listener = vi.fn(); + + midiInput.addLiveNoteActivityListener(listener); midiInput.handleMIDIMessage({ data: new Uint8Array([0x90, 60, 100]) }); midiInput.handleMIDIMessage({ data: new Uint8Array([0x80, 60, 0]) }); expect(audioInterfaceMock.triggerLiveMidiNoteAttack).toHaveBeenCalledWith('1', 60, 100); expect(audioInterfaceMock.releaseLiveMidiNote).toHaveBeenCalledWith('1', 60); + expect(listener).toHaveBeenNthCalledWith(1, { pitch: 60, isNoteOn: true }); + expect(listener).toHaveBeenNthCalledWith(2, { pitch: 60, isNoteOn: false }); + }); + + it('stops notifying removed live note activity listeners', () => { + const midiInput = KGMidiInput.instance() as unknown as { + handleMIDIMessage: (...args: [TestMidiEvent]) => void; + addLiveNoteActivityListener: (...args: [TestLiveNoteActivityListener]) => void; + removeLiveNoteActivityListener: (...args: [TestLiveNoteActivityListener]) => void; + }; + const listener = vi.fn(); + + midiInput.addLiveNoteActivityListener(listener); + midiInput.removeLiveNoteActivityListener(listener); + midiInput.handleMIDIMessage({ data: new Uint8Array([0x90, 60, 100]) }); + + expect(listener).not.toHaveBeenCalled(); }); it('latches live note ownership to the note-on track', () => { const midiInput = KGMidiInput.instance() as unknown as { - handleMIDIMessage: (event: { data: Uint8Array }) => void; + handleMIDIMessage: (...args: [TestMidiEvent]) => void; }; midiInput.handleMIDIMessage({ data: new Uint8Array([0x90, 60, 100]) }); @@ -73,7 +97,7 @@ describe('KGMidiInput pitch bend', () => { it('normalizes MIDI pitch bend and forwards it to the selected track', () => { const midiInput = KGMidiInput.instance() as unknown as { - handleMIDIMessage: (event: { data: Uint8Array }) => void; + handleMIDIMessage: (...args: [TestMidiEvent]) => void; }; midiInput.handleMIDIMessage({ data: new Uint8Array([0xe0, 0x00, 0x40]) }); @@ -87,7 +111,7 @@ describe('KGMidiInput pitch bend', () => { it('maps supported CC messages to live expression and sustain for standard pedals', () => { const midiInput = KGMidiInput.instance() as unknown as { - handleMIDIMessage: (event: { data: Uint8Array }) => void; + handleMIDIMessage: (...args: [TestMidiEvent]) => void; }; midiInput.handleMIDIMessage({ data: new Uint8Array([0xb0, 0x01, 0x20]) }); @@ -106,7 +130,7 @@ describe('KGMidiInput pitch bend', () => { it('calibrates inverted sustain pedals from the first observed CC64 message', () => { const midiInput = KGMidiInput.instance() as unknown as { - handleMIDIMessage: (event: { data: Uint8Array }) => void; + handleMIDIMessage: (...args: [TestMidiEvent]) => void; }; midiInput.handleMIDIMessage({ data: new Uint8Array([0xb0, 0x40, 0x00]) }); @@ -123,7 +147,7 @@ describe('KGMidiInput pitch bend', () => { }); const midiInput = KGMidiInput.instance() as unknown as { - handleMIDIMessage: (event: { data: Uint8Array }) => void; + handleMIDIMessage: (...args: [TestMidiEvent]) => void; }; midiInput.handleMIDIMessage({ data: new Uint8Array([0x90, 60, 100]) }); diff --git a/src/core/midi-input/KGMidiInput.ts b/src/core/midi-input/KGMidiInput.ts index 1ee0213..f85aa11 100644 --- a/src/core/midi-input/KGMidiInput.ts +++ b/src/core/midi-input/KGMidiInput.ts @@ -2,6 +2,13 @@ import { KGAudioInterface } from '../audio-interface/KGAudioInterface'; import { useProjectStore } from '../../stores/projectStore'; import { KGMidiTrack } from '../track/KGMidiTrack'; +export interface LiveMidiNoteActivityEvent { + pitch: number; + isNoteOn: boolean; +} + +type LiveNoteActivityListener = (...args: [LiveMidiNoteActivityEvent]) => void; + /** * KGMidiInput - MIDI input manager for the DAW * Implements the singleton pattern for global MIDI device management @@ -32,6 +39,7 @@ export class KGMidiInput { private onRecordControlChange: ((controller: number, value: number) => void) | null = null; private liveNoteTrackOwnership: Map = new Map(); private sustainPolarityInverted: boolean | null = null; + private liveNoteActivityListeners: LiveNoteActivityListener[] = []; // Private constructor to prevent direct instantiation private constructor() { @@ -178,12 +186,14 @@ export class KGMidiInput { // Note On: command = 0x90 (144) if (command === 0x90 && velocity > 0) { console.log(`MIDI Note On: pitch=${pitch}, velocity=${velocity}, channel=${channel}`); + this.emitLiveNoteActivity({ pitch, isNoteOn: true }); this.triggerNoteOn(pitch, velocity); this.onRecordNoteOn?.(pitch, velocity); } // Note Off: command = 0x80 (128) or Note On with velocity 0 else if (command === 0x80 || (command === 0x90 && velocity === 0)) { console.log(`MIDI Note Off: pitch=${pitch}, channel=${channel}`); + this.emitLiveNoteActivity({ pitch, isNoteOn: false }); this.triggerNoteOff(pitch); this.onRecordNoteOff?.(pitch); } @@ -354,6 +364,16 @@ export class KGMidiInput { return this.sustainPolarityInverted ? !rawPressed : rawPressed; } + private emitLiveNoteActivity(event: LiveMidiNoteActivityEvent): void { + for (const listener of this.liveNoteActivityListeners) { + try { + listener(event); + } catch { + // Swallow listener errors to avoid disrupting MIDI handling. + } + } + } + /** * Clean up MIDI resources */ @@ -374,6 +394,7 @@ export class KGMidiInput { this.isInitialized = false; this.liveNoteTrackOwnership.clear(); this.sustainPolarityInverted = null; + this.liveNoteActivityListeners = []; console.log("MIDI resources disposed successfully"); } catch (error) { @@ -395,6 +416,14 @@ export class KGMidiInput { this.onRecordControlChange = onControlChange; } + public addLiveNoteActivityListener(listener: LiveNoteActivityListener): void { + this.liveNoteActivityListeners.push(listener); + } + + public removeLiveNoteActivityListener(listener: LiveNoteActivityListener): void { + this.liveNoteActivityListeners = this.liveNoteActivityListeners.filter(current => current !== listener); + } + // ===== GETTERS ===== public getIsInitialized(): boolean { diff --git a/src/core/project-upgrader/KGProjectUpgrader.ts b/src/core/project-upgrader/KGProjectUpgrader.ts index bafc84c..5a13d7a 100644 --- a/src/core/project-upgrader/KGProjectUpgrader.ts +++ b/src/core/project-upgrader/KGProjectUpgrader.ts @@ -9,6 +9,8 @@ import { upgradeToV7 } from './upgradeToV7'; import { upgradeToV8 } from './upgradeToV8'; import { upgradeToV9 } from './upgradeToV9'; import { upgradeToV10 } from './upgradeToV10'; +import { upgradeToV11 } from './upgradeToV11'; +import { upgradeToV12 } from './upgradeToV12'; /** * Upgrade the given project to the latest structure version, one version at a time. @@ -68,6 +70,14 @@ export function upgradeProjectToLatest(project: KGProject): KGProject { workingProject = upgradeToV10(workingProject); break; } + case 11: { + workingProject = upgradeToV11(workingProject); + break; + } + case 12: { + workingProject = upgradeToV12(workingProject); + break; + } default: { // If an upgrader is missing, throw to prevent loading incompatible structures throw new Error(`No upgrader found for project structure version ${nextVersion}`); diff --git a/src/core/project-upgrader/upgradeToV10.test.ts b/src/core/project-upgrader/upgradeToV10.test.ts index 3e7b28c..0b98d81 100644 --- a/src/core/project-upgrader/upgradeToV10.test.ts +++ b/src/core/project-upgrader/upgradeToV10.test.ts @@ -26,7 +26,7 @@ describe('upgradeToV10', () => { const upgraded = upgradeProjectToLatest(project); - expect(upgraded.getProjectStructureVersion()).toBe(10); + expect(upgraded.getProjectStructureVersion()).toBe(KGProject.CURRENT_PROJECT_STRUCTURE_VERSION); expect(upgraded.getTracks()[0].getVolumeAutomation()).toHaveLength(1); }); }); diff --git a/src/core/project-upgrader/upgradeToV11.test.ts b/src/core/project-upgrader/upgradeToV11.test.ts new file mode 100644 index 0000000..325a918 --- /dev/null +++ b/src/core/project-upgrader/upgradeToV11.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { KGProject } from '../KGProject'; +import { KGTrack } from '../track/KGTrack'; +import { upgradeProjectToLatest } from './KGProjectUpgrader'; +import { upgradeToV11 } from './upgradeToV11'; + +describe('upgradeToV11', () => { + it('initializes missing mute and solo flags on legacy tracks', () => { + const track = new KGTrack('Legacy Track', 1); + delete (track as unknown as { muted?: unknown }).muted; + delete (track as unknown as { solo?: unknown }).solo; + const project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, 1, [track], 10); + + upgradeToV11(project); + + expect(track.getMuted()).toBe(false); + expect(track.getSolo()).toBe(false); + expect(project.getProjectStructureVersion()).toBe(11); + }); + + it('preserves existing mute and solo flags', () => { + const track = new KGTrack('Legacy Track', 1); + track.setMuted(true); + track.setSolo(true); + const project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, 1, [track], 10); + + upgradeToV11(project); + + expect(track.getMuted()).toBe(true); + expect(track.getSolo()).toBe(true); + }); + + it('upgrades legacy projects through the main upgrader path', () => { + const track = new KGTrack('Legacy Track', 1); + delete (track as unknown as { muted?: unknown }).muted; + delete (track as unknown as { solo?: unknown }).solo; + const project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, 1, [track], 10); + + const upgraded = upgradeProjectToLatest(project); + + expect(upgraded.getProjectStructureVersion()).toBe(KGProject.CURRENT_PROJECT_STRUCTURE_VERSION); + expect(upgraded.getTracks()[0].getMuted()).toBe(false); + expect(upgraded.getTracks()[0].getSolo()).toBe(false); + }); +}); diff --git a/src/core/project-upgrader/upgradeToV11.ts b/src/core/project-upgrader/upgradeToV11.ts new file mode 100644 index 0000000..1ede27d --- /dev/null +++ b/src/core/project-upgrader/upgradeToV11.ts @@ -0,0 +1,21 @@ +import { KGProject } from '../KGProject'; + +export function upgradeToV11(project: KGProject): KGProject { + try { + for (const track of project.getTracks()) { + const muted = (track as unknown as { muted?: unknown }).muted; + if (typeof muted !== 'boolean') { + track.setMuted(false); + } + + const solo = (track as unknown as { solo?: unknown }).solo; + if (typeof solo !== 'boolean') { + track.setSolo(false); + } + } + } finally { + project.setProjectStructureVersion(11); + } + + return project; +} diff --git a/src/core/project-upgrader/upgradeToV12.test.ts b/src/core/project-upgrader/upgradeToV12.test.ts new file mode 100644 index 0000000..8b9cf8b --- /dev/null +++ b/src/core/project-upgrader/upgradeToV12.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { KGProject } from '../KGProject'; +import { upgradeProjectToLatest } from './KGProjectUpgrader'; +import { upgradeToV12 } from './upgradeToV12'; + +describe('upgradeToV12', () => { + it('initializes missing zoom levels to 1', () => { + const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 1, [], 11); + delete (project as unknown as { barWidthMultiplier?: unknown }).barWidthMultiplier; + delete (project as unknown as { pianoRollZoom?: unknown }).pianoRollZoom; + + upgradeToV12(project); + + expect(project.getBarWidthMultiplier()).toBe(1); + expect(project.getPianoRollZoom()).toBe(1); + expect(project.getProjectStructureVersion()).toBe(12); + }); + + it('preserves existing zoom levels', () => { + const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 4, [], 11, 6); + + upgradeToV12(project); + + expect(project.getBarWidthMultiplier()).toBe(4); + expect(project.getPianoRollZoom()).toBe(6); + }); + + it('upgrades legacy projects through the main upgrader path', () => { + const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 1, [], 11); + delete (project as unknown as { barWidthMultiplier?: unknown }).barWidthMultiplier; + delete (project as unknown as { pianoRollZoom?: unknown }).pianoRollZoom; + + const upgraded = upgradeProjectToLatest(project); + + expect(upgraded.getProjectStructureVersion()).toBe(KGProject.CURRENT_PROJECT_STRUCTURE_VERSION); + expect(upgraded.getBarWidthMultiplier()).toBe(1); + expect(upgraded.getPianoRollZoom()).toBe(1); + }); +}); diff --git a/src/core/project-upgrader/upgradeToV12.ts b/src/core/project-upgrader/upgradeToV12.ts new file mode 100644 index 0000000..5b4682c --- /dev/null +++ b/src/core/project-upgrader/upgradeToV12.ts @@ -0,0 +1,23 @@ +import { KGProject } from '../KGProject'; + +function isValidZoomLevel(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +export function upgradeToV12(project: KGProject): KGProject { + try { + const barWidthMultiplier = project.getBarWidthMultiplier?.(); + if (!isValidZoomLevel(barWidthMultiplier)) { + project.setBarWidthMultiplier(1); + } + + const pianoRollZoom = project.getPianoRollZoom?.(); + if (!isValidZoomLevel(pianoRollZoom)) { + project.setPianoRollZoom(1); + } + } finally { + project.setProjectStructureVersion(12); + } + + return project; +} diff --git a/src/core/project-upgrader/upgradeToV8.test.ts b/src/core/project-upgrader/upgradeToV8.test.ts index ba6a341..076155b 100644 --- a/src/core/project-upgrader/upgradeToV8.test.ts +++ b/src/core/project-upgrader/upgradeToV8.test.ts @@ -57,7 +57,7 @@ describe('upgradeToV8', () => { const upgraded = upgradeProjectToLatest(project); - expect(upgraded.getProjectStructureVersion()).toBe(10); + expect(upgraded.getProjectStructureVersion()).toBe(KGProject.CURRENT_PROJECT_STRUCTURE_VERSION); expect((upgraded.getTracks()[0].getRegions()[0] as KGMidiRegion).getPitchBends()).toEqual([]); expect((upgraded.getTracks()[0].getRegions()[0] as KGMidiRegion).getControllerEventsByType()).toHaveLength(128); }); diff --git a/src/core/state/KGPianoRollState.ts b/src/core/state/KGPianoRollState.ts index f426f95..8d15a7c 100644 --- a/src/core/state/KGPianoRollState.ts +++ b/src/core/state/KGPianoRollState.ts @@ -15,6 +15,7 @@ export class KGPianoRollState { private currentMode: string = "ionian"; // Default mode private automationViewEnabled: boolean = false; private currentAutomationType: string = "pitch-bend"; + private pianoRollZoom: number = 1; private sheetMusicViewEnabled: boolean = false; private sheetMusicTrackScopeEnabled: boolean = false; private sheetQuantization: string = '16,48'; @@ -86,6 +87,14 @@ export class KGPianoRollState { this.currentAutomationType = type; } + public getPianoRollZoom(): number { + return this.pianoRollZoom; + } + + public setPianoRollZoom(zoom: number): void { + this.pianoRollZoom = zoom; + } + public getSheetMusicViewEnabled(): boolean { return this.sheetMusicViewEnabled; } diff --git a/src/core/track/KGTrack.ts b/src/core/track/KGTrack.ts index c3692a1..4ae20c2 100644 --- a/src/core/track/KGTrack.ts +++ b/src/core/track/KGTrack.ts @@ -37,6 +37,14 @@ export class KGTrack { @Expose() @WithDefault(AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME) protected volume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME; + + @Expose() + @WithDefault(false) + protected muted: boolean = false; + + @Expose() + @WithDefault(false) + protected solo: boolean = false; @Expose() @Type(() => KGRegion, { @@ -91,6 +99,14 @@ export class KGTrack { return this.volume; } + public getMuted(): boolean { + return this.muted; + } + + public getSolo(): boolean { + return this.solo; + } + // Setters public setName(name: string): void { this.name = name; @@ -120,6 +136,14 @@ export class KGTrack { ); } + public setMuted(muted: boolean): void { + this.muted = muted; + } + + public setSolo(solo: boolean): void { + this.solo = solo; + } + public setRegions(regions: KGRegion[]): void { this.regions = regions; } diff --git a/src/hooks/useGlobalKeyboardHandler.test.tsx b/src/hooks/useGlobalKeyboardHandler.test.tsx new file mode 100644 index 0000000..35f4e40 --- /dev/null +++ b/src/hooks/useGlobalKeyboardHandler.test.tsx @@ -0,0 +1,280 @@ +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(), + setStatus: vi.fn(), + isPlaying: false, + startPlaying: vi.fn(), + stopTransport: vi.fn(), + toggleLoop: vi.fn(), + projectName: 'Test Project', + savedProjectName: 'Test Project', + setSavedProjectName: vi.fn(), + setProjectName: vi.fn(), + isRecording: false, + startRecording: vi.fn(), + stopRecording: vi.fn(), + activeRegionId: null, + selectedRegionIds: ['region-a', 'region-b'], + selectedNoteIds: [], + setActiveRegionId: vi.fn(), + setShowPianoRoll: vi.fn(), + showPianoRoll: false, + openMidiPianoRoll: vi.fn(), + openMidiPianoRollWithSheetMusicView: vi.fn(), + openSpectrogramViewer: vi.fn(), + playheadPosition: 12, + refreshProjectState: vi.fn(), + pianoRollMode: 'midi-edit' as const, +}; + +type StoreState = typeof storeState; + +vi.mock('../stores/projectStore', () => ({ + useProjectStore: (selector?: unknown) => ( + selector ? (selector as (state: StoreState) => unknown)(storeState) : storeState + ), +})); + +vi.mock('../util/osUtil', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + matchesKeyboardShortcut: (event: KeyboardEvent, shortcut: string) => { + if (shortcut === 'ctrl+t') { + return event.ctrlKey && event.key.toLowerCase() === 't'; + } + if (shortcut === 'ctrl+j') { + return event.ctrlKey && event.key.toLowerCase() === 'j'; + } + return false; + }, + }; +}); + +vi.mock('../util/copyPasteUtil', () => ({ + handleCopyOperation: vi.fn(() => false), + handlePasteOperation: vi.fn(() => false), +})); + +vi.mock('../util/saveUtil', () => ({ + saveProject: vi.fn(), +})); + +vi.mock('../core/config/ConfigManager', () => ({ + ConfigManager: { + instance: () => ({ + getIsInitialized: () => true, + get: (path: string) => { + const shortcuts: Record = { + 'hotkeys.main.undo': 'ctrl+z', + 'hotkeys.main.redo': 'ctrl+shift+z', + 'hotkeys.main.copy': 'ctrl+c', + 'hotkeys.main.paste': 'ctrl+v', + 'hotkeys.main.select_all': 'ctrl+a', + 'hotkeys.main.play': 'space', + 'hotkeys.main.loop': 'c', + 'hotkeys.main.save': 'ctrl+s', + 'hotkeys.main.record': 'r', + 'hotkeys.main.split_region': 'ctrl+t', + 'hotkeys.main.merge_regions': 'ctrl+j', + }; + return shortcuts[path]; + }, + }), + }, +})); + +vi.mock('../util/selectionUtil', () => ({ + selectAllNotesInActiveRegion: vi.fn(), +})); + +vi.mock('../core/KGCore', () => ({ + KGCore: { + instance: () => ({ + getCurrentProject: () => ({ + getTracks: () => mockTracks, + }), + }), + }, +})); + +vi.mock('../core/midi-input/KGMidiInput', () => ({ + KGMidiInput: { + instance: () => ({ + getConnectedInputCount: () => 0, + }), + }, +})); + +vi.mock('../core/region/KGMidiRegion', () => ({ + KGMidiRegion: MockMidiRegion, +})); + +vi.mock('../core/track/KGAudioTrack', () => ({ + KGAudioTrack: class {}, +})); + +vi.mock('../util/regionEditUtil', () => ({ + splitSelectedRegionAtPlayhead: regionEditUtilMocks.splitSelectedRegionAtPlayhead, + mergeSelectedMidiRegions: regionEditUtilMocks.mergeSelectedMidiRegions, +})); + +vi.mock('../util/dialogUtil', () => ({ + showAlert: vi.fn(), +})); + +const HookHarness = () => { + useGlobalKeyboardHandler(); + return null; +}; + +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 () => { + regionEditUtilMocks.splitSelectedRegionAtPlayhead.mockResolvedValue('Split 1 note at beat 12.00'); + + render(); + fireEvent.keyDown(document.body, { key: 't', ctrlKey: true }); + + await waitFor(() => { + expect(regionEditUtilMocks.splitSelectedRegionAtPlayhead).toHaveBeenCalledWith({ + selectedRegionIds: ['region-a', 'region-b'], + playheadPosition: 12, + refreshProjectState: storeState.refreshProjectState, + }); + }); + + await waitFor(() => { + expect(storeState.setStatus).toHaveBeenCalledWith('Split 1 note at beat 12.00'); + }); + }); + + it('triggers merge on Ctrl+J', async () => { + regionEditUtilMocks.mergeSelectedMidiRegions.mockResolvedValue('Merged 2 MIDI regions'); + + render(); + fireEvent.keyDown(document.body, { key: 'j', ctrlKey: true }); + + await waitFor(() => { + expect(regionEditUtilMocks.mergeSelectedMidiRegions).toHaveBeenCalledWith({ + selectedRegionIds: ['region-a', 'region-b'], + refreshProjectState: storeState.refreshProjectState, + }); + }); + + await waitFor(() => { + 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 2e005c9..6497542 100644 --- a/src/hooks/useGlobalKeyboardHandler.ts +++ b/src/hooks/useGlobalKeyboardHandler.ts @@ -9,6 +9,7 @@ import { KGCore } from '../core/KGCore'; import { KGMidiInput } from '../core/midi-input/KGMidiInput'; import { KGMidiRegion } from '../core/region/KGMidiRegion'; import { KGAudioTrack } from '../core/track/KGAudioTrack'; +import { mergeSelectedMidiRegions, splitSelectedRegionAtPlayhead } from '../util/regionEditUtil'; import { showAlert } from '../util/dialogUtil'; /** @@ -16,14 +17,14 @@ 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 } = 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(() => { const handleKeyDown = (event: KeyboardEvent) => { // Skip if user is typing in an input field - const target = event.target as HTMLElement; - if (target && ( + const target = event.target; + if (target instanceof HTMLElement && ( target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.contentEditable === 'true' || @@ -71,6 +72,8 @@ export const useGlobalKeyboardHandler = () => { const loopShortcut = configManager.get('hotkeys.main.loop') as string; const saveShortcut = configManager.get('hotkeys.main.save') as string; const recordShortcut = configManager.get('hotkeys.main.record') as string; + const splitShortcut = configManager.get('hotkeys.main.split_region') as string; + const mergeShortcut = configManager.get('hotkeys.main.merge_regions') as string; // Check for undo shortcut if (undoShortcut && matchesKeyboardShortcut(event, undoShortcut)) { @@ -202,6 +205,33 @@ export const useGlobalKeyboardHandler = () => { return; } + if (splitShortcut && matchesKeyboardShortcut(event, splitShortcut)) { + event.preventDefault(); + void splitSelectedRegionAtPlayhead({ + selectedRegionIds, + playheadPosition, + refreshProjectState, + }).then(status => { + if (status) { + setStatus(status); + } + }); + return; + } + + if (mergeShortcut && matchesKeyboardShortcut(event, mergeShortcut)) { + event.preventDefault(); + void mergeSelectedMidiRegions({ + selectedRegionIds, + refreshProjectState, + }).then(status => { + if (status) { + setStatus(status); + } + }); + return; + } + // Check for edit/view shortcut (E) — open piano roll for MIDI, spectrogram for audio if (event.key.toLowerCase() === 'e' && !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { event.preventDefault(); @@ -226,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(); @@ -272,11 +329,14 @@ export const useGlobalKeyboardHandler = () => { startRecording, stopRecording, activeRegionId, + selectedRegionIds, lastSelectedRegionId, setActiveRegionId, setShowPianoRoll, showPianoRoll, - openMidiPianoRoll, + openMidiPianoRollWithSheetMusicView, openSpectrogramViewer, + playheadPosition, + refreshProjectState, ]); // Include dependencies for store actions }; diff --git a/src/hooks/useNoteOperations.test.ts b/src/hooks/useNoteOperations.test.ts new file mode 100644 index 0000000..d0a3c6f --- /dev/null +++ b/src/hooks/useNoteOperations.test.ts @@ -0,0 +1,431 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +type SelectableItem = { getId: () => string }; + +const coreState = { + selectedItems: [] as SelectableItem[], + selectionChangedCallbacks: [] as Array<() => void>, + executeCommand: vi.fn(), +}; + +const projectStoreState = { + selectedNoteIds: [] as string[], + clearAllSelections: () => { + coreState.selectedItems = []; + syncSelectionFromCore(); + }, + bumpAutomationRedrawVersion: vi.fn(), + syncSelectionFromCore: () => { + syncSelectionFromCore(); + }, +}; + +const syncSelectionFromCore = () => { + projectStoreState.selectedNoteIds = coreState.selectedItems.map(item => item.getId()); + coreState.selectionChangedCallbacks.forEach(callback => callback()); +}; + +vi.mock('../core/KGCore', () => ({ + KGCore: { + instance: () => ({ + getSelectedItems: () => coreState.selectedItems, + addSelectedItem: (item: SelectableItem) => { + coreState.selectedItems = coreState.selectedItems.filter(selectedItem => selectedItem.getId() !== item.getId()); + coreState.selectedItems.push(item); + syncSelectionFromCore(); + }, + addSelectedItems: (items: SelectableItem[]) => { + const incomingIds = new Set(items.map(item => item.getId())); + coreState.selectedItems = coreState.selectedItems.filter(item => !incomingIds.has(item.getId())); + coreState.selectedItems.push(...items); + syncSelectionFromCore(); + }, + clearSelectedItems: () => { + coreState.selectedItems = []; + syncSelectionFromCore(); + }, + executeCommand: (...args: unknown[]) => coreState.executeCommand(...args), + onSelectionChanged: (callback: () => void) => { + coreState.selectionChangedCallbacks.push(callback); + }, + }), + }, +})); + +vi.mock('../stores/projectStore', () => ({ + useProjectStore: Object.assign(vi.fn(), { + getState: () => projectStoreState, + }), +})); + +import { useNoteOperations } from './useNoteOperations'; +import { KGCore } from '../core/KGCore'; +import { KGPianoRollState } from '../core/state/KGPianoRollState'; +import { MoveNotesCommand, ResizeNotesCommand } from '../core/commands'; +import { useProjectStore } from '../stores/projectStore'; +import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack } from '../test/utils/mock-data'; + +describe('useNoteOperations', () => { + beforeEach(() => { + vi.restoreAllMocks(); + coreState.selectedItems = []; + coreState.selectionChangedCallbacks = []; + coreState.executeCommand = vi.fn(); + projectStoreState.selectedNoteIds = []; + projectStoreState.bumpAutomationRedrawVersion.mockReset(); + + vi.spyOn(window, 'getComputedStyle').mockReturnValue({ + getPropertyValue: (property: string) => { + if (property === '--region-grid-beat-width') { + return '40'; + } + + if (property === '--region-piano-key-height') { + return '20'; + } + + return ''; + }, + } as CSSStyleDeclaration); + + KGPianoRollState.instance().setActiveTool('pointer'); + KGPianoRollState.instance().setCurrentSnap('1/4'); + }); + + const renderNoteOperations = (activeRegion: ReturnType, track = createMockMidiTrack({ id: 1, regions: [activeRegion] }), updateTrack = vi.fn()) => { + const hook = renderHook(() => useNoteOperations({ + activeRegion, + timeSignature: { numerator: 4, denominator: 4 }, + updateTrack, + tracks: [track], + pianoGridRef: { current: null }, + })); + + return { ...hook, track, updateTrack }; + }; + + it('selects the grabbed note before resizing when it was not part of the current selection', () => { + const noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1, pitch: 60 }); + const noteB = createMockMidiNote({ id: 'note-b', startBeat: 1, endBeat: 2, pitch: 62 }); + const noteC = createMockMidiNote({ id: 'note-c', startBeat: 2, endBeat: 3, pitch: 64 }); + const activeRegion = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + notes: [noteA, noteB, noteC], + }); + const track = createMockMidiTrack({ id: 1, regions: [activeRegion] }); + const updateTrack = vi.fn(); + + noteA.select(); + noteB.select(); + KGCore.instance().addSelectedItems([noteA, noteB]); + + const { result } = renderNoteOperations(activeRegion, track, updateTrack); + + act(() => { + result.current.handleNoteResizeStart(noteC.getId(), 'end', 120); + }); + + expect(KGCore.instance().getSelectedItems().map(item => item.getId())).toEqual([noteC.getId()]); + expect(useProjectStore.getState().selectedNoteIds).toEqual([noteC.getId()]); + expect(noteA.isSelected()).toBe(false); + expect(noteB.isSelected()).toBe(false); + expect(noteC.isSelected()).toBe(true); + + act(() => { + result.current.handleNoteResizeEnd(noteC.getId(), 'end'); + }); + + expect(coreState.executeCommand).toHaveBeenCalledTimes(1); + const resizeCommand = coreState.executeCommand.mock.calls[0][0]; + expect(resizeCommand).toBeInstanceOf(ResizeNotesCommand); + expect((resizeCommand as ResizeNotesCommand).getNoteIdsToResize()).toEqual([noteC.getId()]); + }); + + it('keeps the existing multi-selection when resizing a selected note', () => { + const noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1, pitch: 60 }); + const noteB = createMockMidiNote({ id: 'note-b', startBeat: 1, endBeat: 2, pitch: 62 }); + const noteC = createMockMidiNote({ id: 'note-c', startBeat: 2, endBeat: 3, pitch: 64 }); + const activeRegion = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + notes: [noteA, noteB, noteC], + }); + const track = createMockMidiTrack({ id: 1, regions: [activeRegion] }); + const updateTrack = vi.fn(); + + noteA.select(); + noteB.select(); + KGCore.instance().addSelectedItems([noteA, noteB]); + + const { result } = renderNoteOperations(activeRegion, track, updateTrack); + + act(() => { + result.current.handleNoteResizeStart(noteA.getId(), 'end', 0); + }); + + expect(KGCore.instance().getSelectedItems().map(item => item.getId())).toEqual([noteA.getId(), noteB.getId()]); + expect(useProjectStore.getState().selectedNoteIds).toEqual([noteA.getId(), noteB.getId()]); + expect(noteA.isSelected()).toBe(true); + expect(noteB.isSelected()).toBe(true); + expect(noteC.isSelected()).toBe(false); + + act(() => { + result.current.handleNoteResizeEnd(noteA.getId(), 'end'); + }); + + expect(coreState.executeCommand).toHaveBeenCalledTimes(1); + const resizeCommand = coreState.executeCommand.mock.calls[0][0]; + expect(resizeCommand).toBeInstanceOf(ResizeNotesCommand); + expect((resizeCommand as ResizeNotesCommand).getNoteIdsToResize()).toEqual([noteA.getId(), noteB.getId()]); + }); + + it('previews end resize for all notes in the active multi-selection', () => { + const noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1, pitch: 60 }); + const noteB = createMockMidiNote({ id: 'note-b', startBeat: 2, endBeat: 3, pitch: 62 }); + const activeRegion = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + startFromBeat: 4, + notes: [noteA, noteB], + }); + + noteA.select(); + noteB.select(); + KGCore.instance().addSelectedItems([noteA, noteB]); + + const { result } = renderNoteOperations(activeRegion); + + act(() => { + result.current.handleNoteResizeStart(noteA.getId(), 'end', 0); + result.current.handleNoteResize(noteA.getId(), 'end', 20); + }); + + expect(result.current.tempNoteStyles).toEqual({ + 'note-a': { left: '160px', width: '80px' }, + 'note-b': { left: '240px', width: '80px' }, + }); + }); + + it('previews start resize for all notes in the active multi-selection', () => { + const noteA = createMockMidiNote({ id: 'note-a', startBeat: 1, endBeat: 3, pitch: 60 }); + const noteB = createMockMidiNote({ id: 'note-b', startBeat: 4, endBeat: 6, pitch: 62 }); + const activeRegion = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + startFromBeat: 2, + notes: [noteA, noteB], + }); + + noteA.select(); + noteB.select(); + KGCore.instance().addSelectedItems([noteA, noteB]); + + const { result } = renderNoteOperations(activeRegion); + + act(() => { + result.current.handleNoteResizeStart(noteA.getId(), 'start', 0); + result.current.handleNoteResize(noteA.getId(), 'start', 20); + }); + + expect(result.current.tempNoteStyles).toEqual({ + 'note-a': { left: '120px', width: '80px' }, + 'note-b': { left: '240px', width: '80px' }, + }); + }); + + it('previews only the grabbed note when it was outside the current selection', () => { + const noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1, pitch: 60 }); + const noteB = createMockMidiNote({ id: 'note-b', startBeat: 1, endBeat: 2, pitch: 62 }); + const noteC = createMockMidiNote({ id: 'note-c', startBeat: 2, endBeat: 3, pitch: 64 }); + const activeRegion = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + notes: [noteA, noteB, noteC], + }); + + noteA.select(); + noteB.select(); + KGCore.instance().addSelectedItems([noteA, noteB]); + + const { result } = renderNoteOperations(activeRegion); + + act(() => { + result.current.handleNoteResizeStart(noteC.getId(), 'end', 0); + result.current.handleNoteResize(noteC.getId(), 'end', 20); + }); + + expect(useProjectStore.getState().selectedNoteIds).toEqual([noteC.getId()]); + expect(result.current.tempNoteStyles).toEqual({ + 'note-c': { left: '80px', width: '80px' }, + }); + }); + + it('clears resize preview styles after committing a multi-note resize', () => { + const noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1, pitch: 60 }); + const noteB = createMockMidiNote({ id: 'note-b', startBeat: 2, endBeat: 3, pitch: 62 }); + const activeRegion = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + notes: [noteA, noteB], + }); + + noteA.select(); + noteB.select(); + KGCore.instance().addSelectedItems([noteA, noteB]); + + const { result } = renderNoteOperations(activeRegion); + + act(() => { + result.current.handleNoteResizeStart(noteA.getId(), 'end', 0); + result.current.handleNoteResize(noteA.getId(), 'end', 20); + }); + + expect(Object.keys(result.current.tempNoteStyles)).toEqual(['note-a', 'note-b']); + + act(() => { + result.current.handleNoteResizeEnd(noteA.getId(), 'end'); + }); + + expect(result.current.tempNoteStyles).toEqual({}); + }); + + it('commits the same snapped resize delta that is shown in the preview', () => { + const noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1, pitch: 60 }); + const noteB = createMockMidiNote({ id: 'note-b', startBeat: 2, endBeat: 3, pitch: 62 }); + const activeRegion = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + notes: [noteA, noteB], + }); + + noteA.select(); + noteB.select(); + KGCore.instance().addSelectedItems([noteA, noteB]); + + const { result } = renderNoteOperations(activeRegion); + + act(() => { + result.current.handleNoteResizeStart(noteA.getId(), 'end', 0); + result.current.handleNoteResize(noteA.getId(), 'end', 20); + result.current.handleNoteResizeEnd(noteA.getId(), 'end'); + }); + + expect(coreState.executeCommand).toHaveBeenCalledTimes(1); + const resizeCommand = coreState.executeCommand.mock.calls[0][0] as ResizeNotesCommand; + expect(resizeCommand.getNoteIdsToResize()).toEqual([noteA.getId(), noteB.getId()]); + expect((resizeCommand as unknown as { primaryEndBeatDelta: number }).primaryEndBeatDelta).toBe(1); + }); + + it('previews drag movement for all notes in the active multi-selection', () => { + const noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1, pitch: 60 }); + const noteB = createMockMidiNote({ id: 'note-b', startBeat: 2, endBeat: 3, pitch: 64 }); + const activeRegion = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + startFromBeat: 4, + notes: [noteA, noteB], + }); + + noteA.select(); + noteB.select(); + KGCore.instance().addSelectedItems([noteA, noteB]); + + const { result } = renderNoteOperations(activeRegion); + + act(() => { + result.current.handleNoteDragStart(noteA.getId(), 0, 0); + result.current.handleNoteDrag(noteA.getId(), 20, 15); + }); + + expect(result.current.tempNoteStyles).toEqual({ + 'note-a': { left: '200px', top: '955px', width: '40px', height: '20px', zIndex: 100 }, + 'note-b': { left: '280px', top: '875px', width: '40px', height: '20px', zIndex: 100 }, + }); + }); + + it('previews only the grabbed note during drag when it was outside the current selection', () => { + const noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1, pitch: 60 }); + const noteB = createMockMidiNote({ id: 'note-b', startBeat: 1, endBeat: 2, pitch: 62 }); + const noteC = createMockMidiNote({ id: 'note-c', startBeat: 2, endBeat: 3, pitch: 64 }); + const activeRegion = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + notes: [noteA, noteB, noteC], + }); + + noteA.select(); + noteB.select(); + KGCore.instance().addSelectedItems([noteA, noteB]); + + const { result } = renderNoteOperations(activeRegion); + + act(() => { + result.current.handleNoteDragStart(noteC.getId(), 0, 0); + result.current.handleNoteDrag(noteC.getId(), 20, 15); + }); + + expect(result.current.tempNoteStyles).toEqual({ + 'note-c': { left: '120px', top: '875px', width: '40px', height: '20px', zIndex: 100 }, + }); + }); + + it('clears drag preview styles after committing a multi-note move', () => { + const noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1, pitch: 60 }); + const noteB = createMockMidiNote({ id: 'note-b', startBeat: 2, endBeat: 3, pitch: 64 }); + const activeRegion = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + notes: [noteA, noteB], + }); + + noteA.select(); + noteB.select(); + KGCore.instance().addSelectedItems([noteA, noteB]); + + const { result } = renderNoteOperations(activeRegion); + + act(() => { + result.current.handleNoteDragStart(noteA.getId(), 0, 0); + result.current.handleNoteDrag(noteA.getId(), 20, 15); + }); + + expect(Object.keys(result.current.tempNoteStyles)).toEqual(['note-a', 'note-b']); + + act(() => { + result.current.handleNoteDragEnd(noteA.getId()); + }); + + expect(result.current.tempNoteStyles).toEqual({}); + }); + + it('commits the same drag cohort and deltas that are shown in the preview', () => { + const noteA = createMockMidiNote({ id: 'note-a', startBeat: 0, endBeat: 1, pitch: 60 }); + const noteB = createMockMidiNote({ id: 'note-b', startBeat: 2, endBeat: 3, pitch: 64 }); + const activeRegion = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + notes: [noteA, noteB], + }); + + noteA.select(); + noteB.select(); + KGCore.instance().addSelectedItems([noteA, noteB]); + + const { result } = renderNoteOperations(activeRegion); + + act(() => { + result.current.handleNoteDragStart(noteA.getId(), 0, 0); + result.current.handleNoteDrag(noteA.getId(), 20, 15); + result.current.handleNoteDragEnd(noteA.getId()); + }); + + expect(coreState.executeCommand).toHaveBeenCalledTimes(1); + const moveCommand = coreState.executeCommand.mock.calls[0][0] as MoveNotesCommand; + expect(moveCommand).toBeInstanceOf(MoveNotesCommand); + expect(moveCommand.getNoteIdsToMove()).toEqual([noteA.getId(), noteB.getId()]); + expect(moveCommand.getStartBeatDelta()).toBe(1); + expect(moveCommand.getPitchDelta()).toBe(-1); + }); +}); diff --git a/src/hooks/useNoteOperations.ts b/src/hooks/useNoteOperations.ts index 3a36b0b..275e23f 100644 --- a/src/hooks/useNoteOperations.ts +++ b/src/hooks/useNoteOperations.ts @@ -25,6 +25,25 @@ interface UseNoteOperationsProps { pianoGridRef: MutableRefObject; } +interface ResizePreviewBaseline { + noteId: string; + originalStartBeat: number; + originalEndBeat: number; + originalLeft: number; + originalWidth: number; +} + +interface DragPreviewBaseline { + noteId: string; + originalStartBeat: number; + originalEndBeat: number; + originalPitch: number; + originalLeft: number; + originalTop: number; + originalWidth: number; + originalHeight: number; +} + export const useNoteOperations = ({ activeRegion, timeSignature, @@ -44,6 +63,8 @@ export const useNoteOperations = ({ const currentResizeLeft = useRef(null); const initialStartBeatRef = useRef(null); const initialEndBeatRef = useRef(null); + const resizePreviewBaselinesRef = useRef([]); + const resizePreviewNoteIdsRef = useRef([]); // Refs for drag operations const initialDragLeft = useRef(null); @@ -51,12 +72,29 @@ export const useNoteOperations = ({ const currentDragLeft = useRef(null); const currentDragTop = useRef(null); const initialPitchRef = useRef(null); + const dragPreviewBaselinesRef = useRef([]); + const dragPreviewNoteIdsRef = useRef([]); // Counter to trigger re-renders when notes are updated const [noteUpdateCounter, setNoteUpdateCounter] = useState(0); // Get KGCore instance for accessing selected items const core = KGCore.instance(); + + const clearTempNoteStyles = (noteIds?: string[]) => { + if (!noteIds || noteIds.length === 0) { + setTempNoteStyles({}); + return; + } + + setTempNoteStyles(prev => { + const updated = { ...prev }; + noteIds.forEach(id => { + delete updated[id]; + }); + return updated; + }); + }; // Utility function to delete selected notes from the active region using commands const deleteSelectedNotes = () => { @@ -279,6 +317,32 @@ export const useNoteOperations = ({ // Find the note being resized const note = activeRegion.getNotes().find(n => n.getId() === noteId); if (!note) return; + + const selectedNotesInRegion = core.getSelectedItems().filter(item => + item instanceof KGMidiNote && + activeRegion.getNotes().some(regionNote => regionNote.getId() === item.getId()) + ) as KGMidiNote[]; + const isResizedNoteSelected = selectedNotesInRegion.some(selectedNote => selectedNote.getId() === noteId); + + if (!isResizedNoteSelected) { + selectedNotesInRegion.forEach(selectedNote => { + selectedNote.deselect(); + }); + useProjectStore.getState().clearAllSelections(); + + note.select(); + core.addSelectedItem(note); + KGPianoRollState.instance().setLastEditedNoteLength(note.getEndBeat() - note.getStartBeat()); + + const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId()); + if (track) { + updateTrack(track); + } + } + + const resizeTargetNotes = isResizedNoteSelected + ? selectedNotesInRegion + : [note]; // Store the initial start and end beats initialStartBeatRef.current = note.getStartBeat(); @@ -302,15 +366,28 @@ export const useNoteOperations = ({ currentResizeWidth.current = width; currentResizeLeft.current = left; - // Set initial style to current position/size - const initialStyle = { - left: `${left}px`, - width: `${width}px`, - }; - + resizePreviewBaselinesRef.current = resizeTargetNotes.map(targetNote => { + const targetAbsStartBeat = targetNote.getStartBeat() + regionStartBeat; + const targetAbsEndBeat = targetNote.getEndBeat() + regionStartBeat; + return { + noteId: targetNote.getId(), + originalStartBeat: targetNote.getStartBeat(), + originalEndBeat: targetNote.getEndBeat(), + originalLeft: targetAbsStartBeat * beatWidth, + originalWidth: (targetAbsEndBeat - targetAbsStartBeat) * beatWidth, + }; + }); + resizePreviewNoteIdsRef.current = resizeTargetNotes.map(targetNote => targetNote.getId()); + setTempNoteStyles(prev => ({ ...prev, - [noteId]: initialStyle + ...Object.fromEntries(resizePreviewBaselinesRef.current.map(baseline => [ + baseline.noteId, + { + left: `${baseline.originalLeft}px`, + width: `${baseline.originalWidth}px`, + }, + ])), })); }; @@ -380,15 +457,41 @@ export const useNoteOperations = ({ currentResizeWidth.current = snappedWidth; currentResizeLeft.current = newLeft; - // Update the temporary style for this note - const newStyle = { - left: `${newLeft}px`, - width: `${snappedWidth}px`, - }; - + const startBeatDelta = resizeEdge === 'start' + ? (newLeft - originalLeft) / beatWidth + : 0; + const endBeatDelta = resizeEdge === 'end' + ? (snappedWidth - originalWidth) / beatWidth + : 0; + + const previewBaselines = resizePreviewBaselinesRef.current.length > 0 + ? resizePreviewBaselinesRef.current + : [{ + noteId, + originalStartBeat: note.getStartBeat(), + originalEndBeat: note.getEndBeat(), + originalLeft: originalLeft, + originalWidth: originalWidth, + }]; + setTempNoteStyles(prev => ({ ...prev, - [noteId]: newStyle + ...Object.fromEntries(previewBaselines.map(baseline => { + const previewLeft = resizeEdge === 'start' + ? baseline.originalLeft + (startBeatDelta * beatWidth) + : baseline.originalLeft; + const previewWidth = resizeEdge === 'end' + ? baseline.originalWidth + (endBeatDelta * beatWidth) + : baseline.originalWidth - (startBeatDelta * beatWidth); + + return [ + baseline.noteId, + { + left: `${previewLeft}px`, + width: `${previewWidth}px`, + }, + ]; + })), })); if (DEBUG_MODE.PIANO_ROLL) { @@ -418,11 +521,9 @@ export const useNoteOperations = ({ initialStartBeatRef.current === null || initialEndBeatRef.current === null) { // Reset resizing state setResizingNoteId(null); - setTempNoteStyles(prev => { - const updated = { ...prev }; - delete updated[noteId]; - return updated; - }); + clearTempNoteStyles(resizePreviewNoteIdsRef.current); + resizePreviewBaselinesRef.current = []; + resizePreviewNoteIdsRef.current = []; return; } @@ -513,15 +614,13 @@ export const useNoteOperations = ({ console.error('Error resizing notes:', error); // Reset resizing state and return early on error setResizingNoteId(null); - setTempNoteStyles(prev => { - const updated = { ...prev }; - delete updated[noteId]; - return updated; - }); + clearTempNoteStyles(resizePreviewNoteIdsRef.current); currentResizeWidth.current = null; currentResizeLeft.current = null; initialStartBeatRef.current = null; initialEndBeatRef.current = null; + resizePreviewBaselinesRef.current = []; + resizePreviewNoteIdsRef.current = []; return; } @@ -538,15 +637,13 @@ export const useNoteOperations = ({ // Reset resizing state setResizingNoteId(null); - setTempNoteStyles(prev => { - const updated = { ...prev }; - delete updated[noteId]; - return updated; - }); + clearTempNoteStyles(resizePreviewNoteIdsRef.current); currentResizeWidth.current = null; currentResizeLeft.current = null; initialStartBeatRef.current = null; initialEndBeatRef.current = null; + resizePreviewBaselinesRef.current = []; + resizePreviewNoteIdsRef.current = []; // Increment the note update counter to trigger a re-render setNoteUpdateCounter(prev => prev + 1); @@ -576,6 +673,15 @@ export const useNoteOperations = ({ // Find the note being dragged const note = activeRegion.getNotes().find(n => n.getId() === noteId); if (!note) return; + + const selectedNotesInRegion = core.getSelectedItems().filter(item => + item instanceof KGMidiNote && + activeRegion.getNotes().some(regionNote => regionNote.getId() === item.getId()) + ) as KGMidiNote[]; + const isDraggedNoteSelected = selectedNotesInRegion.some(selectedNote => selectedNote.getId() === noteId); + const dragTargetNotes = isDraggedNoteSelected + ? selectedNotesInRegion + : [note]; // Store the initial pitch initialPitchRef.current = note.getPitch(); @@ -603,19 +709,37 @@ export const useNoteOperations = ({ initialDragTop.current = top; currentDragLeft.current = left; currentDragTop.current = top; - - // Set initial style - const initialStyle = { - left: `${left}px`, - top: `${top}px`, - width: `${width}px`, - height: `${noteHeight}px`, - zIndex: 100, // Bring to front during drag - }; - + + dragPreviewBaselinesRef.current = dragTargetNotes.map(targetNote => { + const targetAbsStartBeat = targetNote.getStartBeat() + regionStartBeat; + const targetWidth = (targetNote.getEndBeat() - targetNote.getStartBeat()) * beatWidth; + const targetTop = (107 - targetNote.getPitch()) * noteHeight; + + return { + noteId: targetNote.getId(), + originalStartBeat: targetNote.getStartBeat(), + originalEndBeat: targetNote.getEndBeat(), + originalPitch: targetNote.getPitch(), + originalLeft: targetAbsStartBeat * beatWidth, + originalTop: targetTop, + originalWidth: targetWidth, + originalHeight: noteHeight, + }; + }); + dragPreviewNoteIdsRef.current = dragTargetNotes.map(targetNote => targetNote.getId()); + setTempNoteStyles(prev => ({ ...prev, - [noteId]: initialStyle + ...Object.fromEntries(dragPreviewBaselinesRef.current.map(baseline => [ + baseline.noteId, + { + left: `${baseline.originalLeft}px`, + top: `${baseline.originalTop}px`, + width: `${baseline.originalWidth}px`, + height: `${baseline.originalHeight}px`, + zIndex: 100, + }, + ])), })); }; @@ -659,21 +783,33 @@ export const useNoteOperations = ({ currentDragLeft.current = newLeft; currentDragTop.current = newTop; - // Calculate width based on note duration - const width = (note.getEndBeat() - note.getStartBeat()) * beatWidth; - - // Update the temporary style for this note - const newStyle = { - left: `${newLeft}px`, - top: `${newTop}px`, - width: `${width}px`, - height: `${noteHeight}px`, - zIndex: 100, // Keep on top during drag - }; - + const previewBaselines = dragPreviewBaselinesRef.current.length > 0 + ? dragPreviewBaselinesRef.current + : [{ + noteId, + originalStartBeat: note.getStartBeat(), + originalEndBeat: note.getEndBeat(), + originalPitch: note.getPitch(), + originalLeft, + originalTop, + originalWidth: (note.getEndBeat() - note.getStartBeat()) * beatWidth, + originalHeight: noteHeight, + }]; + const leftDelta = newLeft - originalLeft; + const topDelta = newTop - originalTop; + setTempNoteStyles(prev => ({ ...prev, - [noteId]: newStyle + ...Object.fromEntries(previewBaselines.map(baseline => [ + baseline.noteId, + { + left: `${baseline.originalLeft + leftDelta}px`, + top: `${baseline.originalTop + topDelta}px`, + width: `${baseline.originalWidth}px`, + height: `${baseline.originalHeight}px`, + zIndex: 100, + }, + ])), })); if (DEBUG_MODE.PIANO_ROLL) { @@ -705,11 +841,9 @@ export const useNoteOperations = ({ initialPitchRef.current === null) { // Reset dragging state setDraggingNoteId(null); - setTempNoteStyles(prev => { - const updated = { ...prev }; - delete updated[noteId]; - return updated; - }); + clearTempNoteStyles(dragPreviewNoteIdsRef.current); + dragPreviewBaselinesRef.current = []; + dragPreviewNoteIdsRef.current = []; return; } @@ -771,16 +905,14 @@ export const useNoteOperations = ({ console.error('Error moving notes:', error); // Reset dragging state and return early on error setDraggingNoteId(null); - setTempNoteStyles(prev => { - const updated = { ...prev }; - delete updated[noteId]; - return updated; - }); + clearTempNoteStyles(dragPreviewNoteIdsRef.current); currentDragLeft.current = null; currentDragTop.current = null; initialDragLeft.current = null; initialDragTop.current = null; initialPitchRef.current = null; + dragPreviewBaselinesRef.current = []; + dragPreviewNoteIdsRef.current = []; return; } @@ -798,16 +930,14 @@ export const useNoteOperations = ({ // Reset dragging state setDraggingNoteId(null); - setTempNoteStyles(prev => { - const updated = { ...prev }; - delete updated[noteId]; - return updated; - }); + clearTempNoteStyles(dragPreviewNoteIdsRef.current); currentDragLeft.current = null; currentDragTop.current = null; initialDragLeft.current = null; initialDragTop.current = null; initialPitchRef.current = null; + dragPreviewBaselinesRef.current = []; + dragPreviewNoteIdsRef.current = []; // Increment the note update counter to trigger a re-render setNoteUpdateCounter(prev => prev + 1); diff --git a/src/stores/projectStore.test.ts b/src/stores/projectStore.test.ts index 66b1478..40bef52 100644 --- a/src/stores/projectStore.test.ts +++ b/src/stores/projectStore.test.ts @@ -3,6 +3,11 @@ 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(), + setPianoRollZoom: vi.fn(), +})); + let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')]; const mockProject = { getTimeSignature: () => ({ numerator: 4, denominator: 4 }), @@ -15,13 +20,23 @@ const mockProject = { getSelectedMode: () => 'major', getIsLooping: () => false, getLoopingRange: () => [0, 0] as [number, number], + getPianoRollZoom: () => 1, }; +let currentProject = mockProject; const mockAudioInterface = { getTransportPosition: vi.fn().mockReturnValue(8), startAudioRecording: vi.fn().mockResolvedValue({ usedDeviceId: 'default', fellBackToDefault: false }), stopAudioRecording: vi.fn().mockResolvedValue(null), cancelAudioRecording: vi.fn().mockResolvedValue(undefined), + removeTrackSynth: vi.fn(), + removeTrackAudioPlayerBus: vi.fn(), + createTrackAudioPlayerBus: vi.fn().mockResolvedValue(undefined), + loadAudioBufferForTrack: vi.fn(), + createTrackSynth: vi.fn(), + setTrackVolume: vi.fn(), + setTrackMute: vi.fn(), + setTrackSolo: vi.fn(), }; const configValues = new Map([ @@ -29,7 +44,10 @@ const configValues = new Map([ ]); const mockCore = { - getCurrentProject: () => mockProject, + getCurrentProject: () => currentProject, + setCurrentProject: vi.fn((project: typeof mockProject) => { + currentProject = project; + }), setPlayheadUpdateCallback: vi.fn(), setPlaybackStateChangeCallback: vi.fn(), setLoopBoundaryReachedCallback: vi.fn(), @@ -45,6 +63,7 @@ const mockCore = { redo: vi.fn(() => true), clearSelectedItems: vi.fn(), getStatus: () => 'Ready', + setStatus: vi.fn(), getPlayheadPosition: () => 0, setPlayheadPosition: vi.fn(), getIsPlaying: () => false, @@ -76,11 +95,20 @@ 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(); + pianoRollStateMocks.setPianoRollZoom.mockReset(); mockTracks = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')]; + currentProject = mockProject; mockCore.startPlaying.mockReset(); mockCore.startPlaying.mockResolvedValue(undefined); mockCore.stopPlaying.mockReset(); @@ -118,10 +146,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 c8bf4dd..d1418e6 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; @@ -305,6 +308,7 @@ export const useProjectStore = create((set, get) => { // Get initial ChatBox state from config const configManager = ConfigManager.instance(); + KGPianoRollState.instance().setPianoRollZoom(currentProject.getPianoRollZoom()); const initialChatBoxState = configManager.getIsInitialized() ? (configManager.get('chatbox.default_open') as boolean) ?? false : false; @@ -430,6 +434,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, @@ -873,6 +879,14 @@ export const useProjectStore = create((set, get) => { } } + // Reapply restored mute/solo state after all buses exist so solo logic can be + // computed against the full track set. + for (const track of tracks) { + const trackId = track.getId().toString(); + audioInterface.setTrackMute(trackId, track.getMuted()); + audioInterface.setTrackSolo(trackId, track.getSolo()); + } + // Update CSS variables updateTimeSignatureCSS(timeSignature); updateMaxBarsCSS(maxBars); @@ -929,6 +943,7 @@ export const useProjectStore = create((set, get) => { // Reset piano roll state for new/loaded project KGPianoRollState.instance().setLastEditedNoteLength(1); + KGPianoRollState.instance().setPianoRollZoom(projectToLoad.getPianoRollZoom()); // Add a status message KGCore.instance().setStatus(`Project "${projectToLoad.getName()}" loaded with audio setup`); @@ -1552,15 +1567,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 })); @@ -1579,6 +1626,8 @@ export const useProjectStore = create((set, get) => { activeRegionId: null, hybridAudioRegionId: null, pianoRollMode: 'midi-edit', + requestedSheetMusicViewEnabled: false, + pianoRollViewRequestVersion: 0, activeTrackAutomationTrackId: null, activeTrackAutomationType: null, trackAutomationRedrawVersion: 0, diff --git a/src/test/utils/mock-data.ts b/src/test/utils/mock-data.ts index 829bb29..9024bcf 100644 --- a/src/test/utils/mock-data.ts +++ b/src/test/utils/mock-data.ts @@ -165,7 +165,7 @@ export const createMockProject = (overrides: Partial<{ [0, 0], // loopingRange 1, // barWidthMultiplier defaults.tracks, // tracks - 5 // projectStructureVersion + KGProject.CURRENT_PROJECT_STRUCTURE_VERSION // projectStructureVersion ); return project; diff --git a/src/util/localLLMConfig.ts b/src/util/localLLMConfig.ts index 053e756..c32a580 100644 --- a/src/util/localLLMConfig.ts +++ b/src/util/localLLMConfig.ts @@ -1,7 +1,8 @@ export const LOCAL_LLM_PROVIDER_KEY = 'local_browser'; -export const LOCAL_LLM_MODEL_URL = +export const LOCAL_LLM_DEFAULT_MODEL_URL = 'https://huggingface.co/notabilia/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.task'; export const LOCAL_LLM_MODEL_FILENAME = 'gemma-4-E4B-it-web.task'; +export const LOCAL_LLM_MODEL_EXPECTED_SIZE_BYTES = 2964324352; export const LOCAL_LLM_DISPLAY_NAME = 'Gemma 4 E4B'; export const LOCAL_LLM_LEGACY_FILENAMES = [ 'gemma-3n-E4B-it-int4-Web.litertlm', diff --git a/src/util/localLLMModelCache.ts b/src/util/localLLMModelCache.ts index df71f1f..7ebfc9a 100644 --- a/src/util/localLLMModelCache.ts +++ b/src/util/localLLMModelCache.ts @@ -1,5 +1,5 @@ import { OpfsModelCache, type ModelDownloadProgress } from './opfsModelCache'; -import { LOCAL_LLM_MODEL_FILENAME } from './localLLMConfig'; +import { LOCAL_LLM_MODEL_EXPECTED_SIZE_BYTES, LOCAL_LLM_MODEL_FILENAME } from './localLLMConfig'; const cache = new OpfsModelCache({ directoryName: 'models' }); let writingToCachePromise: Promise | null = null; @@ -51,7 +51,9 @@ const createProgressReader = ( export class LocalLLMModelCache { public static async exists(filename: string = LOCAL_LLM_MODEL_FILENAME): Promise { - return cache.exists(filename); + return cache.exists(filename, { + expectedSizeBytes: LOCAL_LLM_MODEL_EXPECTED_SIZE_BYTES, + }); } public static async getFile(filename: string = LOCAL_LLM_MODEL_FILENAME): Promise { @@ -98,6 +100,9 @@ export class LocalLLMModelCache { streamForCache, filename, totalBytes > 0 ? totalBytes : null, + { + expectedSizeBytes: LOCAL_LLM_MODEL_EXPECTED_SIZE_BYTES, + }, progress => onProgress?.({ ...progress, fromCache: false }), ); writingToCachePromise = writingToCachePromise.finally(() => { @@ -117,6 +122,13 @@ export class LocalLLMModelCache { filename: string = LOCAL_LLM_MODEL_FILENAME, onProgress?: (progress: ModelDownloadProgress) => void, ): Promise { - await cache.download(sourceUrl, filename, onProgress); + await cache.download( + sourceUrl, + filename, + { + expectedSizeBytes: LOCAL_LLM_MODEL_EXPECTED_SIZE_BYTES, + }, + onProgress, + ); } } diff --git a/src/util/localLLMModelManager.ts b/src/util/localLLMModelManager.ts index 99354ff..82edefa 100644 --- a/src/util/localLLMModelManager.ts +++ b/src/util/localLLMModelManager.ts @@ -2,7 +2,6 @@ import { detectLocalLLMRuntimeSupport, LOCAL_LLM_LEGACY_FILENAMES, LOCAL_LLM_MODEL_FILENAME, - LOCAL_LLM_MODEL_URL, type LocalLLMRuntimeSupport, } from './localLLMConfig'; import { LocalLLMModelCache } from './localLLMModelCache'; diff --git a/src/util/localSeparatorConfig.ts b/src/util/localSeparatorConfig.ts index 752f468..4a52ec5 100644 --- a/src/util/localSeparatorConfig.ts +++ b/src/util/localSeparatorConfig.ts @@ -1,9 +1,10 @@ import type { LocalSeparatorModelConfig } from './localSeparatorTypes'; -export const LOCAL_SEPARATOR_MODEL_URL = +export const LOCAL_SEPARATOR_DEFAULT_MODEL_URL = 'https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx'; export const LOCAL_SEPARATOR_MODEL_FILENAME = 'UVR-MDX-NET-Inst_HQ_3.onnx'; +export const LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES = 66759214; export const LOCAL_SEPARATOR_MODEL_CONFIG: LocalSeparatorModelConfig = { filename: LOCAL_SEPARATOR_MODEL_FILENAME, diff --git a/src/util/localSeparatorModelCache.ts b/src/util/localSeparatorModelCache.ts index b941cd1..619cbc5 100644 --- a/src/util/localSeparatorModelCache.ts +++ b/src/util/localSeparatorModelCache.ts @@ -1,4 +1,7 @@ -import { LOCAL_SEPARATOR_MODEL_FILENAME } from './localSeparatorConfig'; +import { + LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES, + LOCAL_SEPARATOR_MODEL_FILENAME, +} from './localSeparatorConfig'; import { OpfsModelCache, type ModelDownloadProgress } from './opfsModelCache'; const cache = new OpfsModelCache({ directoryName: 'models' }); @@ -7,7 +10,9 @@ export { type ModelDownloadProgress }; export class LocalSeparatorModelCache { public static async exists(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise { - return cache.exists(filename); + return cache.exists(filename, { + expectedSizeBytes: LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES, + }); } public static async getFile(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise { @@ -27,6 +32,13 @@ export class LocalSeparatorModelCache { filename: string = LOCAL_SEPARATOR_MODEL_FILENAME, onProgress?: (progress: ModelDownloadProgress) => void, ): Promise { - await cache.download(sourceUrl, filename, onProgress); + await cache.download( + sourceUrl, + filename, + { + expectedSizeBytes: LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES, + }, + onProgress, + ); } } diff --git a/src/util/opfsModelCache.ts b/src/util/opfsModelCache.ts index 02044f6..766326b 100644 --- a/src/util/opfsModelCache.ts +++ b/src/util/opfsModelCache.ts @@ -9,6 +9,10 @@ interface OpfsModelCacheOptions { sizeSuffix?: string; } +interface ModelCacheValidationOptions { + expectedSizeBytes?: number | null; +} + export class OpfsModelCache { private readonly directoryName: string; private readonly sizeSuffix: string; @@ -18,7 +22,7 @@ export class OpfsModelCache { this.sizeSuffix = options.sizeSuffix ?? '.size'; } - public async exists(filename: string): Promise { + public async exists(filename: string, options: ModelCacheValidationOptions = {}): Promise { try { const dir = await this.getDir(); const fileHandle = await dir.getFileHandle(filename); @@ -29,6 +33,10 @@ export class OpfsModelCache { await this.delete(filename); return false; } + if (options.expectedSizeBytes != null && expectedSize !== options.expectedSizeBytes) { + await this.delete(filename); + return false; + } if (file.size !== expectedSize) { await this.delete(filename); return false; @@ -64,6 +72,7 @@ export class OpfsModelCache { public async download( sourceUrl: string, filename: string, + options: ModelCacheValidationOptions = {}, onProgress?: (progress: ModelDownloadProgress) => void, ): Promise { const response = await fetch(sourceUrl); @@ -75,13 +84,14 @@ export class OpfsModelCache { if (!response.body) { throw new Error('Model download response did not include a readable body.'); } - await this.downloadStream(response.body, filename, totalBytes, onProgress); + await this.downloadStream(response.body, filename, totalBytes, options, onProgress); } public async downloadStream( stream: ReadableStream, filename: string, totalBytes: number | null, + options: ModelCacheValidationOptions = {}, onProgress?: (progress: ModelDownloadProgress) => void, ): Promise { const dir = await this.getDir(); @@ -111,6 +121,9 @@ export class OpfsModelCache { if (!Number.isFinite(sizeValue) || sizeValue <= 0) { throw new Error('Model download did not provide a valid size.'); } + if (options.expectedSizeBytes != null && receivedBytes !== options.expectedSizeBytes) { + throw new Error(`Model download size mismatch for ${filename}: expected ${options.expectedSizeBytes} bytes, got ${receivedBytes}.`); + } const sizeHandle = await dir.getFileHandle(this.getSizeFilename(filename), { create: true }); const sizeWritable = await sizeHandle.createWritable(); diff --git a/src/util/regionEditUtil.test.ts b/src/util/regionEditUtil.test.ts new file mode 100644 index 0000000..ae596bf --- /dev/null +++ b/src/util/regionEditUtil.test.ts @@ -0,0 +1,214 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Selectable } from '../components/interfaces'; +import { KGMidiNote } from '../core/midi/KGMidiNote'; +import { createMockMidiRegion, createMockMidiTrack, createMockProject } from '../test/utils/mock-data'; +import { splitSelectedRegionAtPlayhead } from './regionEditUtil'; + +const storeState = { + showPianoRoll: false, + pianoRollMode: 'midi-edit' as 'midi-edit' | 'spectrogram' | 'hybrid', + activeRegionId: null as string | null, + selectedNoteIds: [] as string[], + setShowPianoRoll: vi.fn(), + setActiveRegionId: vi.fn(), +}; + +const mockCore = { + getCurrentProject: vi.fn(), + getSelectedItems: vi.fn<() => Selectable[]>(() => []), + clearSelectedItems: vi.fn(), + addSelectedItems: vi.fn(), + executeCommand: vi.fn((command: { execute: () => void }) => { + command.execute(); + }), +}; + +const dialogMocks = vi.hoisted(() => ({ + showAlert: vi.fn(), + showConfirm: vi.fn(), +})); + +const pianoRollStateMock = vi.hoisted(() => ({ + getSheetMusicViewEnabled: vi.fn(() => false), +})); + +vi.mock('../stores/projectStore', () => ({ + useProjectStore: { + getState: vi.fn(() => storeState), + }, +})); + +vi.mock('../core/KGCore', () => ({ + KGCore: { + instance: vi.fn(() => mockCore), + }, +})); + +vi.mock('../core/state/KGPianoRollState', () => ({ + KGPianoRollState: { + instance: vi.fn(() => pianoRollStateMock), + }, +})); + +vi.mock('./dialogUtil', () => ({ + showAlert: dialogMocks.showAlert, + showConfirm: dialogMocks.showConfirm, +})); + +describe('splitSelectedRegionAtPlayhead', () => { + beforeEach(() => { + vi.clearAllMocks(); + storeState.showPianoRoll = false; + storeState.pianoRollMode = 'midi-edit'; + storeState.activeRegionId = null; + storeState.selectedNoteIds = []; + storeState.setShowPianoRoll.mockReset(); + storeState.setActiveRegionId.mockReset(); + pianoRollStateMock.getSheetMusicViewEnabled.mockReturnValue(false); + mockCore.getSelectedItems.mockReset(); + mockCore.getSelectedItems.mockReturnValue([]); + mockCore.clearSelectedItems.mockReset(); + mockCore.addSelectedItems.mockReset(); + }); + + it('falls back to region split when the piano roll is closed', async () => { + const region = createMockMidiRegion({ id: 'region-1', trackId: '1', trackIndex: 0, startFromBeat: 0, length: 8 }); + const track = createMockMidiTrack({ id: 1, regions: [region] }); + mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] })); + + const status = await splitSelectedRegionAtPlayhead({ + selectedRegionIds: ['region-1'], + playheadPosition: 4, + refreshProjectState: vi.fn(), + }); + + expect(status).toBe('Split region at beat 4.00'); + expect(mockCore.executeCommand).toHaveBeenCalledTimes(1); + expect(dialogMocks.showAlert).not.toHaveBeenCalled(); + }); + + it('falls back to region split when not in piano-roll view', async () => { + const region = createMockMidiRegion({ id: 'region-1', trackId: '1', trackIndex: 0, startFromBeat: 0, length: 8 }); + const track = createMockMidiTrack({ id: 1, regions: [region] }); + mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] })); + storeState.showPianoRoll = true; + storeState.pianoRollMode = 'spectrogram'; + storeState.selectedNoteIds = ['note-1']; + + const status = await splitSelectedRegionAtPlayhead({ + selectedRegionIds: ['region-1'], + playheadPosition: 4, + refreshProjectState: vi.fn(), + }); + + expect(status).toBe('Split region at beat 4.00'); + expect(mockCore.executeCommand).toHaveBeenCalledTimes(1); + }); + + it('falls back to region split when sheet music view is enabled', async () => { + const region = createMockMidiRegion({ id: 'region-1', trackId: '1', trackIndex: 0, startFromBeat: 0, length: 8 }); + const track = createMockMidiTrack({ id: 1, regions: [region] }); + mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] })); + storeState.showPianoRoll = true; + storeState.activeRegionId = 'region-1'; + storeState.selectedNoteIds = ['note-1']; + pianoRollStateMock.getSheetMusicViewEnabled.mockReturnValue(true); + + const status = await splitSelectedRegionAtPlayhead({ + selectedRegionIds: ['region-1'], + playheadPosition: 4, + refreshProjectState: vi.fn(), + }); + + expect(status).toBe('Split region at beat 4.00'); + expect(mockCore.executeCommand).toHaveBeenCalledTimes(1); + }); + + it('splits selected notes when the piano roll is open in midi-edit view', async () => { + const note = new KGMidiNote('note-1', 1, 5, 60, 100); + const region = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + trackIndex: 0, + notes: [note], + }); + const track = createMockMidiTrack({ id: 1, regions: [region] }); + mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] })); + storeState.showPianoRoll = true; + storeState.activeRegionId = 'region-1'; + storeState.selectedNoteIds = ['note-1']; + const refreshProjectState = vi.fn(); + mockCore.getSelectedItems.mockReturnValue([note]); + + const status = await splitSelectedRegionAtPlayhead({ + selectedRegionIds: ['region-1'], + playheadPosition: 3, + refreshProjectState, + }); + + expect(status).toBe('Split 1 note at beat 3.00'); + expect(mockCore.executeCommand).toHaveBeenCalledTimes(1); + expect(refreshProjectState).toHaveBeenCalledTimes(1); + expect(region.getNotes().map(candidate => [candidate.getStartBeat(), candidate.getEndBeat()])).toEqual([ + [1, 3], + [3, 5], + ]); + }); + + it('splits selected notes using playhead position relative to the region start', async () => { + const note = new KGMidiNote('note-1', 1, 5, 60, 100); + const region = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + trackIndex: 0, + startFromBeat: 8, + length: 8, + notes: [note], + }); + const track = createMockMidiTrack({ id: 1, regions: [region] }); + mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] })); + storeState.showPianoRoll = true; + storeState.activeRegionId = 'region-1'; + storeState.selectedNoteIds = ['note-1']; + mockCore.getSelectedItems.mockReturnValue([note]); + + const status = await splitSelectedRegionAtPlayhead({ + selectedRegionIds: ['region-1'], + playheadPosition: 11, + refreshProjectState: vi.fn(), + }); + + expect(status).toBe('Split 1 note at beat 11.00'); + expect(region.getNotes().map(candidate => [candidate.getStartBeat(), candidate.getEndBeat()])).toEqual([ + [1, 3], + [3, 5], + ]); + }); + + it('shows an alert when no selected note crosses the playhead', async () => { + const note = new KGMidiNote('note-1', 1, 2, 60, 100); + const region = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + trackIndex: 0, + notes: [note], + }); + const track = createMockMidiTrack({ id: 1, regions: [region] }); + mockCore.getCurrentProject.mockReturnValue(createMockProject({ tracks: [track] })); + storeState.showPianoRoll = true; + storeState.activeRegionId = 'region-1'; + storeState.selectedNoteIds = ['note-1']; + + const status = await splitSelectedRegionAtPlayhead({ + selectedRegionIds: ['region-1'], + playheadPosition: 3, + refreshProjectState: vi.fn(), + }); + + expect(status).toBeNull(); + expect(dialogMocks.showAlert).toHaveBeenCalledWith( + 'The playhead is not inside any selected note. Move the playhead inside a selected note before splitting.' + ); + expect(mockCore.executeCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/src/util/regionEditUtil.ts b/src/util/regionEditUtil.ts new file mode 100644 index 0000000..5aa945e --- /dev/null +++ b/src/util/regionEditUtil.ts @@ -0,0 +1,250 @@ +import { KGCore } from '../core/KGCore'; +import { SplitSelectedNotesCommand } from '../core/commands/note/SplitSelectedNotesCommand'; +import { SplitRegionCommand } from '../core/commands/region/SplitRegionCommand'; +import { MergeMidiRegionsCommand } from '../core/commands/region/MergeMidiRegionsCommand'; +import { KGMidiRegion } from '../core/region/KGMidiRegion'; +import { KGPianoRollState } from '../core/state/KGPianoRollState'; +import { useProjectStore } from '../stores/projectStore'; +import { showAlert, showConfirm } from './dialogUtil'; + +interface SplitSelectedRegionParams { + selectedRegionIds: string[]; + playheadPosition: number; + refreshProjectState: () => void; +} + +interface MergeSelectedMidiRegionsParams { + selectedRegionIds: string[]; + refreshProjectState: () => void; +} + +export const splitSelectedRegionAtPlayhead = async ({ + selectedRegionIds, + playheadPosition, + refreshProjectState, +}: SplitSelectedRegionParams): Promise => { + const { + activeRegionId, + pianoRollMode, + selectedNoteIds, + showPianoRoll, + } = useProjectStore.getState(); + const sheetMusicViewEnabled = KGPianoRollState.instance().getSheetMusicViewEnabled(); + + if ( + showPianoRoll && + pianoRollMode === 'midi-edit' && + !sheetMusicViewEnabled && + activeRegionId && + selectedNoteIds.length > 0 + ) { + return splitSelectedNotesAtPlayhead({ + activeRegionId, + selectedNoteIds, + playheadPosition, + refreshProjectState, + }); + } + + return splitSingleSelectedRegionAtPlayhead({ + selectedRegionIds, + playheadPosition, + refreshProjectState, + }); +}; + +const splitSelectedNotesAtPlayhead = async ({ + activeRegionId, + selectedNoteIds, + playheadPosition, + refreshProjectState, +}: { + activeRegionId: string; + selectedNoteIds: string[]; + playheadPosition: number; + refreshProjectState: () => void; +}): Promise => { + const tracks = KGCore.instance().getCurrentProject().getTracks(); + let activeRegion: KGMidiRegion | null = null; + + for (const track of tracks) { + const region = track.getRegions().find(candidate => candidate.getId() === activeRegionId); + if (region instanceof KGMidiRegion) { + activeRegion = region; + break; + } + } + + if (!activeRegion) { + await showAlert('The active MIDI region could not be found. Please reopen the piano roll and try again.'); + return null; + } + + const selectedNoteIdSet = new Set(selectedNoteIds); + const selectedNotes = activeRegion.getNotes().filter(note => selectedNoteIdSet.has(note.getId())); + if (selectedNotes.length === 0) { + await showAlert('The selected notes could not be found in the active MIDI region. Please reselect the notes and try again.'); + return null; + } + + const regionRelativePlayhead = playheadPosition - activeRegion.getStartFromBeat(); + const splitCount = selectedNotes.filter(note => ( + note.getStartBeat() < regionRelativePlayhead && regionRelativePlayhead < note.getEndBeat() + )).length; + if (splitCount === 0) { + await showAlert('The playhead is not inside any selected note. Move the playhead inside a selected note before splitting.'); + return null; + } + + try { + const command = new SplitSelectedNotesCommand(activeRegionId, selectedNoteIds, regionRelativePlayhead); + KGCore.instance().executeCommand(command, { rethrow: true }); + refreshProjectState(); + return `Split ${splitCount} note${splitCount === 1 ? '' : 's'} at beat ${playheadPosition.toFixed(2)}`; + } catch (error) { + await showAlert(error instanceof Error ? error.message : 'Unable to split the selected notes.'); + return null; + } +}; + +const splitSingleSelectedRegionAtPlayhead = async ({ + selectedRegionIds, + playheadPosition, + refreshProjectState, +}: SplitSelectedRegionParams): Promise => { + if (selectedRegionIds.length === 0) { + await showAlert('Please select a region to split.'); + return null; + } + + if (selectedRegionIds.length > 1) { + await showAlert('Please select exactly one region to split.'); + return null; + } + + const regionId = selectedRegionIds[selectedRegionIds.length - 1]; + const tracks = KGCore.instance().getCurrentProject().getTracks(); + let targetRegion = null; + + for (const track of tracks) { + const foundRegion = track.getRegions().find(region => region.getId() === regionId); + if (foundRegion) { + targetRegion = foundRegion; + break; + } + } + + if (!targetRegion) { + await showAlert('Selected region not found.'); + return null; + } + + const regionStart = targetRegion.getStartFromBeat(); + const regionEnd = regionStart + targetRegion.getLength(); + + if (playheadPosition <= regionStart || playheadPosition >= regionEnd) { + await showAlert('The playhead is not inside the selected region. Move the playhead inside the region before splitting.'); + return null; + } + + const command = new SplitRegionCommand(regionId, playheadPosition); + KGCore.instance().executeCommand(command); + refreshProjectState(); + + return `Split region at beat ${playheadPosition.toFixed(2)}`; +}; + +export const mergeSelectedMidiRegions = async ({ + selectedRegionIds, + refreshProjectState, +}: MergeSelectedMidiRegionsParams): Promise => { + if (selectedRegionIds.length < 2) { + await showAlert('Please select at least two MIDI regions on the same track to merge.'); + return null; + } + + const tracks = KGCore.instance().getCurrentProject().getTracks(); + const selectedRegionIdSet = new Set(selectedRegionIds); + const selectedMidiRegions: KGMidiRegion[] = []; + let targetTrackId: string | null = null; + + for (const track of tracks) { + for (const region of track.getRegions()) { + if (!selectedRegionIdSet.has(region.getId())) { + continue; + } + + if (!(region instanceof KGMidiRegion)) { + await showAlert('Only MIDI regions can be merged. Please adjust your selection and try again.'); + return null; + } + + const regionTrackId = track.getId().toString(); + if (targetTrackId && targetTrackId !== regionTrackId) { + await showAlert('Please select only MIDI regions from a single track before merging.'); + return null; + } + + targetTrackId = regionTrackId; + selectedMidiRegions.push(region); + } + } + + if (selectedMidiRegions.length !== selectedRegionIds.length || !targetTrackId) { + await showAlert('Some selected regions could not be found. Please reselect the MIDI regions and try again.'); + return null; + } + + const sortedSelectedRegions = [...selectedMidiRegions].sort((a, b) => { + const startDelta = a.getStartFromBeat() - b.getStartFromBeat(); + if (startDelta !== 0) { + return startDelta; + } + return a.getLength() - b.getLength(); + }); + + let regionIdsToMerge = selectedRegionIds; + const firstSelectedRegion = sortedSelectedRegions[0]; + const lastSelectedRegion = sortedSelectedRegions[sortedSelectedRegions.length - 1]; + const spanStart = firstSelectedRegion.getStartFromBeat(); + const spanEnd = lastSelectedRegion.getStartFromBeat() + lastSelectedRegion.getLength(); + + const targetTrack = tracks.find(track => track.getId().toString() === targetTrackId); + const inBetweenRegions = targetTrack + ?.getRegions() + .filter(region => ( + region instanceof KGMidiRegion && + !selectedRegionIdSet.has(region.getId()) && + region.getStartFromBeat() >= spanStart && + region.getStartFromBeat() <= spanEnd + )) ?? []; + + if (inBetweenRegions.length > 0) { + const shouldIncludeInBetweenRegions = await showConfirm( + 'There are additional MIDI regions between the first and last selected regions on this track. Would you like KGStudio to merge those as well?', + { + confirmLabel: 'Merge All In Between', + cancelLabel: 'Stop', + } + ); + + if (!shouldIncludeInBetweenRegions) { + return null; + } + + regionIdsToMerge = Array.from(new Set([ + ...selectedRegionIds, + ...inBetweenRegions.map(region => region.getId()), + ])); + } + + try { + const command = new MergeMidiRegionsCommand(regionIdsToMerge); + KGCore.instance().executeCommand(command, { rethrow: true }); + refreshProjectState(); + return `Merged ${regionIdsToMerge.length} MIDI regions`; + } catch (error) { + await showAlert(error instanceof Error ? error.message : 'Unable to merge the selected MIDI regions.'); + return null; + } +}; diff --git a/src/util/scaleUtil.test.ts b/src/util/scaleUtil.test.ts index c1c11a0..5938b4e 100644 --- a/src/util/scaleUtil.test.ts +++ b/src/util/scaleUtil.test.ts @@ -387,6 +387,12 @@ describe('scaleUtil', () => { expect(typeof result).toBe('string'); expect(result).toContain('#282828'); expect(result).toContain('#303030'); + expect(result).toContain('#404040 calc(var(--region-grid-bar-width) - 1px)'); + expect(result).toContain('#343434 calc(var(--region-grid-beat-width) - 1px)'); + expect(result).toContain('#303030 calc(var(--region-piano-key-height) * 0)'); + expect(result).toContain('#404040 calc(var(--region-piano-key-height) * 12 - 1px)'); + expect(result).toContain('#282828 calc(var(--region-piano-key-height) * 1)'); + expect(result).toContain('#343434 calc(var(--region-piano-key-height) * 2 - 1px)'); }); it('should generate different backgrounds for different modes', () => { diff --git a/src/util/scaleUtil.ts b/src/util/scaleUtil.ts index 5cb72ac..04e0f37 100644 --- a/src/util/scaleUtil.ts +++ b/src/util/scaleUtil.ts @@ -304,6 +304,9 @@ export const generatePianoGridBackground = ( selectedMode: string, keySignature: KeySignature ): string => { + const majorGridLineColor = '#404040'; + const minorGridLineColor = '#343434'; + // Get root note and scale pitch classes const rootNote = getRootNoteFromKeySignature(keySignature); const modeSteps = getModeSteps(selectedMode); @@ -314,37 +317,36 @@ export const generatePianoGridBackground = ( const pitch = pianoRollIndexToPitch(index); const pitchClass = pitch % 12; const isInScale = scalePitchClasses.includes(pitchClass); + const isCRow = pitchClass === 0; // Calculate row positions using CSS calc() with --region-piano-key-height variable const rowTop = `calc(var(--region-piano-key-height) * ${index})`; const rowBottomMinusOne = `calc(var(--region-piano-key-height) * ${index + 1} - 1px)`; const rowBottom = `calc(var(--region-piano-key-height) * ${index + 1})`; + const rowFillColor = isInScale ? '#303030' : '#282828'; + const horizontalLineColor = isCRow ? majorGridLineColor : minorGridLineColor; // Match the event list palette while preserving scale-aware row distinction. - if (isInScale) { - return ` - #282828 ${rowTop}, - #282828 ${rowBottomMinusOne}, - #3a3a3a ${rowBottomMinusOne}, - #3a3a3a ${rowBottom} - `.trim(); - } else { - return ` - #303030 ${rowTop}, - #303030 ${rowBottomMinusOne}, - #3a3a3a ${rowBottomMinusOne}, - #3a3a3a ${rowBottom} - `.trim(); - } + return ` + ${rowFillColor} ${rowTop}, + ${rowFillColor} ${rowBottomMinusOne}, + ${horizontalLineColor} ${rowBottomMinusOne}, + ${horizontalLineColor} ${rowBottom} + `.trim(); }).join(',\n'); // Return complete background-image with vertical and horizontal gradients - // Note: Vertical beat lines gradient should be preserved from existing CSS + // Major bar lines sit above minor beat lines so bar boundaries remain the primary anchors. return ` + linear-gradient(to right, + transparent calc(var(--region-grid-bar-width) - 1px), + ${majorGridLineColor} calc(var(--region-grid-bar-width) - 1px), + ${majorGridLineColor} var(--region-grid-bar-width) + ), linear-gradient(to right, transparent calc(var(--region-grid-beat-width) - 1px), - #3a3a3a calc(var(--region-grid-beat-width) - 1px), - #3a3a3a var(--region-grid-beat-width) + ${minorGridLineColor} calc(var(--region-grid-beat-width) - 1px), + ${minorGridLineColor} var(--region-grid-beat-width) ), linear-gradient(to bottom, ${horizontalLines}) `;