Merge pull request #45 from KGAudioLab/feat/2026-05-17-misc
Feat/2026 05 17 misc
This commit is contained in:
Vendored
+5
-5
@@ -2,15 +2,15 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"type": "npm",
|
"type": "shell",
|
||||||
"script": "dev",
|
|
||||||
"label": "npm: dev",
|
"label": "npm: dev",
|
||||||
"detail": "vite",
|
"detail": "vite",
|
||||||
"isBackground": true,
|
"isBackground": true,
|
||||||
|
"command": "source ~/.nvm/nvm.sh && nvm use 20 && npm run dev",
|
||||||
"options": {
|
"options": {
|
||||||
"shell": {
|
"shell": {
|
||||||
"executable": "/bin/zsh",
|
"executable": "/bin/zsh",
|
||||||
"args": ["-c", "source ~/.nvm/nvm.sh && nvm use 20 && npm run dev"]
|
"args": ["-c"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"problemMatcher": {
|
"problemMatcher": {
|
||||||
@@ -25,11 +25,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "npm",
|
"type": "shell",
|
||||||
"script": "dev",
|
|
||||||
"label": "npm: dev (Windows)",
|
"label": "npm: dev (Windows)",
|
||||||
"detail": "vite",
|
"detail": "vite",
|
||||||
"isBackground": true,
|
"isBackground": true,
|
||||||
|
"command": "npm run dev",
|
||||||
"options": {
|
"options": {
|
||||||
"shell": {
|
"shell": {
|
||||||
"executable": "cmd.exe",
|
"executable": "cmd.exe",
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "K.G.Studio",
|
"name": "K.G.Studio",
|
||||||
"version": "0.16.0-build.20260510",
|
"version": "0.17.3-build.20260515",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "K.G.Studio",
|
"name": "K.G.Studio",
|
||||||
"version": "0.16.0-build.20260510",
|
"version": "0.17.3-build.20260515",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@breezystack/lamejs": "^1.2.7",
|
"@breezystack/lamejs": "^1.2.7",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
|
|||||||
+9
-2
@@ -27,7 +27,11 @@
|
|||||||
"model": ""
|
"model": ""
|
||||||
},
|
},
|
||||||
"local_browser": {
|
"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": {
|
"soundfont": {
|
||||||
"base_url": "https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/"
|
"base_url": "https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/"
|
||||||
@@ -49,7 +53,9 @@
|
|||||||
"copy": "ctrl+c",
|
"copy": "ctrl+c",
|
||||||
"cut": "ctrl+x",
|
"cut": "ctrl+x",
|
||||||
"paste": "ctrl+v",
|
"paste": "ctrl+v",
|
||||||
"save": "ctrl+s"
|
"save": "ctrl+s",
|
||||||
|
"split_region": "ctrl+t",
|
||||||
|
"merge_regions": "ctrl+j"
|
||||||
},
|
},
|
||||||
"piano_roll": {
|
"piano_roll": {
|
||||||
"switch": "tab",
|
"switch": "tab",
|
||||||
@@ -76,6 +82,7 @@
|
|||||||
"default_open": true
|
"default_open": true
|
||||||
},
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
|
"bounce_starts_from_beat_1": true,
|
||||||
"enable_audio_capture_for_screen_sharing": false,
|
"enable_audio_capture_for_screen_sharing": false,
|
||||||
"input_device_id": "default",
|
"input_device_id": "default",
|
||||||
"lookahead_time": 0.05,
|
"lookahead_time": 0.05,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
LOCAL_LLM_DEFAULT_CONTEXT_LENGTH,
|
LOCAL_LLM_DEFAULT_CONTEXT_LENGTH,
|
||||||
LOCAL_LLM_MODEL_FILENAME,
|
LOCAL_LLM_MODEL_FILENAME,
|
||||||
LOCAL_LLM_MODEL_URL,
|
LOCAL_LLM_DEFAULT_MODEL_URL,
|
||||||
normalizeLocalLLMContextLength,
|
normalizeLocalLLMContextLength,
|
||||||
} from '../../util/localLLMConfig';
|
} from '../../util/localLLMConfig';
|
||||||
import { LocalLLMModelCache } from '../../util/localLLMModelCache';
|
import { LocalLLMModelCache } from '../../util/localLLMModelCache';
|
||||||
@@ -67,12 +67,13 @@ export class LocalBrowserLLMProvider implements LLMProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const maxTokens = this.getConfiguredContextLength();
|
const maxTokens = this.getConfiguredContextLength();
|
||||||
|
const modelUrl = this.getConfiguredModelUrl();
|
||||||
console.log(`[localLLM] Initializing with max context length: ${maxTokens} tokens`);
|
console.log(`[localLLM] Initializing with max context length: ${maxTokens} tokens`);
|
||||||
|
|
||||||
const [{ FilesetResolver, LlmInference }, modelLoad] = await Promise.all([
|
const [{ FilesetResolver, LlmInference }, modelLoad] = await Promise.all([
|
||||||
this.getMediaPipeModule(),
|
this.getMediaPipeModule(),
|
||||||
LocalLLMModelCache.loadModelReaderWithCache(
|
LocalLLMModelCache.loadModelReaderWithCache(
|
||||||
LOCAL_LLM_MODEL_URL,
|
modelUrl,
|
||||||
LOCAL_LLM_MODEL_FILENAME,
|
LOCAL_LLM_MODEL_FILENAME,
|
||||||
progress => {
|
progress => {
|
||||||
LocalLLMModelManager.notifyLoadProgress(progress.receivedBytes, progress.totalBytes, progress.fromCache);
|
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 {
|
private applyTemplate(message: { role: 'user' | 'model'; text: string }): string {
|
||||||
const template = PROMPT_TEMPLATE[message.role];
|
const template = PROMPT_TEMPLATE[message.role];
|
||||||
return `${template.pre}${message.text}${template.post}`;
|
return `${template.pre}${message.text}${template.post}`;
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import KGOnePanel from './KGOnePanel';
|
|||||||
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||||
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
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 kgoneEnabled = false;
|
||||||
let selectedRegionIds: string[] = [];
|
let selectedRegionIds: string[] = [];
|
||||||
let localModelCached = false;
|
let localModelCached = false;
|
||||||
@@ -31,6 +35,7 @@ vi.mock('../core/config/ConfigManager', () => ({
|
|||||||
get: (key: string) => {
|
get: (key: string) => {
|
||||||
if (key === 'general.kgone.enabled') return kgoneEnabled;
|
if (key === 'general.kgone.enabled') return kgoneEnabled;
|
||||||
if (key === 'general.kgone.base_url') return 'http://127.0.0.1:8000';
|
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;
|
return undefined;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -79,8 +84,9 @@ vi.mock('../util/audioUtil', () => ({
|
|||||||
vi.mock('../util/localSeparatorModelCache', () => ({
|
vi.mock('../util/localSeparatorModelCache', () => ({
|
||||||
LocalSeparatorModelCache: {
|
LocalSeparatorModelCache: {
|
||||||
exists: vi.fn(async () => localModelCached),
|
exists: vi.fn(async () => localModelCached),
|
||||||
download: vi.fn(async () => {
|
download: vi.fn(async (url: string, filename: string, onProgress: (progress: unknown) => void) => {
|
||||||
localModelCached = true;
|
localModelCached = true;
|
||||||
|
return mockLocalSeparatorDownload(url, filename, onProgress);
|
||||||
}),
|
}),
|
||||||
delete: vi.fn(async () => {
|
delete: vi.fn(async () => {
|
||||||
localModelCached = false;
|
localModelCached = false;
|
||||||
@@ -124,6 +130,7 @@ describe('KGOnePanel local separator mode', () => {
|
|||||||
{ name: 'Instrumental', blob: new Blob(['instrumental'], { type: 'audio/wav' }) },
|
{ name: 'Instrumental', blob: new Blob(['instrumental'], { type: 'audio/wav' }) },
|
||||||
{ name: 'Vocals', blob: new Blob(['vocals'], { type: 'audio/wav' }) },
|
{ name: 'Vocals', blob: new Blob(['vocals'], { type: 'audio/wav' }) },
|
||||||
];
|
];
|
||||||
|
mockLocalSeparatorDownload.mockClear();
|
||||||
mockRefreshProjectState.mockReset();
|
mockRefreshProjectState.mockReset();
|
||||||
mockExecuteCommand.mockReset();
|
mockExecuteCommand.mockReset();
|
||||||
});
|
});
|
||||||
@@ -155,6 +162,20 @@ describe('KGOnePanel local separator mode', () => {
|
|||||||
expect(screen.getByLabelText('MDX overlap')).toBeInTheDocument();
|
expect(screen.getByLabelText('MDX overlap')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the configured UVR5 model URL when downloading the local model', async () => {
|
||||||
|
render(<KGOnePanel isVisible={true} />);
|
||||||
|
|
||||||
|
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 () => {
|
it('prompts for an audio region when the model is cached but nothing is selected', async () => {
|
||||||
localModelCached = true;
|
localModelCached = true;
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { showAlert } from '../util/dialogUtil';
|
|||||||
import {
|
import {
|
||||||
LOCAL_SEPARATOR_MODEL_CONFIG,
|
LOCAL_SEPARATOR_MODEL_CONFIG,
|
||||||
LOCAL_SEPARATOR_MODEL_FILENAME,
|
LOCAL_SEPARATOR_MODEL_FILENAME,
|
||||||
LOCAL_SEPARATOR_MODEL_URL,
|
LOCAL_SEPARATOR_DEFAULT_MODEL_URL,
|
||||||
} from '../util/localSeparatorConfig';
|
} from '../util/localSeparatorConfig';
|
||||||
import { LocalSeparatorModelCache } from '../util/localSeparatorModelCache';
|
import { LocalSeparatorModelCache } from '../util/localSeparatorModelCache';
|
||||||
import { runLocalSeparator } from '../util/localSeparatorRunner';
|
import { runLocalSeparator } from '../util/localSeparatorRunner';
|
||||||
@@ -959,6 +959,13 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => {
|
|||||||
return null;
|
return null;
|
||||||
}, [selectedRegionIds]);
|
}, [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 isGenerating = genStatus !== 'idle' && genStatus !== 'done' && genStatus !== 'error';
|
||||||
|
|
||||||
const handleDownloadLocalModel = useCallback(async () => {
|
const handleDownloadLocalModel = useCallback(async () => {
|
||||||
@@ -969,7 +976,7 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await LocalSeparatorModelCache.download(
|
await LocalSeparatorModelCache.download(
|
||||||
LOCAL_SEPARATOR_MODEL_URL,
|
getConfiguredLocalSeparatorModelUrl(),
|
||||||
LOCAL_SEPARATOR_MODEL_FILENAME,
|
LOCAL_SEPARATOR_MODEL_FILENAME,
|
||||||
progress => {
|
progress => {
|
||||||
const receivedMb = (progress.receivedBytes / (1024 * 1024)).toFixed(1);
|
const receivedMb = (progress.receivedBytes / (1024 * 1024)).toFixed(1);
|
||||||
@@ -992,7 +999,7 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsDownloadingLocalModel(false);
|
setIsDownloadingLocalModel(false);
|
||||||
}
|
}
|
||||||
}, [refreshLocalModelCacheState]);
|
}, [getConfiguredLocalSeparatorModelUrl, refreshLocalModelCacheState]);
|
||||||
|
|
||||||
const handleDeleteLocalModel = useCallback(async () => {
|
const handleDeleteLocalModel = useCallback(async () => {
|
||||||
setIsDeletingLocalModel(true);
|
setIsDeletingLocalModel(true);
|
||||||
|
|||||||
@@ -3,13 +3,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import MainContent from './MainContent';
|
import MainContent from './MainContent';
|
||||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
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';
|
import { createMockMidiTrack } from '../test/utils/mock-data';
|
||||||
|
|
||||||
const midiRegion = new KGMidiRegion('region-1', '1', 0, 'Region 1', 0, 4);
|
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 = {
|
const storeState = {
|
||||||
tracks: [track],
|
tracks: [midiTrack, audioTrack],
|
||||||
maxBars: 8,
|
maxBars: 8,
|
||||||
barWidthMultiplier: 1,
|
barWidthMultiplier: 1,
|
||||||
reorderTracks: vi.fn(),
|
reorderTracks: vi.fn(),
|
||||||
@@ -35,6 +42,8 @@ const storeState = {
|
|||||||
storeState.activeRegionId = regionId;
|
storeState.activeRegionId = regionId;
|
||||||
}),
|
}),
|
||||||
pianoRollMode: 'midi-edit' as const,
|
pianoRollMode: 'midi-edit' as const,
|
||||||
|
requestedSheetMusicViewEnabled: false,
|
||||||
|
pianoRollViewRequestVersion: 0,
|
||||||
openMidiPianoRoll: vi.fn(),
|
openMidiPianoRoll: vi.fn(),
|
||||||
openSpectrogramViewer: vi.fn(),
|
openSpectrogramViewer: vi.fn(),
|
||||||
openHybridMode: vi.fn(),
|
openHybridMode: vi.fn(),
|
||||||
@@ -69,13 +78,16 @@ vi.mock('../stores/projectStore', () => ({
|
|||||||
vi.mock('../core/KGCore', () => ({
|
vi.mock('../core/KGCore', () => ({
|
||||||
KGCore: {
|
KGCore: {
|
||||||
instance: () => ({
|
instance: () => ({
|
||||||
addSelectedItems: (items: KGMidiRegion[]) => {
|
addSelectedItems: (items: Array<{ getId(): string }>) => {
|
||||||
storeState.selectedRegionIds = items.map(item => item.getId());
|
storeState.selectedRegionIds = items.map(item => item.getId());
|
||||||
},
|
},
|
||||||
clearSelectedItems: () => {
|
clearSelectedItems: () => {
|
||||||
storeState.selectedRegionIds = [];
|
storeState.selectedRegionIds = [];
|
||||||
},
|
},
|
||||||
executeCommand: vi.fn(),
|
executeCommand: vi.fn(),
|
||||||
|
getCurrentProject: () => ({
|
||||||
|
getTracks: () => storeState.tracks,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -92,14 +104,26 @@ vi.mock('./track/TrackInfoPanel', () => ({
|
|||||||
|
|
||||||
vi.mock('./track/TrackGridPanel', () => ({
|
vi.mock('./track/TrackGridPanel', () => ({
|
||||||
default: ({ onRegionClick }: { onRegionClick?: RegionClickHandler }) => (
|
default: ({ onRegionClick }: { onRegionClick?: RegionClickHandler }) => (
|
||||||
<button type="button" onClick={() => onRegionClick?.('region-1', { shiftKey: false })}>
|
<>
|
||||||
select-region
|
<button type="button" onClick={() => onRegionClick?.('region-1', { shiftKey: false })}>
|
||||||
</button>
|
select-midi-region
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onRegionClick?.('region-2', { shiftKey: false })}>
|
||||||
|
select-second-midi-region
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onRegionClick?.('audio-1', { shiftKey: false })}>
|
||||||
|
select-audio-region
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./piano-roll/PianoRoll', () => ({
|
vi.mock('./piano-roll/PianoRoll', () => ({
|
||||||
default: () => <div data-testid="piano-roll" />,
|
default: ({ onClose }: { onClose?: () => void }) => (
|
||||||
|
<div data-testid="piano-roll">
|
||||||
|
<button type="button" onClick={onClose}>close-piano-roll</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('MainContent', () => {
|
describe('MainContent', () => {
|
||||||
@@ -118,11 +142,65 @@ describe('MainContent', () => {
|
|||||||
it('updates activeRegionId when selecting a region with piano roll closed', () => {
|
it('updates activeRegionId when selecting a region with piano roll closed', () => {
|
||||||
render(<MainContent />);
|
render(<MainContent />);
|
||||||
|
|
||||||
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.activeRegionId).toBe('region-1');
|
||||||
expect(storeState.setActiveRegionId).toHaveBeenCalledWith('region-1');
|
expect(storeState.setActiveRegionId).toHaveBeenCalledWith('region-1');
|
||||||
expect(storeState.showPianoRoll).toBe(false);
|
expect(storeState.showPianoRoll).toBe(false);
|
||||||
expect(storeState.openMidiPianoRoll).not.toHaveBeenCalled();
|
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(<MainContent />);
|
||||||
|
|
||||||
|
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(<MainContent />);
|
||||||
|
|
||||||
|
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(<MainContent />);
|
||||||
|
|
||||||
|
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(<MainContent />);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
setShowPianoRoll,
|
setShowPianoRoll,
|
||||||
setActiveRegionId,
|
setActiveRegionId,
|
||||||
pianoRollMode,
|
pianoRollMode,
|
||||||
|
requestedSheetMusicViewEnabled,
|
||||||
|
pianoRollViewRequestVersion,
|
||||||
openMidiPianoRoll,
|
openMidiPianoRoll,
|
||||||
openSpectrogramViewer,
|
openSpectrogramViewer,
|
||||||
openHybridMode,
|
openHybridMode,
|
||||||
@@ -334,6 +336,21 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
setRegions(updatedRegions);
|
setRegions(updatedRegions);
|
||||||
}, [tracks, timeSignature]);
|
}, [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.
|
// Apply auto-selection for newly created/imported regions after the regions state commits.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pendingRegionId = pendingAutoSelectionRegionIdRef.current;
|
const pendingRegionId = pendingAutoSelectionRegionIdRef.current;
|
||||||
@@ -587,7 +604,9 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
setSelectedRegionId(lastSelectedRegionId);
|
setSelectedRegionId(lastSelectedRegionId);
|
||||||
setActiveRegionId(lastSelectedRegionId);
|
if (lastSelectedRegionId) {
|
||||||
|
setActiveRegionId(lastSelectedRegionId);
|
||||||
|
}
|
||||||
|
|
||||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
console.log(`Selected regions: ${selectedRegions.map(selectedRegion => selectedRegion.getId()).join(', ')}`);
|
console.log(`Selected regions: ${selectedRegions.map(selectedRegion => selectedRegion.getId()).join(', ')}`);
|
||||||
@@ -598,7 +617,6 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!lastSelectedRegionId) {
|
if (!lastSelectedRegionId) {
|
||||||
setShowPianoRoll(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1052,6 +1070,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
onClose={handlePianoRollClose}
|
onClose={handlePianoRollClose}
|
||||||
regionId={activeRegionId}
|
regionId={activeRegionId}
|
||||||
mode={pianoRollMode}
|
mode={pianoRollMode}
|
||||||
|
requestedSheetMusicViewEnabled={requestedSheetMusicViewEnabled}
|
||||||
|
pianoRollViewRequestVersion={pianoRollViewRequestVersion}
|
||||||
audioRegion={(() => {
|
audioRegion={(() => {
|
||||||
// spectrogram mode: audio region IS the activeRegionId
|
// spectrogram mode: audio region IS the activeRegionId
|
||||||
// hybrid mode: audio region is hybridAudioRegionId
|
// hybrid mode: audio region is hybridAudioRegionId
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
height: 50px;
|
height: 50px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border-bottom: 1px solid #3a3a3a;
|
border-bottom: 1px solid #3a3a3a;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-left,
|
.toolbar-left,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import Toolbar from './Toolbar';
|
import Toolbar from './Toolbar';
|
||||||
|
|
||||||
@@ -52,13 +52,13 @@ const storeState = {
|
|||||||
setShowPianoRoll: vi.fn(),
|
setShowPianoRoll: vi.fn(),
|
||||||
activeRegionId: null,
|
activeRegionId: null,
|
||||||
setActiveRegionId: vi.fn(),
|
setActiveRegionId: vi.fn(),
|
||||||
selectedRegionIds: [],
|
selectedRegionIds: [] as string[],
|
||||||
selectedTrackId: null,
|
selectedTrackId: null,
|
||||||
playheadPosition: 0,
|
playheadPosition: 0,
|
||||||
refreshProjectState: vi.fn(),
|
refreshProjectState: vi.fn(),
|
||||||
requestMainContentScroll: vi.fn(),
|
requestMainContentScroll: vi.fn(),
|
||||||
requestPianoRollScroll: vi.fn(),
|
requestPianoRollScroll: vi.fn(),
|
||||||
tracks: [],
|
tracks: [] as unknown[],
|
||||||
};
|
};
|
||||||
|
|
||||||
type StoreState = typeof storeState;
|
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('../util/regionDeleteUtil', () => ({ regionDeleteManager: { deleteSelectedRegions: vi.fn(() => false) } }));
|
||||||
vi.mock('../core/commands/region/SplitRegionCommand', () => ({ SplitRegionCommand: class {} }));
|
vi.mock('../core/commands/region/SplitRegionCommand', () => ({ SplitRegionCommand: class {} }));
|
||||||
vi.mock('../core/commands/region/MergeMidiRegionsCommand', () => ({ MergeMidiRegionsCommand: 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', () => ({
|
vi.mock('../util/copyPasteUtil', () => ({
|
||||||
handleCopyOperation: vi.fn(() => false),
|
handleCopyOperation: vi.fn(() => false),
|
||||||
handlePasteOperation: vi.fn(() => false),
|
handlePasteOperation: vi.fn(() => false),
|
||||||
@@ -135,6 +143,8 @@ vi.mock('../util/dialogUtil', () => ({
|
|||||||
|
|
||||||
describe('Toolbar settings side-panel behavior', () => {
|
describe('Toolbar settings side-panel behavior', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
regionEditUtilMocks.splitSelectedRegionAtPlayheadMock.mockReset();
|
||||||
|
regionEditUtilMocks.mergeSelectedMidiRegionsMock.mockReset();
|
||||||
storeState.toggleChatBox.mockClear();
|
storeState.toggleChatBox.mockClear();
|
||||||
storeState.toggleKGOnePanel.mockClear();
|
storeState.toggleKGOnePanel.mockClear();
|
||||||
storeState.toggleEventListPanel.mockClear();
|
storeState.toggleEventListPanel.mockClear();
|
||||||
@@ -162,4 +172,25 @@ describe('Toolbar settings side-panel behavior', () => {
|
|||||||
expect(storeState.activateSidePanel).toHaveBeenCalledWith('eventList');
|
expect(storeState.activateSidePanel).toHaveBeenCalledWith('eventList');
|
||||||
expect(storeState.toggleEventListPanel).not.toHaveBeenCalled();
|
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(<Toolbar />);
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+15
-118
@@ -22,8 +22,6 @@ import { plainToInstance } from 'class-transformer';
|
|||||||
import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles, FaListUl } from 'react-icons/fa6';
|
import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles, FaListUl } from 'react-icons/fa6';
|
||||||
import { KGMainContentState } from '../core/state/KGMainContentState';
|
import { KGMainContentState } from '../core/state/KGMainContentState';
|
||||||
import { regionDeleteManager } from '../util/regionDeleteUtil';
|
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 { handleCopyOperation, handlePasteOperation } from '../util/copyPasteUtil';
|
||||||
import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil';
|
import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil';
|
||||||
import { KEY_SIGNATURE_MAP } from '../constants/coreConstants';
|
import { KEY_SIGNATURE_MAP } from '../constants/coreConstants';
|
||||||
@@ -35,7 +33,7 @@ import OpenProjectModal from './common/OpenProjectModal';
|
|||||||
import { clearChatHistoryAndUI } from '../util/chatUtil';
|
import { clearChatHistoryAndUI } from '../util/chatUtil';
|
||||||
import PianoIcon from './common/icons/PianoIcon';
|
import PianoIcon from './common/icons/PianoIcon';
|
||||||
import MetronomeIcon from './common/icons/MetronomeIcon';
|
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';
|
import { showAlert, showChoice, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil';
|
||||||
|
|
||||||
const Toolbar: React.FC = () => {
|
const Toolbar: React.FC = () => {
|
||||||
@@ -791,43 +789,19 @@ const Toolbar: React.FC = () => {
|
|||||||
console.log("Split button clicked");
|
console.log("Split button clicked");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedRegionIds.length === 0) {
|
const status = await splitSelectedRegionAtPlayhead({
|
||||||
await showAlert("Please select a region to split.");
|
selectedRegionIds,
|
||||||
return;
|
playheadPosition,
|
||||||
}
|
refreshProjectState,
|
||||||
if (selectedRegionIds.length > 1) {
|
});
|
||||||
await showAlert("Please select exactly one region to split.");
|
if (!status) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const regionId = lastSelectedRegionId;
|
setStatus(status);
|
||||||
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)}`);
|
|
||||||
|
|
||||||
if (DEBUG_MODE.TOOLBAR) {
|
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');
|
console.log('Merge button clicked');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedRegionIds.length < 2) {
|
const status = await mergeSelectedMidiRegions({
|
||||||
await showAlert('Please select at least two MIDI regions on the same track to merge.');
|
selectedRegionIds,
|
||||||
return;
|
refreshProjectState,
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
if (!status) {
|
||||||
let regionIdsToMerge = selectedRegionIds;
|
return;
|
||||||
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()),
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
setStatus(status);
|
||||||
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.');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle undo button click
|
// Handle undo button click
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export interface RegionUI {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RegionPreviewContentStyle {
|
||||||
|
left: string;
|
||||||
|
width: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegionClickOptions {
|
export interface RegionClickOptions {
|
||||||
shiftKey: boolean;
|
shiftKey: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(<PianoKeys activeRegion={activeRegion} />);
|
||||||
|
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(<PianoKeys activeRegion={activeRegion} />);
|
||||||
|
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(<PianoKeys activeRegion={activeRegion} />);
|
||||||
|
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(<PianoKeys activeRegion={activeRegion} />);
|
||||||
|
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(<PianoKeys activeRegion={activeRegion} />);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
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 { useProjectStore } from '../../stores/projectStore';
|
||||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||||
|
import { KGMidiInput, type LiveMidiNoteActivityEvent } from '../../core/midi-input/KGMidiInput';
|
||||||
|
|
||||||
interface PianoKeysProps {
|
interface PianoKeysProps {
|
||||||
activeRegion: KGMidiRegion | null;
|
activeRegion: KGMidiRegion | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function incrementPitchCount(source: Map<number, number>, pitch: number): Map<number, number> {
|
||||||
|
const next = new Map(source);
|
||||||
|
next.set(pitch, (next.get(pitch) ?? 0) + 1);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrementPitchCount(source: Map<number, number>, pitch: number): Map<number, number> {
|
||||||
|
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<PianoKeysProps> = ({ activeRegion }) => {
|
const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
|
||||||
const [pressedKeys, setPressedKeys] = useState<Set<string>>(new Set());
|
const [mouseActivePitches, setMouseActivePitches] = useState<Map<number, number>>(new Map());
|
||||||
const pressedKeysRef = useRef<Set<string>>(new Set());
|
const [midiActivePitches, setMidiActivePitches] = useState<Map<number, number>>(new Map());
|
||||||
const { tracks } = useProjectStore();
|
const mouseActivePitchesRef = useRef<Map<number, number>>(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
|
// Check if current active region belongs to a drum track
|
||||||
const isDrumTrack = React.useMemo(() => {
|
const isDrumTrack = useMemo(() => {
|
||||||
if (!activeRegion) return false;
|
if (!activeRegion) return false;
|
||||||
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
|
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
|
||||||
return track instanceof KGMidiTrack && track.getInstrument() === 'standard';
|
return track instanceof KGMidiTrack && track.getInstrument() === 'standard';
|
||||||
}, [activeRegion, tracks]);
|
}, [activeRegion, tracks]);
|
||||||
|
|
||||||
|
const playbackActivePitches = useMemo(() => {
|
||||||
|
if (!activeRegion || !isPlaying) {
|
||||||
|
return new Set<number>();
|
||||||
|
}
|
||||||
|
|
||||||
|
const activePitches = new Set<number>();
|
||||||
|
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
|
// Handle mouse down on piano key
|
||||||
const handleKeyMouseDown = (keyId: string) => {
|
const handleKeyMouseDown = (keyId: string) => {
|
||||||
|
const pitch = noteNameToPitch(keyId);
|
||||||
|
|
||||||
// Prevent double pressing the same key
|
// Prevent double pressing the same key
|
||||||
if (pressedKeysRef.current.has(keyId)) {
|
if ((mouseActivePitchesRef.current.get(pitch) ?? 0) > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,9 +101,6 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
|
|||||||
const trackId = activeRegion.getTrackId();
|
const trackId = activeRegion.getTrackId();
|
||||||
|
|
||||||
try {
|
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
|
// Get audio interface and start playing the note
|
||||||
const audioInterface = KGAudioInterface.instance();
|
const audioInterface = KGAudioInterface.instance();
|
||||||
if (audioInterface.getIsInitialized()) {
|
if (audioInterface.getIsInitialized()) {
|
||||||
@@ -54,11 +115,9 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
|
|||||||
if (audioInterface.getIsAudioContextStarted()) {
|
if (audioInterface.getIsAudioContextStarted()) {
|
||||||
audioInterface.triggerNoteAttack(trackId, pitch, 127);
|
audioInterface.triggerNoteAttack(trackId, pitch, 127);
|
||||||
|
|
||||||
// Update pressed keys state
|
const nextMouseActivePitches = incrementPitchCount(mouseActivePitchesRef.current, pitch);
|
||||||
const newPressedKeys = new Set(pressedKeysRef.current);
|
mouseActivePitchesRef.current = nextMouseActivePitches;
|
||||||
newPressedKeys.add(keyId);
|
setMouseActivePitches(nextMouseActivePitches);
|
||||||
pressedKeysRef.current = newPressedKeys;
|
|
||||||
setPressedKeys(newPressedKeys);
|
|
||||||
|
|
||||||
console.log(`Started playing piano key: ${keyId} (pitch ${pitch})`);
|
console.log(`Started playing piano key: ${keyId} (pitch ${pitch})`);
|
||||||
}
|
}
|
||||||
@@ -70,8 +129,10 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
|
|||||||
|
|
||||||
// Handle mouse up on piano key
|
// Handle mouse up on piano key
|
||||||
const handleKeyMouseUp = (keyId: string) => {
|
const handleKeyMouseUp = (keyId: string) => {
|
||||||
|
const pitch = noteNameToPitch(keyId);
|
||||||
|
|
||||||
// Only release if key was actually pressed
|
// Only release if key was actually pressed
|
||||||
if (!pressedKeysRef.current.has(keyId)) {
|
if ((mouseActivePitchesRef.current.get(pitch) ?? 0) === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,19 +144,14 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
|
|||||||
const trackId = activeRegion.getTrackId();
|
const trackId = activeRegion.getTrackId();
|
||||||
|
|
||||||
try {
|
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
|
// Get audio interface and stop playing the note
|
||||||
const audioInterface = KGAudioInterface.instance();
|
const audioInterface = KGAudioInterface.instance();
|
||||||
if (audioInterface.getIsInitialized() && audioInterface.getIsAudioContextStarted()) {
|
if (audioInterface.getIsInitialized() && audioInterface.getIsAudioContextStarted()) {
|
||||||
audioInterface.releaseNote(trackId, pitch);
|
audioInterface.releaseNote(trackId, pitch);
|
||||||
|
|
||||||
// Update pressed keys state
|
const nextMouseActivePitches = decrementPitchCount(mouseActivePitchesRef.current, pitch);
|
||||||
const newPressedKeys = new Set(pressedKeysRef.current);
|
mouseActivePitchesRef.current = nextMouseActivePitches;
|
||||||
newPressedKeys.delete(keyId);
|
setMouseActivePitches(nextMouseActivePitches);
|
||||||
pressedKeysRef.current = newPressedKeys;
|
|
||||||
setPressedKeys(newPressedKeys);
|
|
||||||
|
|
||||||
console.log(`Stopped playing piano key: ${keyId} (pitch ${pitch})`);
|
console.log(`Stopped playing piano key: ${keyId} (pitch ${pitch})`);
|
||||||
}
|
}
|
||||||
@@ -123,14 +179,25 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
|
|||||||
const note = notes[i];
|
const note = notes[i];
|
||||||
const isSharp = note.includes('#');
|
const isSharp = note.includes('#');
|
||||||
const keyId = `${note}${octave}`;
|
const keyId = `${note}${octave}`;
|
||||||
const isPressed = pressedKeys.has(keyId);
|
const pitch = noteNameToPitch(keyId);
|
||||||
const keyClass = `piano-key ${isSharp ? 'sharp' : 'natural'} ${isPressed ? 'pressed' : ''}`;
|
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';
|
const isC = note === 'C';
|
||||||
|
|
||||||
// For drum tracks, show drum labels when available
|
// For drum tracks, show drum labels when available
|
||||||
let labelContent = null;
|
let labelContent = null;
|
||||||
if (isDrumTrack) {
|
if (isDrumTrack) {
|
||||||
const pitch = noteNameToPitch(keyId);
|
|
||||||
const drumInfo = midiPercussionKeyMap[pitch];
|
const drumInfo = midiPercussionKeyMap[pitch];
|
||||||
if (drumInfo) {
|
if (drumInfo) {
|
||||||
labelContent = <span className="key-label">{drumInfo.shortName}</span>;
|
labelContent = <span className="key-label">{drumInfo.shortName}</span>;
|
||||||
@@ -153,6 +220,7 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{labelContent}
|
{labelContent}
|
||||||
|
{showIndicator ? <span className="piano-key-activity-dot" data-testid={`piano-key-dot-${keyId}`} /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -572,6 +572,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 1px solid #3a3a3a;
|
border-bottom: 1px solid #3a3a3a;
|
||||||
|
position: relative;
|
||||||
|
transition: background-color 0.08s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.piano-key.natural {
|
.piano-key.natural {
|
||||||
@@ -584,16 +586,47 @@
|
|||||||
color: #e0e0e0;
|
color: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.piano-key.natural.visual-active {
|
||||||
|
background-color: #b8b8b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.piano-key.sharp.visual-active {
|
||||||
|
background-color: #5a5a5a;
|
||||||
|
}
|
||||||
|
|
||||||
.key-label {
|
.key-label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding-left: 5px;
|
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 {
|
.piano-grid {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: relative;
|
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 */
|
/* background-image is now set dynamically via React inline styles in PianoGrid component */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ vi.mock('../../stores/projectStore', () => ({
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createPendingModeSwitchRequest,
|
createPendingModeSwitchRequest,
|
||||||
|
getRegionStartScrollLeft,
|
||||||
getRegionPlayheadRelation,
|
getRegionPlayheadRelation,
|
||||||
getScrollLeftForViewportRequest,
|
getScrollLeftForViewportRequest,
|
||||||
} from './PianoRoll';
|
} from './PianoRoll';
|
||||||
@@ -57,6 +58,13 @@ describe('PianoRoll viewport switch helpers', () => {
|
|||||||
expect(getRegionPlayheadRelation(25, 16, 24)).toBe('after');
|
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', () => {
|
it('centers an in-region playhead when switching to region-scope sheet view', () => {
|
||||||
const request = createPendingModeSwitchRequest({
|
const request = createPendingModeSwitchRequest({
|
||||||
playheadBeat: 20,
|
playheadBeat: 20,
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ interface PianoRollProps {
|
|||||||
initialPosition?: { x: number; y: number };
|
initialPosition?: { x: number; y: number };
|
||||||
initialSize?: { width: number; height: number };
|
initialSize?: { width: number; height: number };
|
||||||
mode?: 'midi-edit' | 'spectrogram' | 'hybrid';
|
mode?: 'midi-edit' | 'spectrogram' | 'hybrid';
|
||||||
|
requestedSheetMusicViewEnabled?: boolean;
|
||||||
|
pianoRollViewRequestVersion?: number;
|
||||||
audioRegion?: KGAudioRegion;
|
audioRegion?: KGAudioRegion;
|
||||||
trackId?: string;
|
trackId?: string;
|
||||||
projectName?: string;
|
projectName?: string;
|
||||||
@@ -74,6 +76,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
initialPosition,
|
initialPosition,
|
||||||
initialSize,
|
initialSize,
|
||||||
mode = 'midi-edit',
|
mode = 'midi-edit',
|
||||||
|
requestedSheetMusicViewEnabled = false,
|
||||||
|
pianoRollViewRequestVersion = 0,
|
||||||
audioRegion,
|
audioRegion,
|
||||||
trackId,
|
trackId,
|
||||||
projectName,
|
projectName,
|
||||||
@@ -92,7 +96,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
useState<SpectrogramHeightResolution>(3);
|
useState<SpectrogramHeightResolution>(3);
|
||||||
|
|
||||||
// Piano roll zoom (1x–8x); updates --region-grid-beat-width CSS variable
|
// Piano roll zoom (1x–8x); updates --region-grid-beat-width CSS variable
|
||||||
const [pianoRollZoom, setPianoRollZoom] = useState<number>(1);
|
const [pianoRollZoom, setPianoRollZoom] = useState<number>(() => KGPianoRollState.instance().getPianoRollZoom());
|
||||||
const [automationEnabled, setAutomationEnabled] = useState(false);
|
const [automationEnabled, setAutomationEnabled] = useState(false);
|
||||||
const [automationType, setAutomationType] = useState<PianoRollAutomationType>('pitch-bend');
|
const [automationType, setAutomationType] = useState<PianoRollAutomationType>('pitch-bend');
|
||||||
const [sheetMusicViewEnabled, setSheetMusicViewEnabled] = useState(false);
|
const [sheetMusicViewEnabled, setSheetMusicViewEnabled] = useState(false);
|
||||||
@@ -153,6 +157,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
const pendingModeSwitchRequestRef = useRef<PendingModeSwitchRequest | null>(null);
|
const pendingModeSwitchRequestRef = useRef<PendingModeSwitchRequest | null>(null);
|
||||||
const previousSheetMusicViewEnabledRef = useRef<boolean>(false);
|
const previousSheetMusicViewEnabledRef = useRef<boolean>(false);
|
||||||
const previousActiveRegionIdRef = useRef<string | null>(null);
|
const previousActiveRegionIdRef = useRef<string | null>(null);
|
||||||
|
const lastAppliedViewRequestVersionRef = useRef<number>(0);
|
||||||
|
|
||||||
// Ref for storing the setNoteUpdateCounter function
|
// Ref for storing the setNoteUpdateCounter function
|
||||||
const triggerNoteUpdateRef = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null);
|
const triggerNoteUpdateRef = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null);
|
||||||
@@ -268,6 +273,42 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
}
|
}
|
||||||
}, []); // Empty dependency array means this runs once on mount
|
}, []); // Empty dependency array means this runs once on mount
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSpectrogram) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pianoRollViewRequestVersion === 0 || lastAppliedViewRequestVersionRef.current === pianoRollViewRequestVersion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastAppliedViewRequestVersionRef.current = pianoRollViewRequestVersion;
|
||||||
|
|
||||||
|
if (activeRegion) {
|
||||||
|
pendingModeSwitchRequestRef.current = createPendingModeSwitchRequest({
|
||||||
|
playheadBeat: playheadPosition,
|
||||||
|
regionStartBeat: activeRegion.getStartFromBeat(),
|
||||||
|
regionEndBeat: activeRegion.getStartFromBeat() + activeRegion.getLength(),
|
||||||
|
sourceSheetMusicViewEnabled: sheetMusicViewEnabled,
|
||||||
|
destinationSheetMusicViewEnabled: requestedSheetMusicViewEnabled,
|
||||||
|
destinationSheetMusicTrackScopeEnabled: requestedSheetMusicViewEnabled && sheetMusicTrackScopeEnabled,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
pendingModeSwitchRequestRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSheetMusicViewEnabled(requestedSheetMusicViewEnabled);
|
||||||
|
KGPianoRollState.instance().setSheetMusicViewEnabled(requestedSheetMusicViewEnabled);
|
||||||
|
}, [
|
||||||
|
activeRegion,
|
||||||
|
isSpectrogram,
|
||||||
|
pianoRollViewRequestVersion,
|
||||||
|
playheadPosition,
|
||||||
|
requestedSheetMusicViewEnabled,
|
||||||
|
sheetMusicTrackScopeEnabled,
|
||||||
|
sheetMusicViewEnabled,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubscribe: (() => void) | undefined;
|
let unsubscribe: (() => void) | undefined;
|
||||||
|
|
||||||
@@ -744,6 +785,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
KGPianoRollState.instance().setPianoRollZoom(nextZoom);
|
||||||
|
KGCore.instance().getCurrentProject().setPianoRollZoom(nextZoom);
|
||||||
setPianoRollZoom(nextZoom);
|
setPianoRollZoom(nextZoom);
|
||||||
}, [pianoRollZoom]);
|
}, [pianoRollZoom]);
|
||||||
|
|
||||||
@@ -986,7 +1029,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
};
|
};
|
||||||
}, [pianoRollZoom]);
|
}, [pianoRollZoom]);
|
||||||
|
|
||||||
// Scroll horizontally to the active region's starting bar
|
// Scroll horizontally to the active region's starting position
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pianoRollNoteScrollRef.current || !activeRegion) {
|
if (!pianoRollNoteScrollRef.current || !activeRegion) {
|
||||||
previousActiveRegionIdRef.current = activeRegion?.getId() ?? null;
|
previousActiveRegionIdRef.current = activeRegion?.getId() ?? null;
|
||||||
@@ -1002,27 +1045,17 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
// Get the starting beat of the region
|
// Get the starting beat of the region
|
||||||
const startBeat = activeRegion.getStartFromBeat();
|
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) {
|
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 scrollPosition = getRegionStartScrollLeft(startBeat);
|
||||||
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;
|
|
||||||
|
|
||||||
// Scroll to the calculated position
|
// Scroll to the calculated position
|
||||||
pianoRollNoteScrollRef.current.scrollLeft = Math.max(0, scrollPosition);
|
pianoRollNoteScrollRef.current.scrollLeft = Math.max(0, scrollPosition);
|
||||||
previousActiveRegionIdRef.current = activeRegion.getId();
|
previousActiveRegionIdRef.current = activeRegion.getId();
|
||||||
}
|
}
|
||||||
}, [activeRegion, timeSignature]);
|
}, [activeRegion]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const request = pendingModeSwitchRequestRef.current;
|
const request = pendingModeSwitchRequestRef.current;
|
||||||
@@ -1548,3 +1581,11 @@ export function getScrollLeftForViewportRequest({
|
|||||||
container,
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -239,8 +239,8 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
|||||||
|
|
||||||
const noteId = note.getId();
|
const noteId = note.getId();
|
||||||
|
|
||||||
// Check if this note is being resized or dragged and has a temporary style
|
// Use preview geometry whenever this note has an active temporary style.
|
||||||
if ((resizingNoteId === noteId || draggingNoteId === noteId) && tempNoteStyles[noteId]) {
|
if (tempNoteStyles[noteId]) {
|
||||||
// Use the temporary style for position and size
|
// Use the temporary style for position and size
|
||||||
const tempStyle = tempNoteStyles[noteId];
|
const tempStyle = tempNoteStyles[noteId];
|
||||||
|
|
||||||
@@ -351,7 +351,6 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
|||||||
keySignature={sheetKeySignature}
|
keySignature={sheetKeySignature}
|
||||||
instrument={sheetInstrument}
|
instrument={sheetInstrument}
|
||||||
quantization={sheetQuantization}
|
quantization={sheetQuantization}
|
||||||
noteScrollRef={noteScrollRef}
|
|
||||||
onMetricsChange={onSheetMeasureMetricsChange ?? NOOP_SHEET_METRICS_CHANGE}
|
onMetricsChange={onSheetMeasureMetricsChange ?? NOOP_SHEET_METRICS_CHANGE}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -130,10 +130,23 @@ describe('PianoRollToolbar', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'Sheet Music View' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Sheet Music View' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /16,48/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /16,48/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'Show Entire Track' })).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: 'Pointer Tool' })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole('button', { name: /Pitch Bend/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: /Pitch Bend/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the zoom button outside sheet mode', () => {
|
||||||
|
render(
|
||||||
|
<PianoRollToolbar
|
||||||
|
{...baseProps}
|
||||||
|
sheetMusicViewEnabled={false}
|
||||||
|
mode="midi-edit"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: '1x' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('toggles the full-track sheet scope button', () => {
|
it('toggles the full-track sheet scope button', () => {
|
||||||
const onSheetMusicTrackScopeToggle = vi.fn();
|
const onSheetMusicTrackScopeToggle = vi.fn();
|
||||||
|
|
||||||
|
|||||||
@@ -256,28 +256,30 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="quant-dropdown-container" ref={zoomSliderRef}>
|
{!sheetMusicViewEnabled && (
|
||||||
<button
|
<div className="quant-dropdown-container" ref={zoomSliderRef}>
|
||||||
className="quant-button"
|
<button
|
||||||
onClick={() => setShowZoomSlider(!showZoomSlider)}
|
className="quant-button"
|
||||||
title="Zoom"
|
onClick={() => setShowZoomSlider(!showZoomSlider)}
|
||||||
>
|
title="Zoom"
|
||||||
{zoom}x
|
>
|
||||||
</button>
|
{zoom}x
|
||||||
{showZoomSlider && (
|
</button>
|
||||||
<div className="piano-roll-zoom-popup">
|
{showZoomSlider && (
|
||||||
<input
|
<div className="piano-roll-zoom-popup">
|
||||||
type="range"
|
<input
|
||||||
min="1"
|
type="range"
|
||||||
max="8"
|
min="1"
|
||||||
step="1"
|
max="8"
|
||||||
value={zoom}
|
step="1"
|
||||||
onChange={(e) => onZoomChange(parseInt(e.target.value))}
|
value={zoom}
|
||||||
/>
|
onChange={(e) => onZoomChange(parseInt(e.target.value))}
|
||||||
<span className="piano-roll-zoom-value">{zoom}x</span>
|
/>
|
||||||
</div>
|
<span className="piano-roll-zoom-value">{zoom}x</span>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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: () => <div data-testid="header" /> }));
|
||||||
|
vi.mock('./NoteAttributeBar', () => ({ default: () => <div data-testid="note-attribute-bar" /> }));
|
||||||
|
vi.mock('./PianoRollContent', () => ({ default: () => <div data-testid="content" /> }));
|
||||||
|
vi.mock('./PianoRollToolbar', () => ({
|
||||||
|
default: (props: { zoom: number; onZoomChange: (value: number) => void }) => {
|
||||||
|
latestToolbarProps = props;
|
||||||
|
return <div data-testid="toolbar">{props.zoom}x</div>;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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(
|
||||||
|
<PianoRoll
|
||||||
|
onClose={vi.fn()}
|
||||||
|
regionId="region-1"
|
||||||
|
initialPosition={{ x: 0, y: 0 }}
|
||||||
|
initialSize={{ width: 800, height: 400 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(latestToolbarProps?.zoom).toBe(1);
|
||||||
|
|
||||||
|
latestToolbarProps?.onZoomChange(3);
|
||||||
|
|
||||||
|
expect(pianoRollState.setPianoRollZoom).toHaveBeenCalledWith(3);
|
||||||
|
expect(mockProject.setPianoRollZoom).toHaveBeenCalledWith(3);
|
||||||
|
|
||||||
|
firstRender.unmount();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PianoRoll
|
||||||
|
onClose={vi.fn()}
|
||||||
|
regionId="region-1"
|
||||||
|
initialPosition={{ x: 0, y: 0 }}
|
||||||
|
initialSize={{ width: 800, height: 400 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(latestToolbarProps?.zoom).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 }) => (
|
||||||
|
<div data-testid="playhead" data-pixel={pixelPositionOverride ?? 0} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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(
|
||||||
|
<SheetMusicView
|
||||||
|
activeRegion={activeRegion}
|
||||||
|
midiRegions={[activeRegion]}
|
||||||
|
maxBars={8}
|
||||||
|
sheetMusicTrackScopeEnabled={false}
|
||||||
|
timeSignature={{ numerator: 4, denominator: 4 }}
|
||||||
|
keySignature="C major"
|
||||||
|
instrument="acoustic_grand_piano"
|
||||||
|
quantization={quantization}
|
||||||
|
onMetricsChange={onMetricsChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<SheetMusicView
|
||||||
|
activeRegion={activeRegion}
|
||||||
|
midiRegions={[activeRegion, anotherRegion]}
|
||||||
|
maxBars={8}
|
||||||
|
sheetMusicTrackScopeEnabled={true}
|
||||||
|
timeSignature={{ numerator: 4, denominator: 4 }}
|
||||||
|
keySignature="C major"
|
||||||
|
instrument="acoustic_grand_piano"
|
||||||
|
quantization={quantization}
|
||||||
|
onMetricsChange={onMetricsChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<SheetMusicView
|
||||||
|
activeRegion={activeRegion}
|
||||||
|
midiRegions={[activeRegion]}
|
||||||
|
maxBars={4}
|
||||||
|
sheetMusicTrackScopeEnabled={false}
|
||||||
|
timeSignature={{ numerator: 4, denominator: 4 }}
|
||||||
|
keySignature="C major"
|
||||||
|
instrument="acoustic_grand_piano"
|
||||||
|
quantization={quantization}
|
||||||
|
onMetricsChange={onMetricsChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('playhead')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,7 +26,6 @@ interface SheetMusicViewProps {
|
|||||||
keySignature: KeySignature;
|
keySignature: KeySignature;
|
||||||
instrument: InstrumentType;
|
instrument: InstrumentType;
|
||||||
quantization: SheetQuantization;
|
quantization: SheetQuantization;
|
||||||
noteScrollRef: React.MutableRefObject<HTMLDivElement | null>;
|
|
||||||
onMetricsChange: (metrics: SheetMeasureMetric[]) => void;
|
onMetricsChange: (metrics: SheetMeasureMetric[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +60,6 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
|
|||||||
keySignature,
|
keySignature,
|
||||||
instrument,
|
instrument,
|
||||||
quantization,
|
quantization,
|
||||||
noteScrollRef,
|
|
||||||
onMetricsChange,
|
onMetricsChange,
|
||||||
}) => {
|
}) => {
|
||||||
const setPlayheadPosition = useProjectStore(state => state.setPlayheadPosition);
|
const setPlayheadPosition = useProjectStore(state => state.setPlayheadPosition);
|
||||||
@@ -245,7 +243,7 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rect = headerRef.current.getBoundingClientRect();
|
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 => (
|
const metric = metrics.find(candidate => (
|
||||||
relativeX >= candidate.leftPx && relativeX <= candidate.leftPx + candidate.widthPx
|
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.quantization.raw === next.quantization.raw &&
|
||||||
previous.timeSignature.numerator === next.timeSignature.numerator &&
|
previous.timeSignature.numerator === next.timeSignature.numerator &&
|
||||||
previous.timeSignature.denominator === next.timeSignature.denominator &&
|
previous.timeSignature.denominator === next.timeSignature.denominator &&
|
||||||
previous.noteScrollRef === next.noteScrollRef &&
|
|
||||||
previous.onMetricsChange === next.onMetricsChange
|
previous.onMetricsChange === next.onMetricsChange
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const BehaviorSettings: React.FC = () => {
|
|||||||
const [midiAutomationInterpolationIntervalMs, setMidiAutomationInterpolationIntervalMs] = useState<number>(10);
|
const [midiAutomationInterpolationIntervalMs, setMidiAutomationInterpolationIntervalMs] = useState<number>(10);
|
||||||
const [playbackDelay, setPlaybackDelay] = useState<string>('200');
|
const [playbackDelay, setPlaybackDelay] = useState<string>('200');
|
||||||
const [recordingOffset, setRecordingOffset] = useState<string>('0');
|
const [recordingOffset, setRecordingOffset] = useState<string>('0');
|
||||||
|
const [bounceStartsFromBeat1, setBounceStartsFromBeat1] = useState<boolean>(true);
|
||||||
const [enableAudioCapture, setEnableAudioCapture] = useState<boolean>(false);
|
const [enableAudioCapture, setEnableAudioCapture] = useState<boolean>(false);
|
||||||
const [lookaheadValidationErrors, setLookaheadValidationErrors] = useState<string[]>([]);
|
const [lookaheadValidationErrors, setLookaheadValidationErrors] = useState<string[]>([]);
|
||||||
const [playbackDelayValidationErrors, setPlaybackDelayValidationErrors] = useState<string[]>([]);
|
const [playbackDelayValidationErrors, setPlaybackDelayValidationErrors] = useState<string[]>([]);
|
||||||
@@ -42,6 +43,7 @@ const BehaviorSettings: React.FC = () => {
|
|||||||
setPlaybackDelay(((playbackDelaySeconds * 1000).toFixed(0)));
|
setPlaybackDelay(((playbackDelaySeconds * 1000).toFixed(0)));
|
||||||
const recordingOffsetSeconds = (configManager.get('audio.recording_offset') as number) ?? 0;
|
const recordingOffsetSeconds = (configManager.get('audio.recording_offset') as number) ?? 0;
|
||||||
setRecordingOffset(((recordingOffsetSeconds * 1000).toFixed(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);
|
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);
|
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 (
|
return (
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<div className="settings-section-header">
|
<div className="settings-section-header">
|
||||||
@@ -325,6 +333,23 @@ const BehaviorSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-item">
|
||||||
|
<label className="settings-label">
|
||||||
|
Bounce Starts From Beat 1
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="settings-select"
|
||||||
|
value={bounceStartsFromBeat1 ? 'yes' : 'no'}
|
||||||
|
onChange={(e) => handleBounceStartsFromBeat1Change(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="no">No</option>
|
||||||
|
<option value="yes">Yes</option>
|
||||||
|
</select>
|
||||||
|
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="settings-item">
|
<div className="settings-item">
|
||||||
<label className="settings-label">
|
<label className="settings-label">
|
||||||
Capture Audio for Screen Sharing
|
Capture Audio for Screen Sharing
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
import GeneralSettings from './GeneralSettings';
|
import GeneralSettings from './GeneralSettings';
|
||||||
|
|
||||||
|
const { localSeparatorModelCacheMock } = vi.hoisted(() => ({
|
||||||
|
localSeparatorModelCacheMock: {
|
||||||
|
delete: vi.fn().mockResolvedValue(undefined),
|
||||||
|
exists: vi.fn().mockResolvedValue(true),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const configState = new Map<string, unknown>([
|
const configState = new Map<string, unknown>([
|
||||||
['general.llm_provider', 'local_browser'],
|
['general.llm_provider', 'local_browser'],
|
||||||
['general.persist_api_keys_non_localhost', false],
|
['general.persist_api_keys_non_localhost', false],
|
||||||
@@ -20,6 +27,8 @@ const configState = new Map<string, unknown>([
|
|||||||
['general.openai_compatible.base_url', ''],
|
['general.openai_compatible.base_url', ''],
|
||||||
['general.openai_compatible.model', ''],
|
['general.openai_compatible.model', ''],
|
||||||
['general.local_browser.context_length', 65536],
|
['general.local_browser.context_length', 65536],
|
||||||
|
['general.local_browser.model_url', 'https://huggingface.co/notabilia/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.task'],
|
||||||
|
['general.uvr5_web_runtime.mdx_net_model_url', 'https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx'],
|
||||||
['general.soundfont.base_url', 'https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/'],
|
['general.soundfont.base_url', 'https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/'],
|
||||||
['general.kgone.enabled', false],
|
['general.kgone.enabled', false],
|
||||||
['general.kgone.base_url', 'http://127.0.0.1:8000'],
|
['general.kgone.base_url', 'http://127.0.0.1:8000'],
|
||||||
@@ -71,6 +80,10 @@ vi.mock('../../../util/localLLMModelManager', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../util/localSeparatorModelCache', () => ({
|
||||||
|
LocalSeparatorModelCache: localSeparatorModelCacheMock,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('GeneralSettings', () => {
|
describe('GeneralSettings', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
configState.set('general.local_browser.context_length', 65536);
|
configState.set('general.local_browser.context_length', 65536);
|
||||||
@@ -91,6 +104,9 @@ describe('GeneralSettings', () => {
|
|||||||
secureContext: true,
|
secureContext: true,
|
||||||
reason: null,
|
reason: null,
|
||||||
};
|
};
|
||||||
|
localSeparatorModelCacheMock.delete.mockClear();
|
||||||
|
localSeparatorModelCacheMock.exists.mockClear();
|
||||||
|
localSeparatorModelCacheMock.exists.mockResolvedValue(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the local context length selector and VRAM hint', async () => {
|
it('renders the local context length selector and VRAM hint', async () => {
|
||||||
@@ -119,6 +135,65 @@ describe('GeneralSettings', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders and persists local runtime download URLs', async () => {
|
||||||
|
render(<GeneralSettings />);
|
||||||
|
|
||||||
|
expect(await screen.findByDisplayValue('https://huggingface.co/notabilia/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.task')).toBeTruthy();
|
||||||
|
expect(screen.getByDisplayValue('https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx')).toBeTruthy();
|
||||||
|
|
||||||
|
const inputs = screen.getAllByRole('textbox');
|
||||||
|
const gemmaUrlInput = inputs.find(input =>
|
||||||
|
(input as HTMLInputElement).value.includes('gemma-4-E4B-it-web.task'),
|
||||||
|
) as HTMLInputElement | undefined;
|
||||||
|
const uvr5UrlInput = inputs.find(input =>
|
||||||
|
(input as HTMLInputElement).value.includes('UVR-MDX-NET-Inst_HQ_3.onnx'),
|
||||||
|
) as HTMLInputElement | undefined;
|
||||||
|
|
||||||
|
expect(gemmaUrlInput).toBeTruthy();
|
||||||
|
expect(uvr5UrlInput).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.change(gemmaUrlInput!, { target: { value: 'https://example.com/gemma.task' } });
|
||||||
|
fireEvent.change(uvr5UrlInput!, { target: { value: 'https://example.com/uvr5.onnx' } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(configManagerMock.set).toHaveBeenCalledWith('general.local_browser.model_url', 'https://example.com/gemma.task');
|
||||||
|
expect(configManagerMock.set).toHaveBeenCalledWith('general.uvr5_web_runtime.mdx_net_model_url', 'https://example.com/uvr5.onnx');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores default download URLs and deletes the UVR5 model cache', async () => {
|
||||||
|
localSeparatorModelCacheMock.exists
|
||||||
|
.mockResolvedValueOnce(true)
|
||||||
|
.mockResolvedValueOnce(false);
|
||||||
|
|
||||||
|
render(<GeneralSettings />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('UVR5 Web Runtime')).toBeTruthy();
|
||||||
|
|
||||||
|
const restoreLinks = screen.getAllByText('Restore default');
|
||||||
|
fireEvent.click(restoreLinks[0]);
|
||||||
|
fireEvent.click(restoreLinks[1]);
|
||||||
|
const uvr5DeleteButton = screen.getAllByRole('button', { name: 'Delete Cached Model' })[1];
|
||||||
|
expect(uvr5DeleteButton).not.toBeDisabled();
|
||||||
|
fireEvent.click(uvr5DeleteButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(configManagerMock.set).toHaveBeenCalledWith(
|
||||||
|
'general.local_browser.model_url',
|
||||||
|
'https://huggingface.co/notabilia/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.task',
|
||||||
|
);
|
||||||
|
expect(configManagerMock.set).toHaveBeenCalledWith(
|
||||||
|
'general.uvr5_web_runtime.mdx_net_model_url',
|
||||||
|
'https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx',
|
||||||
|
);
|
||||||
|
expect(localSeparatorModelCacheMock.delete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByRole('button', { name: 'Delete Cached Model' })[1]).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps local runtime available when runtime may fail on this host', async () => {
|
it('keeps local runtime available when runtime may fail on this host', async () => {
|
||||||
localModelState.runtimeSupport = {
|
localModelState.runtimeSupport = {
|
||||||
supported: true,
|
supported: true,
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { ConfigManager } from '../../../core/config/ConfigManager';
|
import { ConfigManager } from '../../../core/config/ConfigManager';
|
||||||
import { LocalLLMModelManager, type LocalLLMModelState } from '../../../util/localLLMModelManager';
|
import { LocalLLMModelManager, type LocalLLMModelState } from '../../../util/localLLMModelManager';
|
||||||
|
import { LocalSeparatorModelCache } from '../../../util/localSeparatorModelCache';
|
||||||
import {
|
import {
|
||||||
formatLocalLLMContextLength,
|
formatLocalLLMContextLength,
|
||||||
LOCAL_LLM_CONTEXT_LENGTH_OPTIONS,
|
LOCAL_LLM_CONTEXT_LENGTH_OPTIONS,
|
||||||
|
LOCAL_LLM_DEFAULT_MODEL_URL,
|
||||||
LOCAL_LLM_DEFAULT_CONTEXT_LENGTH,
|
LOCAL_LLM_DEFAULT_CONTEXT_LENGTH,
|
||||||
LOCAL_LLM_DISPLAY_NAME,
|
LOCAL_LLM_DISPLAY_NAME,
|
||||||
LOCAL_LLM_PROVIDER_KEY,
|
LOCAL_LLM_PROVIDER_KEY,
|
||||||
normalizeLocalLLMContextLength,
|
normalizeLocalLLMContextLength,
|
||||||
type LocalLLMContextLength,
|
type LocalLLMContextLength,
|
||||||
} from '../../../util/localLLMConfig';
|
} from '../../../util/localLLMConfig';
|
||||||
|
import { LOCAL_SEPARATOR_DEFAULT_MODEL_URL } from '../../../util/localSeparatorConfig';
|
||||||
|
|
||||||
const GeneralSettings: React.FC = () => {
|
const GeneralSettings: React.FC = () => {
|
||||||
const [llmProvider, setLlmProvider] = useState<string>(LOCAL_LLM_PROVIDER_KEY);
|
const [llmProvider, setLlmProvider] = useState<string>(LOCAL_LLM_PROVIDER_KEY);
|
||||||
@@ -34,6 +37,11 @@ const GeneralSettings: React.FC = () => {
|
|||||||
const [soundfontServerManaged, setSoundfontServerManaged] = useState<boolean>(false);
|
const [soundfontServerManaged, setSoundfontServerManaged] = useState<boolean>(false);
|
||||||
const [localContextLength, setLocalContextLength] = useState<LocalLLMContextLength>(LOCAL_LLM_DEFAULT_CONTEXT_LENGTH);
|
const [localContextLength, setLocalContextLength] = useState<LocalLLMContextLength>(LOCAL_LLM_DEFAULT_CONTEXT_LENGTH);
|
||||||
const [localModelState, setLocalModelState] = useState<LocalLLMModelState>(LocalLLMModelManager.getState());
|
const [localModelState, setLocalModelState] = useState<LocalLLMModelState>(LocalLLMModelManager.getState());
|
||||||
|
const [localModelUrl, setLocalModelUrl] = useState<string>('');
|
||||||
|
const [uvr5ModelUrl, setUvr5ModelUrl] = useState<string>('');
|
||||||
|
const [isUvr5ModelCached, setIsUvr5ModelCached] = useState<boolean>(false);
|
||||||
|
const [isCheckingUvr5ModelCache, setIsCheckingUvr5ModelCache] = useState<boolean>(false);
|
||||||
|
const [isDeletingUvr5Model, setIsDeletingUvr5Model] = useState<boolean>(false);
|
||||||
|
|
||||||
const configManager = ConfigManager.instance();
|
const configManager = ConfigManager.instance();
|
||||||
|
|
||||||
@@ -51,6 +59,18 @@ const GeneralSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const refreshUvr5ModelCacheState = useCallback(async () => {
|
||||||
|
setIsCheckingUvr5ModelCache(true);
|
||||||
|
try {
|
||||||
|
setIsUvr5ModelCached(await LocalSeparatorModelCache.exists());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check UVR5 cached model state:', error);
|
||||||
|
setIsUvr5ModelCached(false);
|
||||||
|
} finally {
|
||||||
|
setIsCheckingUvr5ModelCache(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Load configuration values on component mount
|
// Load configuration values on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
@@ -74,6 +94,8 @@ const GeneralSettings: React.FC = () => {
|
|||||||
setCompatibleBaseUrl((configManager.get('general.openai_compatible.base_url') as string) || '');
|
setCompatibleBaseUrl((configManager.get('general.openai_compatible.base_url') as string) || '');
|
||||||
setCompatibleModel((configManager.get('general.openai_compatible.model') as string) || '');
|
setCompatibleModel((configManager.get('general.openai_compatible.model') as string) || '');
|
||||||
setLocalContextLength(normalizeLocalLLMContextLength(configManager.get('general.local_browser.context_length')));
|
setLocalContextLength(normalizeLocalLLMContextLength(configManager.get('general.local_browser.context_length')));
|
||||||
|
setLocalModelUrl((configManager.get('general.local_browser.model_url') as string) || LOCAL_LLM_DEFAULT_MODEL_URL);
|
||||||
|
setUvr5ModelUrl((configManager.get('general.uvr5_web_runtime.mdx_net_model_url') as string) || LOCAL_SEPARATOR_DEFAULT_MODEL_URL);
|
||||||
setSoundfontBaseUrl((configManager.get('general.soundfont.base_url') as string) || '');
|
setSoundfontBaseUrl((configManager.get('general.soundfont.base_url') as string) || '');
|
||||||
setKgoneEnabled((configManager.get('general.kgone.enabled') as boolean) ?? false);
|
setKgoneEnabled((configManager.get('general.kgone.enabled') as boolean) ?? false);
|
||||||
setKgoneBaseUrl((configManager.get('general.kgone.base_url') as string) || '');
|
setKgoneBaseUrl((configManager.get('general.kgone.base_url') as string) || '');
|
||||||
@@ -83,8 +105,9 @@ const GeneralSettings: React.FC = () => {
|
|||||||
|
|
||||||
loadConfig();
|
loadConfig();
|
||||||
const unsubscribe = LocalLLMModelManager.subscribe(setLocalModelState);
|
const unsubscribe = LocalLLMModelManager.subscribe(setLocalModelState);
|
||||||
|
void refreshUvr5ModelCacheState();
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [configManager]);
|
}, [configManager, refreshUvr5ModelCacheState]);
|
||||||
|
|
||||||
// Debounced save function for text inputs
|
// Debounced save function for text inputs
|
||||||
const debouncedSave = useCallback((key: string, value: string) => {
|
const debouncedSave = useCallback((key: string, value: string) => {
|
||||||
@@ -212,6 +235,16 @@ const GeneralSettings: React.FC = () => {
|
|||||||
debouncedSave('general.kgone.base_url', value);
|
debouncedSave('general.kgone.base_url', value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLocalModelUrlChange = (value: string) => {
|
||||||
|
setLocalModelUrl(value);
|
||||||
|
debouncedSave('general.local_browser.model_url', value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUvr5ModelUrlChange = (value: string) => {
|
||||||
|
setUvr5ModelUrl(value);
|
||||||
|
debouncedSave('general.uvr5_web_runtime.mdx_net_model_url', value);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteLocalModel = async () => {
|
const handleDeleteLocalModel = async () => {
|
||||||
try {
|
try {
|
||||||
await LocalLLMModelManager.deleteCachedModel();
|
await LocalLLMModelManager.deleteCachedModel();
|
||||||
@@ -220,6 +253,19 @@ const GeneralSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteUvr5Model = async () => {
|
||||||
|
setIsDeletingUvr5Model(true);
|
||||||
|
try {
|
||||||
|
await LocalSeparatorModelCache.delete();
|
||||||
|
setIsUvr5ModelCached(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete UVR5 cached model:', error);
|
||||||
|
} finally {
|
||||||
|
setIsDeletingUvr5Model(false);
|
||||||
|
await refreshUvr5ModelCacheState();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLocalContextLengthChange = async (value: string) => {
|
const handleLocalContextLengthChange = async (value: string) => {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
const normalized = normalizeLocalLLMContextLength(parsed);
|
const normalized = normalizeLocalLLMContextLength(parsed);
|
||||||
@@ -325,6 +371,32 @@ const GeneralSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-item">
|
||||||
|
<label className="settings-label">
|
||||||
|
Download URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="settings-input"
|
||||||
|
placeholder={`e.g. ${LOCAL_LLM_DEFAULT_MODEL_URL}`}
|
||||||
|
value={localModelUrl}
|
||||||
|
onChange={(e) => handleLocalModelUrlChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||||
|
Changing this URL may break downloads or point to an incompatible model file.{' '}
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleLocalModelUrlChange(LOCAL_LLM_DEFAULT_MODEL_URL);
|
||||||
|
}}
|
||||||
|
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Restore default
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!localModelState.isCached && !localModelState.isDownloading && localModelState.runtimeSupport.supported && (
|
{!localModelState.isCached && !localModelState.isDownloading && localModelState.runtimeSupport.supported && (
|
||||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px', marginBottom: '8px' }}>
|
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px', marginBottom: '8px' }}>
|
||||||
The local model downloads automatically the next time you chat with `Local LLM (Browser)`.
|
The local model downloads automatically the next time you chat with `Local LLM (Browser)`.
|
||||||
@@ -366,6 +438,47 @@ const GeneralSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-group">
|
||||||
|
<h4>UVR5 Web Runtime</h4>
|
||||||
|
|
||||||
|
<div className="settings-item">
|
||||||
|
<label className="settings-label">
|
||||||
|
UVR-MDX-NET-Inst_HQ_3 Download URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="settings-input"
|
||||||
|
placeholder={`e.g. ${LOCAL_SEPARATOR_DEFAULT_MODEL_URL}`}
|
||||||
|
value={uvr5ModelUrl}
|
||||||
|
onChange={(e) => handleUvr5ModelUrlChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||||
|
Changing this URL may break downloads or point to an incompatible model file.{' '}
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleUvr5ModelUrlChange(LOCAL_SEPARATOR_DEFAULT_MODEL_URL);
|
||||||
|
}}
|
||||||
|
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Restore default
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-item" style={{ marginTop: '12px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="settings-btn settings-btn-danger"
|
||||||
|
onClick={() => void handleDeleteUvr5Model()}
|
||||||
|
disabled={isCheckingUvr5ModelCache || isDeletingUvr5Model || !isUvr5ModelCached}
|
||||||
|
>
|
||||||
|
{isDeletingUvr5Model ? 'Deleting...' : 'Delete Cached Model'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="settings-group">
|
<div className="settings-group">
|
||||||
<h4>OpenAI</h4>
|
<h4>OpenAI</h4>
|
||||||
|
|
||||||
|
|||||||
@@ -73,12 +73,26 @@
|
|||||||
background-color: #87CEFA; /* Light blue */
|
background-color: #87CEFA; /* Light blue */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative; /* Allow overlayed controls */
|
position: relative; /* Allow overlayed controls */
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.region-content.audio-region-content {
|
.region-content.audio-region-content {
|
||||||
background-color: #90EE90; /* Light green for audio regions */
|
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 */
|
/* Audio region overrides */
|
||||||
.track-region.audio-region {
|
.track-region.audio-region {
|
||||||
background-color: #3a6b4a;
|
background-color: #3a6b4a;
|
||||||
|
|||||||
@@ -147,4 +147,35 @@ describe('RegionItem', () => {
|
|||||||
expect(context.stroke).toHaveBeenCalled();
|
expect(context.stroke).toHaveBeenCalled();
|
||||||
rectSpy.mockRestore();
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
|||||||
import { useProjectStore } from '../../stores/projectStore';
|
import { useProjectStore } from '../../stores/projectStore';
|
||||||
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
||||||
import type { AudioRecordingPeak } from '../../core/audio-interface/KGAudioRecorder';
|
import type { AudioRecordingPeak } from '../../core/audio-interface/KGAudioRecorder';
|
||||||
|
import type { RegionPreviewContentStyle } from '../interfaces';
|
||||||
|
|
||||||
const DRAG_START_THRESHOLD_PX = 4;
|
const DRAG_START_THRESHOLD_PX = 4;
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ interface RegionItemProps {
|
|||||||
previewWaveformPeaks?: AudioRecordingPeak[];
|
previewWaveformPeaks?: AudioRecordingPeak[];
|
||||||
isPreview?: boolean;
|
isPreview?: boolean;
|
||||||
isAudioRegion?: boolean;
|
isAudioRegion?: boolean;
|
||||||
|
previewContentStyle?: RegionPreviewContentStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RegionItem: React.FC<RegionItemProps> = ({
|
const RegionItem: React.FC<RegionItemProps> = ({
|
||||||
@@ -73,6 +75,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
previewWaveformPeaks,
|
previewWaveformPeaks,
|
||||||
isPreview = false,
|
isPreview = false,
|
||||||
isAudioRegion = false,
|
isAudioRegion = false,
|
||||||
|
previewContentStyle,
|
||||||
}) => {
|
}) => {
|
||||||
// Get selection state and time signature from store
|
// Get selection state and time signature from store
|
||||||
const { selectedRegionIds, timeSignature, bpm } = useProjectStore();
|
const { selectedRegionIds, timeSignature, bpm } = useProjectStore();
|
||||||
@@ -98,24 +101,26 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
|
|
||||||
// Canvas ref for note visualization
|
// Canvas ref for note visualization
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const regionContentRef = useRef<HTMLDivElement | null>(null);
|
const previewContentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// Function to render notes on canvas
|
// Function to render notes on canvas
|
||||||
const renderNotesOnCanvas = () => {
|
const renderNotesOnCanvas = () => {
|
||||||
if (!canvasRef.current || !regionContentRef.current || !midiRegion) return;
|
if (!canvasRef.current || !previewContentRef.current || !midiRegion) return;
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
// Get the current dimensions of the region content
|
// Get the current dimensions of the region content
|
||||||
const contentRect = regionContentRef.current.getBoundingClientRect();
|
const contentRect = previewContentRef.current.getBoundingClientRect();
|
||||||
const width = contentRect.width;
|
const width = Math.max(1, Math.round(contentRect.width));
|
||||||
const height = contentRect.height;
|
const height = Math.max(1, Math.round(contentRect.height));
|
||||||
|
|
||||||
// Set canvas size to match the region content
|
// Set canvas size to match the region content
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
|
canvas.style.width = `${width}px`;
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
|
||||||
// Clear the canvas
|
// Clear the canvas
|
||||||
ctx.clearRect(0, 0, width, height);
|
ctx.clearRect(0, 0, width, height);
|
||||||
@@ -242,18 +247,20 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
|
|
||||||
// Function to render audio waveform on canvas
|
// Function to render audio waveform on canvas
|
||||||
const renderWaveformOnCanvas = () => {
|
const renderWaveformOnCanvas = () => {
|
||||||
if (!canvasRef.current || !regionContentRef.current || !audioBuffer) return;
|
if (!canvasRef.current || !previewContentRef.current || !audioBuffer) return;
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
const contentRect = regionContentRef.current.getBoundingClientRect();
|
const contentRect = previewContentRef.current.getBoundingClientRect();
|
||||||
const width = contentRect.width;
|
const width = Math.max(1, Math.round(contentRect.width));
|
||||||
const height = contentRect.height;
|
const height = Math.max(1, Math.round(contentRect.height));
|
||||||
|
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
|
canvas.style.width = `${width}px`;
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
|
||||||
ctx.clearRect(0, 0, width, height);
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
@@ -311,18 +318,20 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderPreviewWaveformOnCanvas = () => {
|
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 canvas = canvasRef.current;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
const contentRect = regionContentRef.current.getBoundingClientRect();
|
const contentRect = previewContentRef.current.getBoundingClientRect();
|
||||||
const width = contentRect.width;
|
const width = Math.max(1, Math.round(contentRect.width));
|
||||||
const height = contentRect.height;
|
const height = Math.max(1, Math.round(contentRect.height));
|
||||||
|
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
|
canvas.style.width = `${width}px`;
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
ctx.clearRect(0, 0, width, height);
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
const centerY = height / 2;
|
const centerY = height / 2;
|
||||||
@@ -374,11 +383,11 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
renderNotesOnCanvas();
|
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
|
// Re-render canvas when region content size changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!regionContentRef.current) return;
|
if (!previewContentRef.current) return;
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
if (previewWaveformPeaks && previewWaveformPeaks.length > 0) {
|
if (previewWaveformPeaks && previewWaveformPeaks.length > 0) {
|
||||||
@@ -390,14 +399,14 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeObserver.observe(regionContentRef.current);
|
resizeObserver.observe(previewContentRef.current);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (regionContentRef.current) {
|
if (previewContentRef.current) {
|
||||||
resizeObserver.unobserve(regionContentRef.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
|
// Handle mouse movement to detect edge proximity
|
||||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
@@ -668,7 +677,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
<div className="region-header">
|
<div className="region-header">
|
||||||
{name}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
<div className={`region-content${(audioRegion || isAudioRegion) ? ' audio-region-content' : ''}`} ref={regionContentRef}>
|
<div className={`region-content${(audioRegion || isAudioRegion) ? ' audio-region-content' : ''}`}>
|
||||||
{!isPreview && <div className="region-left-buttons">
|
{!isPreview && <div className="region-left-buttons">
|
||||||
{!audioRegion && (
|
{!audioRegion && (
|
||||||
<button
|
<button
|
||||||
@@ -771,7 +780,14 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
<canvas ref={canvasRef} />
|
<div
|
||||||
|
className="region-preview-content"
|
||||||
|
ref={previewContentRef}
|
||||||
|
style={previewContentStyle}
|
||||||
|
data-preview-content-active={previewContentStyle ? 'true' : 'false'}
|
||||||
|
>
|
||||||
|
<canvas ref={canvasRef} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,41 +1,71 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
import { act, render } from '@testing-library/react';
|
||||||
import { render } from '@testing-library/react';
|
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import TrackGridItem from './TrackGridItem';
|
import TrackGridItem from './TrackGridItem';
|
||||||
import { KGAudioTrack } from '../../core/track/KGAudioTrack';
|
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<string, Record<string, unknown>>();
|
||||||
|
|
||||||
vi.mock('../../stores/projectStore', () => ({
|
vi.mock('../../stores/projectStore', () => ({
|
||||||
useProjectStore: (selector?: (state: {
|
useProjectStore: (selector?: (state: typeof storeState) => unknown) => (
|
||||||
selectedRegionIds: string[];
|
selector ? selector(storeState) : storeState
|
||||||
activeTrackAutomationTrackId: string | null;
|
),
|
||||||
activeTrackAutomationType: null;
|
}));
|
||||||
trackAutomationRedrawVersion: number;
|
|
||||||
recordingMode: 'audio' | 'midi' | null;
|
vi.mock('./RegionItem', () => ({
|
||||||
recordingTargetTrackIndex: number | null;
|
default: (props: Record<string, unknown>) => {
|
||||||
recordingCommitStartBeatAbsolute: number;
|
regionItemProps.set(props.id as string, props);
|
||||||
recordingAudioPreviewCurrentBeat: number;
|
return (
|
||||||
recordingAudioPreviewPeaks: Array<{ min: number; max: number }>;
|
<div
|
||||||
recordingAudioPreviewFileName: string | null;
|
data-region-id={props.id as string}
|
||||||
timeSignature: { numerator: number; denominator: number };
|
data-preview-region={(props.isPreview as boolean | undefined) ? 'true' : 'false'}
|
||||||
}) => unknown) => {
|
style={props.style as React.CSSProperties}
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
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(() => {
|
beforeAll(() => {
|
||||||
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
|
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
|
||||||
value: vi.fn(() => ({
|
value: vi.fn(() => ({
|
||||||
@@ -57,11 +87,102 @@ describe('TrackGridItem recording preview', () => {
|
|||||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
|
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<Record<string, React.CSSProperties>>({});
|
||||||
|
const [previewRegionContentStyles, setPreviewRegionContentStyles] = useState<Record<string, RegionPreviewContentStyle>>({});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TrackGridItem
|
||||||
|
track={trackA}
|
||||||
|
index={0}
|
||||||
|
previewRegionStyles={previewRegionStyles}
|
||||||
|
setPreviewRegionStyles={setPreviewRegionStyles}
|
||||||
|
previewRegionContentStyles={previewRegionContentStyles}
|
||||||
|
setPreviewRegionContentStyles={setPreviewRegionContentStyles}
|
||||||
|
{...baseProps}
|
||||||
|
/>
|
||||||
|
<TrackGridItem
|
||||||
|
track={trackB}
|
||||||
|
index={1}
|
||||||
|
previewRegionStyles={previewRegionStyles}
|
||||||
|
setPreviewRegionStyles={setPreviewRegionStyles}
|
||||||
|
previewRegionContentStyles={previewRegionContentStyles}
|
||||||
|
setPreviewRegionContentStyles={setPreviewRegionContentStyles}
|
||||||
|
{...baseProps}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<SharedPreviewHarness />);
|
||||||
|
|
||||||
|
return baseProps;
|
||||||
|
};
|
||||||
|
|
||||||
it('renders a non-interactive preview region on the recording audio track', () => {
|
it('renders a non-interactive preview region on the recording audio track', () => {
|
||||||
const track = new KGAudioTrack('Audio Track', 1);
|
const track = new KGAudioTrack('Audio Track', 1);
|
||||||
track.setTrackIndex(0);
|
track.setTrackIndex(0);
|
||||||
|
|
||||||
const view = render(
|
render(
|
||||||
<TrackGridItem
|
<TrackGridItem
|
||||||
track={track}
|
track={track}
|
||||||
index={0}
|
index={0}
|
||||||
@@ -70,12 +191,253 @@ describe('TrackGridItem recording preview', () => {
|
|||||||
regions={[]}
|
regions={[]}
|
||||||
maxBars={8}
|
maxBars={8}
|
||||||
selectedRegionId={null}
|
selectedRegionId={null}
|
||||||
gridContainerRef={{ current: document.createElement('div') }}
|
gridContainerRef={createGridContainerRef()}
|
||||||
onDoubleClick={vi.fn()}
|
onDoubleClick={vi.fn()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const previewRegion = view.container.querySelector('[data-preview-region="true"]');
|
expect(regionItemProps.get('audio-recording-preview')).toBeTruthy();
|
||||||
expect(previewRegion).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(
|
||||||
|
<TrackGridItem
|
||||||
|
track={track}
|
||||||
|
index={0}
|
||||||
|
isDragging={false}
|
||||||
|
isDragOver={false}
|
||||||
|
regions={[regionC]}
|
||||||
|
maxBars={8}
|
||||||
|
selectedRegionId={null}
|
||||||
|
gridContainerRef={gridContainerRef}
|
||||||
|
onDoubleClick={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,12 +5,29 @@ import { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
|||||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||||
import RegionItem from './RegionItem';
|
import RegionItem from './RegionItem';
|
||||||
import TrackAutomationLane from './TrackAutomationLane';
|
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 { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
|
||||||
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
||||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||||
import { useProjectStore } from '../../stores/projectStore';
|
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 {
|
interface TrackGridItemProps {
|
||||||
track: KGTrack;
|
track: KGTrack;
|
||||||
index: number;
|
index: number;
|
||||||
@@ -35,6 +52,10 @@ interface TrackGridItemProps {
|
|||||||
onOpenHybrid?: (regionId: string) => void;
|
onOpenHybrid?: (regionId: string) => void;
|
||||||
allTracks?: KGTrack[]; // Added to access all tracks for drag operations
|
allTracks?: KGTrack[]; // Added to access all tracks for drag operations
|
||||||
onKGOneClipDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void;
|
onKGOneClipDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void;
|
||||||
|
previewRegionStyles?: Record<string, React.CSSProperties>;
|
||||||
|
setPreviewRegionStyles?: React.Dispatch<React.SetStateAction<Record<string, React.CSSProperties>>>;
|
||||||
|
previewRegionContentStyles?: Record<string, RegionPreviewContentStyle>;
|
||||||
|
setPreviewRegionContentStyles?: React.Dispatch<React.SetStateAction<Record<string, RegionPreviewContentStyle>>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
||||||
@@ -61,6 +82,10 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
onOpenHybrid,
|
onOpenHybrid,
|
||||||
allTracks,
|
allTracks,
|
||||||
onKGOneClipDrop,
|
onKGOneClipDrop,
|
||||||
|
previewRegionStyles,
|
||||||
|
setPreviewRegionStyles,
|
||||||
|
previewRegionContentStyles,
|
||||||
|
setPreviewRegionContentStyles,
|
||||||
}) => {
|
}) => {
|
||||||
const selectedRegionIds = useProjectStore(state => state.selectedRegionIds);
|
const selectedRegionIds = useProjectStore(state => state.selectedRegionIds);
|
||||||
const activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId);
|
const activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId);
|
||||||
@@ -76,7 +101,8 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
const [containerWidth, setContainerWidth] = useState(0);
|
const [containerWidth, setContainerWidth] = useState(0);
|
||||||
const [resizingRegion, setResizingRegion] = useState<string | null>(null);
|
const [resizingRegion, setResizingRegion] = useState<string | null>(null);
|
||||||
const [draggingRegion, setDraggingRegion] = useState<string | null>(null);
|
const [draggingRegion, setDraggingRegion] = useState<string | null>(null);
|
||||||
const [tempRegionStyles, setTempRegionStyles] = useState<Record<string, React.CSSProperties>>({});
|
const [localTempRegionStyles, setLocalTempRegionStyles] = useState<Record<string, React.CSSProperties>>({});
|
||||||
|
const [localPreviewRegionContentStyles, setLocalPreviewRegionContentStyles] = useState<Record<string, RegionPreviewContentStyle>>({});
|
||||||
const [isModifierPressed, setIsModifierPressed] = useState(false);
|
const [isModifierPressed, setIsModifierPressed] = useState(false);
|
||||||
|
|
||||||
// Refs for resize operations
|
// Refs for resize operations
|
||||||
@@ -86,14 +112,71 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
const currentResizeRegion = useRef<RegionUI | null>(null);
|
const currentResizeRegion = useRef<RegionUI | null>(null);
|
||||||
const initialBarNumberRef = useRef<number | null>(null);
|
const initialBarNumberRef = useRef<number | null>(null);
|
||||||
const initialLengthRef = useRef<number | null>(null);
|
const initialLengthRef = useRef<number | null>(null);
|
||||||
|
const resizePreviewBaselinesRef = useRef<RegionResizePreviewBaseline[]>([]);
|
||||||
|
const resizePreviewRegionIdsRef = useRef<string[]>([]);
|
||||||
|
|
||||||
// Refs for drag operations
|
// Refs for drag operations
|
||||||
const currentDragLeft = useRef<number | null>(null);
|
const currentDragLeft = useRef<number | null>(null);
|
||||||
const currentDragTop = useRef<number | null>(null);
|
const currentDragTop = useRef<number | null>(null);
|
||||||
const currentDragRegion = useRef<RegionUI | null>(null);
|
const currentDragRegion = useRef<RegionUI | null>(null);
|
||||||
|
const dragPreviewBaselinesRef = useRef<RegionDragPreviewBaseline[]>([]);
|
||||||
|
const dragPreviewRegionIdsRef = useRef<string[]>([]);
|
||||||
const trackElementRef = useRef<HTMLDivElement | null>(null);
|
const trackElementRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const isBulkRegionEdit = (regionId: string) => selectedRegionIds.length > 1 && selectedRegionIds.includes(regionId);
|
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<HTMLElement>('[data-region-id]'))
|
||||||
|
.find(element => element.getAttribute('data-region-id') === regionId);
|
||||||
|
const regionContentElement = regionElement?.querySelector<HTMLElement>('.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
|
// Update container width when the grid container changes size
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -144,7 +227,7 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
// Calculate region position and style
|
// Calculate region position and style
|
||||||
const getRegionStyle = (region: RegionUI) => {
|
const getRegionStyle = (region: RegionUI) => {
|
||||||
// Check if there's a temporary style for this region during resize or drag
|
// 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];
|
return tempRegionStyles[region.id];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,16 +279,41 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
currentResizeWidth.current = region.length * barWidth;
|
currentResizeWidth.current = region.length * barWidth;
|
||||||
currentResizeLeft.current = (region.barNumber - 1) * barWidth;
|
currentResizeLeft.current = (region.barNumber - 1) * barWidth;
|
||||||
|
|
||||||
// Set initial style to current position/size
|
const previewRegionIds = getPreviewRegionIds(regionId);
|
||||||
const initialStyle = {
|
resizePreviewBaselinesRef.current = previewRegionIds
|
||||||
left: `${currentResizeLeft.current}px`,
|
.map(id => regions.find(candidate => candidate.id === id))
|
||||||
width: `${currentResizeWidth.current}px`,
|
.filter((candidate): candidate is RegionUI => candidate !== undefined)
|
||||||
position: 'absolute' as const, // Fixed: Use const assertion
|
.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 => ({
|
setTempRegionStyles(prev => ({
|
||||||
...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<TrackGridItemProps> = ({
|
|||||||
console.log(`RESIZE: regionId=${regionId}, action=${resizeAction}, deltaX=${deltaX}, newBarNumber=${newBarNumber}, newLength=${newLength}`);
|
console.log(`RESIZE: regionId=${regionId}, action=${resizeAction}, deltaX=${deltaX}, newBarNumber=${newBarNumber}, newLength=${newLength}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the temporary style for this region
|
const leftDelta = newLeft - originalLeft;
|
||||||
const newStyle = {
|
const widthDelta = newWidth - originalWidth;
|
||||||
left: `${newLeft}px`,
|
const previewBaselines = resizePreviewBaselinesRef.current.length > 0
|
||||||
width: `${newWidth}px`,
|
? resizePreviewBaselinesRef.current
|
||||||
position: 'absolute' as const, // Fixed: Use const assertion
|
: [{
|
||||||
};
|
regionId,
|
||||||
|
originalBarNumber: region.barNumber,
|
||||||
|
originalLength: region.length,
|
||||||
|
originalLeft,
|
||||||
|
originalWidth,
|
||||||
|
originalContentWidth: getMeasuredRegionContentWidth(regionId, originalWidth),
|
||||||
|
}];
|
||||||
|
|
||||||
setTempRegionStyles(prev => ({
|
setTempRegionStyles(prev => ({
|
||||||
...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
|
// Notify parent about resize
|
||||||
@@ -328,16 +460,15 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
|
|
||||||
// Clear resizing state
|
// Clear resizing state
|
||||||
setResizingRegion(null);
|
setResizingRegion(null);
|
||||||
setTempRegionStyles(prev => {
|
clearTempRegionStyles(resizePreviewRegionIdsRef.current);
|
||||||
const updated = { ...prev };
|
clearTempPreviewRegionContentStyles(resizePreviewRegionIdsRef.current);
|
||||||
delete updated[regionId];
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
currentResizeWidth.current = null;
|
currentResizeWidth.current = null;
|
||||||
currentResizeLeft.current = null;
|
currentResizeLeft.current = null;
|
||||||
currentResizeRegion.current = null;
|
currentResizeRegion.current = null;
|
||||||
initialBarNumberRef.current = null;
|
initialBarNumberRef.current = null;
|
||||||
initialLengthRef.current = null;
|
initialLengthRef.current = null;
|
||||||
|
resizePreviewBaselinesRef.current = [];
|
||||||
|
resizePreviewRegionIdsRef.current = [];
|
||||||
|
|
||||||
// Notify parent about resize end with rounded values
|
// Notify parent about resize end with rounded values
|
||||||
if (onRegionResizeEnd) {
|
if (onRegionResizeEnd) {
|
||||||
@@ -379,17 +510,31 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
currentDragLeft.current = left;
|
currentDragLeft.current = left;
|
||||||
currentDragTop.current = 0; // Initially at the top of the current track
|
currentDragTop.current = 0; // Initially at the top of the current track
|
||||||
|
|
||||||
// Set initial style
|
const previewRegionIds = getPreviewRegionIds(regionId);
|
||||||
const initialStyle = {
|
dragPreviewBaselinesRef.current = previewRegionIds
|
||||||
left: `${left}px`,
|
.map(id => regions.find(candidate => candidate.id === id))
|
||||||
width: `${width}px`,
|
.filter((candidate): candidate is RegionUI => candidate !== undefined)
|
||||||
position: 'absolute' as const, // Fixed: Use const assertion
|
.map(candidate => ({
|
||||||
zIndex: 100, // Bring to front during drag
|
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 => ({
|
setTempRegionStyles(prev => ({
|
||||||
...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<TrackGridItemProps> = ({
|
|||||||
console.log(`DRAG: regionId=${regionId}, deltaX=${deltaX}, deltaY=${deltaY}, newBarNumber=${newBarNumber}`);
|
console.log(`DRAG: regionId=${regionId}, deltaX=${deltaX}, deltaY=${deltaY}, newBarNumber=${newBarNumber}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the temporary style for this region
|
const leftDelta = newLeft - initialLeft;
|
||||||
const newStyle = {
|
const previewBaselines = dragPreviewBaselinesRef.current.length > 0
|
||||||
left: `${newLeft}px`,
|
? dragPreviewBaselinesRef.current
|
||||||
width: `${region.length * barWidth}px`,
|
: [{
|
||||||
position: 'absolute' as const,
|
regionId,
|
||||||
zIndex: 100, // Keep on top during drag
|
originalBarNumber: region.barNumber,
|
||||||
transform: `translateY(${appliedDeltaY}px)`,
|
originalTrackIndex: region.trackIndex,
|
||||||
};
|
originalLeft: initialLeft,
|
||||||
|
originalWidth: region.length * barWidth,
|
||||||
|
}];
|
||||||
|
|
||||||
setTempRegionStyles(prev => ({
|
setTempRegionStyles(prev => ({
|
||||||
...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
|
// We'll calculate the track index on drag end, but still notify parent about the drag
|
||||||
@@ -506,14 +662,13 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
|
|
||||||
// Clear dragging state
|
// Clear dragging state
|
||||||
setDraggingRegion(null);
|
setDraggingRegion(null);
|
||||||
setTempRegionStyles(prev => {
|
clearTempRegionStyles(dragPreviewRegionIdsRef.current);
|
||||||
const updated = { ...prev };
|
clearTempPreviewRegionContentStyles(dragPreviewRegionIdsRef.current);
|
||||||
delete updated[regionId];
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
currentDragLeft.current = null;
|
currentDragLeft.current = null;
|
||||||
currentDragTop.current = null;
|
currentDragTop.current = null;
|
||||||
currentDragRegion.current = null;
|
currentDragRegion.current = null;
|
||||||
|
dragPreviewBaselinesRef.current = [];
|
||||||
|
dragPreviewRegionIdsRef.current = [];
|
||||||
|
|
||||||
if (finalBarNumber === region.barNumber && finalTrackIndex === region.trackIndex) {
|
if (finalBarNumber === region.barNumber && finalTrackIndex === region.trackIndex) {
|
||||||
if (DEBUG_MODE.TRACK_GRID_ITEM) {
|
if (DEBUG_MODE.TRACK_GRID_ITEM) {
|
||||||
@@ -572,7 +727,7 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (!isAutomationActive) {
|
if (!isAutomationActive) {
|
||||||
onClick && onClick(e, index);
|
onClick?.(e, index);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
ref={trackElementRef}
|
ref={trackElementRef}
|
||||||
@@ -640,6 +795,7 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
midiRegion={midiRegion}
|
midiRegion={midiRegion}
|
||||||
audioRegion={audioRegion}
|
audioRegion={audioRegion}
|
||||||
audioBuffer={audioBuffer}
|
audioBuffer={audioBuffer}
|
||||||
|
previewContentStyle={tempPreviewRegionContentStyles[region.id]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
|||||||
import TrackGridItem from './TrackGridItem';
|
import TrackGridItem from './TrackGridItem';
|
||||||
import { Playhead, FileImportModal } from '../common';
|
import { Playhead, FileImportModal } from '../common';
|
||||||
import SelectionBox from '../piano-roll/SelectionBox';
|
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 { DEBUG_MODE, PIANO_ROLL_CONSTANTS, REGION_CONSTANTS } from '../../constants';
|
||||||
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
||||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||||
@@ -66,6 +66,8 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
const refreshProjectState = useProjectStore(state => state.refreshProjectState);
|
const refreshProjectState = useProjectStore(state => state.refreshProjectState);
|
||||||
const gridContainerRef = useRef<HTMLDivElement>(null);
|
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const [showAudioImportModal, setShowAudioImportModal] = useState(false);
|
const [showAudioImportModal, setShowAudioImportModal] = useState(false);
|
||||||
|
const [previewRegionStyles, setPreviewRegionStyles] = useState<Record<string, React.CSSProperties>>({});
|
||||||
|
const [previewRegionContentStyles, setPreviewRegionContentStyles] = useState<Record<string, RegionPreviewContentStyle>>({});
|
||||||
const pendingAudioImportRef = useRef<{ barNumber: number; trackIndex: number } | null>(null);
|
const pendingAudioImportRef = useRef<{ barNumber: number; trackIndex: number } | null>(null);
|
||||||
const isLassoSelectingRef = useRef(false);
|
const isLassoSelectingRef = useRef(false);
|
||||||
const isLassoShiftPressedRef = useRef(false);
|
const isLassoShiftPressedRef = useRef(false);
|
||||||
@@ -907,6 +909,10 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
onOpenHybrid={onOpenHybrid}
|
onOpenHybrid={onOpenHybrid}
|
||||||
allTracks={tracks}
|
allTracks={tracks}
|
||||||
onKGOneClipDrop={handleExternalDrop}
|
onKGOneClipDrop={handleExternalDrop}
|
||||||
|
previewRegionStyles={previewRegionStyles}
|
||||||
|
setPreviewRegionStyles={setPreviewRegionStyles}
|
||||||
|
previewRegionContentStyles={previewRegionContentStyles}
|
||||||
|
setPreviewRegionContentStyles={setPreviewRegionContentStyles}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
const volumeInputRef = useRef<HTMLInputElement>(null);
|
const volumeInputRef = useRef<HTMLInputElement>(null);
|
||||||
// Local flag to track slider interaction; not used for rendering
|
// Local flag to track slider interaction; not used for rendering
|
||||||
const isAdjustingVolumeRef = useRef(false);
|
const isAdjustingVolumeRef = useRef(false);
|
||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(track.getMuted());
|
||||||
const [solo, setSolo] = useState(false);
|
const [solo, setSolo] = useState(track.getSolo());
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
// Close dropdown when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -135,11 +135,10 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
setVolume(track.getVolume());
|
setVolume(track.getVolume());
|
||||||
}, [allTracks, track]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const audioInterface = KGAudioInterface.instance();
|
setMuted(track.getMuted());
|
||||||
setMuted(audioInterface.getTrackMuted(track.getId().toString()));
|
setSolo(track.getSolo());
|
||||||
setSolo(audioInterface.getTrackSolo(track.getId().toString()));
|
|
||||||
}, [allTracks, track]);
|
}, [allTracks, track]);
|
||||||
|
|
||||||
// Handle track name edit within the component
|
// Handle track name edit within the component
|
||||||
@@ -252,22 +251,20 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const next = !muted;
|
const next = !muted;
|
||||||
setMuted(next);
|
setMuted(next);
|
||||||
try {
|
useProjectStore.getState().updateTrackProperties(track.getId(), { muted: next }).catch(err => {
|
||||||
KGAudioInterface.instance().setTrackMute(track.getId().toString(), next);
|
setMuted(track.getMuted());
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to toggle mute:', err);
|
console.error('Failed to toggle mute:', err);
|
||||||
}
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleSolo = (e: React.MouseEvent<HTMLButtonElement>) => {
|
const handleToggleSolo = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const next = !solo;
|
const next = !solo;
|
||||||
setSolo(next);
|
setSolo(next);
|
||||||
try {
|
useProjectStore.getState().updateTrackProperties(track.getId(), { solo: next }).catch(err => {
|
||||||
KGAudioInterface.instance().setTrackSolo(track.getId().toString(), next);
|
setSolo(track.getSolo());
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to toggle solo:', err);
|
console.error('Failed to toggle solo:', err);
|
||||||
}
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle track click
|
// Handle track click
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export const OPFS_CONSTANTS = {
|
|||||||
|
|
||||||
export const CONFIG_UPGRADER_CONSTANTS = {
|
export const CONFIG_UPGRADER_CONSTANTS = {
|
||||||
VERSION_KEY: '__config_version',
|
VERSION_KEY: '__config_version',
|
||||||
CURRENT_VERSION: 3,
|
CURRENT_VERSION: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const URL_CONSTANTS = {
|
export const URL_CONSTANTS = {
|
||||||
|
|||||||
+15
-2
@@ -49,11 +49,15 @@ export class KGProject {
|
|||||||
@WithDefault(1)
|
@WithDefault(1)
|
||||||
private barWidthMultiplier: number = 1;
|
private barWidthMultiplier: number = 1;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@WithDefault(1)
|
||||||
|
private pianoRollZoom: number = 1;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@WithDefault(0)
|
@WithDefault(0)
|
||||||
private projectStructureVersion: number = 0;
|
private projectStructureVersion: number = 0;
|
||||||
|
|
||||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 10;
|
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 12;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Type(() => KGTrack, {
|
@Type(() => KGTrack, {
|
||||||
@@ -69,7 +73,7 @@ export class KGProject {
|
|||||||
private tracks: KGTrack[] = [];
|
private tracks: KGTrack[] = [];
|
||||||
|
|
||||||
// Constructor
|
// 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.name = name;
|
||||||
this.maxBars = maxBars;
|
this.maxBars = maxBars;
|
||||||
this.currentBars = currentBars;
|
this.currentBars = currentBars;
|
||||||
@@ -82,6 +86,7 @@ export class KGProject {
|
|||||||
this.barWidthMultiplier = barWidthMultiplier;
|
this.barWidthMultiplier = barWidthMultiplier;
|
||||||
this.tracks = tracks;
|
this.tracks = tracks;
|
||||||
this.projectStructureVersion = projectStructureVersion;
|
this.projectStructureVersion = projectStructureVersion;
|
||||||
|
this.pianoRollZoom = pianoRollZoom;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
@@ -181,4 +186,12 @@ export class KGProject {
|
|||||||
public setBarWidthMultiplier(barWidthMultiplier: number): void {
|
public setBarWidthMultiplier(barWidthMultiplier: number): void {
|
||||||
this.barWidthMultiplier = barWidthMultiplier;
|
this.barWidthMultiplier = barWidthMultiplier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getPianoRollZoom(): number {
|
||||||
|
return this.pianoRollZoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setPianoRollZoom(pianoRollZoom: number): void {
|
||||||
|
this.pianoRollZoom = pianoRollZoom;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,11 +242,13 @@ export class KGAudioInterface {
|
|||||||
console.log(`Creating audio bus for track ${trackId} with instrument ${instrumentType}`);
|
console.log(`Creating audio bus for track ${trackId} with instrument ${instrumentType}`);
|
||||||
|
|
||||||
// Create new audio bus
|
// 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 project = KGCore.instance().getCurrentProject();
|
||||||
const track = project.getTracks().find(t => t.getId().toString() === trackId);
|
const track = project.getTracks().find(t => t.getId().toString() === trackId);
|
||||||
const initialVolume = track ? track.getVolume() : AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME;
|
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
|
// Connect to master gain if available, otherwise to destination
|
||||||
if (this.masterGain) {
|
if (this.masterGain) {
|
||||||
@@ -299,7 +301,11 @@ export class KGAudioInterface {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`Creating audio player bus for track ${trackId}`);
|
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) {
|
if (this.masterGain) {
|
||||||
playerBus.connect(this.masterGain);
|
playerBus.connect(this.masterGain);
|
||||||
|
|||||||
@@ -1,7 +1,37 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { applyOfflinePitchBendAutomation, encodeWav, getOfflineTrackGain, getOfflineTrackVolumeDb } from './KGOfflineRenderer';
|
import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack, createMockProject } from '../../test/utils/mock-data';
|
||||||
import { bakeMidiAutomationPointsInWindow } from '../../util/midiAutomationUtil';
|
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.
|
* Create a minimal AudioBuffer-like object for testing.
|
||||||
* In the jsdom test environment, AudioBuffer is not available,
|
* 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);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export class KGOfflineRenderer {
|
|||||||
|
|
||||||
let renderStartBeat = 0;
|
let renderStartBeat = 0;
|
||||||
let renderEndBeat: number;
|
let renderEndBeat: number;
|
||||||
|
const bounceStartsFromBeat1 = (ConfigManager.instance().get('audio.bounce_starts_from_beat_1') as boolean) ?? true;
|
||||||
|
|
||||||
const isLooping = project.getIsLooping();
|
const isLooping = project.getIsLooping();
|
||||||
// Looping range is determined up-front; non-looping range is computed
|
// Looping range is determined up-front; non-looping range is computed
|
||||||
@@ -272,7 +273,7 @@ export class KGOfflineRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (contentEnd > 0) {
|
if (contentEnd > 0) {
|
||||||
renderStartBeat = contentStart;
|
renderStartBeat = bounceStartsFromBeat1 ? 0 : contentStart;
|
||||||
renderEndBeat = contentEnd;
|
renderEndBeat = contentEnd;
|
||||||
}
|
}
|
||||||
// else: no content found, keep the full project range as fallback
|
// else: no content found, keep the full project range as fallback
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export { DeleteNotesCommand, DeleteNoteCommand } from './note/DeleteNotesCommand
|
|||||||
export { ResizeNotesCommand } from './note/ResizeNotesCommand';
|
export { ResizeNotesCommand } from './note/ResizeNotesCommand';
|
||||||
export { MoveNotesCommand } from './note/MoveNotesCommand';
|
export { MoveNotesCommand } from './note/MoveNotesCommand';
|
||||||
export { PasteNotesCommand } from './note/PasteNotesCommand';
|
export { PasteNotesCommand } from './note/PasteNotesCommand';
|
||||||
|
export { SplitSelectedNotesCommand } from './note/SplitSelectedNotesCommand';
|
||||||
export { UpdateNotePropertiesCommand } from './note/UpdateNotePropertiesCommand';
|
export { UpdateNotePropertiesCommand } from './note/UpdateNotePropertiesCommand';
|
||||||
export { UpdatePitchBendPropertiesCommand } from './note/UpdatePitchBendPropertiesCommand';
|
export { UpdatePitchBendPropertiesCommand } from './note/UpdatePitchBendPropertiesCommand';
|
||||||
export { UpdateControllerEventPropertiesCommand } from './note/UpdateControllerEventPropertiesCommand';
|
export { UpdateControllerEventPropertiesCommand } from './note/UpdateControllerEventPropertiesCommand';
|
||||||
|
|||||||
@@ -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<typeof vi.fn>;
|
||||||
|
getSelectedItems: ReturnType<typeof vi.fn>;
|
||||||
|
clearSelectedItems: ReturnType<typeof vi.fn>;
|
||||||
|
addSelectedItems: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,8 @@ export interface TrackUpdateProperties {
|
|||||||
instrument?: InstrumentType; // Only applies to MIDI tracks
|
instrument?: InstrumentType; // Only applies to MIDI tracks
|
||||||
type?: TrackType;
|
type?: TrackType;
|
||||||
volume?: number;
|
volume?: number;
|
||||||
|
muted?: boolean;
|
||||||
|
solo?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +49,8 @@ export class UpdateTrackCommand extends KGCommand {
|
|||||||
name: this.targetTrack.getName(),
|
name: this.targetTrack.getName(),
|
||||||
type: this.targetTrack.getType(),
|
type: this.targetTrack.getType(),
|
||||||
volume: this.targetTrack.getVolume(),
|
volume: this.targetTrack.getVolume(),
|
||||||
|
muted: this.targetTrack.getMuted(),
|
||||||
|
solo: this.targetTrack.getSolo(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store original instrument if it's a MIDI track
|
// Store original instrument if it's a MIDI track
|
||||||
@@ -103,6 +107,32 @@ export class UpdateTrackCommand extends KGCommand {
|
|||||||
updatedProperties.push(`volume: ${originalVolume} → ${newVolume}`);
|
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) {
|
if (updatedProperties.length > 0) {
|
||||||
console.log(`Updated track ${this.trackId}: ${updatedProperties.join(', ')}`);
|
console.log(`Updated track ${this.trackId}: ${updatedProperties.join(', ')}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -156,6 +186,24 @@ export class UpdateTrackCommand extends KGCommand {
|
|||||||
restoredProperties.push(`volume: ${this.originalProperties.volume}`);
|
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(', ')}`);
|
console.log(`Restored track ${this.trackId}: ${restoredProperties.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +223,12 @@ export class UpdateTrackCommand extends KGCommand {
|
|||||||
if (this.newProperties.volume !== undefined) {
|
if (this.newProperties.volume !== undefined) {
|
||||||
updatedProps.push('volume');
|
updatedProps.push('volume');
|
||||||
}
|
}
|
||||||
|
if (this.newProperties.muted !== undefined) {
|
||||||
|
updatedProps.push('muted');
|
||||||
|
}
|
||||||
|
if (this.newProperties.solo !== undefined) {
|
||||||
|
updatedProps.push('solo');
|
||||||
|
}
|
||||||
|
|
||||||
if (updatedProps.length === 1) {
|
if (updatedProps.length === 1) {
|
||||||
return `Update track "${trackName}" ${updatedProps[0]}`;
|
return `Update track "${trackName}" ${updatedProps[0]}`;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { CONFIG_UPGRADER_CONSTANTS } from '../../constants/coreConstants';
|
|||||||
import { upgradeConfigToV1 } from './upgradeConfigToV1';
|
import { upgradeConfigToV1 } from './upgradeConfigToV1';
|
||||||
import { upgradeConfigToV2 } from './upgradeConfigToV2';
|
import { upgradeConfigToV2 } from './upgradeConfigToV2';
|
||||||
import { upgradeConfigToV3 } from './upgradeConfigToV3';
|
import { upgradeConfigToV3 } from './upgradeConfigToV3';
|
||||||
|
import { upgradeConfigToV4 } from './upgradeConfigToV4';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KGConfigUpgrader — Orchestrates app-level migrations (e.g., storage backend changes).
|
* KGConfigUpgrader — Orchestrates app-level migrations (e.g., storage backend changes).
|
||||||
@@ -43,6 +44,10 @@ export class KGConfigUpgrader {
|
|||||||
await upgradeConfigToV3();
|
await upgradeConfigToV3();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 4: {
|
||||||
|
await upgradeConfigToV4();
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
throw new Error(`No config upgrader found for version ${nextVersion}`);
|
throw new Error(`No config upgrader found for version ${nextVersion}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, unknown> = {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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<string, unknown> = {
|
||||||
|
audio: {
|
||||||
|
bounce_starts_from_beat_1: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
getRawMock.mockResolvedValue(config);
|
||||||
|
|
||||||
|
await upgradeConfigToV4();
|
||||||
|
|
||||||
|
expect(saveRawMock).not.toHaveBeenCalled();
|
||||||
|
expect((config.audio as Record<string, unknown>).bounce_starts_from_beat_1).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { KGConfigStorage } from '../io/KGConfigStorage';
|
||||||
|
|
||||||
|
const CONFIG_KEY = 'userConfig';
|
||||||
|
|
||||||
|
export async function upgradeConfigToV4(): Promise<void> {
|
||||||
|
const storage = KGConfigStorage.getInstance();
|
||||||
|
const rawConfig = await storage.getRaw(CONFIG_KEY);
|
||||||
|
if (!rawConfig || typeof rawConfig !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = rawConfig as Record<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
if ('bounce_starts_from_beat_1' in audioRecord) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
audioRecord.bounce_starts_from_beat_1 = true;
|
||||||
|
await storage.saveRaw(CONFIG_KEY, config);
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@ interface AppConfig {
|
|||||||
persist_api_keys_non_localhost: boolean;
|
persist_api_keys_non_localhost: boolean;
|
||||||
local_browser: {
|
local_browser: {
|
||||||
context_length: 32768 | 65536 | 131072;
|
context_length: 32768 | 65536 | 131072;
|
||||||
|
model_url: string;
|
||||||
|
};
|
||||||
|
uvr5_web_runtime: {
|
||||||
|
mdx_net_model_url: string;
|
||||||
};
|
};
|
||||||
openai: {
|
openai: {
|
||||||
api_key: string;
|
api_key: string;
|
||||||
@@ -47,6 +51,7 @@ interface AppConfig {
|
|||||||
hold_to_create_region: string;
|
hold_to_create_region: string;
|
||||||
play: string;
|
play: string;
|
||||||
loop: string;
|
loop: string;
|
||||||
|
record: string;
|
||||||
undo: string;
|
undo: string;
|
||||||
redo: string;
|
redo: string;
|
||||||
select_all: string;
|
select_all: string;
|
||||||
@@ -54,6 +59,8 @@ interface AppConfig {
|
|||||||
cut: string;
|
cut: string;
|
||||||
paste: string;
|
paste: string;
|
||||||
save: string;
|
save: string;
|
||||||
|
split_region: string;
|
||||||
|
merge_regions: string;
|
||||||
};
|
};
|
||||||
piano_roll: {
|
piano_roll: {
|
||||||
switch: string;
|
switch: string;
|
||||||
@@ -80,6 +87,7 @@ interface AppConfig {
|
|||||||
default_open: boolean;
|
default_open: boolean;
|
||||||
};
|
};
|
||||||
audio: {
|
audio: {
|
||||||
|
bounce_starts_from_beat_1: boolean;
|
||||||
enable_audio_capture_for_screen_sharing: boolean;
|
enable_audio_capture_for_screen_sharing: boolean;
|
||||||
input_device_id: string;
|
input_device_id: string;
|
||||||
lookahead_time: number;
|
lookahead_time: number;
|
||||||
@@ -212,7 +220,11 @@ export class ConfigManager {
|
|||||||
model: ''
|
model: ''
|
||||||
},
|
},
|
||||||
local_browser: {
|
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: {
|
soundfont: {
|
||||||
base_url: 'https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/'
|
base_url: 'https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/'
|
||||||
@@ -227,13 +239,16 @@ export class ConfigManager {
|
|||||||
hold_to_create_region: 'ctrl',
|
hold_to_create_region: 'ctrl',
|
||||||
play: 'space',
|
play: 'space',
|
||||||
loop: 'c',
|
loop: 'c',
|
||||||
|
record: 'r',
|
||||||
undo: 'ctrl+z',
|
undo: 'ctrl+z',
|
||||||
redo: 'ctrl+shift+z',
|
redo: 'ctrl+shift+z',
|
||||||
select_all: 'ctrl+a',
|
select_all: 'ctrl+a',
|
||||||
copy: 'ctrl+c',
|
copy: 'ctrl+c',
|
||||||
cut: 'ctrl+x',
|
cut: 'ctrl+x',
|
||||||
paste: 'ctrl+v',
|
paste: 'ctrl+v',
|
||||||
save: 'ctrl+s'
|
save: 'ctrl+s',
|
||||||
|
split_region: 'ctrl+t',
|
||||||
|
merge_regions: 'ctrl+j'
|
||||||
},
|
},
|
||||||
piano_roll: {
|
piano_roll: {
|
||||||
switch: 'tab',
|
switch: 'tab',
|
||||||
@@ -260,6 +275,7 @@ export class ConfigManager {
|
|||||||
default_open: true
|
default_open: true
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
|
bounce_starts_from_beat_1: true,
|
||||||
enable_audio_capture_for_screen_sharing: false,
|
enable_audio_capture_for_screen_sharing: false,
|
||||||
input_device_id: 'default',
|
input_device_id: 'default',
|
||||||
lookahead_time: 0.05,
|
lookahead_time: 0.05,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage';
|
import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage';
|
||||||
import { KGProject } from '../KGProject';
|
import { KGProject } from '../KGProject';
|
||||||
|
import { KGTrack } from '../track/KGTrack';
|
||||||
|
|
||||||
// --- OPFS mock infrastructure ---
|
// --- OPFS mock infrastructure ---
|
||||||
|
|
||||||
@@ -104,6 +105,13 @@ describe('KGProjectStorage', () => {
|
|||||||
return new KGProject(name, 16, 0, 120);
|
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 () => {
|
it('initializes and creates the projects directory', async () => {
|
||||||
// The projects directory should exist after init
|
// The projects directory should exist after init
|
||||||
const projects = await mockRoot.getDirectoryHandle('projects');
|
const projects = await mockRoot.getDirectoryHandle('projects');
|
||||||
@@ -121,6 +129,36 @@ describe('KGProjectStorage', () => {
|
|||||||
expect(loaded!.getBpm()).toBe(120);
|
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 () => {
|
it('creates meta.json and media/ directory on save', async () => {
|
||||||
const project = createTestProject('My Song');
|
const project = createTestProject('My Song');
|
||||||
await storage.save('My Song', project);
|
await storage.save('My Song', project);
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { LocalSeparatorModelCache } from '../../util/localSeparatorModelCache';
|
import { LocalSeparatorModelCache } from '../../util/localSeparatorModelCache';
|
||||||
|
import {
|
||||||
|
LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES,
|
||||||
|
LOCAL_SEPARATOR_MODEL_FILENAME,
|
||||||
|
} from '../../util/localSeparatorConfig';
|
||||||
|
|
||||||
class MockWritableFileStream {
|
class MockWritableFileStream {
|
||||||
private readonly handle: MockFileSystemFileHandle;
|
private readonly handle: MockFileSystemFileHandle;
|
||||||
@@ -109,47 +113,81 @@ vi.stubGlobal('navigator', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('LocalSeparatorModelCache', () => {
|
describe('LocalSeparatorModelCache', () => {
|
||||||
|
const makeModelBytes = (fill: number): Uint8Array => new Uint8Array(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES).fill(fill);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockRoot.clear();
|
mockRoot.clear();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('downloads and stores a model in OPFS cache', async () => {
|
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]), {
|
vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Length': '3' },
|
headers: { 'Content-Length': '3' },
|
||||||
})));
|
})));
|
||||||
|
|
||||||
await LocalSeparatorModelCache.download('https://example.com/model.onnx', 'model.onnx');
|
await expect(LocalSeparatorModelCache.download('https://example.com/model.onnx')).rejects.toThrow(/size mismatch/i);
|
||||||
|
expect(await LocalSeparatorModelCache.exists()).toBe(false);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { KGAudioTrack } from '../track/KGAudioTrack';
|
import { KGAudioTrack } from '../track/KGAudioTrack';
|
||||||
import { KGMidiTrack } from '../track/KGMidiTrack';
|
import { KGMidiTrack } from '../track/KGMidiTrack';
|
||||||
|
|
||||||
|
type TestMidiEvent = { data: Uint8Array };
|
||||||
|
type TestLiveNoteActivityListener = (...args: [{ pitch: number; isNoteOn: boolean }]) => void;
|
||||||
|
|
||||||
const { getStateMock, audioInterfaceMock } = vi.hoisted(() => ({
|
const { getStateMock, audioInterfaceMock } = vi.hoisted(() => ({
|
||||||
getStateMock: vi.fn(),
|
getStateMock: vi.fn(),
|
||||||
audioInterfaceMock: {
|
audioInterfaceMock: {
|
||||||
@@ -45,19 +48,40 @@ describe('KGMidiInput pitch bend', () => {
|
|||||||
|
|
||||||
it('routes live MIDI note on/off through the live monitoring path', () => {
|
it('routes live MIDI note on/off through the live monitoring path', () => {
|
||||||
const midiInput = KGMidiInput.instance() as unknown as {
|
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([0x90, 60, 100]) });
|
||||||
midiInput.handleMIDIMessage({ data: new Uint8Array([0x80, 60, 0]) });
|
midiInput.handleMIDIMessage({ data: new Uint8Array([0x80, 60, 0]) });
|
||||||
|
|
||||||
expect(audioInterfaceMock.triggerLiveMidiNoteAttack).toHaveBeenCalledWith('1', 60, 100);
|
expect(audioInterfaceMock.triggerLiveMidiNoteAttack).toHaveBeenCalledWith('1', 60, 100);
|
||||||
expect(audioInterfaceMock.releaseLiveMidiNote).toHaveBeenCalledWith('1', 60);
|
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', () => {
|
it('latches live note ownership to the note-on track', () => {
|
||||||
const midiInput = KGMidiInput.instance() as unknown as {
|
const midiInput = KGMidiInput.instance() as unknown as {
|
||||||
handleMIDIMessage: (event: { data: Uint8Array }) => void;
|
handleMIDIMessage: (...args: [TestMidiEvent]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
midiInput.handleMIDIMessage({ data: new Uint8Array([0x90, 60, 100]) });
|
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', () => {
|
it('normalizes MIDI pitch bend and forwards it to the selected track', () => {
|
||||||
const midiInput = KGMidiInput.instance() as unknown as {
|
const midiInput = KGMidiInput.instance() as unknown as {
|
||||||
handleMIDIMessage: (event: { data: Uint8Array }) => void;
|
handleMIDIMessage: (...args: [TestMidiEvent]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
midiInput.handleMIDIMessage({ data: new Uint8Array([0xe0, 0x00, 0x40]) });
|
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', () => {
|
it('maps supported CC messages to live expression and sustain for standard pedals', () => {
|
||||||
const midiInput = KGMidiInput.instance() as unknown as {
|
const midiInput = KGMidiInput.instance() as unknown as {
|
||||||
handleMIDIMessage: (event: { data: Uint8Array }) => void;
|
handleMIDIMessage: (...args: [TestMidiEvent]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
midiInput.handleMIDIMessage({ data: new Uint8Array([0xb0, 0x01, 0x20]) });
|
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', () => {
|
it('calibrates inverted sustain pedals from the first observed CC64 message', () => {
|
||||||
const midiInput = KGMidiInput.instance() as unknown as {
|
const midiInput = KGMidiInput.instance() as unknown as {
|
||||||
handleMIDIMessage: (event: { data: Uint8Array }) => void;
|
handleMIDIMessage: (...args: [TestMidiEvent]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
midiInput.handleMIDIMessage({ data: new Uint8Array([0xb0, 0x40, 0x00]) });
|
midiInput.handleMIDIMessage({ data: new Uint8Array([0xb0, 0x40, 0x00]) });
|
||||||
@@ -123,7 +147,7 @@ describe('KGMidiInput pitch bend', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const midiInput = KGMidiInput.instance() as unknown as {
|
const midiInput = KGMidiInput.instance() as unknown as {
|
||||||
handleMIDIMessage: (event: { data: Uint8Array }) => void;
|
handleMIDIMessage: (...args: [TestMidiEvent]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
midiInput.handleMIDIMessage({ data: new Uint8Array([0x90, 60, 100]) });
|
midiInput.handleMIDIMessage({ data: new Uint8Array([0x90, 60, 100]) });
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import { KGAudioInterface } from '../audio-interface/KGAudioInterface';
|
|||||||
import { useProjectStore } from '../../stores/projectStore';
|
import { useProjectStore } from '../../stores/projectStore';
|
||||||
import { KGMidiTrack } from '../track/KGMidiTrack';
|
import { KGMidiTrack } from '../track/KGMidiTrack';
|
||||||
|
|
||||||
|
export interface LiveMidiNoteActivityEvent {
|
||||||
|
pitch: number;
|
||||||
|
isNoteOn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LiveNoteActivityListener = (...args: [LiveMidiNoteActivityEvent]) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KGMidiInput - MIDI input manager for the DAW
|
* KGMidiInput - MIDI input manager for the DAW
|
||||||
* Implements the singleton pattern for global MIDI device management
|
* 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 onRecordControlChange: ((controller: number, value: number) => void) | null = null;
|
||||||
private liveNoteTrackOwnership: Map<number, string[]> = new Map();
|
private liveNoteTrackOwnership: Map<number, string[]> = new Map();
|
||||||
private sustainPolarityInverted: boolean | null = null;
|
private sustainPolarityInverted: boolean | null = null;
|
||||||
|
private liveNoteActivityListeners: LiveNoteActivityListener[] = [];
|
||||||
|
|
||||||
// Private constructor to prevent direct instantiation
|
// Private constructor to prevent direct instantiation
|
||||||
private constructor() {
|
private constructor() {
|
||||||
@@ -178,12 +186,14 @@ export class KGMidiInput {
|
|||||||
// Note On: command = 0x90 (144)
|
// Note On: command = 0x90 (144)
|
||||||
if (command === 0x90 && velocity > 0) {
|
if (command === 0x90 && velocity > 0) {
|
||||||
console.log(`MIDI Note On: pitch=${pitch}, velocity=${velocity}, channel=${channel}`);
|
console.log(`MIDI Note On: pitch=${pitch}, velocity=${velocity}, channel=${channel}`);
|
||||||
|
this.emitLiveNoteActivity({ pitch, isNoteOn: true });
|
||||||
this.triggerNoteOn(pitch, velocity);
|
this.triggerNoteOn(pitch, velocity);
|
||||||
this.onRecordNoteOn?.(pitch, velocity);
|
this.onRecordNoteOn?.(pitch, velocity);
|
||||||
}
|
}
|
||||||
// Note Off: command = 0x80 (128) or Note On with velocity 0
|
// Note Off: command = 0x80 (128) or Note On with velocity 0
|
||||||
else if (command === 0x80 || (command === 0x90 && velocity === 0)) {
|
else if (command === 0x80 || (command === 0x90 && velocity === 0)) {
|
||||||
console.log(`MIDI Note Off: pitch=${pitch}, channel=${channel}`);
|
console.log(`MIDI Note Off: pitch=${pitch}, channel=${channel}`);
|
||||||
|
this.emitLiveNoteActivity({ pitch, isNoteOn: false });
|
||||||
this.triggerNoteOff(pitch);
|
this.triggerNoteOff(pitch);
|
||||||
this.onRecordNoteOff?.(pitch);
|
this.onRecordNoteOff?.(pitch);
|
||||||
}
|
}
|
||||||
@@ -354,6 +364,16 @@ export class KGMidiInput {
|
|||||||
return this.sustainPolarityInverted ? !rawPressed : rawPressed;
|
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
|
* Clean up MIDI resources
|
||||||
*/
|
*/
|
||||||
@@ -374,6 +394,7 @@ export class KGMidiInput {
|
|||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
this.liveNoteTrackOwnership.clear();
|
this.liveNoteTrackOwnership.clear();
|
||||||
this.sustainPolarityInverted = null;
|
this.sustainPolarityInverted = null;
|
||||||
|
this.liveNoteActivityListeners = [];
|
||||||
|
|
||||||
console.log("MIDI resources disposed successfully");
|
console.log("MIDI resources disposed successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -395,6 +416,14 @@ export class KGMidiInput {
|
|||||||
this.onRecordControlChange = onControlChange;
|
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 =====
|
// ===== GETTERS =====
|
||||||
|
|
||||||
public getIsInitialized(): boolean {
|
public getIsInitialized(): boolean {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { upgradeToV7 } from './upgradeToV7';
|
|||||||
import { upgradeToV8 } from './upgradeToV8';
|
import { upgradeToV8 } from './upgradeToV8';
|
||||||
import { upgradeToV9 } from './upgradeToV9';
|
import { upgradeToV9 } from './upgradeToV9';
|
||||||
import { upgradeToV10 } from './upgradeToV10';
|
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.
|
* 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);
|
workingProject = upgradeToV10(workingProject);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 11: {
|
||||||
|
workingProject = upgradeToV11(workingProject);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 12: {
|
||||||
|
workingProject = upgradeToV12(workingProject);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
// If an upgrader is missing, throw to prevent loading incompatible structures
|
// If an upgrader is missing, throw to prevent loading incompatible structures
|
||||||
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe('upgradeToV10', () => {
|
|||||||
|
|
||||||
const upgraded = upgradeProjectToLatest(project);
|
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);
|
expect(upgraded.getTracks()[0].getVolumeAutomation()).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ describe('upgradeToV8', () => {
|
|||||||
|
|
||||||
const upgraded = upgradeProjectToLatest(project);
|
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).getPitchBends()).toEqual([]);
|
||||||
expect((upgraded.getTracks()[0].getRegions()[0] as KGMidiRegion).getControllerEventsByType()).toHaveLength(128);
|
expect((upgraded.getTracks()[0].getRegions()[0] as KGMidiRegion).getControllerEventsByType()).toHaveLength(128);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export class KGPianoRollState {
|
|||||||
private currentMode: string = "ionian"; // Default mode
|
private currentMode: string = "ionian"; // Default mode
|
||||||
private automationViewEnabled: boolean = false;
|
private automationViewEnabled: boolean = false;
|
||||||
private currentAutomationType: string = "pitch-bend";
|
private currentAutomationType: string = "pitch-bend";
|
||||||
|
private pianoRollZoom: number = 1;
|
||||||
private sheetMusicViewEnabled: boolean = false;
|
private sheetMusicViewEnabled: boolean = false;
|
||||||
private sheetMusicTrackScopeEnabled: boolean = false;
|
private sheetMusicTrackScopeEnabled: boolean = false;
|
||||||
private sheetQuantization: string = '16,48';
|
private sheetQuantization: string = '16,48';
|
||||||
@@ -86,6 +87,14 @@ export class KGPianoRollState {
|
|||||||
this.currentAutomationType = type;
|
this.currentAutomationType = type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getPianoRollZoom(): number {
|
||||||
|
return this.pianoRollZoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setPianoRollZoom(zoom: number): void {
|
||||||
|
this.pianoRollZoom = zoom;
|
||||||
|
}
|
||||||
|
|
||||||
public getSheetMusicViewEnabled(): boolean {
|
public getSheetMusicViewEnabled(): boolean {
|
||||||
return this.sheetMusicViewEnabled;
|
return this.sheetMusicViewEnabled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ export class KGTrack {
|
|||||||
@WithDefault(AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME)
|
@WithDefault(AUDIO_INTERFACE_CONSTANTS.DEFAULT_TRACK_VOLUME)
|
||||||
protected volume: number = 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()
|
@Expose()
|
||||||
@Type(() => KGRegion, {
|
@Type(() => KGRegion, {
|
||||||
discriminator: {
|
discriminator: {
|
||||||
@@ -91,6 +99,14 @@ export class KGTrack {
|
|||||||
return this.volume;
|
return this.volume;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getMuted(): boolean {
|
||||||
|
return this.muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSolo(): boolean {
|
||||||
|
return this.solo;
|
||||||
|
}
|
||||||
|
|
||||||
// Setters
|
// Setters
|
||||||
public setName(name: string): void {
|
public setName(name: string): void {
|
||||||
this.name = name;
|
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 {
|
public setRegions(regions: KGRegion[]): void {
|
||||||
this.regions = regions;
|
this.regions = regions;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<typeof import('../util/osUtil')>();
|
||||||
|
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<string, string> = {
|
||||||
|
'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(<HookHarness />);
|
||||||
|
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(<HookHarness />);
|
||||||
|
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(<HookHarness />);
|
||||||
|
fireEvent.keyDown(document.body, { key: 'e' });
|
||||||
|
|
||||||
|
expect(storeState.openMidiPianoRollWithSheetMusicView).toHaveBeenCalledWith('region-b', false);
|
||||||
|
expect(storeState.openSpectrogramViewer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a selected MIDI region in sheet music view on N', () => {
|
||||||
|
mockTracks = [
|
||||||
|
{
|
||||||
|
getRegions: () => [new MockMidiRegion('region-b')],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<HookHarness />);
|
||||||
|
fireEvent.keyDown(document.body, { key: 'n' });
|
||||||
|
|
||||||
|
expect(storeState.openMidiPianoRollWithSheetMusicView).toHaveBeenCalledWith('region-b', true);
|
||||||
|
expect(storeState.setShowPianoRoll).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the editor alert on N when no region is selected', () => {
|
||||||
|
storeState.selectedRegionIds = [];
|
||||||
|
|
||||||
|
render(<HookHarness />);
|
||||||
|
fireEvent.keyDown(document.body, { key: 'n' });
|
||||||
|
|
||||||
|
expect(showAlert).toHaveBeenCalledWith('Please select a region to open the editor.');
|
||||||
|
expect(storeState.openMidiPianoRollWithSheetMusicView).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not open spectrogram on N for an audio region', () => {
|
||||||
|
mockTracks = [
|
||||||
|
{
|
||||||
|
getRegions: () => [
|
||||||
|
{
|
||||||
|
getId: () => 'region-b',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<HookHarness />);
|
||||||
|
fireEvent.keyDown(document.body, { key: 'n' });
|
||||||
|
|
||||||
|
expect(showAlert).toHaveBeenCalledWith('Sheet music view is only available for MIDI regions.');
|
||||||
|
expect(storeState.openSpectrogramViewer).not.toHaveBeenCalled();
|
||||||
|
expect(storeState.openMidiPianoRollWithSheetMusicView).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not close the editor when N is pressed while piano roll is already open', () => {
|
||||||
|
storeState.showPianoRoll = true;
|
||||||
|
mockTracks = [
|
||||||
|
{
|
||||||
|
getRegions: () => [new MockMidiRegion('region-b')],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<HookHarness />);
|
||||||
|
fireEvent.keyDown(document.body, { key: 'n' });
|
||||||
|
|
||||||
|
expect(storeState.setShowPianoRoll).not.toHaveBeenCalled();
|
||||||
|
expect(storeState.openMidiPianoRollWithSheetMusicView).toHaveBeenCalledWith('region-b', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import { KGCore } from '../core/KGCore';
|
|||||||
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
||||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||||
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
||||||
|
import { mergeSelectedMidiRegions, splitSelectedRegionAtPlayhead } from '../util/regionEditUtil';
|
||||||
import { showAlert } from '../util/dialogUtil';
|
import { showAlert } from '../util/dialogUtil';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,14 +17,14 @@ import { showAlert } from '../util/dialogUtil';
|
|||||||
* Handles keyboard shortcuts defined in the configuration
|
* Handles keyboard shortcuts defined in the configuration
|
||||||
*/
|
*/
|
||||||
export const useGlobalKeyboardHandler = () => {
|
export const useGlobalKeyboardHandler = () => {
|
||||||
const { undo, redo, setStatus, isPlaying, startPlaying, stopTransport, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName, isRecording, startRecording, stopRecording, activeRegionId, selectedRegionIds, setActiveRegionId, setShowPianoRoll, showPianoRoll, openMidiPianoRoll, openSpectrogramViewer } = useProjectStore();
|
const { undo, redo, setStatus, isPlaying, startPlaying, stopTransport, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName, isRecording, startRecording, stopRecording, activeRegionId, selectedRegionIds, setActiveRegionId, setShowPianoRoll, showPianoRoll, openMidiPianoRollWithSheetMusicView, openSpectrogramViewer, playheadPosition, refreshProjectState } = useProjectStore();
|
||||||
const lastSelectedRegionId = selectedRegionIds[selectedRegionIds.length - 1] ?? null;
|
const lastSelectedRegionId = selectedRegionIds[selectedRegionIds.length - 1] ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
// Skip if user is typing in an input field
|
// Skip if user is typing in an input field
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target;
|
||||||
if (target && (
|
if (target instanceof HTMLElement && (
|
||||||
target.tagName === 'INPUT' ||
|
target.tagName === 'INPUT' ||
|
||||||
target.tagName === 'TEXTAREA' ||
|
target.tagName === 'TEXTAREA' ||
|
||||||
target.contentEditable === 'true' ||
|
target.contentEditable === 'true' ||
|
||||||
@@ -71,6 +72,8 @@ export const useGlobalKeyboardHandler = () => {
|
|||||||
const loopShortcut = configManager.get('hotkeys.main.loop') as string;
|
const loopShortcut = configManager.get('hotkeys.main.loop') as string;
|
||||||
const saveShortcut = configManager.get('hotkeys.main.save') as string;
|
const saveShortcut = configManager.get('hotkeys.main.save') as string;
|
||||||
const recordShortcut = configManager.get('hotkeys.main.record') 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
|
// Check for undo shortcut
|
||||||
if (undoShortcut && matchesKeyboardShortcut(event, undoShortcut)) {
|
if (undoShortcut && matchesKeyboardShortcut(event, undoShortcut)) {
|
||||||
@@ -202,6 +205,33 @@ export const useGlobalKeyboardHandler = () => {
|
|||||||
return;
|
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
|
// 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) {
|
if (event.key.toLowerCase() === 'e' && !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -226,13 +256,40 @@ export const useGlobalKeyboardHandler = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (foundMidi) {
|
if (foundMidi) {
|
||||||
openMidiPianoRoll(candidateId);
|
openMidiPianoRollWithSheetMusicView(candidateId, false);
|
||||||
} else if (foundAudio) {
|
} else if (foundAudio) {
|
||||||
openSpectrogramViewer(candidateId);
|
openSpectrogramViewer(candidateId);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for sheet music shortcut (N) — open piano roll for MIDI in sheet music view
|
||||||
|
if (event.key.toLowerCase() === 'n' && !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
const candidateId = activeRegionId ?? lastSelectedRegionId;
|
||||||
|
if (!candidateId) {
|
||||||
|
void showAlert('Please select a region to open the editor.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tracks = KGCore.instance().getCurrentProject().getTracks();
|
||||||
|
let foundMidi = false;
|
||||||
|
let foundAudio = false;
|
||||||
|
for (const track of tracks) {
|
||||||
|
const region = track.getRegions().find(r => r.getId() === candidateId);
|
||||||
|
if (region) {
|
||||||
|
if (region instanceof KGMidiRegion) foundMidi = true;
|
||||||
|
else foundAudio = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundMidi) {
|
||||||
|
openMidiPianoRollWithSheetMusicView(candidateId, true);
|
||||||
|
} else if (foundAudio) {
|
||||||
|
void showAlert('Sheet music view is only available for MIDI regions.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check for save shortcut
|
// Check for save shortcut
|
||||||
if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) {
|
if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -272,11 +329,14 @@ export const useGlobalKeyboardHandler = () => {
|
|||||||
startRecording,
|
startRecording,
|
||||||
stopRecording,
|
stopRecording,
|
||||||
activeRegionId,
|
activeRegionId,
|
||||||
|
selectedRegionIds,
|
||||||
lastSelectedRegionId,
|
lastSelectedRegionId,
|
||||||
setActiveRegionId,
|
setActiveRegionId,
|
||||||
setShowPianoRoll,
|
setShowPianoRoll,
|
||||||
showPianoRoll,
|
showPianoRoll,
|
||||||
openMidiPianoRoll,
|
openMidiPianoRollWithSheetMusicView,
|
||||||
openSpectrogramViewer,
|
openSpectrogramViewer,
|
||||||
|
playheadPosition,
|
||||||
|
refreshProjectState,
|
||||||
]); // Include dependencies for store actions
|
]); // Include dependencies for store actions
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<typeof createMockMidiRegion>, 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+193
-63
@@ -25,6 +25,25 @@ interface UseNoteOperationsProps {
|
|||||||
pianoGridRef: MutableRefObject<HTMLDivElement | null>;
|
pianoGridRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = ({
|
export const useNoteOperations = ({
|
||||||
activeRegion,
|
activeRegion,
|
||||||
timeSignature,
|
timeSignature,
|
||||||
@@ -44,6 +63,8 @@ export const useNoteOperations = ({
|
|||||||
const currentResizeLeft = useRef<number | null>(null);
|
const currentResizeLeft = useRef<number | null>(null);
|
||||||
const initialStartBeatRef = useRef<number | null>(null);
|
const initialStartBeatRef = useRef<number | null>(null);
|
||||||
const initialEndBeatRef = useRef<number | null>(null);
|
const initialEndBeatRef = useRef<number | null>(null);
|
||||||
|
const resizePreviewBaselinesRef = useRef<ResizePreviewBaseline[]>([]);
|
||||||
|
const resizePreviewNoteIdsRef = useRef<string[]>([]);
|
||||||
|
|
||||||
// Refs for drag operations
|
// Refs for drag operations
|
||||||
const initialDragLeft = useRef<number | null>(null);
|
const initialDragLeft = useRef<number | null>(null);
|
||||||
@@ -51,6 +72,8 @@ export const useNoteOperations = ({
|
|||||||
const currentDragLeft = useRef<number | null>(null);
|
const currentDragLeft = useRef<number | null>(null);
|
||||||
const currentDragTop = useRef<number | null>(null);
|
const currentDragTop = useRef<number | null>(null);
|
||||||
const initialPitchRef = useRef<number | null>(null);
|
const initialPitchRef = useRef<number | null>(null);
|
||||||
|
const dragPreviewBaselinesRef = useRef<DragPreviewBaseline[]>([]);
|
||||||
|
const dragPreviewNoteIdsRef = useRef<string[]>([]);
|
||||||
|
|
||||||
// Counter to trigger re-renders when notes are updated
|
// Counter to trigger re-renders when notes are updated
|
||||||
const [noteUpdateCounter, setNoteUpdateCounter] = useState(0);
|
const [noteUpdateCounter, setNoteUpdateCounter] = useState(0);
|
||||||
@@ -58,6 +81,21 @@ export const useNoteOperations = ({
|
|||||||
// Get KGCore instance for accessing selected items
|
// Get KGCore instance for accessing selected items
|
||||||
const core = KGCore.instance();
|
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
|
// Utility function to delete selected notes from the active region using commands
|
||||||
const deleteSelectedNotes = () => {
|
const deleteSelectedNotes = () => {
|
||||||
if (!activeRegion) return false;
|
if (!activeRegion) return false;
|
||||||
@@ -280,6 +318,32 @@ export const useNoteOperations = ({
|
|||||||
const note = activeRegion.getNotes().find(n => n.getId() === noteId);
|
const note = activeRegion.getNotes().find(n => n.getId() === noteId);
|
||||||
if (!note) return;
|
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
|
// Store the initial start and end beats
|
||||||
initialStartBeatRef.current = note.getStartBeat();
|
initialStartBeatRef.current = note.getStartBeat();
|
||||||
initialEndBeatRef.current = note.getEndBeat();
|
initialEndBeatRef.current = note.getEndBeat();
|
||||||
@@ -302,15 +366,28 @@ export const useNoteOperations = ({
|
|||||||
currentResizeWidth.current = width;
|
currentResizeWidth.current = width;
|
||||||
currentResizeLeft.current = left;
|
currentResizeLeft.current = left;
|
||||||
|
|
||||||
// Set initial style to current position/size
|
resizePreviewBaselinesRef.current = resizeTargetNotes.map(targetNote => {
|
||||||
const initialStyle = {
|
const targetAbsStartBeat = targetNote.getStartBeat() + regionStartBeat;
|
||||||
left: `${left}px`,
|
const targetAbsEndBeat = targetNote.getEndBeat() + regionStartBeat;
|
||||||
width: `${width}px`,
|
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 => ({
|
setTempNoteStyles(prev => ({
|
||||||
...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;
|
currentResizeWidth.current = snappedWidth;
|
||||||
currentResizeLeft.current = newLeft;
|
currentResizeLeft.current = newLeft;
|
||||||
|
|
||||||
// Update the temporary style for this note
|
const startBeatDelta = resizeEdge === 'start'
|
||||||
const newStyle = {
|
? (newLeft - originalLeft) / beatWidth
|
||||||
left: `${newLeft}px`,
|
: 0;
|
||||||
width: `${snappedWidth}px`,
|
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 => ({
|
setTempNoteStyles(prev => ({
|
||||||
...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) {
|
if (DEBUG_MODE.PIANO_ROLL) {
|
||||||
@@ -418,11 +521,9 @@ export const useNoteOperations = ({
|
|||||||
initialStartBeatRef.current === null || initialEndBeatRef.current === null) {
|
initialStartBeatRef.current === null || initialEndBeatRef.current === null) {
|
||||||
// Reset resizing state
|
// Reset resizing state
|
||||||
setResizingNoteId(null);
|
setResizingNoteId(null);
|
||||||
setTempNoteStyles(prev => {
|
clearTempNoteStyles(resizePreviewNoteIdsRef.current);
|
||||||
const updated = { ...prev };
|
resizePreviewBaselinesRef.current = [];
|
||||||
delete updated[noteId];
|
resizePreviewNoteIdsRef.current = [];
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,15 +614,13 @@ export const useNoteOperations = ({
|
|||||||
console.error('Error resizing notes:', error);
|
console.error('Error resizing notes:', error);
|
||||||
// Reset resizing state and return early on error
|
// Reset resizing state and return early on error
|
||||||
setResizingNoteId(null);
|
setResizingNoteId(null);
|
||||||
setTempNoteStyles(prev => {
|
clearTempNoteStyles(resizePreviewNoteIdsRef.current);
|
||||||
const updated = { ...prev };
|
|
||||||
delete updated[noteId];
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
currentResizeWidth.current = null;
|
currentResizeWidth.current = null;
|
||||||
currentResizeLeft.current = null;
|
currentResizeLeft.current = null;
|
||||||
initialStartBeatRef.current = null;
|
initialStartBeatRef.current = null;
|
||||||
initialEndBeatRef.current = null;
|
initialEndBeatRef.current = null;
|
||||||
|
resizePreviewBaselinesRef.current = [];
|
||||||
|
resizePreviewNoteIdsRef.current = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,15 +637,13 @@ export const useNoteOperations = ({
|
|||||||
|
|
||||||
// Reset resizing state
|
// Reset resizing state
|
||||||
setResizingNoteId(null);
|
setResizingNoteId(null);
|
||||||
setTempNoteStyles(prev => {
|
clearTempNoteStyles(resizePreviewNoteIdsRef.current);
|
||||||
const updated = { ...prev };
|
|
||||||
delete updated[noteId];
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
currentResizeWidth.current = null;
|
currentResizeWidth.current = null;
|
||||||
currentResizeLeft.current = null;
|
currentResizeLeft.current = null;
|
||||||
initialStartBeatRef.current = null;
|
initialStartBeatRef.current = null;
|
||||||
initialEndBeatRef.current = null;
|
initialEndBeatRef.current = null;
|
||||||
|
resizePreviewBaselinesRef.current = [];
|
||||||
|
resizePreviewNoteIdsRef.current = [];
|
||||||
|
|
||||||
// Increment the note update counter to trigger a re-render
|
// Increment the note update counter to trigger a re-render
|
||||||
setNoteUpdateCounter(prev => prev + 1);
|
setNoteUpdateCounter(prev => prev + 1);
|
||||||
@@ -577,6 +674,15 @@ export const useNoteOperations = ({
|
|||||||
const note = activeRegion.getNotes().find(n => n.getId() === noteId);
|
const note = activeRegion.getNotes().find(n => n.getId() === noteId);
|
||||||
if (!note) return;
|
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
|
// Store the initial pitch
|
||||||
initialPitchRef.current = note.getPitch();
|
initialPitchRef.current = note.getPitch();
|
||||||
|
|
||||||
@@ -604,18 +710,36 @@ export const useNoteOperations = ({
|
|||||||
currentDragLeft.current = left;
|
currentDragLeft.current = left;
|
||||||
currentDragTop.current = top;
|
currentDragTop.current = top;
|
||||||
|
|
||||||
// Set initial style
|
dragPreviewBaselinesRef.current = dragTargetNotes.map(targetNote => {
|
||||||
const initialStyle = {
|
const targetAbsStartBeat = targetNote.getStartBeat() + regionStartBeat;
|
||||||
left: `${left}px`,
|
const targetWidth = (targetNote.getEndBeat() - targetNote.getStartBeat()) * beatWidth;
|
||||||
top: `${top}px`,
|
const targetTop = (107 - targetNote.getPitch()) * noteHeight;
|
||||||
width: `${width}px`,
|
|
||||||
height: `${noteHeight}px`,
|
return {
|
||||||
zIndex: 100, // Bring to front during drag
|
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 => ({
|
setTempNoteStyles(prev => ({
|
||||||
...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;
|
currentDragLeft.current = newLeft;
|
||||||
currentDragTop.current = newTop;
|
currentDragTop.current = newTop;
|
||||||
|
|
||||||
// Calculate width based on note duration
|
const previewBaselines = dragPreviewBaselinesRef.current.length > 0
|
||||||
const width = (note.getEndBeat() - note.getStartBeat()) * beatWidth;
|
? dragPreviewBaselinesRef.current
|
||||||
|
: [{
|
||||||
// Update the temporary style for this note
|
noteId,
|
||||||
const newStyle = {
|
originalStartBeat: note.getStartBeat(),
|
||||||
left: `${newLeft}px`,
|
originalEndBeat: note.getEndBeat(),
|
||||||
top: `${newTop}px`,
|
originalPitch: note.getPitch(),
|
||||||
width: `${width}px`,
|
originalLeft,
|
||||||
height: `${noteHeight}px`,
|
originalTop,
|
||||||
zIndex: 100, // Keep on top during drag
|
originalWidth: (note.getEndBeat() - note.getStartBeat()) * beatWidth,
|
||||||
};
|
originalHeight: noteHeight,
|
||||||
|
}];
|
||||||
|
const leftDelta = newLeft - originalLeft;
|
||||||
|
const topDelta = newTop - originalTop;
|
||||||
|
|
||||||
setTempNoteStyles(prev => ({
|
setTempNoteStyles(prev => ({
|
||||||
...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) {
|
if (DEBUG_MODE.PIANO_ROLL) {
|
||||||
@@ -705,11 +841,9 @@ export const useNoteOperations = ({
|
|||||||
initialPitchRef.current === null) {
|
initialPitchRef.current === null) {
|
||||||
// Reset dragging state
|
// Reset dragging state
|
||||||
setDraggingNoteId(null);
|
setDraggingNoteId(null);
|
||||||
setTempNoteStyles(prev => {
|
clearTempNoteStyles(dragPreviewNoteIdsRef.current);
|
||||||
const updated = { ...prev };
|
dragPreviewBaselinesRef.current = [];
|
||||||
delete updated[noteId];
|
dragPreviewNoteIdsRef.current = [];
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,16 +905,14 @@ export const useNoteOperations = ({
|
|||||||
console.error('Error moving notes:', error);
|
console.error('Error moving notes:', error);
|
||||||
// Reset dragging state and return early on error
|
// Reset dragging state and return early on error
|
||||||
setDraggingNoteId(null);
|
setDraggingNoteId(null);
|
||||||
setTempNoteStyles(prev => {
|
clearTempNoteStyles(dragPreviewNoteIdsRef.current);
|
||||||
const updated = { ...prev };
|
|
||||||
delete updated[noteId];
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
currentDragLeft.current = null;
|
currentDragLeft.current = null;
|
||||||
currentDragTop.current = null;
|
currentDragTop.current = null;
|
||||||
initialDragLeft.current = null;
|
initialDragLeft.current = null;
|
||||||
initialDragTop.current = null;
|
initialDragTop.current = null;
|
||||||
initialPitchRef.current = null;
|
initialPitchRef.current = null;
|
||||||
|
dragPreviewBaselinesRef.current = [];
|
||||||
|
dragPreviewNoteIdsRef.current = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -798,16 +930,14 @@ export const useNoteOperations = ({
|
|||||||
|
|
||||||
// Reset dragging state
|
// Reset dragging state
|
||||||
setDraggingNoteId(null);
|
setDraggingNoteId(null);
|
||||||
setTempNoteStyles(prev => {
|
clearTempNoteStyles(dragPreviewNoteIdsRef.current);
|
||||||
const updated = { ...prev };
|
|
||||||
delete updated[noteId];
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
currentDragLeft.current = null;
|
currentDragLeft.current = null;
|
||||||
currentDragTop.current = null;
|
currentDragTop.current = null;
|
||||||
initialDragLeft.current = null;
|
initialDragLeft.current = null;
|
||||||
initialDragTop.current = null;
|
initialDragTop.current = null;
|
||||||
initialPitchRef.current = null;
|
initialPitchRef.current = null;
|
||||||
|
dragPreviewBaselinesRef.current = [];
|
||||||
|
dragPreviewNoteIdsRef.current = [];
|
||||||
|
|
||||||
// Increment the note update counter to trigger a re-render
|
// Increment the note update counter to trigger a re-render
|
||||||
setNoteUpdateCounter(prev => prev + 1);
|
setNoteUpdateCounter(prev => prev + 1);
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { act } from '@testing-library/react';
|
|||||||
import { KGTrack } from '../core/track/KGTrack';
|
import { KGTrack } from '../core/track/KGTrack';
|
||||||
import { KGMidiTrack } from '../core/track/KGMidiTrack';
|
import { KGMidiTrack } from '../core/track/KGMidiTrack';
|
||||||
|
|
||||||
|
const pianoRollStateMocks = vi.hoisted(() => ({
|
||||||
|
setSheetMusicViewEnabled: vi.fn(),
|
||||||
|
setPianoRollZoom: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
|
let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
|
||||||
const mockProject = {
|
const mockProject = {
|
||||||
getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
|
getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
|
||||||
@@ -15,13 +20,23 @@ const mockProject = {
|
|||||||
getSelectedMode: () => 'major',
|
getSelectedMode: () => 'major',
|
||||||
getIsLooping: () => false,
|
getIsLooping: () => false,
|
||||||
getLoopingRange: () => [0, 0] as [number, number],
|
getLoopingRange: () => [0, 0] as [number, number],
|
||||||
|
getPianoRollZoom: () => 1,
|
||||||
};
|
};
|
||||||
|
let currentProject = mockProject;
|
||||||
|
|
||||||
const mockAudioInterface = {
|
const mockAudioInterface = {
|
||||||
getTransportPosition: vi.fn().mockReturnValue(8),
|
getTransportPosition: vi.fn().mockReturnValue(8),
|
||||||
startAudioRecording: vi.fn().mockResolvedValue({ usedDeviceId: 'default', fellBackToDefault: false }),
|
startAudioRecording: vi.fn().mockResolvedValue({ usedDeviceId: 'default', fellBackToDefault: false }),
|
||||||
stopAudioRecording: vi.fn().mockResolvedValue(null),
|
stopAudioRecording: vi.fn().mockResolvedValue(null),
|
||||||
cancelAudioRecording: vi.fn().mockResolvedValue(undefined),
|
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<string, unknown>([
|
const configValues = new Map<string, unknown>([
|
||||||
@@ -29,7 +44,10 @@ const configValues = new Map<string, unknown>([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const mockCore = {
|
const mockCore = {
|
||||||
getCurrentProject: () => mockProject,
|
getCurrentProject: () => currentProject,
|
||||||
|
setCurrentProject: vi.fn((project: typeof mockProject) => {
|
||||||
|
currentProject = project;
|
||||||
|
}),
|
||||||
setPlayheadUpdateCallback: vi.fn(),
|
setPlayheadUpdateCallback: vi.fn(),
|
||||||
setPlaybackStateChangeCallback: vi.fn(),
|
setPlaybackStateChangeCallback: vi.fn(),
|
||||||
setLoopBoundaryReachedCallback: vi.fn(),
|
setLoopBoundaryReachedCallback: vi.fn(),
|
||||||
@@ -45,6 +63,7 @@ const mockCore = {
|
|||||||
redo: vi.fn(() => true),
|
redo: vi.fn(() => true),
|
||||||
clearSelectedItems: vi.fn(),
|
clearSelectedItems: vi.fn(),
|
||||||
getStatus: () => 'Ready',
|
getStatus: () => 'Ready',
|
||||||
|
setStatus: vi.fn(),
|
||||||
getPlayheadPosition: () => 0,
|
getPlayheadPosition: () => 0,
|
||||||
setPlayheadPosition: vi.fn(),
|
setPlayheadPosition: vi.fn(),
|
||||||
getIsPlaying: () => false,
|
getIsPlaying: () => false,
|
||||||
@@ -76,11 +95,20 @@ vi.mock('../core/config/ConfigManager', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../core/state/KGPianoRollState', () => ({
|
||||||
|
KGPianoRollState: {
|
||||||
|
instance: () => pianoRollStateMocks,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
describe('projectStore piano roll state', () => {
|
describe('projectStore piano roll state', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
pianoRollStateMocks.setSheetMusicViewEnabled.mockReset();
|
||||||
|
pianoRollStateMocks.setPianoRollZoom.mockReset();
|
||||||
mockTracks = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
|
mockTracks = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
|
||||||
|
currentProject = mockProject;
|
||||||
mockCore.startPlaying.mockReset();
|
mockCore.startPlaying.mockReset();
|
||||||
mockCore.startPlaying.mockResolvedValue(undefined);
|
mockCore.startPlaying.mockResolvedValue(undefined);
|
||||||
mockCore.stopPlaying.mockReset();
|
mockCore.stopPlaying.mockReset();
|
||||||
@@ -118,10 +146,34 @@ describe('projectStore piano roll state', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
state = useProjectStore.getState();
|
state = useProjectStore.getState();
|
||||||
|
expect(pianoRollStateMocks.setSheetMusicViewEnabled).toHaveBeenCalledWith(false);
|
||||||
expect(state.showPianoRoll).toBe(true);
|
expect(state.showPianoRoll).toBe(true);
|
||||||
expect(state.pianoRollMode).toBe('midi-edit');
|
expect(state.pianoRollMode).toBe('midi-edit');
|
||||||
expect(state.activeRegionId).toBe('midi-b');
|
expect(state.activeRegionId).toBe('midi-b');
|
||||||
expect(state.hybridAudioRegionId).toBeNull();
|
expect(state.hybridAudioRegionId).toBeNull();
|
||||||
|
expect(state.requestedSheetMusicViewEnabled).toBe(false);
|
||||||
|
expect(state.pianoRollViewRequestVersion).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a MIDI region in sheet music view when requested', async () => {
|
||||||
|
const { useProjectStore } = await import('./projectStore');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useProjectStore.getState().openHybridMode('midi-a', 'audio-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useProjectStore.getState().openMidiPianoRollWithSheetMusicView('midi-b', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useProjectStore.getState();
|
||||||
|
expect(pianoRollStateMocks.setSheetMusicViewEnabled).toHaveBeenCalledWith(true);
|
||||||
|
expect(state.showPianoRoll).toBe(true);
|
||||||
|
expect(state.pianoRollMode).toBe('midi-edit');
|
||||||
|
expect(state.activeRegionId).toBe('midi-b');
|
||||||
|
expect(state.hybridAudioRegionId).toBeNull();
|
||||||
|
expect(state.requestedSheetMusicViewEnabled).toBe(true);
|
||||||
|
expect(state.pianoRollViewRequestVersion).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tracks playback preparation around startPlaying success', async () => {
|
it('tracks playback preparation around startPlaying success', async () => {
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ interface ProjectState {
|
|||||||
activeRegionId: string | null;
|
activeRegionId: string | null;
|
||||||
pianoRollMode: 'midi-edit' | 'spectrogram' | 'hybrid';
|
pianoRollMode: 'midi-edit' | 'spectrogram' | 'hybrid';
|
||||||
hybridAudioRegionId: string | null;
|
hybridAudioRegionId: string | null;
|
||||||
|
requestedSheetMusicViewEnabled: boolean;
|
||||||
|
pianoRollViewRequestVersion: number;
|
||||||
automationRedrawVersion: number;
|
automationRedrawVersion: number;
|
||||||
activeTrackAutomationTrackId: string | null;
|
activeTrackAutomationTrackId: string | null;
|
||||||
activeTrackAutomationType: TrackAutomationType | null;
|
activeTrackAutomationType: TrackAutomationType | null;
|
||||||
@@ -194,6 +196,7 @@ interface ProjectState {
|
|||||||
setShowPianoRoll: (show: boolean) => void;
|
setShowPianoRoll: (show: boolean) => void;
|
||||||
setActiveRegionId: (regionId: string | null) => void;
|
setActiveRegionId: (regionId: string | null) => void;
|
||||||
openMidiPianoRoll: (regionId: string) => void;
|
openMidiPianoRoll: (regionId: string) => void;
|
||||||
|
openMidiPianoRollWithSheetMusicView: (regionId: string, sheetMusicViewEnabled: boolean) => void;
|
||||||
openSpectrogramViewer: (regionId: string) => void;
|
openSpectrogramViewer: (regionId: string) => void;
|
||||||
openHybridMode: (midiRegionId: string, audioRegionId: string) => void;
|
openHybridMode: (midiRegionId: string, audioRegionId: string) => void;
|
||||||
bumpAutomationRedrawVersion: () => void;
|
bumpAutomationRedrawVersion: () => void;
|
||||||
@@ -305,6 +308,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
|
|
||||||
// Get initial ChatBox state from config
|
// Get initial ChatBox state from config
|
||||||
const configManager = ConfigManager.instance();
|
const configManager = ConfigManager.instance();
|
||||||
|
KGPianoRollState.instance().setPianoRollZoom(currentProject.getPianoRollZoom());
|
||||||
const initialChatBoxState = configManager.getIsInitialized()
|
const initialChatBoxState = configManager.getIsInitialized()
|
||||||
? (configManager.get('chatbox.default_open') as boolean) ?? false
|
? (configManager.get('chatbox.default_open') as boolean) ?? false
|
||||||
: false;
|
: false;
|
||||||
@@ -430,6 +434,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
activeRegionId: null,
|
activeRegionId: null,
|
||||||
pianoRollMode: 'midi-edit' as const,
|
pianoRollMode: 'midi-edit' as const,
|
||||||
hybridAudioRegionId: null,
|
hybridAudioRegionId: null,
|
||||||
|
requestedSheetMusicViewEnabled: false,
|
||||||
|
pianoRollViewRequestVersion: 0,
|
||||||
automationRedrawVersion: 0,
|
automationRedrawVersion: 0,
|
||||||
activeTrackAutomationTrackId: null,
|
activeTrackAutomationTrackId: null,
|
||||||
activeTrackAutomationType: null,
|
activeTrackAutomationType: null,
|
||||||
@@ -873,6 +879,14 @@ export const useProjectStore = create<ProjectState>((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
|
// Update CSS variables
|
||||||
updateTimeSignatureCSS(timeSignature);
|
updateTimeSignatureCSS(timeSignature);
|
||||||
updateMaxBarsCSS(maxBars);
|
updateMaxBarsCSS(maxBars);
|
||||||
@@ -929,6 +943,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
|
|
||||||
// Reset piano roll state for new/loaded project
|
// Reset piano roll state for new/loaded project
|
||||||
KGPianoRollState.instance().setLastEditedNoteLength(1);
|
KGPianoRollState.instance().setLastEditedNoteLength(1);
|
||||||
|
KGPianoRollState.instance().setPianoRollZoom(projectToLoad.getPianoRollZoom());
|
||||||
|
|
||||||
// Add a status message
|
// Add a status message
|
||||||
KGCore.instance().setStatus(`Project "${projectToLoad.getName()}" loaded with audio setup`);
|
KGCore.instance().setStatus(`Project "${projectToLoad.getName()}" loaded with audio setup`);
|
||||||
@@ -1552,15 +1567,47 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
openMidiPianoRoll: (regionId: string) => {
|
openMidiPianoRoll: (regionId: string) => {
|
||||||
set({ showPianoRoll: true, activeRegionId: regionId, pianoRollMode: 'midi-edit', hybridAudioRegionId: null });
|
KGPianoRollState.instance().setSheetMusicViewEnabled(false);
|
||||||
|
set(state => ({
|
||||||
|
showPianoRoll: true,
|
||||||
|
activeRegionId: regionId,
|
||||||
|
pianoRollMode: 'midi-edit',
|
||||||
|
hybridAudioRegionId: null,
|
||||||
|
requestedSheetMusicViewEnabled: false,
|
||||||
|
pianoRollViewRequestVersion: state.pianoRollViewRequestVersion + 1,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
openMidiPianoRollWithSheetMusicView: (regionId: string, sheetMusicViewEnabled: boolean) => {
|
||||||
|
KGPianoRollState.instance().setSheetMusicViewEnabled(sheetMusicViewEnabled);
|
||||||
|
set(state => ({
|
||||||
|
showPianoRoll: true,
|
||||||
|
activeRegionId: regionId,
|
||||||
|
pianoRollMode: 'midi-edit',
|
||||||
|
hybridAudioRegionId: null,
|
||||||
|
requestedSheetMusicViewEnabled: sheetMusicViewEnabled,
|
||||||
|
pianoRollViewRequestVersion: state.pianoRollViewRequestVersion + 1,
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
openSpectrogramViewer: (regionId: string) => {
|
openSpectrogramViewer: (regionId: string) => {
|
||||||
set({ showPianoRoll: true, activeRegionId: regionId, pianoRollMode: 'spectrogram', hybridAudioRegionId: null });
|
set({
|
||||||
|
showPianoRoll: true,
|
||||||
|
activeRegionId: regionId,
|
||||||
|
pianoRollMode: 'spectrogram',
|
||||||
|
hybridAudioRegionId: null,
|
||||||
|
requestedSheetMusicViewEnabled: false,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
openHybridMode: (midiRegionId: string, audioRegionId: string) => {
|
openHybridMode: (midiRegionId: string, audioRegionId: string) => {
|
||||||
set({ showPianoRoll: true, activeRegionId: midiRegionId, hybridAudioRegionId: audioRegionId, pianoRollMode: 'hybrid' });
|
set({
|
||||||
|
showPianoRoll: true,
|
||||||
|
activeRegionId: midiRegionId,
|
||||||
|
hybridAudioRegionId: audioRegionId,
|
||||||
|
pianoRollMode: 'hybrid',
|
||||||
|
requestedSheetMusicViewEnabled: false,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
bumpAutomationRedrawVersion: () => {
|
bumpAutomationRedrawVersion: () => {
|
||||||
set(state => ({ automationRedrawVersion: state.automationRedrawVersion + 1 }));
|
set(state => ({ automationRedrawVersion: state.automationRedrawVersion + 1 }));
|
||||||
@@ -1579,6 +1626,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
activeRegionId: null,
|
activeRegionId: null,
|
||||||
hybridAudioRegionId: null,
|
hybridAudioRegionId: null,
|
||||||
pianoRollMode: 'midi-edit',
|
pianoRollMode: 'midi-edit',
|
||||||
|
requestedSheetMusicViewEnabled: false,
|
||||||
|
pianoRollViewRequestVersion: 0,
|
||||||
activeTrackAutomationTrackId: null,
|
activeTrackAutomationTrackId: null,
|
||||||
activeTrackAutomationType: null,
|
activeTrackAutomationType: null,
|
||||||
trackAutomationRedrawVersion: 0,
|
trackAutomationRedrawVersion: 0,
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export const createMockProject = (overrides: Partial<{
|
|||||||
[0, 0], // loopingRange
|
[0, 0], // loopingRange
|
||||||
1, // barWidthMultiplier
|
1, // barWidthMultiplier
|
||||||
defaults.tracks, // tracks
|
defaults.tracks, // tracks
|
||||||
5 // projectStructureVersion
|
KGProject.CURRENT_PROJECT_STRUCTURE_VERSION // projectStructureVersion
|
||||||
);
|
);
|
||||||
|
|
||||||
return project;
|
return project;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
export const LOCAL_LLM_PROVIDER_KEY = 'local_browser';
|
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';
|
'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_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_DISPLAY_NAME = 'Gemma 4 E4B';
|
||||||
export const LOCAL_LLM_LEGACY_FILENAMES = [
|
export const LOCAL_LLM_LEGACY_FILENAMES = [
|
||||||
'gemma-3n-E4B-it-int4-Web.litertlm',
|
'gemma-3n-E4B-it-int4-Web.litertlm',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { OpfsModelCache, type ModelDownloadProgress } from './opfsModelCache';
|
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' });
|
const cache = new OpfsModelCache({ directoryName: 'models' });
|
||||||
let writingToCachePromise: Promise<void> | null = null;
|
let writingToCachePromise: Promise<void> | null = null;
|
||||||
@@ -51,7 +51,9 @@ const createProgressReader = (
|
|||||||
|
|
||||||
export class LocalLLMModelCache {
|
export class LocalLLMModelCache {
|
||||||
public static async exists(filename: string = LOCAL_LLM_MODEL_FILENAME): Promise<boolean> {
|
public static async exists(filename: string = LOCAL_LLM_MODEL_FILENAME): Promise<boolean> {
|
||||||
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<File> {
|
public static async getFile(filename: string = LOCAL_LLM_MODEL_FILENAME): Promise<File> {
|
||||||
@@ -98,6 +100,9 @@ export class LocalLLMModelCache {
|
|||||||
streamForCache,
|
streamForCache,
|
||||||
filename,
|
filename,
|
||||||
totalBytes > 0 ? totalBytes : null,
|
totalBytes > 0 ? totalBytes : null,
|
||||||
|
{
|
||||||
|
expectedSizeBytes: LOCAL_LLM_MODEL_EXPECTED_SIZE_BYTES,
|
||||||
|
},
|
||||||
progress => onProgress?.({ ...progress, fromCache: false }),
|
progress => onProgress?.({ ...progress, fromCache: false }),
|
||||||
);
|
);
|
||||||
writingToCachePromise = writingToCachePromise.finally(() => {
|
writingToCachePromise = writingToCachePromise.finally(() => {
|
||||||
@@ -117,6 +122,13 @@ export class LocalLLMModelCache {
|
|||||||
filename: string = LOCAL_LLM_MODEL_FILENAME,
|
filename: string = LOCAL_LLM_MODEL_FILENAME,
|
||||||
onProgress?: (progress: ModelDownloadProgress) => void,
|
onProgress?: (progress: ModelDownloadProgress) => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await cache.download(sourceUrl, filename, onProgress);
|
await cache.download(
|
||||||
|
sourceUrl,
|
||||||
|
filename,
|
||||||
|
{
|
||||||
|
expectedSizeBytes: LOCAL_LLM_MODEL_EXPECTED_SIZE_BYTES,
|
||||||
|
},
|
||||||
|
onProgress,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
detectLocalLLMRuntimeSupport,
|
detectLocalLLMRuntimeSupport,
|
||||||
LOCAL_LLM_LEGACY_FILENAMES,
|
LOCAL_LLM_LEGACY_FILENAMES,
|
||||||
LOCAL_LLM_MODEL_FILENAME,
|
LOCAL_LLM_MODEL_FILENAME,
|
||||||
LOCAL_LLM_MODEL_URL,
|
|
||||||
type LocalLLMRuntimeSupport,
|
type LocalLLMRuntimeSupport,
|
||||||
} from './localLLMConfig';
|
} from './localLLMConfig';
|
||||||
import { LocalLLMModelCache } from './localLLMModelCache';
|
import { LocalLLMModelCache } from './localLLMModelCache';
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { LocalSeparatorModelConfig } from './localSeparatorTypes';
|
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';
|
'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_FILENAME = 'UVR-MDX-NET-Inst_HQ_3.onnx';
|
||||||
|
export const LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES = 66759214;
|
||||||
|
|
||||||
export const LOCAL_SEPARATOR_MODEL_CONFIG: LocalSeparatorModelConfig = {
|
export const LOCAL_SEPARATOR_MODEL_CONFIG: LocalSeparatorModelConfig = {
|
||||||
filename: LOCAL_SEPARATOR_MODEL_FILENAME,
|
filename: LOCAL_SEPARATOR_MODEL_FILENAME,
|
||||||
|
|||||||
@@ -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';
|
import { OpfsModelCache, type ModelDownloadProgress } from './opfsModelCache';
|
||||||
|
|
||||||
const cache = new OpfsModelCache({ directoryName: 'models' });
|
const cache = new OpfsModelCache({ directoryName: 'models' });
|
||||||
@@ -7,7 +10,9 @@ export { type ModelDownloadProgress };
|
|||||||
|
|
||||||
export class LocalSeparatorModelCache {
|
export class LocalSeparatorModelCache {
|
||||||
public static async exists(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise<boolean> {
|
public static async exists(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise<boolean> {
|
||||||
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<File> {
|
public static async getFile(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise<File> {
|
||||||
@@ -27,6 +32,13 @@ export class LocalSeparatorModelCache {
|
|||||||
filename: string = LOCAL_SEPARATOR_MODEL_FILENAME,
|
filename: string = LOCAL_SEPARATOR_MODEL_FILENAME,
|
||||||
onProgress?: (progress: ModelDownloadProgress) => void,
|
onProgress?: (progress: ModelDownloadProgress) => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await cache.download(sourceUrl, filename, onProgress);
|
await cache.download(
|
||||||
|
sourceUrl,
|
||||||
|
filename,
|
||||||
|
{
|
||||||
|
expectedSizeBytes: LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES,
|
||||||
|
},
|
||||||
|
onProgress,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ interface OpfsModelCacheOptions {
|
|||||||
sizeSuffix?: string;
|
sizeSuffix?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ModelCacheValidationOptions {
|
||||||
|
expectedSizeBytes?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class OpfsModelCache {
|
export class OpfsModelCache {
|
||||||
private readonly directoryName: string;
|
private readonly directoryName: string;
|
||||||
private readonly sizeSuffix: string;
|
private readonly sizeSuffix: string;
|
||||||
@@ -18,7 +22,7 @@ export class OpfsModelCache {
|
|||||||
this.sizeSuffix = options.sizeSuffix ?? '.size';
|
this.sizeSuffix = options.sizeSuffix ?? '.size';
|
||||||
}
|
}
|
||||||
|
|
||||||
public async exists(filename: string): Promise<boolean> {
|
public async exists(filename: string, options: ModelCacheValidationOptions = {}): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const dir = await this.getDir();
|
const dir = await this.getDir();
|
||||||
const fileHandle = await dir.getFileHandle(filename);
|
const fileHandle = await dir.getFileHandle(filename);
|
||||||
@@ -29,6 +33,10 @@ export class OpfsModelCache {
|
|||||||
await this.delete(filename);
|
await this.delete(filename);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (options.expectedSizeBytes != null && expectedSize !== options.expectedSizeBytes) {
|
||||||
|
await this.delete(filename);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (file.size !== expectedSize) {
|
if (file.size !== expectedSize) {
|
||||||
await this.delete(filename);
|
await this.delete(filename);
|
||||||
return false;
|
return false;
|
||||||
@@ -64,6 +72,7 @@ export class OpfsModelCache {
|
|||||||
public async download(
|
public async download(
|
||||||
sourceUrl: string,
|
sourceUrl: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
|
options: ModelCacheValidationOptions = {},
|
||||||
onProgress?: (progress: ModelDownloadProgress) => void,
|
onProgress?: (progress: ModelDownloadProgress) => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const response = await fetch(sourceUrl);
|
const response = await fetch(sourceUrl);
|
||||||
@@ -75,13 +84,14 @@ export class OpfsModelCache {
|
|||||||
if (!response.body) {
|
if (!response.body) {
|
||||||
throw new Error('Model download response did not include a readable 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(
|
public async downloadStream(
|
||||||
stream: ReadableStream<Uint8Array>,
|
stream: ReadableStream<Uint8Array>,
|
||||||
filename: string,
|
filename: string,
|
||||||
totalBytes: number | null,
|
totalBytes: number | null,
|
||||||
|
options: ModelCacheValidationOptions = {},
|
||||||
onProgress?: (progress: ModelDownloadProgress) => void,
|
onProgress?: (progress: ModelDownloadProgress) => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const dir = await this.getDir();
|
const dir = await this.getDir();
|
||||||
@@ -111,6 +121,9 @@ export class OpfsModelCache {
|
|||||||
if (!Number.isFinite(sizeValue) || sizeValue <= 0) {
|
if (!Number.isFinite(sizeValue) || sizeValue <= 0) {
|
||||||
throw new Error('Model download did not provide a valid size.');
|
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 sizeHandle = await dir.getFileHandle(this.getSizeFilename(filename), { create: true });
|
||||||
const sizeWritable = await sizeHandle.createWritable();
|
const sizeWritable = await sizeHandle.createWritable();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string | null> => {
|
||||||
|
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<string | null> => {
|
||||||
|
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<string | null> => {
|
||||||
|
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<string | null> => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -387,6 +387,12 @@ describe('scaleUtil', () => {
|
|||||||
expect(typeof result).toBe('string');
|
expect(typeof result).toBe('string');
|
||||||
expect(result).toContain('#282828');
|
expect(result).toContain('#282828');
|
||||||
expect(result).toContain('#303030');
|
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', () => {
|
it('should generate different backgrounds for different modes', () => {
|
||||||
|
|||||||
+20
-18
@@ -304,6 +304,9 @@ export const generatePianoGridBackground = (
|
|||||||
selectedMode: string,
|
selectedMode: string,
|
||||||
keySignature: KeySignature
|
keySignature: KeySignature
|
||||||
): string => {
|
): string => {
|
||||||
|
const majorGridLineColor = '#404040';
|
||||||
|
const minorGridLineColor = '#343434';
|
||||||
|
|
||||||
// Get root note and scale pitch classes
|
// Get root note and scale pitch classes
|
||||||
const rootNote = getRootNoteFromKeySignature(keySignature);
|
const rootNote = getRootNoteFromKeySignature(keySignature);
|
||||||
const modeSteps = getModeSteps(selectedMode);
|
const modeSteps = getModeSteps(selectedMode);
|
||||||
@@ -314,37 +317,36 @@ export const generatePianoGridBackground = (
|
|||||||
const pitch = pianoRollIndexToPitch(index);
|
const pitch = pianoRollIndexToPitch(index);
|
||||||
const pitchClass = pitch % 12;
|
const pitchClass = pitch % 12;
|
||||||
const isInScale = scalePitchClasses.includes(pitchClass);
|
const isInScale = scalePitchClasses.includes(pitchClass);
|
||||||
|
const isCRow = pitchClass === 0;
|
||||||
|
|
||||||
// Calculate row positions using CSS calc() with --region-piano-key-height variable
|
// Calculate row positions using CSS calc() with --region-piano-key-height variable
|
||||||
const rowTop = `calc(var(--region-piano-key-height) * ${index})`;
|
const rowTop = `calc(var(--region-piano-key-height) * ${index})`;
|
||||||
const rowBottomMinusOne = `calc(var(--region-piano-key-height) * ${index + 1} - 1px)`;
|
const rowBottomMinusOne = `calc(var(--region-piano-key-height) * ${index + 1} - 1px)`;
|
||||||
const rowBottom = `calc(var(--region-piano-key-height) * ${index + 1})`;
|
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.
|
// Match the event list palette while preserving scale-aware row distinction.
|
||||||
if (isInScale) {
|
return `
|
||||||
return `
|
${rowFillColor} ${rowTop},
|
||||||
#282828 ${rowTop},
|
${rowFillColor} ${rowBottomMinusOne},
|
||||||
#282828 ${rowBottomMinusOne},
|
${horizontalLineColor} ${rowBottomMinusOne},
|
||||||
#3a3a3a ${rowBottomMinusOne},
|
${horizontalLineColor} ${rowBottom}
|
||||||
#3a3a3a ${rowBottom}
|
`.trim();
|
||||||
`.trim();
|
|
||||||
} else {
|
|
||||||
return `
|
|
||||||
#303030 ${rowTop},
|
|
||||||
#303030 ${rowBottomMinusOne},
|
|
||||||
#3a3a3a ${rowBottomMinusOne},
|
|
||||||
#3a3a3a ${rowBottom}
|
|
||||||
`.trim();
|
|
||||||
}
|
|
||||||
}).join(',\n');
|
}).join(',\n');
|
||||||
|
|
||||||
// Return complete background-image with vertical and horizontal gradients
|
// 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 `
|
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,
|
linear-gradient(to right,
|
||||||
transparent calc(var(--region-grid-beat-width) - 1px),
|
transparent calc(var(--region-grid-beat-width) - 1px),
|
||||||
#3a3a3a calc(var(--region-grid-beat-width) - 1px),
|
${minorGridLineColor} calc(var(--region-grid-beat-width) - 1px),
|
||||||
#3a3a3a var(--region-grid-beat-width)
|
${minorGridLineColor} var(--region-grid-beat-width)
|
||||||
),
|
),
|
||||||
linear-gradient(to bottom, ${horizontalLines})
|
linear-gradient(to bottom, ${horizontalLines})
|
||||||
`;
|
`;
|
||||||
|
|||||||
Reference in New Issue
Block a user