diff --git a/src/components/piano-roll/PianoRoll.css b/src/components/piano-roll/PianoRoll.css index 9de4593..56730dd 100644 --- a/src/components/piano-roll/PianoRoll.css +++ b/src/components/piano-roll/PianoRoll.css @@ -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; @@ -783,4 +802,4 @@ color: #e0e0e0; min-width: 20px; text-align: center; -} +} \ No newline at end of file diff --git a/src/components/piano-roll/PianoRoll.test.ts b/src/components/piano-roll/PianoRoll.test.ts new file mode 100644 index 0000000..c9c9c31 --- /dev/null +++ b/src/components/piano-roll/PianoRoll.test.ts @@ -0,0 +1,262 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../stores/projectStore', () => ({ + useProjectStore: Object.assign( + (selector: (state: Record) => 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); + }); +}); diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index 516fc27..56cfd76 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -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 = ({ const [automationEnabled, setAutomationEnabled] = useState(false); const [automationType, setAutomationType] = useState('pitch-bend'); const [sheetMusicViewEnabled, setSheetMusicViewEnabled] = useState(false); + const [sheetMusicTrackScopeEnabled, setSheetMusicTrackScopeEnabled] = useState(false); const [sheetQuantization, setSheetQuantization] = useState('16,48'); const [sheetMeasureMetrics, setSheetMeasureMetrics] = useState([]); @@ -133,7 +150,7 @@ const PianoRoll: React.FC = ({ const pianoRollExpectedScrollLeftRef = useRef(-1); const pianoRollIsPlayingRef = useRef(false); const pendingZoomAnchorBeatRef = useRef(null); - const pendingModeSwitchAnchorBeatRef = useRef(null); + const pendingModeSwitchRequestRef = useRef(null); const previousSheetMusicViewEnabledRef = useRef(false); const previousActiveRegionIdRef = useRef(null); @@ -243,6 +260,7 @@ const PianoRoll: React.FC = ({ 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 = ({ }, []); 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ // 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 = ({ return; } - if (pendingModeSwitchAnchorBeatRef.current !== null) { + if (pendingModeSwitchRequestRef.current !== null) { previousActiveRegionIdRef.current = activeRegion.getId(); return; } @@ -982,28 +1025,29 @@ const PianoRoll: React.FC = ({ }, [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 = ({ = ({ automationType={automationType} automationRedrawVersion={automationRedrawVersion} sheetMusicViewEnabled={sheetMusicViewEnabled} + sheetMusicTrackScopeEnabled={sheetMusicTrackScopeEnabled} sheetQuantization={parsedSheetQuantization} sheetKeySignature={keySignature} sheetInstrument={activeInstrument} @@ -1280,77 +1327,224 @@ const PianoRoll: React.FC = ({ 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, + }); } diff --git a/src/components/piano-roll/PianoRollContent.test.tsx b/src/components/piano-roll/PianoRollContent.test.tsx index 1ffa2f6..aac6600 100644 --- a/src/components/piano-roll/PianoRollContent.test.tsx +++ b/src/components/piano-roll/PianoRollContent.test.tsx @@ -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: () =>
vi.mock('./PianoGrid', () => ({ default: ({ children }: { children?: React.ReactNode }) =>
{children}
})); vi.mock('./PianoNote', () => ({ default: () =>
})); vi.mock('./PianoRollAutomationLane', () => ({ default: () =>
})); -vi.mock('./SheetMusicView', () => ({ default: () =>
})); +const sheetMusicViewSpy = vi.fn(); + +vi.mock('./SheetMusicView', () => ({ + default: (props: unknown) => { + sheetMusicViewSpy(props); + return
; + }, +})); 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( { 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, + })); }); }); diff --git a/src/components/piano-roll/PianoRollContent.tsx b/src/components/piano-roll/PianoRollContent.tsx index c1b2161..957f4f1 100644 --- a/src/components/piano-roll/PianoRollContent.tsx +++ b/src/components/piano-roll/PianoRollContent.tsx @@ -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 = ({ 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 = ({ {sheetMusicViewEnabled && activeRegion && sheetQuantization ? ( 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} diff --git a/src/components/piano-roll/PianoRollToolbar.test.tsx b/src/components/piano-roll/PianoRollToolbar.test.tsx index 6975055..8e16cf5 100644 --- a/src/components/piano-roll/PianoRollToolbar.test.tsx +++ b/src/components/piano-roll/PianoRollToolbar.test.tsx @@ -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( + + ); + + 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( + + ); + + expect(screen.queryByRole('button', { name: 'Show Entire Track' })).not.toBeInTheDocument(); + + rerender( + + ); + + expect(screen.queryByRole('button', { name: 'Show Entire Track' })).not.toBeInTheDocument(); + }); }); diff --git a/src/components/piano-roll/PianoRollToolbar.tsx b/src/components/piano-roll/PianoRollToolbar.tsx index ae411bc..b9b6df0 100644 --- a/src/components/piano-roll/PianoRollToolbar.tsx +++ b/src/components/piano-roll/PianoRollToolbar.tsx @@ -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 = ({ sheetMusicViewEnabled = false, onSheetMusicViewToggle, + sheetMusicTrackScopeEnabled = false, + onSheetMusicTrackScopeToggle, sheetQuantization = '16,48', onSheetQuantizationChange, sheetQuantizationOptions = [], @@ -112,14 +117,14 @@ const PianoRollToolbar: React.FC = ({ onClick={() => onToolSelect('pointer')} title="Pointer Tool" > - + {showAutomationControls && (
@@ -175,6 +180,16 @@ const PianoRollToolbar: React.FC = ({ > ♬ + {mode !== 'spectrogram' && ( + + )}
)} diff --git a/src/components/piano-roll/SheetMusicView.tsx b/src/components/piano-roll/SheetMusicView.tsx index ef3020e..3620b0c 100644 --- a/src/components/piano-roll/SheetMusicView.tsx +++ b/src/components/piano-roll/SheetMusicView.tsx @@ -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 = ({ activeRegion, + midiRegions, + maxBars, + sheetMusicTrackScopeEnabled, timeSignature, keySignature, instrument, @@ -66,8 +73,10 @@ const SheetMusicView: React.FC = ({ const lastDrawSignatureRef = useRef(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(() => { if (!activeRegion) { @@ -75,11 +84,14 @@ const SheetMusicView: React.FC = ({ } 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 = ({ 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 = ({ 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 = ({ 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 = ({ }); 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 = ({ 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 = ({ ))}
-
+ {/*
Sheet music view is under development and may not fully reflect the exact musical notation. -
+
*/}
= ({ ))} - + {measureModels.map((measure, index) => (
= ({ interface SheetMusicPlayheadProps { activeRegion: KGMidiRegion | null; metrics: SheetMeasureMetric[]; + sheetMusicTrackScopeEnabled: boolean; } -const SheetMusicPlayhead: React.FC = memo(({ activeRegion, metrics }) => { +const SheetMusicPlayhead: React.FC = memo(({ + activeRegion, + metrics, + sheetMusicTrackScopeEnabled, +}) => { const playheadPosition = useProjectStore(state => state.playheadPosition); const playheadPixel = useMemo(() => { @@ -316,10 +340,12 @@ const SheetMusicPlayhead: React.FC = 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 ; }); @@ -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 && diff --git a/src/components/piano-roll/sheetNotation.test.ts b/src/components/piano-roll/sheetNotation.test.ts index 8f4e7c4..36a6f78 100644 --- a/src/components/piano-roll/sheetNotation.test.ts +++ b/src/components/piano-roll/sheetNotation.test.ts @@ -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); + }); }); diff --git a/src/components/piano-roll/sheetNotation.ts b/src/components/piano-roll/sheetNotation.ts index 5d986e7..8c6a5ed 100644 --- a/src/components/piano-roll/sheetNotation.ts +++ b/src/components/piano-roll/sheetNotation.ts @@ -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 }]; diff --git a/src/core/state/KGPianoRollState.ts b/src/core/state/KGPianoRollState.ts index acf899a..f426f95 100644 --- a/src/core/state/KGPianoRollState.ts +++ b/src/core/state/KGPianoRollState.ts @@ -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; }