feat: persist piano roll zoom level into project; fixed an issue in the horizontal auto-scrolling logic in piano roll window
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -96,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);
|
||||||
@@ -785,6 +785,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
KGPianoRollState.instance().setPianoRollZoom(nextZoom);
|
||||||
|
KGCore.instance().getCurrentProject().setPianoRollZoom(nextZoom);
|
||||||
setPianoRollZoom(nextZoom);
|
setPianoRollZoom(nextZoom);
|
||||||
}, [pianoRollZoom]);
|
}, [pianoRollZoom]);
|
||||||
|
|
||||||
@@ -1027,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;
|
||||||
@@ -1043,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;
|
||||||
@@ -1589,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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+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 = 11;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,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');
|
||||||
@@ -138,6 +145,20 @@ describe('KGProjectStorage', () => {
|
|||||||
expect(loaded!.getTracks()[0].getSolo()).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);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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 { 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.
|
||||||
@@ -73,6 +74,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
|
|||||||
workingProject = upgradeToV11(workingProject);
|
workingProject = upgradeToV11(workingProject);
|
||||||
break;
|
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(11);
|
expect(upgraded.getProjectStructureVersion()).toBe(KGProject.CURRENT_PROJECT_STRUCTURE_VERSION);
|
||||||
expect(upgraded.getTracks()[0].getVolumeAutomation()).toHaveLength(1);
|
expect(upgraded.getTracks()[0].getVolumeAutomation()).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe('upgradeToV11', () => {
|
|||||||
|
|
||||||
const upgraded = upgradeProjectToLatest(project);
|
const upgraded = upgradeProjectToLatest(project);
|
||||||
|
|
||||||
expect(upgraded.getProjectStructureVersion()).toBe(11);
|
expect(upgraded.getProjectStructureVersion()).toBe(KGProject.CURRENT_PROJECT_STRUCTURE_VERSION);
|
||||||
expect(upgraded.getTracks()[0].getMuted()).toBe(false);
|
expect(upgraded.getTracks()[0].getMuted()).toBe(false);
|
||||||
expect(upgraded.getTracks()[0].getSolo()).toBe(false);
|
expect(upgraded.getTracks()[0].getSolo()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(11);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { KGMidiTrack } from '../core/track/KGMidiTrack';
|
|||||||
|
|
||||||
const pianoRollStateMocks = vi.hoisted(() => ({
|
const pianoRollStateMocks = vi.hoisted(() => ({
|
||||||
setSheetMusicViewEnabled: vi.fn(),
|
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')];
|
||||||
@@ -19,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>([
|
||||||
@@ -33,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(),
|
||||||
@@ -49,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,
|
||||||
@@ -91,7 +106,9 @@ describe('projectStore piano roll state', () => {
|
|||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
pianoRollStateMocks.setSheetMusicViewEnabled.mockReset();
|
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();
|
||||||
|
|||||||
@@ -308,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;
|
||||||
@@ -942,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`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user