feat: added track scope mode for sheet music view; adjusted viewport behavior when switching between piano roll view and sheet music view
This commit is contained in:
@@ -156,6 +156,25 @@
|
|||||||
transition: color 0.18s ease, fill 0.18s ease;
|
transition: color 0.18s ease, fill 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.piano-roll-toolbar .sheet-track-scope-toggle {
|
||||||
|
width: 20px;
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.piano-roll-toolbar .sheet-track-scope-toggle .sheet-track-scope-icon {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.piano-roll-toolbar .piano-roll-tool-icon {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.piano-roll-toolbar .tool-button.sheet-mode-toggle,
|
.piano-roll-toolbar .tool-button.sheet-mode-toggle,
|
||||||
.piano-roll-toolbar .tool-button:not(.icon-only) {
|
.piano-roll-toolbar .tool-button:not(.icon-only) {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../stores/projectStore', () => ({
|
||||||
|
useProjectStore: Object.assign(
|
||||||
|
(selector: (state: Record<string, unknown>) => unknown) => selector({
|
||||||
|
maxBars: 8,
|
||||||
|
tracks: [],
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
getState: () => ({
|
||||||
|
setAutoScrollEnabled: vi.fn(),
|
||||||
|
}),
|
||||||
|
setState: vi.fn(),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createPendingModeSwitchRequest,
|
||||||
|
getRegionPlayheadRelation,
|
||||||
|
getScrollLeftForViewportRequest,
|
||||||
|
} from './PianoRoll';
|
||||||
|
import type { SheetMeasureMetric } from './sheetNotationTypes';
|
||||||
|
|
||||||
|
function createContainer({ clientWidth, scrollWidth }: { clientWidth: number; scrollWidth: number }): HTMLDivElement {
|
||||||
|
return {
|
||||||
|
clientWidth,
|
||||||
|
scrollWidth,
|
||||||
|
} as HTMLDivElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PianoRoll viewport switch helpers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.documentElement.style.setProperty('--region-grid-beat-width', '40px');
|
||||||
|
document.documentElement.style.setProperty('--region-piano-key-width', '60px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies playhead position relative to the active region', () => {
|
||||||
|
expect(getRegionPlayheadRelation(15, 16, 24)).toBe('before');
|
||||||
|
expect(getRegionPlayheadRelation(20, 16, 24)).toBe('inside');
|
||||||
|
expect(getRegionPlayheadRelation(25, 16, 24)).toBe('after');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('centers an in-region playhead when switching to region-scope sheet view', () => {
|
||||||
|
const request = createPendingModeSwitchRequest({
|
||||||
|
playheadBeat: 20,
|
||||||
|
regionStartBeat: 16,
|
||||||
|
regionEndBeat: 24,
|
||||||
|
sourceSheetMusicViewEnabled: false,
|
||||||
|
destinationSheetMusicViewEnabled: true,
|
||||||
|
destinationSheetMusicTrackScopeEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request).toMatchObject({
|
||||||
|
alignment: 'center',
|
||||||
|
anchorBeat: 20,
|
||||||
|
clampScope: 'region',
|
||||||
|
});
|
||||||
|
|
||||||
|
const metrics: SheetMeasureMetric[] = [
|
||||||
|
{ barIndex: 0, startBeat: 0, endBeat: 4, leftPx: 0, widthPx: 200 },
|
||||||
|
{ barIndex: 1, startBeat: 4, endBeat: 8, leftPx: 200, widthPx: 200 },
|
||||||
|
];
|
||||||
|
const scrollLeft = getScrollLeftForViewportRequest({
|
||||||
|
request,
|
||||||
|
container: createContainer({ clientWidth: 200, scrollWidth: 400 }),
|
||||||
|
sheetMeasureMetrics: metrics,
|
||||||
|
activeRegionStartBeat: 16,
|
||||||
|
activeRegionEndBeat: 24,
|
||||||
|
songEndBeat: 32,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scrollLeft).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('snaps to the region start when the playhead is before the active region', () => {
|
||||||
|
const request = createPendingModeSwitchRequest({
|
||||||
|
playheadBeat: 12,
|
||||||
|
regionStartBeat: 16,
|
||||||
|
regionEndBeat: 24,
|
||||||
|
sourceSheetMusicViewEnabled: true,
|
||||||
|
destinationSheetMusicViewEnabled: false,
|
||||||
|
destinationSheetMusicTrackScopeEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request).toMatchObject({
|
||||||
|
alignment: 'region-start',
|
||||||
|
anchorBeat: 16,
|
||||||
|
clampScope: 'region',
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrollLeft = getScrollLeftForViewportRequest({
|
||||||
|
request,
|
||||||
|
container: createContainer({ clientWidth: 260, scrollWidth: 2000 }),
|
||||||
|
sheetMeasureMetrics: [],
|
||||||
|
activeRegionStartBeat: 16,
|
||||||
|
activeRegionEndBeat: 24,
|
||||||
|
songEndBeat: 32,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scrollLeft).toBe(640);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('snaps to the region end when the playhead is after the active region', () => {
|
||||||
|
const request = createPendingModeSwitchRequest({
|
||||||
|
playheadBeat: 28,
|
||||||
|
regionStartBeat: 16,
|
||||||
|
regionEndBeat: 24,
|
||||||
|
sourceSheetMusicViewEnabled: true,
|
||||||
|
destinationSheetMusicViewEnabled: true,
|
||||||
|
destinationSheetMusicTrackScopeEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request).toMatchObject({
|
||||||
|
alignment: 'region-end',
|
||||||
|
anchorBeat: 24,
|
||||||
|
clampScope: 'region',
|
||||||
|
});
|
||||||
|
|
||||||
|
const metrics: SheetMeasureMetric[] = [
|
||||||
|
{ barIndex: 0, startBeat: 0, endBeat: 4, leftPx: 0, widthPx: 200 },
|
||||||
|
{ barIndex: 1, startBeat: 4, endBeat: 8, leftPx: 200, widthPx: 200 },
|
||||||
|
];
|
||||||
|
const scrollLeft = getScrollLeftForViewportRequest({
|
||||||
|
request,
|
||||||
|
container: createContainer({ clientWidth: 200, scrollWidth: 400 }),
|
||||||
|
sheetMeasureMetrics: metrics,
|
||||||
|
activeRegionStartBeat: 16,
|
||||||
|
activeRegionEndBeat: 24,
|
||||||
|
songEndBeat: 32,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scrollLeft).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the track-scope special case only when entering sheet music from piano roll', () => {
|
||||||
|
const specialCaseRequest = createPendingModeSwitchRequest({
|
||||||
|
playheadBeat: 28,
|
||||||
|
regionStartBeat: 16,
|
||||||
|
regionEndBeat: 24,
|
||||||
|
sourceSheetMusicViewEnabled: false,
|
||||||
|
destinationSheetMusicViewEnabled: true,
|
||||||
|
destinationSheetMusicTrackScopeEnabled: true,
|
||||||
|
});
|
||||||
|
const regularTrackScopeRequest = createPendingModeSwitchRequest({
|
||||||
|
playheadBeat: 28,
|
||||||
|
regionStartBeat: 16,
|
||||||
|
regionEndBeat: 24,
|
||||||
|
sourceSheetMusicViewEnabled: true,
|
||||||
|
destinationSheetMusicViewEnabled: true,
|
||||||
|
destinationSheetMusicTrackScopeEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(specialCaseRequest).toMatchObject({
|
||||||
|
alignment: 'center',
|
||||||
|
anchorBeat: 28,
|
||||||
|
clampScope: 'track',
|
||||||
|
});
|
||||||
|
expect(regularTrackScopeRequest).toMatchObject({
|
||||||
|
alignment: 'region-end',
|
||||||
|
anchorBeat: 24,
|
||||||
|
clampScope: 'region',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats sheet-music to piano-roll switches as region-scoped even when source sheet view was track-scoped', () => {
|
||||||
|
const request = createPendingModeSwitchRequest({
|
||||||
|
playheadBeat: 20,
|
||||||
|
regionStartBeat: 16,
|
||||||
|
regionEndBeat: 24,
|
||||||
|
sourceSheetMusicViewEnabled: true,
|
||||||
|
destinationSheetMusicViewEnabled: false,
|
||||||
|
destinationSheetMusicTrackScopeEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request).toMatchObject({
|
||||||
|
alignment: 'center',
|
||||||
|
anchorBeat: 20,
|
||||||
|
clampScope: 'region',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps centered piano-roll scroll at the start and end of the active region', () => {
|
||||||
|
const container = createContainer({ clientWidth: 260, scrollWidth: 2000 });
|
||||||
|
const startClamp = getScrollLeftForViewportRequest({
|
||||||
|
request: {
|
||||||
|
sourceSheetMusicViewEnabled: false,
|
||||||
|
destinationSheetMusicViewEnabled: false,
|
||||||
|
destinationSheetMusicTrackScopeEnabled: false,
|
||||||
|
alignment: 'center',
|
||||||
|
anchorBeat: 16,
|
||||||
|
clampScope: 'region',
|
||||||
|
},
|
||||||
|
container,
|
||||||
|
sheetMeasureMetrics: [],
|
||||||
|
activeRegionStartBeat: 16,
|
||||||
|
activeRegionEndBeat: 24,
|
||||||
|
songEndBeat: 32,
|
||||||
|
});
|
||||||
|
const endClamp = getScrollLeftForViewportRequest({
|
||||||
|
request: {
|
||||||
|
sourceSheetMusicViewEnabled: false,
|
||||||
|
destinationSheetMusicViewEnabled: false,
|
||||||
|
destinationSheetMusicTrackScopeEnabled: false,
|
||||||
|
alignment: 'center',
|
||||||
|
anchorBeat: 24,
|
||||||
|
clampScope: 'region',
|
||||||
|
},
|
||||||
|
container,
|
||||||
|
sheetMeasureMetrics: [],
|
||||||
|
activeRegionStartBeat: 16,
|
||||||
|
activeRegionEndBeat: 24,
|
||||||
|
songEndBeat: 32,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(startClamp).toBe(640);
|
||||||
|
expect(endClamp).toBe(760);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('centers the playhead in track-scope sheet view with song-bound clamping', () => {
|
||||||
|
const request = createPendingModeSwitchRequest({
|
||||||
|
playheadBeat: 14,
|
||||||
|
regionStartBeat: 16,
|
||||||
|
regionEndBeat: 24,
|
||||||
|
sourceSheetMusicViewEnabled: false,
|
||||||
|
destinationSheetMusicViewEnabled: true,
|
||||||
|
destinationSheetMusicTrackScopeEnabled: true,
|
||||||
|
});
|
||||||
|
const metrics: SheetMeasureMetric[] = [
|
||||||
|
{ barIndex: 0, startBeat: 0, endBeat: 4, leftPx: 0, widthPx: 200 },
|
||||||
|
{ barIndex: 1, startBeat: 4, endBeat: 8, leftPx: 200, widthPx: 200 },
|
||||||
|
{ barIndex: 2, startBeat: 8, endBeat: 12, leftPx: 400, widthPx: 200 },
|
||||||
|
{ barIndex: 3, startBeat: 12, endBeat: 16, leftPx: 600, widthPx: 200 },
|
||||||
|
];
|
||||||
|
const scrollLeft = getScrollLeftForViewportRequest({
|
||||||
|
request,
|
||||||
|
container: createContainer({ clientWidth: 200, scrollWidth: 800 }),
|
||||||
|
sheetMeasureMetrics: metrics,
|
||||||
|
activeRegionStartBeat: 16,
|
||||||
|
activeRegionEndBeat: 24,
|
||||||
|
songEndBeat: 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scrollLeft).toBe(600);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,19 +26,35 @@ import type { PianoRollAutomationType } from './pianoRollAutomation';
|
|||||||
import type { SheetMeasureMetric } from './sheetNotationTypes';
|
import type { SheetMeasureMetric } from './sheetNotationTypes';
|
||||||
import { getSheetPlayheadPixel, getSheetQuantizationOptions, parseSheetQuantization } from './sheetNotation';
|
import { getSheetPlayheadPixel, getSheetQuantizationOptions, parseSheetQuantization } from './sheetNotation';
|
||||||
|
|
||||||
interface VisibleCenterBeatOptions {
|
type RegionPlayheadRelation = 'before' | 'inside' | 'after';
|
||||||
container: HTMLDivElement;
|
type ViewportSwitchAlignment = 'center' | 'region-start' | 'region-end';
|
||||||
sheetMusicViewEnabled: boolean;
|
type ViewportClampScope = 'region' | 'track';
|
||||||
sheetMeasureMetrics: SheetMeasureMetric[];
|
|
||||||
activeRegionStartBeat: number;
|
interface PendingModeSwitchRequest {
|
||||||
|
sourceSheetMusicViewEnabled: boolean;
|
||||||
|
destinationSheetMusicViewEnabled: boolean;
|
||||||
|
destinationSheetMusicTrackScopeEnabled: boolean;
|
||||||
|
alignment: ViewportSwitchAlignment;
|
||||||
|
anchorBeat: number;
|
||||||
|
clampScope: ViewportClampScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScrollLeftForBeatOptions {
|
interface ModeSwitchRequestOptions {
|
||||||
anchorBeat: number;
|
playheadBeat: number;
|
||||||
|
regionStartBeat: number;
|
||||||
|
regionEndBeat: number;
|
||||||
|
sourceSheetMusicViewEnabled: boolean;
|
||||||
|
destinationSheetMusicViewEnabled: boolean;
|
||||||
|
destinationSheetMusicTrackScopeEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScrollLeftForViewportRequestOptions {
|
||||||
|
request: PendingModeSwitchRequest;
|
||||||
container: HTMLDivElement;
|
container: HTMLDivElement;
|
||||||
sheetMusicViewEnabled: boolean;
|
|
||||||
sheetMeasureMetrics: SheetMeasureMetric[];
|
sheetMeasureMetrics: SheetMeasureMetric[];
|
||||||
activeRegionStartBeat: number;
|
activeRegionStartBeat: number;
|
||||||
|
activeRegionEndBeat: number;
|
||||||
|
songEndBeat: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PianoRollProps {
|
interface PianoRollProps {
|
||||||
@@ -80,6 +96,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
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);
|
||||||
|
const [sheetMusicTrackScopeEnabled, setSheetMusicTrackScopeEnabled] = useState(false);
|
||||||
const [sheetQuantization, setSheetQuantization] = useState('16,48');
|
const [sheetQuantization, setSheetQuantization] = useState('16,48');
|
||||||
const [sheetMeasureMetrics, setSheetMeasureMetrics] = useState<SheetMeasureMetric[]>([]);
|
const [sheetMeasureMetrics, setSheetMeasureMetrics] = useState<SheetMeasureMetric[]>([]);
|
||||||
|
|
||||||
@@ -133,7 +150,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
const pianoRollExpectedScrollLeftRef = useRef<number>(-1);
|
const pianoRollExpectedScrollLeftRef = useRef<number>(-1);
|
||||||
const pianoRollIsPlayingRef = useRef(false);
|
const pianoRollIsPlayingRef = useRef(false);
|
||||||
const pendingZoomAnchorBeatRef = useRef<number | null>(null);
|
const pendingZoomAnchorBeatRef = useRef<number | null>(null);
|
||||||
const pendingModeSwitchAnchorBeatRef = useRef<number | 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);
|
||||||
|
|
||||||
@@ -243,6 +260,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
setAutomationEnabled(pianoRollState.getAutomationViewEnabled());
|
setAutomationEnabled(pianoRollState.getAutomationViewEnabled());
|
||||||
setAutomationType(pianoRollState.getCurrentAutomationType() as PianoRollAutomationType);
|
setAutomationType(pianoRollState.getCurrentAutomationType() as PianoRollAutomationType);
|
||||||
setSheetMusicViewEnabled(pianoRollState.getSheetMusicViewEnabled());
|
setSheetMusicViewEnabled(pianoRollState.getSheetMusicViewEnabled());
|
||||||
|
setSheetMusicTrackScopeEnabled(pianoRollState.getSheetMusicTrackScopeEnabled());
|
||||||
setSheetQuantization(pianoRollState.getSheetQuantization());
|
setSheetQuantization(pianoRollState.getSheetQuantization());
|
||||||
|
|
||||||
if (DEBUG_MODE.PIANO_ROLL) {
|
if (DEBUG_MODE.PIANO_ROLL) {
|
||||||
@@ -743,17 +761,17 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSheetMusicViewToggle = useCallback(() => {
|
const handleSheetMusicViewToggle = useCallback(() => {
|
||||||
const container = pianoRollNoteScrollRef.current;
|
if (activeRegion) {
|
||||||
if (container && activeRegion) {
|
pendingModeSwitchRequestRef.current = createPendingModeSwitchRequest({
|
||||||
const anchorBeat = getVisibleCenterBeat({
|
playheadBeat: playheadPosition,
|
||||||
container,
|
regionStartBeat: activeRegion.getStartFromBeat(),
|
||||||
sheetMusicViewEnabled,
|
regionEndBeat: activeRegion.getStartFromBeat() + activeRegion.getLength(),
|
||||||
sheetMeasureMetrics,
|
sourceSheetMusicViewEnabled: sheetMusicViewEnabled,
|
||||||
activeRegionStartBeat: activeRegion.getStartFromBeat(),
|
destinationSheetMusicViewEnabled: !sheetMusicViewEnabled,
|
||||||
|
destinationSheetMusicTrackScopeEnabled: !sheetMusicViewEnabled && sheetMusicTrackScopeEnabled,
|
||||||
});
|
});
|
||||||
pendingModeSwitchAnchorBeatRef.current = anchorBeat;
|
|
||||||
} else {
|
} else {
|
||||||
pendingModeSwitchAnchorBeatRef.current = null;
|
pendingModeSwitchRequestRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSheetMusicViewEnabled(current => {
|
setSheetMusicViewEnabled(current => {
|
||||||
@@ -761,13 +779,34 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
KGPianoRollState.instance().setSheetMusicViewEnabled(next);
|
KGPianoRollState.instance().setSheetMusicViewEnabled(next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [activeRegion, sheetMeasureMetrics, sheetMusicViewEnabled]);
|
}, [activeRegion, playheadPosition, sheetMusicTrackScopeEnabled, sheetMusicViewEnabled]);
|
||||||
|
|
||||||
const handleSheetQuantizationChange = useCallback((value: string) => {
|
const handleSheetQuantizationChange = useCallback((value: string) => {
|
||||||
setSheetQuantization(value);
|
setSheetQuantization(value);
|
||||||
KGPianoRollState.instance().setSheetQuantization(value);
|
KGPianoRollState.instance().setSheetQuantization(value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleSheetMusicTrackScopeToggle = useCallback(() => {
|
||||||
|
if (activeRegion) {
|
||||||
|
pendingModeSwitchRequestRef.current = createPendingModeSwitchRequest({
|
||||||
|
playheadBeat: playheadPosition,
|
||||||
|
regionStartBeat: activeRegion.getStartFromBeat(),
|
||||||
|
regionEndBeat: activeRegion.getStartFromBeat() + activeRegion.getLength(),
|
||||||
|
sourceSheetMusicViewEnabled: sheetMusicViewEnabled,
|
||||||
|
destinationSheetMusicViewEnabled: sheetMusicViewEnabled,
|
||||||
|
destinationSheetMusicTrackScopeEnabled: !sheetMusicTrackScopeEnabled,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
pendingModeSwitchRequestRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSheetMusicTrackScopeEnabled(current => {
|
||||||
|
const next = !current;
|
||||||
|
KGPianoRollState.instance().setSheetMusicTrackScopeEnabled(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [activeRegion, playheadPosition, sheetMusicTrackScopeEnabled, sheetMusicViewEnabled]);
|
||||||
|
|
||||||
const handleSheetMeasureMetricsChange = useCallback((metrics: SheetMeasureMetric[]) => {
|
const handleSheetMeasureMetricsChange = useCallback((metrics: SheetMeasureMetric[]) => {
|
||||||
setSheetMeasureMetrics((current) => {
|
setSheetMeasureMetrics((current) => {
|
||||||
if (
|
if (
|
||||||
@@ -848,7 +887,9 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
|
|
||||||
const playheadPixel = sheetMusicViewEnabled && activeRegion
|
const playheadPixel = sheetMusicViewEnabled && activeRegion
|
||||||
? getSheetPlayheadPixel(
|
? getSheetPlayheadPixel(
|
||||||
Math.max(0, playheadPosition - activeRegion.getStartFromBeat()),
|
sheetMusicTrackScopeEnabled
|
||||||
|
? Math.max(0, playheadPosition)
|
||||||
|
: Math.max(0, playheadPosition - activeRegion.getStartFromBeat()),
|
||||||
sheetMeasureMetrics
|
sheetMeasureMetrics
|
||||||
)
|
)
|
||||||
: (() => {
|
: (() => {
|
||||||
@@ -872,7 +913,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
|
|
||||||
pianoRollExpectedScrollLeftRef.current = clampedScrollLeft;
|
pianoRollExpectedScrollLeftRef.current = clampedScrollLeft;
|
||||||
container.scrollLeft = clampedScrollLeft;
|
container.scrollLeft = clampedScrollLeft;
|
||||||
}, [playheadPosition, isPlaying, autoScrollEnabled, sheetMusicViewEnabled, sheetMeasureMetrics, activeRegion]);
|
}, [playheadPosition, isPlaying, autoScrollEnabled, sheetMusicViewEnabled, sheetMusicTrackScopeEnabled, sheetMeasureMetrics, activeRegion]);
|
||||||
|
|
||||||
// Handle scroll requests from main content bar numbers clicks
|
// Handle scroll requests from main content bar numbers clicks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -883,7 +924,9 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
|
|
||||||
const playheadPixel = sheetMusicViewEnabled && activeRegion
|
const playheadPixel = sheetMusicViewEnabled && activeRegion
|
||||||
? getSheetPlayheadPixel(
|
? getSheetPlayheadPixel(
|
||||||
Math.max(0, pianoRollScrollRequest - activeRegion.getStartFromBeat()),
|
sheetMusicTrackScopeEnabled
|
||||||
|
? Math.max(0, pianoRollScrollRequest)
|
||||||
|
: Math.max(0, pianoRollScrollRequest - activeRegion.getStartFromBeat()),
|
||||||
sheetMeasureMetrics
|
sheetMeasureMetrics
|
||||||
)
|
)
|
||||||
: (() => {
|
: (() => {
|
||||||
@@ -909,7 +952,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
|
|
||||||
// Clear the request after handling
|
// Clear the request after handling
|
||||||
useProjectStore.setState({ pianoRollScrollRequest: null });
|
useProjectStore.setState({ pianoRollScrollRequest: null });
|
||||||
}, [pianoRollScrollRequest, sheetMusicViewEnabled, sheetMeasureMetrics, activeRegion]);
|
}, [pianoRollScrollRequest, sheetMusicViewEnabled, sheetMusicTrackScopeEnabled, sheetMeasureMetrics, activeRegion]);
|
||||||
|
|
||||||
// Update --region-grid-beat-width when zoom changes and preserve the centered beat position.
|
// Update --region-grid-beat-width when zoom changes and preserve the centered beat position.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -950,7 +993,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingModeSwitchAnchorBeatRef.current !== null) {
|
if (pendingModeSwitchRequestRef.current !== null) {
|
||||||
previousActiveRegionIdRef.current = activeRegion.getId();
|
previousActiveRegionIdRef.current = activeRegion.getId();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -982,28 +1025,29 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
}, [activeRegion, timeSignature]);
|
}, [activeRegion, timeSignature]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const anchorBeat = pendingModeSwitchAnchorBeatRef.current;
|
const request = pendingModeSwitchRequestRef.current;
|
||||||
const container = pianoRollNoteScrollRef.current;
|
const container = pianoRollNoteScrollRef.current;
|
||||||
if (anchorBeat === null || !container || !activeRegion) {
|
if (!request || !container || !activeRegion) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sheetMusicViewEnabled && sheetMeasureMetrics.length === 0) {
|
if (request.destinationSheetMusicViewEnabled && sheetMeasureMetrics.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetScrollLeft = getScrollLeftForBeat({
|
const targetScrollLeft = getScrollLeftForViewportRequest({
|
||||||
anchorBeat,
|
request,
|
||||||
container,
|
container,
|
||||||
sheetMusicViewEnabled,
|
|
||||||
sheetMeasureMetrics,
|
sheetMeasureMetrics,
|
||||||
activeRegionStartBeat: activeRegion.getStartFromBeat(),
|
activeRegionStartBeat: activeRegion.getStartFromBeat(),
|
||||||
|
activeRegionEndBeat: activeRegion.getStartFromBeat() + activeRegion.getLength(),
|
||||||
|
songEndBeat: maxBars * timeSignature.numerator,
|
||||||
});
|
});
|
||||||
|
|
||||||
pianoRollExpectedScrollLeftRef.current = targetScrollLeft;
|
pianoRollExpectedScrollLeftRef.current = targetScrollLeft;
|
||||||
container.scrollLeft = targetScrollLeft;
|
container.scrollLeft = targetScrollLeft;
|
||||||
pendingModeSwitchAnchorBeatRef.current = null;
|
pendingModeSwitchRequestRef.current = null;
|
||||||
}, [activeRegion, sheetMeasureMetrics, sheetMusicViewEnabled]);
|
}, [activeRegion, maxBars, sheetMeasureMetrics, sheetMusicTrackScopeEnabled, sheetMusicViewEnabled, timeSignature.numerator]);
|
||||||
|
|
||||||
// Add keyboard event listener for piano roll hotkeys (snapping and quantization)
|
// Add keyboard event listener for piano roll hotkeys (snapping and quantization)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1204,6 +1248,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
<PianoRollToolbar
|
<PianoRollToolbar
|
||||||
sheetMusicViewEnabled={sheetMusicViewEnabled}
|
sheetMusicViewEnabled={sheetMusicViewEnabled}
|
||||||
onSheetMusicViewToggle={handleSheetMusicViewToggle}
|
onSheetMusicViewToggle={handleSheetMusicViewToggle}
|
||||||
|
sheetMusicTrackScopeEnabled={sheetMusicTrackScopeEnabled}
|
||||||
|
onSheetMusicTrackScopeToggle={handleSheetMusicTrackScopeToggle}
|
||||||
sheetQuantization={sheetQuantization}
|
sheetQuantization={sheetQuantization}
|
||||||
onSheetQuantizationChange={handleSheetQuantizationChange}
|
onSheetQuantizationChange={handleSheetQuantizationChange}
|
||||||
sheetQuantizationOptions={getSheetQuantizationOptions()}
|
sheetQuantizationOptions={getSheetQuantizationOptions()}
|
||||||
@@ -1262,6 +1308,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
automationType={automationType}
|
automationType={automationType}
|
||||||
automationRedrawVersion={automationRedrawVersion}
|
automationRedrawVersion={automationRedrawVersion}
|
||||||
sheetMusicViewEnabled={sheetMusicViewEnabled}
|
sheetMusicViewEnabled={sheetMusicViewEnabled}
|
||||||
|
sheetMusicTrackScopeEnabled={sheetMusicTrackScopeEnabled}
|
||||||
sheetQuantization={parsedSheetQuantization}
|
sheetQuantization={parsedSheetQuantization}
|
||||||
sheetKeySignature={keySignature}
|
sheetKeySignature={keySignature}
|
||||||
sheetInstrument={activeInstrument}
|
sheetInstrument={activeInstrument}
|
||||||
@@ -1280,77 +1327,224 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
|
|
||||||
export default PianoRoll;
|
export default PianoRoll;
|
||||||
|
|
||||||
function getVisibleCenterBeat({
|
export function getRegionPlayheadRelation(
|
||||||
container,
|
playheadBeat: number,
|
||||||
sheetMusicViewEnabled,
|
regionStartBeat: number,
|
||||||
sheetMeasureMetrics,
|
regionEndBeat: number
|
||||||
activeRegionStartBeat,
|
): RegionPlayheadRelation {
|
||||||
}: VisibleCenterBeatOptions): number {
|
if (playheadBeat < regionStartBeat) {
|
||||||
if (sheetMusicViewEnabled) {
|
return 'before';
|
||||||
const centerPixel = container.scrollLeft + container.clientWidth / 2;
|
|
||||||
return activeRegionStartBeat + getAbsoluteBeatForSheetPixel(centerPixel, sheetMeasureMetrics);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const keysWidth = parseInt(
|
if (playheadBeat > regionEndBeat) {
|
||||||
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
|
return 'after';
|
||||||
) || 60;
|
|
||||||
const beatWidth = parseInt(
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')
|
|
||||||
) || 40;
|
|
||||||
const visibleMusicWidth = Math.max(0, container.clientWidth - keysWidth);
|
|
||||||
return (container.scrollLeft + visibleMusicWidth / 2) / beatWidth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAbsoluteBeatForSheetPixel(pixel: number, metrics: SheetMeasureMetric[]): number {
|
return 'inside';
|
||||||
if (metrics.length === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstMetric = metrics[0];
|
export function createPendingModeSwitchRequest({
|
||||||
if (pixel <= firstMetric.leftPx) {
|
playheadBeat,
|
||||||
return firstMetric.startBeat;
|
regionStartBeat,
|
||||||
|
regionEndBeat,
|
||||||
|
sourceSheetMusicViewEnabled,
|
||||||
|
destinationSheetMusicViewEnabled,
|
||||||
|
destinationSheetMusicTrackScopeEnabled,
|
||||||
|
}: ModeSwitchRequestOptions): PendingModeSwitchRequest {
|
||||||
|
const relation = getRegionPlayheadRelation(playheadBeat, regionStartBeat, regionEndBeat);
|
||||||
|
const enteringTrackScopeSheet = (
|
||||||
|
!sourceSheetMusicViewEnabled &&
|
||||||
|
destinationSheetMusicViewEnabled &&
|
||||||
|
destinationSheetMusicTrackScopeEnabled
|
||||||
|
);
|
||||||
|
|
||||||
|
if (relation === 'inside') {
|
||||||
|
return {
|
||||||
|
sourceSheetMusicViewEnabled,
|
||||||
|
destinationSheetMusicViewEnabled,
|
||||||
|
destinationSheetMusicTrackScopeEnabled,
|
||||||
|
alignment: 'center',
|
||||||
|
anchorBeat: playheadBeat,
|
||||||
|
clampScope: destinationSheetMusicTrackScopeEnabled ? 'track' : 'region',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastMetric = metrics[metrics.length - 1];
|
if (enteringTrackScopeSheet) {
|
||||||
if (pixel >= lastMetric.leftPx + lastMetric.widthPx) {
|
return {
|
||||||
return lastMetric.endBeat;
|
sourceSheetMusicViewEnabled,
|
||||||
|
destinationSheetMusicViewEnabled,
|
||||||
|
destinationSheetMusicTrackScopeEnabled,
|
||||||
|
alignment: 'center',
|
||||||
|
anchorBeat: playheadBeat,
|
||||||
|
clampScope: 'track',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeMetric = metrics.find((metric) => (
|
return {
|
||||||
pixel >= metric.leftPx && pixel < metric.leftPx + metric.widthPx
|
sourceSheetMusicViewEnabled,
|
||||||
));
|
destinationSheetMusicViewEnabled,
|
||||||
|
destinationSheetMusicTrackScopeEnabled,
|
||||||
if (!activeMetric) {
|
alignment: relation === 'before' ? 'region-start' : 'region-end',
|
||||||
return lastMetric.endBeat;
|
anchorBeat: relation === 'before' ? regionStartBeat : regionEndBeat,
|
||||||
|
clampScope: 'region',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const progress = activeMetric.widthPx > 0 ? (pixel - activeMetric.leftPx) / activeMetric.widthPx : 0;
|
function getHorizontalViewportMetrics(container: HTMLDivElement, sheetMusicViewEnabled: boolean): {
|
||||||
return activeMetric.startBeat + progress * (activeMetric.endBeat - activeMetric.startBeat);
|
visibleWidth: number;
|
||||||
}
|
keysWidth: number;
|
||||||
|
} {
|
||||||
function getScrollLeftForBeat({
|
|
||||||
anchorBeat,
|
|
||||||
container,
|
|
||||||
sheetMusicViewEnabled,
|
|
||||||
sheetMeasureMetrics,
|
|
||||||
activeRegionStartBeat,
|
|
||||||
}: ScrollLeftForBeatOptions): number {
|
|
||||||
const pixelPosition = sheetMusicViewEnabled
|
|
||||||
? getSheetPlayheadPixel(Math.max(0, anchorBeat - activeRegionStartBeat), sheetMeasureMetrics)
|
|
||||||
: (() => {
|
|
||||||
const beatWidth = parseInt(
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')
|
|
||||||
) || 40;
|
|
||||||
return anchorBeat * beatWidth;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const keysWidth = sheetMusicViewEnabled
|
const keysWidth = sheetMusicViewEnabled
|
||||||
? 0
|
? 0
|
||||||
: (parseInt(
|
: (parseInt(
|
||||||
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
|
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
|
||||||
) || 60);
|
) || 60);
|
||||||
const visibleMusicWidth = Math.max(0, container.clientWidth - keysWidth);
|
|
||||||
const unclampedScrollLeft = pixelPosition - visibleMusicWidth / 2;
|
|
||||||
|
|
||||||
return Math.max(0, Math.min(unclampedScrollLeft, container.scrollWidth - container.clientWidth));
|
return {
|
||||||
|
visibleWidth: Math.max(0, container.clientWidth - keysWidth),
|
||||||
|
keysWidth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPixelForAbsoluteBeat(
|
||||||
|
beat: number,
|
||||||
|
sheetMusicViewEnabled: boolean,
|
||||||
|
sheetMusicTrackScopeEnabled: boolean,
|
||||||
|
sheetMeasureMetrics: SheetMeasureMetric[],
|
||||||
|
activeRegionStartBeat: number
|
||||||
|
): number {
|
||||||
|
if (sheetMusicViewEnabled) {
|
||||||
|
return getSheetPlayheadPixel(
|
||||||
|
sheetMusicTrackScopeEnabled
|
||||||
|
? Math.max(0, beat)
|
||||||
|
: Math.max(0, beat - activeRegionStartBeat),
|
||||||
|
sheetMeasureMetrics
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const beatWidth = parseInt(
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')
|
||||||
|
) || 40;
|
||||||
|
return beat * beatWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScopeBoundsInPixels(
|
||||||
|
request: PendingModeSwitchRequest,
|
||||||
|
sheetMeasureMetrics: SheetMeasureMetric[],
|
||||||
|
activeRegionStartBeat: number,
|
||||||
|
activeRegionEndBeat: number,
|
||||||
|
songEndBeat: number
|
||||||
|
): { startPx: number; endPx: number } {
|
||||||
|
if (request.destinationSheetMusicViewEnabled) {
|
||||||
|
if (request.clampScope === 'track') {
|
||||||
|
return {
|
||||||
|
startPx: getSheetPlayheadPixel(0, sheetMeasureMetrics),
|
||||||
|
endPx: getSheetPlayheadPixel(songEndBeat, sheetMeasureMetrics),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startPx: getSheetPlayheadPixel(0, sheetMeasureMetrics),
|
||||||
|
endPx: getSheetPlayheadPixel(activeRegionEndBeat - activeRegionStartBeat, sheetMeasureMetrics),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const beatWidth = parseInt(
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')
|
||||||
|
) || 40;
|
||||||
|
|
||||||
|
if (request.clampScope === 'track') {
|
||||||
|
return {
|
||||||
|
startPx: 0,
|
||||||
|
endPx: songEndBeat * beatWidth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startPx: activeRegionStartBeat * beatWidth,
|
||||||
|
endPx: activeRegionEndBeat * beatWidth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampScrollLeftToContainer(container: HTMLDivElement, scrollLeft: number): number {
|
||||||
|
return Math.max(0, Math.min(scrollLeft, container.scrollWidth - container.clientWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCenteredScrollLeft({
|
||||||
|
pixelPosition,
|
||||||
|
visibleWidth,
|
||||||
|
scopeStartPx,
|
||||||
|
scopeEndPx,
|
||||||
|
container,
|
||||||
|
}: {
|
||||||
|
pixelPosition: number;
|
||||||
|
visibleWidth: number;
|
||||||
|
scopeStartPx: number;
|
||||||
|
scopeEndPx: number;
|
||||||
|
container: HTMLDivElement;
|
||||||
|
}): number {
|
||||||
|
const unclamped = pixelPosition - visibleWidth / 2;
|
||||||
|
const maxScopeScrollLeft = Math.max(scopeStartPx, scopeEndPx - visibleWidth);
|
||||||
|
const clampedToScope = Math.max(scopeStartPx, Math.min(unclamped, maxScopeScrollLeft));
|
||||||
|
return clampScrollLeftToContainer(container, clampedToScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegionEndAlignedScrollLeft({
|
||||||
|
visibleWidth,
|
||||||
|
scopeEndPx,
|
||||||
|
container,
|
||||||
|
}: {
|
||||||
|
visibleWidth: number;
|
||||||
|
scopeEndPx: number;
|
||||||
|
container: HTMLDivElement;
|
||||||
|
}): number {
|
||||||
|
return clampScrollLeftToContainer(container, Math.max(0, scopeEndPx - visibleWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScrollLeftForViewportRequest({
|
||||||
|
request,
|
||||||
|
container,
|
||||||
|
sheetMeasureMetrics,
|
||||||
|
activeRegionStartBeat,
|
||||||
|
activeRegionEndBeat,
|
||||||
|
songEndBeat,
|
||||||
|
}: ScrollLeftForViewportRequestOptions): number {
|
||||||
|
const { visibleWidth } = getHorizontalViewportMetrics(
|
||||||
|
container,
|
||||||
|
request.destinationSheetMusicViewEnabled
|
||||||
|
);
|
||||||
|
const pixelPosition = getPixelForAbsoluteBeat(
|
||||||
|
request.anchorBeat,
|
||||||
|
request.destinationSheetMusicViewEnabled,
|
||||||
|
request.destinationSheetMusicTrackScopeEnabled,
|
||||||
|
sheetMeasureMetrics,
|
||||||
|
activeRegionStartBeat
|
||||||
|
);
|
||||||
|
const { startPx, endPx } = getScopeBoundsInPixels(
|
||||||
|
request,
|
||||||
|
sheetMeasureMetrics,
|
||||||
|
activeRegionStartBeat,
|
||||||
|
activeRegionEndBeat,
|
||||||
|
songEndBeat
|
||||||
|
);
|
||||||
|
|
||||||
|
if (request.alignment === 'region-start') {
|
||||||
|
return clampScrollLeftToContainer(container, startPx);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.alignment === 'region-end') {
|
||||||
|
return getRegionEndAlignedScrollLeft({
|
||||||
|
visibleWidth,
|
||||||
|
scopeEndPx: endPx,
|
||||||
|
container,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return getCenteredScrollLeft({
|
||||||
|
pixelPosition,
|
||||||
|
visibleWidth,
|
||||||
|
scopeStartPx: startPx,
|
||||||
|
scopeEndPx: endPx,
|
||||||
|
container,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import PianoRollContent from './PianoRollContent';
|
import PianoRollContent from './PianoRollContent';
|
||||||
import { createMockMidiRegion } from '../../test/utils/mock-data';
|
import { createMockMidiRegion } from '../../test/utils/mock-data';
|
||||||
@@ -49,7 +49,14 @@ vi.mock('./PianoKeys', () => ({ default: () => <div data-testid="piano-keys" />
|
|||||||
vi.mock('./PianoGrid', () => ({ default: ({ children }: { children?: React.ReactNode }) => <div data-testid="piano-grid">{children}</div> }));
|
vi.mock('./PianoGrid', () => ({ default: ({ children }: { children?: React.ReactNode }) => <div data-testid="piano-grid">{children}</div> }));
|
||||||
vi.mock('./PianoNote', () => ({ default: () => <div data-testid="piano-note" /> }));
|
vi.mock('./PianoNote', () => ({ default: () => <div data-testid="piano-note" /> }));
|
||||||
vi.mock('./PianoRollAutomationLane', () => ({ default: () => <div data-testid="automation-lane" /> }));
|
vi.mock('./PianoRollAutomationLane', () => ({ default: () => <div data-testid="automation-lane" /> }));
|
||||||
vi.mock('./SheetMusicView', () => ({ default: () => <div data-testid="sheet-music-view" /> }));
|
const sheetMusicViewSpy = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('./SheetMusicView', () => ({
|
||||||
|
default: (props: unknown) => {
|
||||||
|
sheetMusicViewSpy(props);
|
||||||
|
return <div data-testid="sheet-music-view" />;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
describe('PianoRollContent', () => {
|
describe('PianoRollContent', () => {
|
||||||
const baseProps = {
|
const baseProps = {
|
||||||
@@ -67,6 +74,10 @@ describe('PianoRollContent', () => {
|
|||||||
bpm: 120,
|
bpm: 120,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sheetMusicViewSpy.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the single-pane layout when automation is disabled', () => {
|
it('keeps the single-pane layout when automation is disabled', () => {
|
||||||
render(
|
render(
|
||||||
<PianoRollContent
|
<PianoRollContent
|
||||||
@@ -117,6 +128,7 @@ describe('PianoRollContent', () => {
|
|||||||
automationEnabled={true}
|
automationEnabled={true}
|
||||||
automationType="cc-7"
|
automationType="cc-7"
|
||||||
sheetMusicViewEnabled={true}
|
sheetMusicViewEnabled={true}
|
||||||
|
sheetMusicTrackScopeEnabled={true}
|
||||||
sheetQuantization={parseSheetQuantization('16,48')}
|
sheetQuantization={parseSheetQuantization('16,48')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -124,5 +136,9 @@ describe('PianoRollContent', () => {
|
|||||||
expect(screen.getByTestId('sheet-music-view')).toBeInTheDocument();
|
expect(screen.getByTestId('sheet-music-view')).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('piano-keys')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('piano-keys')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('automation-lane')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('automation-lane')).not.toBeInTheDocument();
|
||||||
|
expect(sheetMusicViewSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
sheetMusicTrackScopeEnabled: true,
|
||||||
|
maxBars: 8,
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ interface PianoRollContentProps {
|
|||||||
automationType?: PianoRollAutomationType;
|
automationType?: PianoRollAutomationType;
|
||||||
automationRedrawVersion?: number;
|
automationRedrawVersion?: number;
|
||||||
sheetMusicViewEnabled?: boolean;
|
sheetMusicViewEnabled?: boolean;
|
||||||
|
sheetMusicTrackScopeEnabled?: boolean;
|
||||||
sheetQuantization?: SheetQuantization;
|
sheetQuantization?: SheetQuantization;
|
||||||
sheetKeySignature?: KeySignature;
|
sheetKeySignature?: KeySignature;
|
||||||
sheetInstrument?: InstrumentType;
|
sheetInstrument?: InstrumentType;
|
||||||
@@ -83,6 +84,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
|||||||
automationType = 'pitch-bend',
|
automationType = 'pitch-bend',
|
||||||
automationRedrawVersion = 0,
|
automationRedrawVersion = 0,
|
||||||
sheetMusicViewEnabled = false,
|
sheetMusicViewEnabled = false,
|
||||||
|
sheetMusicTrackScopeEnabled = false,
|
||||||
sheetQuantization,
|
sheetQuantization,
|
||||||
sheetKeySignature = 'C major',
|
sheetKeySignature = 'C major',
|
||||||
sheetInstrument = 'acoustic_grand_piano',
|
sheetInstrument = 'acoustic_grand_piano',
|
||||||
@@ -340,6 +342,11 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
|||||||
{sheetMusicViewEnabled && activeRegion && sheetQuantization ? (
|
{sheetMusicViewEnabled && activeRegion && sheetQuantization ? (
|
||||||
<SheetMusicView
|
<SheetMusicView
|
||||||
activeRegion={activeRegion}
|
activeRegion={activeRegion}
|
||||||
|
midiRegions={tracks.filter(track => track.getId().toString() === activeRegion?.getTrackId()).flatMap(track => (
|
||||||
|
track.getRegions().filter(region => region instanceof KGMidiRegion)
|
||||||
|
)) as KGMidiRegion[]}
|
||||||
|
maxBars={maxBars}
|
||||||
|
sheetMusicTrackScopeEnabled={sheetMusicTrackScopeEnabled}
|
||||||
timeSignature={timeSignature}
|
timeSignature={timeSignature}
|
||||||
keySignature={sheetKeySignature}
|
keySignature={sheetKeySignature}
|
||||||
instrument={sheetInstrument}
|
instrument={sheetInstrument}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ describe('PianoRollToolbar', () => {
|
|||||||
const baseProps = {
|
const baseProps = {
|
||||||
sheetMusicViewEnabled: false,
|
sheetMusicViewEnabled: false,
|
||||||
onSheetMusicViewToggle: vi.fn(),
|
onSheetMusicViewToggle: vi.fn(),
|
||||||
|
sheetMusicTrackScopeEnabled: false,
|
||||||
|
onSheetMusicTrackScopeToggle: vi.fn(),
|
||||||
sheetQuantization: '16,48',
|
sheetQuantization: '16,48',
|
||||||
onSheetQuantizationChange: vi.fn(),
|
onSheetQuantizationChange: vi.fn(),
|
||||||
sheetQuantizationOptions: ['16,48', '32,96'],
|
sheetQuantizationOptions: ['16,48', '32,96'],
|
||||||
@@ -127,7 +129,46 @@ 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.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('toggles the full-track sheet scope button', () => {
|
||||||
|
const onSheetMusicTrackScopeToggle = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PianoRollToolbar
|
||||||
|
{...baseProps}
|
||||||
|
sheetMusicViewEnabled={true}
|
||||||
|
mode="hybrid"
|
||||||
|
onSheetMusicTrackScopeToggle={onSheetMusicTrackScopeToggle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Show Entire Track' }));
|
||||||
|
expect(onSheetMusicTrackScopeToggle).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the full-track sheet scope button outside sheet mode and spectrogram mode', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<PianoRollToolbar
|
||||||
|
{...baseProps}
|
||||||
|
sheetMusicViewEnabled={false}
|
||||||
|
mode="midi-edit"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: 'Show Entire Track' })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<PianoRollToolbar
|
||||||
|
{...baseProps}
|
||||||
|
sheetMusicViewEnabled={true}
|
||||||
|
mode="spectrogram"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: 'Show Entire Track' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FaMousePointer, FaPencilAlt } from 'react-icons/fa';
|
import { FaMousePointer, FaPencilAlt } from 'react-icons/fa';
|
||||||
|
import { TbArrowBarToUp } from 'react-icons/tb';
|
||||||
import { KGDropdown } from '../common';
|
import { KGDropdown } from '../common';
|
||||||
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||||
import { KGCore } from '../../core/KGCore';
|
import { KGCore } from '../../core/KGCore';
|
||||||
@@ -18,6 +19,8 @@ const POWER_OPTIONS = [
|
|||||||
interface PianoRollToolbarProps {
|
interface PianoRollToolbarProps {
|
||||||
sheetMusicViewEnabled?: boolean;
|
sheetMusicViewEnabled?: boolean;
|
||||||
onSheetMusicViewToggle?: () => void;
|
onSheetMusicViewToggle?: () => void;
|
||||||
|
sheetMusicTrackScopeEnabled?: boolean;
|
||||||
|
onSheetMusicTrackScopeToggle?: () => void;
|
||||||
sheetQuantization?: string;
|
sheetQuantization?: string;
|
||||||
onSheetQuantizationChange?: (value: string) => void;
|
onSheetQuantizationChange?: (value: string) => void;
|
||||||
sheetQuantizationOptions?: string[];
|
sheetQuantizationOptions?: string[];
|
||||||
@@ -50,6 +53,8 @@ interface PianoRollToolbarProps {
|
|||||||
const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||||
sheetMusicViewEnabled = false,
|
sheetMusicViewEnabled = false,
|
||||||
onSheetMusicViewToggle,
|
onSheetMusicViewToggle,
|
||||||
|
sheetMusicTrackScopeEnabled = false,
|
||||||
|
onSheetMusicTrackScopeToggle,
|
||||||
sheetQuantization = '16,48',
|
sheetQuantization = '16,48',
|
||||||
onSheetQuantizationChange,
|
onSheetQuantizationChange,
|
||||||
sheetQuantizationOptions = [],
|
sheetQuantizationOptions = [],
|
||||||
@@ -112,14 +117,14 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
|||||||
onClick={() => onToolSelect('pointer')}
|
onClick={() => onToolSelect('pointer')}
|
||||||
title="Pointer Tool"
|
title="Pointer Tool"
|
||||||
>
|
>
|
||||||
<FaMousePointer />
|
<FaMousePointer className="piano-roll-tool-icon" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`tool-button ${activeTool === 'pencil' ? 'active' : ''}`}
|
className={`tool-button ${activeTool === 'pencil' ? 'active' : ''}`}
|
||||||
onClick={() => onToolSelect('pencil')}
|
onClick={() => onToolSelect('pencil')}
|
||||||
title="Pencil Tool"
|
title="Pencil Tool"
|
||||||
>
|
>
|
||||||
<FaPencilAlt />
|
<FaPencilAlt className="piano-roll-tool-icon" />
|
||||||
</button>
|
</button>
|
||||||
{showAutomationControls && (
|
{showAutomationControls && (
|
||||||
<div className="piano-roll-automation-toolbar-group">
|
<div className="piano-roll-automation-toolbar-group">
|
||||||
@@ -175,6 +180,16 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
|||||||
>
|
>
|
||||||
♬
|
♬
|
||||||
</button>
|
</button>
|
||||||
|
{mode !== 'spectrogram' && (
|
||||||
|
<button
|
||||||
|
className={`tool-button icon-only sheet-track-scope-toggle ${sheetMusicTrackScopeEnabled ? 'active' : ''}`}
|
||||||
|
onClick={() => onSheetMusicTrackScopeToggle?.()}
|
||||||
|
title={sheetMusicTrackScopeEnabled ? 'Show Active Region Only' : 'Show Entire Track'}
|
||||||
|
aria-label={sheetMusicTrackScopeEnabled ? 'Show Active Region Only' : 'Show Entire Track'}
|
||||||
|
>
|
||||||
|
<TbArrowBarToUp className="sheet-track-scope-icon" strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { InstrumentType } from '../../core/track/KGMidiTrack';
|
|||||||
import type { SheetMeasureMetric, SheetMeasureModel, SheetQuantization } from './sheetNotationTypes';
|
import type { SheetMeasureMetric, SheetMeasureModel, SheetQuantization } from './sheetNotationTypes';
|
||||||
import {
|
import {
|
||||||
buildSheetMeasureMetrics,
|
buildSheetMeasureMetrics,
|
||||||
|
getSheetBeatAtPixel,
|
||||||
buildSheetMeasureModels,
|
buildSheetMeasureModels,
|
||||||
getSheetPlayheadPixel,
|
getSheetPlayheadPixel,
|
||||||
projectKeySignatureToVexFlow,
|
projectKeySignatureToVexFlow,
|
||||||
@@ -18,6 +19,9 @@ import {
|
|||||||
|
|
||||||
interface SheetMusicViewProps {
|
interface SheetMusicViewProps {
|
||||||
activeRegion: KGMidiRegion | null;
|
activeRegion: KGMidiRegion | null;
|
||||||
|
midiRegions: KGMidiRegion[];
|
||||||
|
maxBars: number;
|
||||||
|
sheetMusicTrackScopeEnabled: boolean;
|
||||||
timeSignature: { numerator: number; denominator: number };
|
timeSignature: { numerator: number; denominator: number };
|
||||||
keySignature: KeySignature;
|
keySignature: KeySignature;
|
||||||
instrument: InstrumentType;
|
instrument: InstrumentType;
|
||||||
@@ -50,6 +54,9 @@ const STAFF_HEIGHT = 132;
|
|||||||
const FIRST_MEASURE_MODIFIER_WIDTH = 72;
|
const FIRST_MEASURE_MODIFIER_WIDTH = 72;
|
||||||
const SheetMusicView: React.FC<SheetMusicViewProps> = ({
|
const SheetMusicView: React.FC<SheetMusicViewProps> = ({
|
||||||
activeRegion,
|
activeRegion,
|
||||||
|
midiRegions,
|
||||||
|
maxBars,
|
||||||
|
sheetMusicTrackScopeEnabled,
|
||||||
timeSignature,
|
timeSignature,
|
||||||
keySignature,
|
keySignature,
|
||||||
instrument,
|
instrument,
|
||||||
@@ -66,8 +73,10 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
|
|||||||
const lastDrawSignatureRef = useRef<string | null>(null);
|
const lastDrawSignatureRef = useRef<string | null>(null);
|
||||||
const vexKeySignature = useMemo(() => projectKeySignatureToVexFlow(keySignature), [keySignature]);
|
const vexKeySignature = useMemo(() => projectKeySignatureToVexFlow(keySignature), [keySignature]);
|
||||||
const startingBarNumber = useMemo(() => (
|
const startingBarNumber = useMemo(() => (
|
||||||
activeRegion ? Math.floor(activeRegion.getStartFromBeat() / timeSignature.numerator) + 1 : 1
|
sheetMusicTrackScopeEnabled
|
||||||
), [activeRegion, timeSignature.numerator]);
|
? 1
|
||||||
|
: (activeRegion ? Math.floor(activeRegion.getStartFromBeat() / timeSignature.numerator) + 1 : 1)
|
||||||
|
), [activeRegion, sheetMusicTrackScopeEnabled, timeSignature.numerator]);
|
||||||
|
|
||||||
const measureModels = useMemo<SheetMeasureModel[]>(() => {
|
const measureModels = useMemo<SheetMeasureModel[]>(() => {
|
||||||
if (!activeRegion) {
|
if (!activeRegion) {
|
||||||
@@ -75,11 +84,14 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return buildSheetMeasureModels({
|
return buildSheetMeasureModels({
|
||||||
|
scope: sheetMusicTrackScopeEnabled ? 'track' : 'region',
|
||||||
region: activeRegion,
|
region: activeRegion,
|
||||||
|
regions: midiRegions,
|
||||||
|
projectMaxBars: maxBars,
|
||||||
timeSignature,
|
timeSignature,
|
||||||
quantization,
|
quantization,
|
||||||
});
|
});
|
||||||
}, [activeRegion, timeSignature, quantization]);
|
}, [activeRegion, maxBars, midiRegions, quantization, sheetMusicTrackScopeEnabled, timeSignature]);
|
||||||
const measureWidths = useMemo(
|
const measureWidths = useMemo(
|
||||||
() => measureModels.map((measure, index) => (
|
() => measureModels.map((measure, index) => (
|
||||||
Math.max(
|
Math.max(
|
||||||
@@ -95,13 +107,16 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
|
|||||||
return 'treble';
|
return 'treble';
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolveSheetClef(activeRegion.getNotes(), instrument, true);
|
const notes = sheetMusicTrackScopeEnabled
|
||||||
}, [activeRegion, instrument]);
|
? midiRegions.flatMap(region => region.getNotes())
|
||||||
|
: activeRegion.getNotes();
|
||||||
|
return resolveSheetClef(notes, instrument, true);
|
||||||
|
}, [activeRegion, instrument, midiRegions, sheetMusicTrackScopeEnabled]);
|
||||||
const drawSignature = useMemo(() => JSON.stringify({
|
const drawSignature = useMemo(() => JSON.stringify({
|
||||||
regionId: activeRegion?.getId() ?? null,
|
regionId: activeRegion?.getId() ?? null,
|
||||||
regionName: activeRegion?.getName() ?? null,
|
regionIds: midiRegions.map(region => region.getId()),
|
||||||
regionStartBeat: activeRegion?.getStartFromBeat() ?? null,
|
scope: sheetMusicTrackScopeEnabled ? 'track' : 'region',
|
||||||
regionLength: activeRegion?.getLength() ?? null,
|
maxBars,
|
||||||
bars: measureModels.length,
|
bars: measureModels.length,
|
||||||
clef,
|
clef,
|
||||||
instrument,
|
instrument,
|
||||||
@@ -111,7 +126,7 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
|
|||||||
denominator: timeSignature.denominator,
|
denominator: timeSignature.denominator,
|
||||||
measureWidths,
|
measureWidths,
|
||||||
eventCounts: measureModels.map((measure) => measure.events.length),
|
eventCounts: measureModels.map((measure) => measure.events.length),
|
||||||
}), [activeRegion, clef, instrument, keySignature, measureModels, measureWidths, quantization.raw, timeSignature]);
|
}), [activeRegion, clef, instrument, keySignature, maxBars, measureModels, measureWidths, midiRegions, quantization.raw, sheetMusicTrackScopeEnabled, timeSignature]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeRegion) {
|
if (!activeRegion) {
|
||||||
@@ -126,7 +141,7 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextMetrics = buildSheetMeasureMetrics(measureWidths, timeSignature.numerator);
|
const nextMetrics = buildSheetMeasureMetrics(measureModels, measureWidths);
|
||||||
const renderedEvents: RenderedSheetEvent[] = [];
|
const renderedEvents: RenderedSheetEvent[] = [];
|
||||||
|
|
||||||
measureModels.forEach((measure, index) => {
|
measureModels.forEach((measure, index) => {
|
||||||
@@ -239,10 +254,10 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const localX = relativeX - metric.leftPx;
|
const targetBeat = getSheetBeatAtPixel(relativeX, metrics);
|
||||||
const progress = metric.widthPx > 0 ? localX / metric.widthPx : 0;
|
const absoluteBeat = sheetMusicTrackScopeEnabled
|
||||||
const regionBeat = metric.startBeat + progress * (metric.endBeat - metric.startBeat);
|
? targetBeat
|
||||||
const absoluteBeat = activeRegion.getStartFromBeat() + regionBeat;
|
: activeRegion.getStartFromBeat() + targetBeat;
|
||||||
|
|
||||||
setPlayheadPosition(absoluteBeat);
|
setPlayheadPosition(absoluteBeat);
|
||||||
requestMainContentScroll(absoluteBeat);
|
requestMainContentScroll(absoluteBeat);
|
||||||
@@ -262,9 +277,9 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="sheet-music-strip">
|
<div className="sheet-music-strip">
|
||||||
<div className="sheet-music-notice">
|
{/* <div className="sheet-music-notice">
|
||||||
Sheet music view is under development and may not fully reflect the exact musical notation.
|
Sheet music view is under development and may not fully reflect the exact musical notation.
|
||||||
</div>
|
</div> */}
|
||||||
<div className="sheet-music-measures">
|
<div className="sheet-music-measures">
|
||||||
<svg
|
<svg
|
||||||
className="sheet-music-ties"
|
className="sheet-music-ties"
|
||||||
@@ -277,7 +292,11 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
|
|||||||
<path key={tiePath.id} d={tiePath.d} className="sheet-music-tie-path" />
|
<path key={tiePath.id} d={tiePath.d} className="sheet-music-tie-path" />
|
||||||
))}
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
<SheetMusicPlayhead activeRegion={activeRegion} metrics={metrics} />
|
<SheetMusicPlayhead
|
||||||
|
activeRegion={activeRegion}
|
||||||
|
metrics={metrics}
|
||||||
|
sheetMusicTrackScopeEnabled={sheetMusicTrackScopeEnabled}
|
||||||
|
/>
|
||||||
{measureModels.map((measure, index) => (
|
{measureModels.map((measure, index) => (
|
||||||
<div
|
<div
|
||||||
key={`sheet-measure-${measure.barIndex}`}
|
key={`sheet-measure-${measure.barIndex}`}
|
||||||
@@ -305,9 +324,14 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
|
|||||||
interface SheetMusicPlayheadProps {
|
interface SheetMusicPlayheadProps {
|
||||||
activeRegion: KGMidiRegion | null;
|
activeRegion: KGMidiRegion | null;
|
||||||
metrics: SheetMeasureMetric[];
|
metrics: SheetMeasureMetric[];
|
||||||
|
sheetMusicTrackScopeEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SheetMusicPlayhead: React.FC<SheetMusicPlayheadProps> = memo(({ activeRegion, metrics }) => {
|
const SheetMusicPlayhead: React.FC<SheetMusicPlayheadProps> = memo(({
|
||||||
|
activeRegion,
|
||||||
|
metrics,
|
||||||
|
sheetMusicTrackScopeEnabled,
|
||||||
|
}) => {
|
||||||
const playheadPosition = useProjectStore(state => state.playheadPosition);
|
const playheadPosition = useProjectStore(state => state.playheadPosition);
|
||||||
|
|
||||||
const playheadPixel = useMemo(() => {
|
const playheadPixel = useMemo(() => {
|
||||||
@@ -316,10 +340,12 @@ const SheetMusicPlayhead: React.FC<SheetMusicPlayheadProps> = memo(({ activeRegi
|
|||||||
}
|
}
|
||||||
|
|
||||||
return getSheetPlayheadPixel(
|
return getSheetPlayheadPixel(
|
||||||
Math.max(0, playheadPosition - activeRegion.getStartFromBeat()),
|
sheetMusicTrackScopeEnabled
|
||||||
|
? Math.max(0, playheadPosition)
|
||||||
|
: Math.max(0, playheadPosition - activeRegion.getStartFromBeat()),
|
||||||
metrics
|
metrics
|
||||||
);
|
);
|
||||||
}, [activeRegion, metrics, playheadPosition]);
|
}, [activeRegion, metrics, playheadPosition, sheetMusicTrackScopeEnabled]);
|
||||||
|
|
||||||
return <Playhead context="piano-roll" pixelPositionOverride={playheadPixel} />;
|
return <Playhead context="piano-roll" pixelPositionOverride={playheadPixel} />;
|
||||||
});
|
});
|
||||||
@@ -330,6 +356,10 @@ const arePropsEqual = (previous: SheetMusicViewProps, next: SheetMusicViewProps)
|
|||||||
previous.activeRegion?.getName() === next.activeRegion?.getName() &&
|
previous.activeRegion?.getName() === next.activeRegion?.getName() &&
|
||||||
previous.activeRegion?.getLength() === next.activeRegion?.getLength() &&
|
previous.activeRegion?.getLength() === next.activeRegion?.getLength() &&
|
||||||
previous.activeRegion?.getStartFromBeat() === next.activeRegion?.getStartFromBeat() &&
|
previous.activeRegion?.getStartFromBeat() === next.activeRegion?.getStartFromBeat() &&
|
||||||
|
previous.midiRegions.length === next.midiRegions.length &&
|
||||||
|
previous.midiRegions.every((region, index) => region.getId() === next.midiRegions[index]?.getId()) &&
|
||||||
|
previous.maxBars === next.maxBars &&
|
||||||
|
previous.sheetMusicTrackScopeEnabled === next.sheetMusicTrackScopeEnabled &&
|
||||||
previous.instrument === next.instrument &&
|
previous.instrument === next.instrument &&
|
||||||
previous.keySignature === next.keySignature &&
|
previous.keySignature === next.keySignature &&
|
||||||
previous.quantization.raw === next.quantization.raw &&
|
previous.quantization.raw === next.quantization.raw &&
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { createMockMidiNote, createMockMidiRegion } from '../../test/utils/mock-data';
|
import { createMockMidiNote, createMockMidiRegion } from '../../test/utils/mock-data';
|
||||||
import {
|
import {
|
||||||
buildSheetMeasureMetrics,
|
buildSheetMeasureMetrics,
|
||||||
|
getSheetBeatAtPixel,
|
||||||
buildSheetMeasureModels,
|
buildSheetMeasureModels,
|
||||||
getSheetPlayheadPixel,
|
getSheetPlayheadPixel,
|
||||||
getSheetQuantizationOptions,
|
getSheetQuantizationOptions,
|
||||||
@@ -50,7 +51,10 @@ describe('sheetNotation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('maps playhead position through variable-width bars', () => {
|
it('maps playhead position through variable-width bars', () => {
|
||||||
const metrics = buildSheetMeasureMetrics([120, 240], 4);
|
const metrics = buildSheetMeasureMetrics([
|
||||||
|
{ barIndex: 0, startBeat: 0, endBeat: 4, events: [] },
|
||||||
|
{ barIndex: 1, startBeat: 4, endBeat: 8, events: [] },
|
||||||
|
], [120, 240]);
|
||||||
|
|
||||||
expect(getSheetPlayheadPixel(0, metrics)).toBe(0);
|
expect(getSheetPlayheadPixel(0, metrics)).toBe(0);
|
||||||
expect(getSheetPlayheadPixel(2, metrics)).toBe(60);
|
expect(getSheetPlayheadPixel(2, metrics)).toBe(60);
|
||||||
@@ -91,4 +95,48 @@ describe('sheetNotation', () => {
|
|||||||
expect(measures[0].events.filter(event => !event.isRest).map(event => event.startBeat)).toEqual([0, 1, 2, 3]);
|
expect(measures[0].events.filter(event => !event.isRest).map(event => event.startBeat)).toEqual([0, 1, 2, 3]);
|
||||||
expect(measures[1].events.filter(event => !event.isRest).map(event => event.startBeat)).toEqual([4]);
|
expect(measures[1].events.filter(event => !event.isRest).map(event => event.startBeat)).toEqual([4]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds a full-track sheet timeline with rests across empty bars and gaps', () => {
|
||||||
|
const firstRegion = createMockMidiRegion({
|
||||||
|
id: 'region-a',
|
||||||
|
startFromBeat: 4,
|
||||||
|
length: 4,
|
||||||
|
notes: [createMockMidiNote({ id: 'a1', startBeat: 0, endBeat: 1, pitch: 60 })],
|
||||||
|
});
|
||||||
|
const secondRegion = createMockMidiRegion({
|
||||||
|
id: 'region-b',
|
||||||
|
startFromBeat: 12,
|
||||||
|
length: 4,
|
||||||
|
notes: [createMockMidiNote({ id: 'b1', startBeat: 0, endBeat: 1, pitch: 64 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const measures = buildSheetMeasureModels({
|
||||||
|
scope: 'track',
|
||||||
|
region: firstRegion,
|
||||||
|
regions: [secondRegion, firstRegion],
|
||||||
|
projectMaxBars: 6,
|
||||||
|
timeSignature: { numerator: 4, denominator: 4 },
|
||||||
|
quantization: parseSheetQuantization('16,48'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(measures).toHaveLength(6);
|
||||||
|
expect(measures[0].startBeat).toBe(0);
|
||||||
|
expect(measures[5].endBeat).toBe(24);
|
||||||
|
expect(measures[0].events.every(event => event.isRest)).toBe(true);
|
||||||
|
expect(measures[1].events.some(event => !event.isRest && event.startBeat === 4)).toBe(true);
|
||||||
|
expect(measures[2].events.every(event => event.isRest)).toBe(true);
|
||||||
|
expect(measures[3].events.some(event => !event.isRest && event.startBeat === 12)).toBe(true);
|
||||||
|
expect(measures[4].events.every(event => event.isRest)).toBe(true);
|
||||||
|
expect(measures[5].events.every(event => event.isRest)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps absolute track beats through sheet metrics for full-track mode', () => {
|
||||||
|
const metrics = buildSheetMeasureMetrics([
|
||||||
|
{ barIndex: 0, startBeat: 0, endBeat: 4, events: [] },
|
||||||
|
{ barIndex: 1, startBeat: 4, endBeat: 8, events: [] },
|
||||||
|
], [120, 240]);
|
||||||
|
|
||||||
|
expect(getSheetPlayheadPixel(5, metrics)).toBe(180);
|
||||||
|
expect(getSheetBeatAtPixel(180, metrics)).toBe(5);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ const EPSILON = 1e-6;
|
|||||||
export type SheetClef = 'treble' | 'bass' | 'percussion';
|
export type SheetClef = 'treble' | 'bass' | 'percussion';
|
||||||
|
|
||||||
export interface BuildSheetNotationOptions {
|
export interface BuildSheetNotationOptions {
|
||||||
|
scope?: 'region' | 'track';
|
||||||
region: KGMidiRegion;
|
region: KGMidiRegion;
|
||||||
|
regions?: KGMidiRegion[];
|
||||||
|
projectMaxBars?: number;
|
||||||
timeSignature: { numerator: number; denominator: number };
|
timeSignature: { numerator: number; denominator: number };
|
||||||
quantization: SheetQuantization;
|
quantization: SheetQuantization;
|
||||||
}
|
}
|
||||||
@@ -36,6 +39,12 @@ interface WorkingEvent {
|
|||||||
isRest: boolean;
|
isRest: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NormalizedNoteInput {
|
||||||
|
pitch: number;
|
||||||
|
startBeat: number;
|
||||||
|
endBeat: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function getSheetQuantizationOptions(): string[] {
|
export function getSheetQuantizationOptions(): string[] {
|
||||||
return [...SHEET_QUANTIZATION_OPTIONS];
|
return [...SHEET_QUANTIZATION_OPTIONS];
|
||||||
}
|
}
|
||||||
@@ -84,13 +93,14 @@ export function resolveSheetClef(
|
|||||||
return averagePitch >= 60 ? 'treble' : 'bass';
|
return averagePitch >= 60 ? 'treble' : 'bass';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSheetMeasureMetrics(widths: number[], beatsPerBar: number): SheetMeasureMetric[] {
|
export function buildSheetMeasureMetrics(measures: SheetMeasureModel[], widths: number[]): SheetMeasureMetric[] {
|
||||||
let leftPx = 0;
|
let leftPx = 0;
|
||||||
return widths.map((widthPx, index) => {
|
return measures.map((measure, index) => {
|
||||||
|
const widthPx = widths[index] ?? 0;
|
||||||
const metric: SheetMeasureMetric = {
|
const metric: SheetMeasureMetric = {
|
||||||
barIndex: index,
|
barIndex: measure.barIndex,
|
||||||
startBeat: index * beatsPerBar,
|
startBeat: measure.startBeat,
|
||||||
endBeat: (index + 1) * beatsPerBar,
|
endBeat: measure.endBeat,
|
||||||
leftPx,
|
leftPx,
|
||||||
widthPx,
|
widthPx,
|
||||||
};
|
};
|
||||||
@@ -100,32 +110,59 @@ export function buildSheetMeasureMetrics(widths: number[], beatsPerBar: number):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getSheetPlayheadPixel(
|
export function getSheetPlayheadPixel(
|
||||||
regionRelativeBeat: number,
|
sheetBeat: number,
|
||||||
metrics: SheetMeasureMetric[]
|
metrics: SheetMeasureMetric[]
|
||||||
): number {
|
): number {
|
||||||
if (metrics.length === 0) {
|
if (metrics.length === 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (regionRelativeBeat <= metrics[0].startBeat) {
|
if (sheetBeat <= metrics[0].startBeat) {
|
||||||
return metrics[0].leftPx;
|
return metrics[0].leftPx;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastMetric = metrics[metrics.length - 1];
|
const lastMetric = metrics[metrics.length - 1];
|
||||||
if (regionRelativeBeat >= lastMetric.endBeat) {
|
if (sheetBeat >= lastMetric.endBeat) {
|
||||||
return lastMetric.leftPx + lastMetric.widthPx;
|
return lastMetric.leftPx + lastMetric.widthPx;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeMetric = metrics.find(metric => regionRelativeBeat >= metric.startBeat && regionRelativeBeat < metric.endBeat);
|
const activeMetric = metrics.find(metric => sheetBeat >= metric.startBeat && sheetBeat < metric.endBeat);
|
||||||
if (!activeMetric) {
|
if (!activeMetric) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const span = Math.max(activeMetric.endBeat - activeMetric.startBeat, EPSILON);
|
const span = Math.max(activeMetric.endBeat - activeMetric.startBeat, EPSILON);
|
||||||
const progress = (regionRelativeBeat - activeMetric.startBeat) / span;
|
const progress = (sheetBeat - activeMetric.startBeat) / span;
|
||||||
return activeMetric.leftPx + activeMetric.widthPx * progress;
|
return activeMetric.leftPx + activeMetric.widthPx * progress;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSheetBeatAtPixel(
|
||||||
|
pixel: number,
|
||||||
|
metrics: SheetMeasureMetric[]
|
||||||
|
): number {
|
||||||
|
if (metrics.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstMetric = metrics[0];
|
||||||
|
if (pixel <= firstMetric.leftPx) {
|
||||||
|
return firstMetric.startBeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastMetric = metrics[metrics.length - 1];
|
||||||
|
if (pixel >= lastMetric.leftPx + lastMetric.widthPx) {
|
||||||
|
return lastMetric.endBeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeMetric = metrics.find(metric => pixel >= metric.leftPx && pixel < metric.leftPx + metric.widthPx);
|
||||||
|
if (!activeMetric) {
|
||||||
|
return lastMetric.endBeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = activeMetric.widthPx > 0 ? (pixel - activeMetric.leftPx) / activeMetric.widthPx : 0;
|
||||||
|
return activeMetric.startBeat + progress * (activeMetric.endBeat - activeMetric.startBeat);
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveDurationSpec(durationBeats: number, isRest: boolean): { duration: string; dots: number } {
|
export function resolveDurationSpec(durationBeats: number, isRest: boolean): { duration: string; dots: number } {
|
||||||
const withRest = (value: string) => (isRest ? `${value}r` : value);
|
const withRest = (value: string) => (isRest ? `${value}r` : value);
|
||||||
const options = [
|
const options = [
|
||||||
@@ -160,21 +197,37 @@ export function resolveDurationSpec(durationBeats: number, isRest: boolean): { d
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildSheetMeasureModels({
|
export function buildSheetMeasureModels({
|
||||||
|
scope = 'region',
|
||||||
region,
|
region,
|
||||||
|
regions = [region],
|
||||||
|
projectMaxBars,
|
||||||
timeSignature,
|
timeSignature,
|
||||||
quantization,
|
quantization,
|
||||||
}: BuildSheetNotationOptions): SheetMeasureModel[] {
|
}: BuildSheetNotationOptions): SheetMeasureModel[] {
|
||||||
const beatsPerBar = timeSignature.numerator;
|
const beatsPerBar = timeSignature.numerator;
|
||||||
const measureCount = Math.max(1, Math.ceil(region.getLength() / beatsPerBar));
|
const isTrackScope = scope === 'track';
|
||||||
const measureEndBeat = measureCount * beatsPerBar;
|
const timelineStartBeat = isTrackScope ? 0 : 0;
|
||||||
const workingEvents = normalizeNotes(region.getNotes(), quantization.stepBeats, measureEndBeat);
|
const measureCount = isTrackScope
|
||||||
|
? Math.max(1, projectMaxBars ?? 1)
|
||||||
|
: Math.max(1, Math.ceil(region.getLength() / beatsPerBar));
|
||||||
|
const measureEndBeat = isTrackScope
|
||||||
|
? measureCount * beatsPerBar
|
||||||
|
: measureCount * beatsPerBar;
|
||||||
|
const noteInputs = isTrackScope
|
||||||
|
? collectTrackScopeNotes(regions)
|
||||||
|
: region.getNotes().map(note => ({
|
||||||
|
pitch: note.getPitch(),
|
||||||
|
startBeat: note.getStartBeat(),
|
||||||
|
endBeat: note.getEndBeat(),
|
||||||
|
}));
|
||||||
|
const workingEvents = normalizeNotes(noteInputs, quantization.stepBeats, measureEndBeat);
|
||||||
const withRests = insertRests(workingEvents, measureEndBeat);
|
const withRests = insertRests(workingEvents, measureEndBeat);
|
||||||
const splitEvents = splitAcrossBars(withRests, beatsPerBar);
|
const splitEvents = splitAcrossBars(withRests, beatsPerBar);
|
||||||
|
|
||||||
const measures: SheetMeasureModel[] = Array.from({ length: measureCount }, (_, barIndex) => ({
|
const measures: SheetMeasureModel[] = Array.from({ length: measureCount }, (_, barIndex) => ({
|
||||||
barIndex,
|
barIndex,
|
||||||
startBeat: barIndex * beatsPerBar,
|
startBeat: timelineStartBeat + barIndex * beatsPerBar,
|
||||||
endBeat: (barIndex + 1) * beatsPerBar,
|
endBeat: timelineStartBeat + (barIndex + 1) * beatsPerBar,
|
||||||
events: [],
|
events: [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -199,12 +252,12 @@ export function buildSheetMeasureModels({
|
|||||||
return measures;
|
return measures;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeNotes(notes: KGMidiNote[], stepBeats: number, measureEndBeat: number): WorkingEvent[] {
|
function normalizeNotes(notes: NormalizedNoteInput[], stepBeats: number, measureEndBeat: number): WorkingEvent[] {
|
||||||
const clippedNotes = notes
|
const clippedNotes = notes
|
||||||
.map(note => ({
|
.map(note => ({
|
||||||
keys: [midiPitchToVexKey(note.getPitch())],
|
keys: [midiPitchToVexKey(note.pitch)],
|
||||||
startBeat: quantizeBeat(note.getStartBeat(), stepBeats),
|
startBeat: quantizeBeat(note.startBeat, stepBeats),
|
||||||
endBeat: quantizeBeat(note.getEndBeat(), stepBeats),
|
endBeat: quantizeBeat(note.endBeat, stepBeats),
|
||||||
isRest: false,
|
isRest: false,
|
||||||
}))
|
}))
|
||||||
.map(note => ({
|
.map(note => ({
|
||||||
@@ -247,6 +300,27 @@ function normalizeNotes(notes: KGMidiNote[], stepBeats: number, measureEndBeat:
|
|||||||
return merged.filter(note => note.endBeat - note.startBeat > EPSILON);
|
return merged.filter(note => note.endBeat - note.startBeat > EPSILON);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectTrackScopeNotes(regions: KGMidiRegion[]): NormalizedNoteInput[] {
|
||||||
|
return [...regions]
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.getStartFromBeat() !== right.getStartFromBeat()) {
|
||||||
|
return left.getStartFromBeat() - right.getStartFromBeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.getId().localeCompare(right.getId());
|
||||||
|
})
|
||||||
|
.flatMap(region => {
|
||||||
|
const regionStart = region.getStartFromBeat();
|
||||||
|
// Overlapping regions are flattened in deterministic start-beat/id order so
|
||||||
|
// the existing note normalization path can resolve collisions consistently.
|
||||||
|
return region.getNotes().map(note => ({
|
||||||
|
pitch: note.getPitch(),
|
||||||
|
startBeat: regionStart + note.getStartBeat(),
|
||||||
|
endBeat: regionStart + note.getEndBeat(),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function insertRests(events: WorkingEvent[], measureEndBeat: number): WorkingEvent[] {
|
function insertRests(events: WorkingEvent[], measureEndBeat: number): WorkingEvent[] {
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return [{ keys: ['b/4'], startBeat: 0, endBeat: measureEndBeat, isRest: true }];
|
return [{ keys: ['b/4'], startBeat: 0, endBeat: measureEndBeat, isRest: true }];
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export class KGPianoRollState {
|
|||||||
private automationViewEnabled: boolean = false;
|
private automationViewEnabled: boolean = false;
|
||||||
private currentAutomationType: string = "pitch-bend";
|
private currentAutomationType: string = "pitch-bend";
|
||||||
private sheetMusicViewEnabled: boolean = false;
|
private sheetMusicViewEnabled: boolean = false;
|
||||||
|
private sheetMusicTrackScopeEnabled: boolean = false;
|
||||||
private sheetQuantization: string = '16,48';
|
private sheetQuantization: string = '16,48';
|
||||||
|
|
||||||
// Chord guide state
|
// Chord guide state
|
||||||
@@ -93,6 +94,14 @@ export class KGPianoRollState {
|
|||||||
this.sheetMusicViewEnabled = enabled;
|
this.sheetMusicViewEnabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getSheetMusicTrackScopeEnabled(): boolean {
|
||||||
|
return this.sheetMusicTrackScopeEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setSheetMusicTrackScopeEnabled(enabled: boolean): void {
|
||||||
|
this.sheetMusicTrackScopeEnabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
public getSheetQuantization(): string {
|
public getSheetQuantization(): string {
|
||||||
return this.sheetQuantization;
|
return this.sheetQuantization;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user