feat: added track scope mode for sheet music view; adjusted viewport behavior when switching between piano roll view and sheet music view
This commit is contained in:
@@ -156,6 +156,25 @@
|
||||
transition: color 0.18s ease, fill 0.18s ease;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../stores/projectStore', () => ({
|
||||
useProjectStore: Object.assign(
|
||||
(selector: (state: Record<string, unknown>) => unknown) => selector({
|
||||
maxBars: 8,
|
||||
tracks: [],
|
||||
updateTrack: vi.fn(),
|
||||
timeSignature: { numerator: 4, denominator: 4 },
|
||||
showChatBox: false,
|
||||
showKGOnePanel: false,
|
||||
showEventListPanel: false,
|
||||
showInstrumentSelection: false,
|
||||
keySignature: 'C major',
|
||||
selectedMode: 'ionian',
|
||||
setSelectedMode: vi.fn(),
|
||||
playheadPosition: 0,
|
||||
isPlaying: false,
|
||||
autoScrollEnabled: false,
|
||||
bpm: 120,
|
||||
pianoRollScrollRequest: null,
|
||||
selectedNoteIds: [],
|
||||
automationRedrawVersion: 0,
|
||||
}),
|
||||
{
|
||||
getState: () => ({
|
||||
setAutoScrollEnabled: vi.fn(),
|
||||
}),
|
||||
setState: vi.fn(),
|
||||
}
|
||||
),
|
||||
}));
|
||||
|
||||
import {
|
||||
createPendingModeSwitchRequest,
|
||||
getRegionPlayheadRelation,
|
||||
getScrollLeftForViewportRequest,
|
||||
} from './PianoRoll';
|
||||
import type { SheetMeasureMetric } from './sheetNotationTypes';
|
||||
|
||||
function createContainer({ clientWidth, scrollWidth }: { clientWidth: number; scrollWidth: number }): HTMLDivElement {
|
||||
return {
|
||||
clientWidth,
|
||||
scrollWidth,
|
||||
} as HTMLDivElement;
|
||||
}
|
||||
|
||||
describe('PianoRoll viewport switch helpers', () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.style.setProperty('--region-grid-beat-width', '40px');
|
||||
document.documentElement.style.setProperty('--region-piano-key-width', '60px');
|
||||
});
|
||||
|
||||
it('classifies playhead position relative to the active region', () => {
|
||||
expect(getRegionPlayheadRelation(15, 16, 24)).toBe('before');
|
||||
expect(getRegionPlayheadRelation(20, 16, 24)).toBe('inside');
|
||||
expect(getRegionPlayheadRelation(25, 16, 24)).toBe('after');
|
||||
});
|
||||
|
||||
it('centers an in-region playhead when switching to region-scope sheet view', () => {
|
||||
const request = createPendingModeSwitchRequest({
|
||||
playheadBeat: 20,
|
||||
regionStartBeat: 16,
|
||||
regionEndBeat: 24,
|
||||
sourceSheetMusicViewEnabled: false,
|
||||
destinationSheetMusicViewEnabled: true,
|
||||
destinationSheetMusicTrackScopeEnabled: false,
|
||||
});
|
||||
|
||||
expect(request).toMatchObject({
|
||||
alignment: 'center',
|
||||
anchorBeat: 20,
|
||||
clampScope: 'region',
|
||||
});
|
||||
|
||||
const metrics: SheetMeasureMetric[] = [
|
||||
{ barIndex: 0, startBeat: 0, endBeat: 4, leftPx: 0, widthPx: 200 },
|
||||
{ barIndex: 1, startBeat: 4, endBeat: 8, leftPx: 200, widthPx: 200 },
|
||||
];
|
||||
const scrollLeft = getScrollLeftForViewportRequest({
|
||||
request,
|
||||
container: createContainer({ clientWidth: 200, scrollWidth: 400 }),
|
||||
sheetMeasureMetrics: metrics,
|
||||
activeRegionStartBeat: 16,
|
||||
activeRegionEndBeat: 24,
|
||||
songEndBeat: 32,
|
||||
});
|
||||
|
||||
expect(scrollLeft).toBe(100);
|
||||
});
|
||||
|
||||
it('snaps to the region start when the playhead is before the active region', () => {
|
||||
const request = createPendingModeSwitchRequest({
|
||||
playheadBeat: 12,
|
||||
regionStartBeat: 16,
|
||||
regionEndBeat: 24,
|
||||
sourceSheetMusicViewEnabled: true,
|
||||
destinationSheetMusicViewEnabled: false,
|
||||
destinationSheetMusicTrackScopeEnabled: false,
|
||||
});
|
||||
|
||||
expect(request).toMatchObject({
|
||||
alignment: 'region-start',
|
||||
anchorBeat: 16,
|
||||
clampScope: 'region',
|
||||
});
|
||||
|
||||
const scrollLeft = getScrollLeftForViewportRequest({
|
||||
request,
|
||||
container: createContainer({ clientWidth: 260, scrollWidth: 2000 }),
|
||||
sheetMeasureMetrics: [],
|
||||
activeRegionStartBeat: 16,
|
||||
activeRegionEndBeat: 24,
|
||||
songEndBeat: 32,
|
||||
});
|
||||
|
||||
expect(scrollLeft).toBe(640);
|
||||
});
|
||||
|
||||
it('snaps to the region end when the playhead is after the active region', () => {
|
||||
const request = createPendingModeSwitchRequest({
|
||||
playheadBeat: 28,
|
||||
regionStartBeat: 16,
|
||||
regionEndBeat: 24,
|
||||
sourceSheetMusicViewEnabled: true,
|
||||
destinationSheetMusicViewEnabled: true,
|
||||
destinationSheetMusicTrackScopeEnabled: false,
|
||||
});
|
||||
|
||||
expect(request).toMatchObject({
|
||||
alignment: 'region-end',
|
||||
anchorBeat: 24,
|
||||
clampScope: 'region',
|
||||
});
|
||||
|
||||
const metrics: SheetMeasureMetric[] = [
|
||||
{ barIndex: 0, startBeat: 0, endBeat: 4, leftPx: 0, widthPx: 200 },
|
||||
{ barIndex: 1, startBeat: 4, endBeat: 8, leftPx: 200, widthPx: 200 },
|
||||
];
|
||||
const scrollLeft = getScrollLeftForViewportRequest({
|
||||
request,
|
||||
container: createContainer({ clientWidth: 200, scrollWidth: 400 }),
|
||||
sheetMeasureMetrics: metrics,
|
||||
activeRegionStartBeat: 16,
|
||||
activeRegionEndBeat: 24,
|
||||
songEndBeat: 32,
|
||||
});
|
||||
|
||||
expect(scrollLeft).toBe(200);
|
||||
});
|
||||
|
||||
it('uses the track-scope special case only when entering sheet music from piano roll', () => {
|
||||
const specialCaseRequest = createPendingModeSwitchRequest({
|
||||
playheadBeat: 28,
|
||||
regionStartBeat: 16,
|
||||
regionEndBeat: 24,
|
||||
sourceSheetMusicViewEnabled: false,
|
||||
destinationSheetMusicViewEnabled: true,
|
||||
destinationSheetMusicTrackScopeEnabled: true,
|
||||
});
|
||||
const regularTrackScopeRequest = createPendingModeSwitchRequest({
|
||||
playheadBeat: 28,
|
||||
regionStartBeat: 16,
|
||||
regionEndBeat: 24,
|
||||
sourceSheetMusicViewEnabled: true,
|
||||
destinationSheetMusicViewEnabled: true,
|
||||
destinationSheetMusicTrackScopeEnabled: true,
|
||||
});
|
||||
|
||||
expect(specialCaseRequest).toMatchObject({
|
||||
alignment: 'center',
|
||||
anchorBeat: 28,
|
||||
clampScope: 'track',
|
||||
});
|
||||
expect(regularTrackScopeRequest).toMatchObject({
|
||||
alignment: 'region-end',
|
||||
anchorBeat: 24,
|
||||
clampScope: 'region',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats sheet-music to piano-roll switches as region-scoped even when source sheet view was track-scoped', () => {
|
||||
const request = createPendingModeSwitchRequest({
|
||||
playheadBeat: 20,
|
||||
regionStartBeat: 16,
|
||||
regionEndBeat: 24,
|
||||
sourceSheetMusicViewEnabled: true,
|
||||
destinationSheetMusicViewEnabled: false,
|
||||
destinationSheetMusicTrackScopeEnabled: false,
|
||||
});
|
||||
|
||||
expect(request).toMatchObject({
|
||||
alignment: 'center',
|
||||
anchorBeat: 20,
|
||||
clampScope: 'region',
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps centered piano-roll scroll at the start and end of the active region', () => {
|
||||
const container = createContainer({ clientWidth: 260, scrollWidth: 2000 });
|
||||
const startClamp = getScrollLeftForViewportRequest({
|
||||
request: {
|
||||
sourceSheetMusicViewEnabled: false,
|
||||
destinationSheetMusicViewEnabled: false,
|
||||
destinationSheetMusicTrackScopeEnabled: false,
|
||||
alignment: 'center',
|
||||
anchorBeat: 16,
|
||||
clampScope: 'region',
|
||||
},
|
||||
container,
|
||||
sheetMeasureMetrics: [],
|
||||
activeRegionStartBeat: 16,
|
||||
activeRegionEndBeat: 24,
|
||||
songEndBeat: 32,
|
||||
});
|
||||
const endClamp = getScrollLeftForViewportRequest({
|
||||
request: {
|
||||
sourceSheetMusicViewEnabled: false,
|
||||
destinationSheetMusicViewEnabled: false,
|
||||
destinationSheetMusicTrackScopeEnabled: false,
|
||||
alignment: 'center',
|
||||
anchorBeat: 24,
|
||||
clampScope: 'region',
|
||||
},
|
||||
container,
|
||||
sheetMeasureMetrics: [],
|
||||
activeRegionStartBeat: 16,
|
||||
activeRegionEndBeat: 24,
|
||||
songEndBeat: 32,
|
||||
});
|
||||
|
||||
expect(startClamp).toBe(640);
|
||||
expect(endClamp).toBe(760);
|
||||
});
|
||||
|
||||
it('centers the playhead in track-scope sheet view with song-bound clamping', () => {
|
||||
const request = createPendingModeSwitchRequest({
|
||||
playheadBeat: 14,
|
||||
regionStartBeat: 16,
|
||||
regionEndBeat: 24,
|
||||
sourceSheetMusicViewEnabled: false,
|
||||
destinationSheetMusicViewEnabled: true,
|
||||
destinationSheetMusicTrackScopeEnabled: true,
|
||||
});
|
||||
const metrics: SheetMeasureMetric[] = [
|
||||
{ barIndex: 0, startBeat: 0, endBeat: 4, leftPx: 0, widthPx: 200 },
|
||||
{ barIndex: 1, startBeat: 4, endBeat: 8, leftPx: 200, widthPx: 200 },
|
||||
{ barIndex: 2, startBeat: 8, endBeat: 12, leftPx: 400, widthPx: 200 },
|
||||
{ barIndex: 3, startBeat: 12, endBeat: 16, leftPx: 600, widthPx: 200 },
|
||||
];
|
||||
const scrollLeft = getScrollLeftForViewportRequest({
|
||||
request,
|
||||
container: createContainer({ clientWidth: 200, scrollWidth: 800 }),
|
||||
sheetMeasureMetrics: metrics,
|
||||
activeRegionStartBeat: 16,
|
||||
activeRegionEndBeat: 24,
|
||||
songEndBeat: 16,
|
||||
});
|
||||
|
||||
expect(scrollLeft).toBe(600);
|
||||
});
|
||||
});
|
||||
@@ -26,19 +26,35 @@ import type { PianoRollAutomationType } from './pianoRollAutomation';
|
||||
import type { SheetMeasureMetric } from './sheetNotationTypes';
|
||||
import { 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user