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:
Xiaohan-Tian
2026-05-10 13:11:10 -07:00
parent d69b3924e5
commit 578be7b726
11 changed files with 850 additions and 135 deletions
+19
View File
@@ -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;
+262
View File
@@ -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);
});
});
+283 -89
View File
@@ -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') return 'inside';
) || 40;
const visibleMusicWidth = Math.max(0, container.clientWidth - keysWidth);
return (container.scrollLeft + visibleMusicWidth / 2) / beatWidth;
} }
function getAbsoluteBeatForSheetPixel(pixel: number, metrics: SheetMeasureMetric[]): number { export function createPendingModeSwitchRequest({
if (metrics.length === 0) { playheadBeat,
return 0; 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 firstMetric = metrics[0]; if (enteringTrackScopeSheet) {
if (pixel <= firstMetric.leftPx) { return {
return firstMetric.startBeat; sourceSheetMusicViewEnabled,
destinationSheetMusicViewEnabled,
destinationSheetMusicTrackScopeEnabled,
alignment: 'center',
anchorBeat: playheadBeat,
clampScope: 'track',
};
} }
const lastMetric = metrics[metrics.length - 1]; return {
if (pixel >= lastMetric.leftPx + lastMetric.widthPx) { sourceSheetMusicViewEnabled,
return lastMetric.endBeat; destinationSheetMusicViewEnabled,
} destinationSheetMusicTrackScopeEnabled,
alignment: relation === 'before' ? 'region-start' : 'region-end',
const activeMetric = metrics.find((metric) => ( anchorBeat: relation === 'before' ? regionStartBeat : regionEndBeat,
pixel >= metric.leftPx && pixel < metric.leftPx + metric.widthPx clampScope: 'region',
)); };
if (!activeMetric) {
return lastMetric.endBeat;
}
const progress = activeMetric.widthPx > 0 ? (pixel - activeMetric.leftPx) / activeMetric.widthPx : 0;
return activeMetric.startBeat + progress * (activeMetric.endBeat - activeMetric.startBeat);
} }
function getScrollLeftForBeat({ function getHorizontalViewportMetrics(container: HTMLDivElement, sheetMusicViewEnabled: boolean): {
anchorBeat, visibleWidth: number;
container, keysWidth: number;
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();
});
}); });
+17 -2
View File
@@ -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>
)} )}
+50 -20
View File
@@ -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);
});
}); });
+93 -19
View File
@@ -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 }];
+9
View File
@@ -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;
} }