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;
}
.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:not(.icon-only) {
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 { getSheetPlayheadPixel, getSheetQuantizationOptions, parseSheetQuantization } from './sheetNotation';
interface VisibleCenterBeatOptions {
container: HTMLDivElement;
sheetMusicViewEnabled: boolean;
sheetMeasureMetrics: SheetMeasureMetric[];
activeRegionStartBeat: number;
type RegionPlayheadRelation = 'before' | 'inside' | 'after';
type ViewportSwitchAlignment = 'center' | 'region-start' | 'region-end';
type ViewportClampScope = 'region' | 'track';
interface PendingModeSwitchRequest {
sourceSheetMusicViewEnabled: boolean;
destinationSheetMusicViewEnabled: boolean;
destinationSheetMusicTrackScopeEnabled: boolean;
alignment: ViewportSwitchAlignment;
anchorBeat: number;
clampScope: ViewportClampScope;
}
interface ScrollLeftForBeatOptions {
anchorBeat: number;
interface ModeSwitchRequestOptions {
playheadBeat: number;
regionStartBeat: number;
regionEndBeat: number;
sourceSheetMusicViewEnabled: boolean;
destinationSheetMusicViewEnabled: boolean;
destinationSheetMusicTrackScopeEnabled: boolean;
}
interface ScrollLeftForViewportRequestOptions {
request: PendingModeSwitchRequest;
container: HTMLDivElement;
sheetMusicViewEnabled: boolean;
sheetMeasureMetrics: SheetMeasureMetric[];
activeRegionStartBeat: number;
activeRegionEndBeat: number;
songEndBeat: number;
}
interface PianoRollProps {
@@ -80,6 +96,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const [automationEnabled, setAutomationEnabled] = useState(false);
const [automationType, setAutomationType] = useState<PianoRollAutomationType>('pitch-bend');
const [sheetMusicViewEnabled, setSheetMusicViewEnabled] = useState(false);
const [sheetMusicTrackScopeEnabled, setSheetMusicTrackScopeEnabled] = useState(false);
const [sheetQuantization, setSheetQuantization] = useState('16,48');
const [sheetMeasureMetrics, setSheetMeasureMetrics] = useState<SheetMeasureMetric[]>([]);
@@ -133,7 +150,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const pianoRollExpectedScrollLeftRef = useRef<number>(-1);
const pianoRollIsPlayingRef = useRef(false);
const pendingZoomAnchorBeatRef = useRef<number | null>(null);
const pendingModeSwitchAnchorBeatRef = useRef<number | null>(null);
const pendingModeSwitchRequestRef = useRef<PendingModeSwitchRequest | null>(null);
const previousSheetMusicViewEnabledRef = useRef<boolean>(false);
const previousActiveRegionIdRef = useRef<string | null>(null);
@@ -243,6 +260,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
setAutomationEnabled(pianoRollState.getAutomationViewEnabled());
setAutomationType(pianoRollState.getCurrentAutomationType() as PianoRollAutomationType);
setSheetMusicViewEnabled(pianoRollState.getSheetMusicViewEnabled());
setSheetMusicTrackScopeEnabled(pianoRollState.getSheetMusicTrackScopeEnabled());
setSheetQuantization(pianoRollState.getSheetQuantization());
if (DEBUG_MODE.PIANO_ROLL) {
@@ -743,17 +761,17 @@ const PianoRoll: React.FC<PianoRollProps> = ({
}, []);
const handleSheetMusicViewToggle = useCallback(() => {
const container = pianoRollNoteScrollRef.current;
if (container && activeRegion) {
const anchorBeat = getVisibleCenterBeat({
container,
sheetMusicViewEnabled,
sheetMeasureMetrics,
activeRegionStartBeat: activeRegion.getStartFromBeat(),
if (activeRegion) {
pendingModeSwitchRequestRef.current = createPendingModeSwitchRequest({
playheadBeat: playheadPosition,
regionStartBeat: activeRegion.getStartFromBeat(),
regionEndBeat: activeRegion.getStartFromBeat() + activeRegion.getLength(),
sourceSheetMusicViewEnabled: sheetMusicViewEnabled,
destinationSheetMusicViewEnabled: !sheetMusicViewEnabled,
destinationSheetMusicTrackScopeEnabled: !sheetMusicViewEnabled && sheetMusicTrackScopeEnabled,
});
pendingModeSwitchAnchorBeatRef.current = anchorBeat;
} else {
pendingModeSwitchAnchorBeatRef.current = null;
pendingModeSwitchRequestRef.current = null;
}
setSheetMusicViewEnabled(current => {
@@ -761,13 +779,34 @@ const PianoRoll: React.FC<PianoRollProps> = ({
KGPianoRollState.instance().setSheetMusicViewEnabled(next);
return next;
});
}, [activeRegion, sheetMeasureMetrics, sheetMusicViewEnabled]);
}, [activeRegion, playheadPosition, sheetMusicTrackScopeEnabled, sheetMusicViewEnabled]);
const handleSheetQuantizationChange = useCallback((value: string) => {
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[]) => {
setSheetMeasureMetrics((current) => {
if (
@@ -848,7 +887,9 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const playheadPixel = sheetMusicViewEnabled && activeRegion
? getSheetPlayheadPixel(
Math.max(0, playheadPosition - activeRegion.getStartFromBeat()),
sheetMusicTrackScopeEnabled
? Math.max(0, playheadPosition)
: Math.max(0, playheadPosition - activeRegion.getStartFromBeat()),
sheetMeasureMetrics
)
: (() => {
@@ -872,7 +913,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
pianoRollExpectedScrollLeftRef.current = 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
useEffect(() => {
@@ -883,7 +924,9 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const playheadPixel = sheetMusicViewEnabled && activeRegion
? getSheetPlayheadPixel(
Math.max(0, pianoRollScrollRequest - activeRegion.getStartFromBeat()),
sheetMusicTrackScopeEnabled
? Math.max(0, pianoRollScrollRequest)
: Math.max(0, pianoRollScrollRequest - activeRegion.getStartFromBeat()),
sheetMeasureMetrics
)
: (() => {
@@ -909,7 +952,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Clear the request after handling
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.
useLayoutEffect(() => {
@@ -950,7 +993,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
return;
}
if (pendingModeSwitchAnchorBeatRef.current !== null) {
if (pendingModeSwitchRequestRef.current !== null) {
previousActiveRegionIdRef.current = activeRegion.getId();
return;
}
@@ -982,28 +1025,29 @@ const PianoRoll: React.FC<PianoRollProps> = ({
}, [activeRegion, timeSignature]);
useLayoutEffect(() => {
const anchorBeat = pendingModeSwitchAnchorBeatRef.current;
const request = pendingModeSwitchRequestRef.current;
const container = pianoRollNoteScrollRef.current;
if (anchorBeat === null || !container || !activeRegion) {
if (!request || !container || !activeRegion) {
return;
}
if (sheetMusicViewEnabled && sheetMeasureMetrics.length === 0) {
if (request.destinationSheetMusicViewEnabled && sheetMeasureMetrics.length === 0) {
return;
}
const targetScrollLeft = getScrollLeftForBeat({
anchorBeat,
const targetScrollLeft = getScrollLeftForViewportRequest({
request,
container,
sheetMusicViewEnabled,
sheetMeasureMetrics,
activeRegionStartBeat: activeRegion.getStartFromBeat(),
activeRegionEndBeat: activeRegion.getStartFromBeat() + activeRegion.getLength(),
songEndBeat: maxBars * timeSignature.numerator,
});
pianoRollExpectedScrollLeftRef.current = targetScrollLeft;
container.scrollLeft = targetScrollLeft;
pendingModeSwitchAnchorBeatRef.current = null;
}, [activeRegion, sheetMeasureMetrics, sheetMusicViewEnabled]);
pendingModeSwitchRequestRef.current = null;
}, [activeRegion, maxBars, sheetMeasureMetrics, sheetMusicTrackScopeEnabled, sheetMusicViewEnabled, timeSignature.numerator]);
// Add keyboard event listener for piano roll hotkeys (snapping and quantization)
useEffect(() => {
@@ -1204,6 +1248,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
<PianoRollToolbar
sheetMusicViewEnabled={sheetMusicViewEnabled}
onSheetMusicViewToggle={handleSheetMusicViewToggle}
sheetMusicTrackScopeEnabled={sheetMusicTrackScopeEnabled}
onSheetMusicTrackScopeToggle={handleSheetMusicTrackScopeToggle}
sheetQuantization={sheetQuantization}
onSheetQuantizationChange={handleSheetQuantizationChange}
sheetQuantizationOptions={getSheetQuantizationOptions()}
@@ -1262,6 +1308,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
automationType={automationType}
automationRedrawVersion={automationRedrawVersion}
sheetMusicViewEnabled={sheetMusicViewEnabled}
sheetMusicTrackScopeEnabled={sheetMusicTrackScopeEnabled}
sheetQuantization={parsedSheetQuantization}
sheetKeySignature={keySignature}
sheetInstrument={activeInstrument}
@@ -1280,77 +1327,224 @@ const PianoRoll: React.FC<PianoRollProps> = ({
export default PianoRoll;
function getVisibleCenterBeat({
container,
sheetMusicViewEnabled,
sheetMeasureMetrics,
activeRegionStartBeat,
}: VisibleCenterBeatOptions): number {
if (sheetMusicViewEnabled) {
const centerPixel = container.scrollLeft + container.clientWidth / 2;
return activeRegionStartBeat + getAbsoluteBeatForSheetPixel(centerPixel, sheetMeasureMetrics);
export function getRegionPlayheadRelation(
playheadBeat: number,
regionStartBeat: number,
regionEndBeat: number
): RegionPlayheadRelation {
if (playheadBeat < regionStartBeat) {
return 'before';
}
const keysWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
) || 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;
if (playheadBeat > regionEndBeat) {
return 'after';
}
return 'inside';
}
function getAbsoluteBeatForSheetPixel(pixel: number, metrics: SheetMeasureMetric[]): number {
if (metrics.length === 0) {
return 0;
export function createPendingModeSwitchRequest({
playheadBeat,
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 (pixel <= firstMetric.leftPx) {
return firstMetric.startBeat;
if (enteringTrackScopeSheet) {
return {
sourceSheetMusicViewEnabled,
destinationSheetMusicViewEnabled,
destinationSheetMusicTrackScopeEnabled,
alignment: 'center',
anchorBeat: playheadBeat,
clampScope: 'track',
};
}
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);
return {
sourceSheetMusicViewEnabled,
destinationSheetMusicViewEnabled,
destinationSheetMusicTrackScopeEnabled,
alignment: relation === 'before' ? 'region-start' : 'region-end',
anchorBeat: relation === 'before' ? regionStartBeat : regionEndBeat,
clampScope: 'region',
};
}
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;
})();
function getHorizontalViewportMetrics(container: HTMLDivElement, sheetMusicViewEnabled: boolean): {
visibleWidth: number;
keysWidth: number;
} {
const keysWidth = sheetMusicViewEnabled
? 0
: (parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
) || 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 { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import PianoRollContent from './PianoRollContent';
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('./PianoNote', () => ({ default: () => <div data-testid="piano-note" /> }));
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', () => {
const baseProps = {
@@ -67,6 +74,10 @@ describe('PianoRollContent', () => {
bpm: 120,
};
beforeEach(() => {
sheetMusicViewSpy.mockClear();
});
it('keeps the single-pane layout when automation is disabled', () => {
render(
<PianoRollContent
@@ -117,6 +128,7 @@ describe('PianoRollContent', () => {
automationEnabled={true}
automationType="cc-7"
sheetMusicViewEnabled={true}
sheetMusicTrackScopeEnabled={true}
sheetQuantization={parseSheetQuantization('16,48')}
/>
);
@@ -124,5 +136,9 @@ describe('PianoRollContent', () => {
expect(screen.getByTestId('sheet-music-view')).toBeInTheDocument();
expect(screen.queryByTestId('piano-keys')).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;
automationRedrawVersion?: number;
sheetMusicViewEnabled?: boolean;
sheetMusicTrackScopeEnabled?: boolean;
sheetQuantization?: SheetQuantization;
sheetKeySignature?: KeySignature;
sheetInstrument?: InstrumentType;
@@ -83,6 +84,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
automationType = 'pitch-bend',
automationRedrawVersion = 0,
sheetMusicViewEnabled = false,
sheetMusicTrackScopeEnabled = false,
sheetQuantization,
sheetKeySignature = 'C major',
sheetInstrument = 'acoustic_grand_piano',
@@ -340,6 +342,11 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
{sheetMusicViewEnabled && activeRegion && sheetQuantization ? (
<SheetMusicView
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}
keySignature={sheetKeySignature}
instrument={sheetInstrument}
@@ -42,6 +42,8 @@ describe('PianoRollToolbar', () => {
const baseProps = {
sheetMusicViewEnabled: false,
onSheetMusicViewToggle: vi.fn(),
sheetMusicTrackScopeEnabled: false,
onSheetMusicTrackScopeToggle: vi.fn(),
sheetQuantization: '16,48',
onSheetQuantizationChange: vi.fn(),
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: /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: /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 { FaMousePointer, FaPencilAlt } from 'react-icons/fa';
import { TbArrowBarToUp } from 'react-icons/tb';
import { KGDropdown } from '../common';
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import { KGCore } from '../../core/KGCore';
@@ -18,6 +19,8 @@ const POWER_OPTIONS = [
interface PianoRollToolbarProps {
sheetMusicViewEnabled?: boolean;
onSheetMusicViewToggle?: () => void;
sheetMusicTrackScopeEnabled?: boolean;
onSheetMusicTrackScopeToggle?: () => void;
sheetQuantization?: string;
onSheetQuantizationChange?: (value: string) => void;
sheetQuantizationOptions?: string[];
@@ -50,6 +53,8 @@ interface PianoRollToolbarProps {
const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
sheetMusicViewEnabled = false,
onSheetMusicViewToggle,
sheetMusicTrackScopeEnabled = false,
onSheetMusicTrackScopeToggle,
sheetQuantization = '16,48',
onSheetQuantizationChange,
sheetQuantizationOptions = [],
@@ -112,14 +117,14 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
onClick={() => onToolSelect('pointer')}
title="Pointer Tool"
>
<FaMousePointer />
<FaMousePointer className="piano-roll-tool-icon" />
</button>
<button
className={`tool-button ${activeTool === 'pencil' ? 'active' : ''}`}
onClick={() => onToolSelect('pencil')}
title="Pencil Tool"
>
<FaPencilAlt />
<FaPencilAlt className="piano-roll-tool-icon" />
</button>
{showAutomationControls && (
<div className="piano-roll-automation-toolbar-group">
@@ -175,6 +180,16 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
>
</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>
)}
+51 -21
View File
@@ -8,6 +8,7 @@ import type { InstrumentType } from '../../core/track/KGMidiTrack';
import type { SheetMeasureMetric, SheetMeasureModel, SheetQuantization } from './sheetNotationTypes';
import {
buildSheetMeasureMetrics,
getSheetBeatAtPixel,
buildSheetMeasureModels,
getSheetPlayheadPixel,
projectKeySignatureToVexFlow,
@@ -18,6 +19,9 @@ import {
interface SheetMusicViewProps {
activeRegion: KGMidiRegion | null;
midiRegions: KGMidiRegion[];
maxBars: number;
sheetMusicTrackScopeEnabled: boolean;
timeSignature: { numerator: number; denominator: number };
keySignature: KeySignature;
instrument: InstrumentType;
@@ -50,6 +54,9 @@ const STAFF_HEIGHT = 132;
const FIRST_MEASURE_MODIFIER_WIDTH = 72;
const SheetMusicView: React.FC<SheetMusicViewProps> = ({
activeRegion,
midiRegions,
maxBars,
sheetMusicTrackScopeEnabled,
timeSignature,
keySignature,
instrument,
@@ -66,8 +73,10 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
const lastDrawSignatureRef = useRef<string | null>(null);
const vexKeySignature = useMemo(() => projectKeySignatureToVexFlow(keySignature), [keySignature]);
const startingBarNumber = useMemo(() => (
activeRegion ? Math.floor(activeRegion.getStartFromBeat() / timeSignature.numerator) + 1 : 1
), [activeRegion, timeSignature.numerator]);
sheetMusicTrackScopeEnabled
? 1
: (activeRegion ? Math.floor(activeRegion.getStartFromBeat() / timeSignature.numerator) + 1 : 1)
), [activeRegion, sheetMusicTrackScopeEnabled, timeSignature.numerator]);
const measureModels = useMemo<SheetMeasureModel[]>(() => {
if (!activeRegion) {
@@ -75,11 +84,14 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
}
return buildSheetMeasureModels({
scope: sheetMusicTrackScopeEnabled ? 'track' : 'region',
region: activeRegion,
regions: midiRegions,
projectMaxBars: maxBars,
timeSignature,
quantization,
});
}, [activeRegion, timeSignature, quantization]);
}, [activeRegion, maxBars, midiRegions, quantization, sheetMusicTrackScopeEnabled, timeSignature]);
const measureWidths = useMemo(
() => measureModels.map((measure, index) => (
Math.max(
@@ -95,13 +107,16 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
return 'treble';
}
return resolveSheetClef(activeRegion.getNotes(), instrument, true);
}, [activeRegion, instrument]);
const notes = sheetMusicTrackScopeEnabled
? midiRegions.flatMap(region => region.getNotes())
: activeRegion.getNotes();
return resolveSheetClef(notes, instrument, true);
}, [activeRegion, instrument, midiRegions, sheetMusicTrackScopeEnabled]);
const drawSignature = useMemo(() => JSON.stringify({
regionId: activeRegion?.getId() ?? null,
regionName: activeRegion?.getName() ?? null,
regionStartBeat: activeRegion?.getStartFromBeat() ?? null,
regionLength: activeRegion?.getLength() ?? null,
regionIds: midiRegions.map(region => region.getId()),
scope: sheetMusicTrackScopeEnabled ? 'track' : 'region',
maxBars,
bars: measureModels.length,
clef,
instrument,
@@ -111,7 +126,7 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
denominator: timeSignature.denominator,
measureWidths,
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(() => {
if (!activeRegion) {
@@ -126,7 +141,7 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
return;
}
const nextMetrics = buildSheetMeasureMetrics(measureWidths, timeSignature.numerator);
const nextMetrics = buildSheetMeasureMetrics(measureModels, measureWidths);
const renderedEvents: RenderedSheetEvent[] = [];
measureModels.forEach((measure, index) => {
@@ -217,7 +232,7 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
});
setTiePaths((current) => (
current.length === nextTiePaths.length &&
current.every((path, index) => path.id === nextTiePaths[index].id && path.d === nextTiePaths[index].d)
current.every((path, index) => path.id === nextTiePaths[index].id && path.d === nextTiePaths[index].d)
? current
: nextTiePaths
));
@@ -239,10 +254,10 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
return;
}
const localX = relativeX - metric.leftPx;
const progress = metric.widthPx > 0 ? localX / metric.widthPx : 0;
const regionBeat = metric.startBeat + progress * (metric.endBeat - metric.startBeat);
const absoluteBeat = activeRegion.getStartFromBeat() + regionBeat;
const targetBeat = getSheetBeatAtPixel(relativeX, metrics);
const absoluteBeat = sheetMusicTrackScopeEnabled
? targetBeat
: activeRegion.getStartFromBeat() + targetBeat;
setPlayheadPosition(absoluteBeat);
requestMainContentScroll(absoluteBeat);
@@ -262,9 +277,9 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
))}
</div>
<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.
</div>
</div> */}
<div className="sheet-music-measures">
<svg
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" />
))}
</svg>
<SheetMusicPlayhead activeRegion={activeRegion} metrics={metrics} />
<SheetMusicPlayhead
activeRegion={activeRegion}
metrics={metrics}
sheetMusicTrackScopeEnabled={sheetMusicTrackScopeEnabled}
/>
{measureModels.map((measure, index) => (
<div
key={`sheet-measure-${measure.barIndex}`}
@@ -305,9 +324,14 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
interface SheetMusicPlayheadProps {
activeRegion: KGMidiRegion | null;
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 playheadPixel = useMemo(() => {
@@ -316,10 +340,12 @@ const SheetMusicPlayhead: React.FC<SheetMusicPlayheadProps> = memo(({ activeRegi
}
return getSheetPlayheadPixel(
Math.max(0, playheadPosition - activeRegion.getStartFromBeat()),
sheetMusicTrackScopeEnabled
? Math.max(0, playheadPosition)
: Math.max(0, playheadPosition - activeRegion.getStartFromBeat()),
metrics
);
}, [activeRegion, metrics, playheadPosition]);
}, [activeRegion, metrics, playheadPosition, sheetMusicTrackScopeEnabled]);
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?.getLength() === next.activeRegion?.getLength() &&
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.keySignature === next.keySignature &&
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 {
buildSheetMeasureMetrics,
getSheetBeatAtPixel,
buildSheetMeasureModels,
getSheetPlayheadPixel,
getSheetQuantizationOptions,
@@ -50,7 +51,10 @@ describe('sheetNotation', () => {
});
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(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[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 interface BuildSheetNotationOptions {
scope?: 'region' | 'track';
region: KGMidiRegion;
regions?: KGMidiRegion[];
projectMaxBars?: number;
timeSignature: { numerator: number; denominator: number };
quantization: SheetQuantization;
}
@@ -36,6 +39,12 @@ interface WorkingEvent {
isRest: boolean;
}
interface NormalizedNoteInput {
pitch: number;
startBeat: number;
endBeat: number;
}
export function getSheetQuantizationOptions(): string[] {
return [...SHEET_QUANTIZATION_OPTIONS];
}
@@ -84,13 +93,14 @@ export function resolveSheetClef(
return averagePitch >= 60 ? 'treble' : 'bass';
}
export function buildSheetMeasureMetrics(widths: number[], beatsPerBar: number): SheetMeasureMetric[] {
export function buildSheetMeasureMetrics(measures: SheetMeasureModel[], widths: number[]): SheetMeasureMetric[] {
let leftPx = 0;
return widths.map((widthPx, index) => {
return measures.map((measure, index) => {
const widthPx = widths[index] ?? 0;
const metric: SheetMeasureMetric = {
barIndex: index,
startBeat: index * beatsPerBar,
endBeat: (index + 1) * beatsPerBar,
barIndex: measure.barIndex,
startBeat: measure.startBeat,
endBeat: measure.endBeat,
leftPx,
widthPx,
};
@@ -100,32 +110,59 @@ export function buildSheetMeasureMetrics(widths: number[], beatsPerBar: number):
}
export function getSheetPlayheadPixel(
regionRelativeBeat: number,
sheetBeat: number,
metrics: SheetMeasureMetric[]
): number {
if (metrics.length === 0) {
return 0;
}
if (regionRelativeBeat <= metrics[0].startBeat) {
if (sheetBeat <= metrics[0].startBeat) {
return metrics[0].leftPx;
}
const lastMetric = metrics[metrics.length - 1];
if (regionRelativeBeat >= lastMetric.endBeat) {
if (sheetBeat >= lastMetric.endBeat) {
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) {
return 0;
}
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;
}
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 } {
const withRest = (value: string) => (isRest ? `${value}r` : value);
const options = [
@@ -160,21 +197,37 @@ export function resolveDurationSpec(durationBeats: number, isRest: boolean): { d
}
export function buildSheetMeasureModels({
scope = 'region',
region,
regions = [region],
projectMaxBars,
timeSignature,
quantization,
}: BuildSheetNotationOptions): SheetMeasureModel[] {
const beatsPerBar = timeSignature.numerator;
const measureCount = Math.max(1, Math.ceil(region.getLength() / beatsPerBar));
const measureEndBeat = measureCount * beatsPerBar;
const workingEvents = normalizeNotes(region.getNotes(), quantization.stepBeats, measureEndBeat);
const isTrackScope = scope === 'track';
const timelineStartBeat = isTrackScope ? 0 : 0;
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 splitEvents = splitAcrossBars(withRests, beatsPerBar);
const measures: SheetMeasureModel[] = Array.from({ length: measureCount }, (_, barIndex) => ({
barIndex,
startBeat: barIndex * beatsPerBar,
endBeat: (barIndex + 1) * beatsPerBar,
startBeat: timelineStartBeat + barIndex * beatsPerBar,
endBeat: timelineStartBeat + (barIndex + 1) * beatsPerBar,
events: [],
}));
@@ -199,12 +252,12 @@ export function buildSheetMeasureModels({
return measures;
}
function normalizeNotes(notes: KGMidiNote[], stepBeats: number, measureEndBeat: number): WorkingEvent[] {
function normalizeNotes(notes: NormalizedNoteInput[], stepBeats: number, measureEndBeat: number): WorkingEvent[] {
const clippedNotes = notes
.map(note => ({
keys: [midiPitchToVexKey(note.getPitch())],
startBeat: quantizeBeat(note.getStartBeat(), stepBeats),
endBeat: quantizeBeat(note.getEndBeat(), stepBeats),
keys: [midiPitchToVexKey(note.pitch)],
startBeat: quantizeBeat(note.startBeat, stepBeats),
endBeat: quantizeBeat(note.endBeat, stepBeats),
isRest: false,
}))
.map(note => ({
@@ -247,6 +300,27 @@ function normalizeNotes(notes: KGMidiNote[], stepBeats: number, measureEndBeat:
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[] {
if (events.length === 0) {
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 currentAutomationType: string = "pitch-bend";
private sheetMusicViewEnabled: boolean = false;
private sheetMusicTrackScopeEnabled: boolean = false;
private sheetQuantization: string = '16,48';
// Chord guide state
@@ -93,6 +94,14 @@ export class KGPianoRollState {
this.sheetMusicViewEnabled = enabled;
}
public getSheetMusicTrackScopeEnabled(): boolean {
return this.sheetMusicTrackScopeEnabled;
}
public setSheetMusicTrackScopeEnabled(enabled: boolean): void {
this.sheetMusicTrackScopeEnabled = enabled;
}
public getSheetQuantization(): string {
return this.sheetQuantization;
}