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