From d69b3924e52ebfc597ffd75ecd40ea9f24d8db2e Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sat, 9 May 2026 23:50:02 -0700 Subject: [PATCH 1/4] feat: added sheet music (stuff notation) view to the piano roll window MIDI mode --- package-lock.json | 11 +- package.json | 1 + src/components/common/Playhead.tsx | 11 +- src/components/piano-roll/PianoRoll.css | 125 ++++++ src/components/piano-roll/PianoRoll.tsx | 306 +++++++++++-- .../piano-roll/PianoRollContent.test.tsx | 19 + .../piano-roll/PianoRollContent.tsx | 102 +++-- .../piano-roll/PianoRollToolbar.test.tsx | 21 + .../piano-roll/PianoRollToolbar.tsx | 46 +- src/components/piano-roll/SheetMusicView.tsx | 411 ++++++++++++++++++ .../piano-roll/sheetNotation.test.ts | 94 ++++ src/components/piano-roll/sheetNotation.ts | 325 ++++++++++++++ .../piano-roll/sheetNotationTypes.ts | 30 ++ src/core/state/KGPianoRollState.ts | 18 + 14 files changed, 1440 insertions(+), 80 deletions(-) create mode 100644 src/components/piano-roll/SheetMusicView.tsx create mode 100644 src/components/piano-roll/sheetNotation.test.ts create mode 100644 src/components/piano-roll/sheetNotation.ts create mode 100644 src/components/piano-roll/sheetNotationTypes.ts diff --git a/package-lock.json b/package-lock.json index 70ced97..3ce903b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "K.G.Studio", - "version": "0.12.0-build.20260430", + "version": "0.15.0-build.20260510", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "K.G.Studio", - "version": "0.12.0-build.20260430", + "version": "0.15.0-build.20260510", "dependencies": { "@breezystack/lamejs": "^1.2.7", "class-transformer": "^0.5.1", @@ -22,6 +22,7 @@ "reflect-metadata": "^0.2.2", "remark-gfm": "^4.0.1", "tone": "^15.1.22", + "vexflow": "^5.0.0", "zustand": "^5.0.6" }, "devDependencies": { @@ -13734,6 +13735,12 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/vexflow": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vexflow/-/vexflow-5.0.0.tgz", + "integrity": "sha512-rjB7TV4ygKE5Fl3W5OlG+0dHv22CFufUJdMG6oNgvcn0zp34u+sOboZsadQXnF1O3tZ3myXThaUIaLkJlpNM2Q==", + "license": "MIT" + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/package.json b/package.json index fb03781..19e4aeb 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "reflect-metadata": "^0.2.2", "remark-gfm": "^4.0.1", "tone": "^15.1.22", + "vexflow": "^5.0.0", "zustand": "^5.0.6" }, "devDependencies": { diff --git a/src/components/common/Playhead.tsx b/src/components/common/Playhead.tsx index 8981fb3..0167fa9 100644 --- a/src/components/common/Playhead.tsx +++ b/src/components/common/Playhead.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { KGCore } from '../../core/KGCore'; import { useProjectStore } from '../../stores/projectStore'; interface PlayheadProps { @@ -7,13 +6,19 @@ interface PlayheadProps { context: 'main-grid' | 'piano-roll'; /** For piano roll context, the region start beat offset */ regionStartBeat?: number; + /** Optional exact pixel override for variable-width layouts */ + pixelPositionOverride?: number; } -const Playhead: React.FC = ({ context, regionStartBeat = 0 }) => { +const Playhead: React.FC = ({ context, pixelPositionOverride }) => { const { timeSignature, playheadPosition } = useProjectStore(); // Calculate the pixel position based on context const getPixelPosition = (): number => { + if (typeof pixelPositionOverride === 'number') { + return pixelPositionOverride; + } + if (context === 'main-grid') { // In main grid, convert beats to bars, then bars to pixels const beatsPerBar = timeSignature.numerator; @@ -79,4 +84,4 @@ const Playhead: React.FC = ({ context, regionStartBeat = 0 }) => ); }; -export default Playhead; \ No newline at end of file +export default Playhead; diff --git a/src/components/piano-roll/PianoRoll.css b/src/components/piano-roll/PianoRoll.css index d5664d9..9de4593 100644 --- a/src/components/piano-roll/PianoRoll.css +++ b/src/components/piano-roll/PianoRoll.css @@ -152,9 +152,16 @@ } .piano-roll-toolbar .tool-button svg { + font-size: 14px; transition: color 0.18s ease, fill 0.18s ease; } +.piano-roll-toolbar .tool-button.sheet-mode-toggle, +.piano-roll-toolbar .tool-button:not(.icon-only) { + font-size: 13px; + line-height: 1; +} + .piano-roll-toolbar .tool-button:hover { --toolbar-button-bg: #3a3a3a; --toolbar-button-fg: #e0e0e0; @@ -397,6 +404,124 @@ width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width)); } +.piano-roll-body.sheet-music-body { + min-height: 0; + width: max-content; +} + +.sheet-music-view { + position: relative; + flex: 1; + min-width: 0; + min-height: 240px; + background: linear-gradient(180deg, #faf7ef 0%, #f2ede0 100%); + color: #1f1f1f; +} + +.sheet-music-header { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: stretch; + height: 20px; + background: rgba(34, 34, 34, 0.92); + color: #ddd; + border-bottom: 1px solid #3a3a3a; + cursor: pointer; +} + +.sheet-music-bar-number { + flex: 0 0 auto; + box-sizing: border-box; + border-left: 1px solid #4a4a4a; + padding-left: 10px; + font-size: 10px; + line-height: 20px; +} + +.sheet-music-strip { + position: relative; + min-height: 220px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0.08) 100%); +} + +.sheet-music-notice { + position: sticky; + top: 8px; + left: 8px; + margin: 8px 0 0 8px; + width: fit-content; + max-width: min(420px, calc(100% - 20px)); + padding: 5px 9px; + background: rgba(42, 42, 42, 0.72); + color: rgba(255, 255, 255, 0.88); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 4px; + font-size: 11px; + line-height: 1.35; + text-align: left; + backdrop-filter: blur(4px); + z-index: 3; + pointer-events: none; +} + +.sheet-music-measures { + position: relative; + display: inline-flex; + align-items: flex-start; + min-width: 100%; + padding: 12px 0 24px; +} + +.sheet-music-ties { + position: absolute; + top: 12px; + left: 0; + pointer-events: none; + overflow: visible; + z-index: 1; +} + +.sheet-music-tie-path { + fill: #111; + stroke: none; +} + +.sheet-music-measure { + flex: 0 0 auto; + position: relative; + z-index: 0; +} + +.sheet-music-measure-host { + position: relative; + width: 100%; + height: 100%; +} + +.sheet-music-measure svg { + display: block; + overflow: visible; +} + +.sheet-music-measure svg path, +.sheet-music-measure svg rect, +.sheet-music-measure svg line, +.sheet-music-measure svg text { + stroke: #111 !important; + fill: #111 !important; +} + +.sheet-music-measure svg path[fill="none"] { + fill: none !important; + stroke: #111 !important; +} + +.sheet-music-measure svg .vf-stave path { + stroke-width: 1.5px !important; +} + .piano-keys-container { width: var(--region-piano-key-width); flex-shrink: 0; diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index 7c45e52..516fc27 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -1,6 +1,5 @@ import React, { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo } from 'react'; import './PianoRoll.css'; -import type { MouseEvent } from 'react'; import { useProjectStore } from '../../stores/projectStore'; import { FaGripLines } from 'react-icons/fa'; import { KGMidiRegion } from '../../core/region/KGMidiRegion'; @@ -12,6 +11,7 @@ import NoteAttributeBar from './NoteAttributeBar'; import PianoRollContent from './PianoRollContent'; import { KGCore } from '../../core/KGCore'; import { KGMidiNote } from '../../core/midi/KGMidiNote'; +import { KGMidiTrack, type InstrumentType } from '../../core/track/KGMidiTrack'; import { KGPianoRollState } from '../../core/state/KGPianoRollState'; import { ConfigManager } from '../../core/config/ConfigManager'; import { beatsToBar } from '../../util/midiUtil'; @@ -23,6 +23,23 @@ import { type SpectrogramHeightResolution, } from '../../util/spectrogramUtil'; 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; +} + +interface ScrollLeftForBeatOptions { + anchorBeat: number; + container: HTMLDivElement; + sheetMusicViewEnabled: boolean; + sheetMeasureMetrics: SheetMeasureMetric[]; + activeRegionStartBeat: number; +} interface PianoRollProps { onClose: () => void; @@ -62,6 +79,9 @@ const PianoRoll: React.FC = ({ const [pianoRollZoom, setPianoRollZoom] = useState(1); const [automationEnabled, setAutomationEnabled] = useState(false); const [automationType, setAutomationType] = useState('pitch-bend'); + const [sheetMusicViewEnabled, setSheetMusicViewEnabled] = useState(false); + const [sheetQuantization, setSheetQuantization] = useState('16,48'); + const [sheetMeasureMetrics, setSheetMeasureMetrics] = useState([]); // Quantization state const [quantPosition, setQuantPosition] = useState('1/8'); @@ -88,6 +108,20 @@ const PianoRoll: React.FC = ({ () => activeRegion?.getNotes().filter(n => selectedNoteIds.includes(n.getId())) ?? [], [activeRegion, selectedNoteIds] ); + const parentMidiTrack = useMemo(() => { + if (!activeRegion) { + return null; + } + + return tracks.find(track => track.getId().toString() === activeRegion.getTrackId()) ?? null; + }, [activeRegion, tracks]); + const activeInstrument = useMemo(() => ( + parentMidiTrack instanceof KGMidiTrack ? parentMidiTrack.getInstrument() : 'acoustic_grand_piano' + ), [parentMidiTrack]); + const parsedSheetQuantization = useMemo( + () => parseSheetQuantization(sheetQuantization), + [sheetQuantization] + ); const pianoRollRef = useRef(null); const pianoRollContentRef = useRef(null); @@ -99,6 +133,9 @@ const PianoRoll: React.FC = ({ const pianoRollExpectedScrollLeftRef = useRef(-1); const pianoRollIsPlayingRef = useRef(false); const pendingZoomAnchorBeatRef = useRef(null); + const pendingModeSwitchAnchorBeatRef = useRef(null); + const previousSheetMusicViewEnabledRef = useRef(false); + const previousActiveRegionIdRef = useRef(null); // Ref for storing the setNoteUpdateCounter function const triggerNoteUpdateRef = useRef> | null>(null); @@ -205,6 +242,8 @@ const PianoRoll: React.FC = ({ setActiveTool(currentTool); setAutomationEnabled(pianoRollState.getAutomationViewEnabled()); setAutomationType(pianoRollState.getCurrentAutomationType() as PianoRollAutomationType); + setSheetMusicViewEnabled(pianoRollState.getSheetMusicViewEnabled()); + setSheetQuantization(pianoRollState.getSheetQuantization()); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Synced piano roll state on mount - snap: ${currentSnap}, tool: ${currentTool}`); @@ -703,35 +742,80 @@ const PianoRoll: React.FC = ({ KGPianoRollState.instance().setCurrentAutomationType(value); }, []); + const handleSheetMusicViewToggle = useCallback(() => { + const container = pianoRollNoteScrollRef.current; + if (container && activeRegion) { + const anchorBeat = getVisibleCenterBeat({ + container, + sheetMusicViewEnabled, + sheetMeasureMetrics, + activeRegionStartBeat: activeRegion.getStartFromBeat(), + }); + pendingModeSwitchAnchorBeatRef.current = anchorBeat; + } else { + pendingModeSwitchAnchorBeatRef.current = null; + } + + setSheetMusicViewEnabled(current => { + const next = !current; + KGPianoRollState.instance().setSheetMusicViewEnabled(next); + return next; + }); + }, [activeRegion, sheetMeasureMetrics, sheetMusicViewEnabled]); + + const handleSheetQuantizationChange = useCallback((value: string) => { + setSheetQuantization(value); + KGPianoRollState.instance().setSheetQuantization(value); + }, []); + + const handleSheetMeasureMetricsChange = useCallback((metrics: SheetMeasureMetric[]) => { + setSheetMeasureMetrics((current) => { + if ( + current.length === metrics.length && + current.every((metric, index) => ( + metric.barIndex === metrics[index].barIndex && + metric.startBeat === metrics[index].startBeat && + metric.endBeat === metrics[index].endBeat && + metric.leftPx === metrics[index].leftPx && + metric.widthPx === metrics[index].widthPx + )) + ) { + return current; + } + + return metrics; + }); + }, []); + + const centerPianoRollOnDefaultVerticalPosition = useCallback(() => { + const container = pianoRollNoteScrollRef.current; + if (!container) { + return; + } + + const keyHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20; + const c4Position = 4 * 12 * keyHeight; + const totalHeight = 8 * 12 * keyHeight; + const viewportHeight = container.clientHeight; + const scrollPosition = (totalHeight - c4Position) - (viewportHeight / 2); + + container.scrollTop = Math.max(0, scrollPosition); + }, []); + // Calculate C4 position and scroll to it when piano roll opens useEffect(() => { - if (pianoRollNoteScrollRef.current) { - // Calculate position of C4 - // We have 8 octaves (0-7), and C4 is in the middle - // Each octave has 12 notes, each note is piano key height - // C4 is in octave 4, and C is the first note in each octave + centerPianoRollOnDefaultVerticalPosition(); + }, [centerPianoRollOnDefaultVerticalPosition]); - // Calculate from the bottom: - // - Octaves 0-3 = 4 octaves = 4 * 12 * piano key height - // - Within octave 4, C is the first note (from bottom), so 0px additional - const keyHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20; - const c4Position = 4 * 12 * keyHeight; // pixels from bottom + useEffect(() => { + const previous = previousSheetMusicViewEnabledRef.current; - // Total height of all notes (8 octaves * 12 notes * piano key height) - const totalHeight = 8 * 12 * keyHeight; - - // Get the viewport height of the piano roll content - const viewportHeight = pianoRollNoteScrollRef.current.clientHeight; - - // Calculate scroll position to center C4 - // We need to scroll from the top, so we calculate: - // (total height - C4 position) - (viewport height / 2) - const scrollPosition = (totalHeight - c4Position) - (viewportHeight / 2); - - // Scroll to the calculated position - pianoRollNoteScrollRef.current.scrollTop = Math.max(0, scrollPosition); + if (previous !== sheetMusicViewEnabled && !sheetMusicViewEnabled) { + centerPianoRollOnDefaultVerticalPosition(); } - }, []); + + previousSheetMusicViewEnabledRef.current = sheetMusicViewEnabled; + }, [centerPianoRollOnDefaultVerticalPosition, sheetMusicViewEnabled]); // Sync isPlayingRef for use inside scroll event closure useEffect(() => { @@ -762,15 +846,24 @@ const PianoRoll: React.FC = ({ const container = pianoRollNoteScrollRef.current; if (!container) return; - const beatWidth = parseInt( - getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width') - ) || 40; - const playheadPixel = playheadPosition * beatWidth; + const playheadPixel = sheetMusicViewEnabled && activeRegion + ? getSheetPlayheadPixel( + Math.max(0, playheadPosition - activeRegion.getStartFromBeat()), + sheetMeasureMetrics + ) + : (() => { + const beatWidth = parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width') + ) || 40; + return playheadPosition * beatWidth; + })(); // Center the playhead in the visible grid area (excluding the 60px sticky piano keys panel) - const keysWidth = parseInt( - getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width') - ) || 60; + const keysWidth = sheetMusicViewEnabled + ? 0 + : (parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width') + ) || 60); const targetScrollLeft = playheadPixel - (container.clientWidth - keysWidth) / 2; const clampedScrollLeft = Math.max( 0, @@ -779,7 +872,7 @@ const PianoRoll: React.FC = ({ pianoRollExpectedScrollLeftRef.current = clampedScrollLeft; container.scrollLeft = clampedScrollLeft; - }, [playheadPosition, isPlaying, autoScrollEnabled]); + }, [playheadPosition, isPlaying, autoScrollEnabled, sheetMusicViewEnabled, sheetMeasureMetrics, activeRegion]); // Handle scroll requests from main content bar numbers clicks useEffect(() => { @@ -788,15 +881,24 @@ const PianoRoll: React.FC = ({ const container = pianoRollNoteScrollRef.current; if (!container) return; - const beatWidth = parseInt( - getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width') - ) || 40; - const playheadPixel = pianoRollScrollRequest * beatWidth; + const playheadPixel = sheetMusicViewEnabled && activeRegion + ? getSheetPlayheadPixel( + Math.max(0, pianoRollScrollRequest - activeRegion.getStartFromBeat()), + sheetMeasureMetrics + ) + : (() => { + const beatWidth = parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width') + ) || 40; + return pianoRollScrollRequest * beatWidth; + })(); // Center the playhead in the visible grid area (excluding the 60px sticky piano keys panel) - const keysWidth = parseInt( - getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width') - ) || 60; + const keysWidth = sheetMusicViewEnabled + ? 0 + : (parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width') + ) || 60); const targetScrollLeft = playheadPixel - (container.clientWidth - keysWidth) / 2; const clampedScrollLeft = Math.max( 0, @@ -807,7 +909,7 @@ const PianoRoll: React.FC = ({ // Clear the request after handling useProjectStore.setState({ pianoRollScrollRequest: null }); - }, [pianoRollScrollRequest]); + }, [pianoRollScrollRequest, sheetMusicViewEnabled, sheetMeasureMetrics, activeRegion]); // Update --region-grid-beat-width when zoom changes and preserve the centered beat position. useLayoutEffect(() => { @@ -843,7 +945,17 @@ const PianoRoll: React.FC = ({ // Scroll horizontally to the active region's starting bar useEffect(() => { - if (pianoRollNoteScrollRef.current && activeRegion) { + if (!pianoRollNoteScrollRef.current || !activeRegion) { + previousActiveRegionIdRef.current = activeRegion?.getId() ?? null; + return; + } + + if (pendingModeSwitchAnchorBeatRef.current !== null) { + previousActiveRegionIdRef.current = activeRegion.getId(); + return; + } + + if (previousActiveRegionIdRef.current !== activeRegion.getId()) { // Get the starting beat of the region const startBeat = activeRegion.getStartFromBeat(); @@ -865,9 +977,34 @@ const PianoRoll: React.FC = ({ // Scroll to the calculated position pianoRollNoteScrollRef.current.scrollLeft = Math.max(0, scrollPosition); + previousActiveRegionIdRef.current = activeRegion.getId(); } }, [activeRegion, timeSignature]); + useLayoutEffect(() => { + const anchorBeat = pendingModeSwitchAnchorBeatRef.current; + const container = pianoRollNoteScrollRef.current; + if (anchorBeat === null || !container || !activeRegion) { + return; + } + + if (sheetMusicViewEnabled && sheetMeasureMetrics.length === 0) { + return; + } + + const targetScrollLeft = getScrollLeftForBeat({ + anchorBeat, + container, + sheetMusicViewEnabled, + sheetMeasureMetrics, + activeRegionStartBeat: activeRegion.getStartFromBeat(), + }); + + pianoRollExpectedScrollLeftRef.current = targetScrollLeft; + container.scrollLeft = targetScrollLeft; + pendingModeSwitchAnchorBeatRef.current = null; + }, [activeRegion, sheetMeasureMetrics, sheetMusicViewEnabled]); + // Add keyboard event listener for piano roll hotkeys (snapping and quantization) useEffect(() => { const handlePianoRollKeyDown = (event: KeyboardEvent) => { @@ -1065,6 +1202,11 @@ const PianoRoll: React.FC = ({ /> = ({ automationEnabled={automationEnabled} automationType={automationType} automationRedrawVersion={automationRedrawVersion} + sheetMusicViewEnabled={sheetMusicViewEnabled} + sheetQuantization={parsedSheetQuantization} + sheetKeySignature={keySignature} + sheetInstrument={activeInstrument} + onSheetMeasureMetricsChange={handleSheetMeasureMetricsChange} />
= ({ }; 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); + } + + 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; +} + +function getAbsoluteBeatForSheetPixel(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); +} + +function getScrollLeftForBeat({ + anchorBeat, + container, + sheetMusicViewEnabled, + sheetMeasureMetrics, + activeRegionStartBeat, +}: ScrollLeftForBeatOptions): number { + const pixelPosition = sheetMusicViewEnabled + ? getSheetPlayheadPixel(Math.max(0, anchorBeat - activeRegionStartBeat), sheetMeasureMetrics) + : (() => { + const beatWidth = parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width') + ) || 40; + return anchorBeat * beatWidth; + })(); + + const keysWidth = sheetMusicViewEnabled + ? 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)); +} diff --git a/src/components/piano-roll/PianoRollContent.test.tsx b/src/components/piano-roll/PianoRollContent.test.tsx index b00e6bd..1ffa2f6 100644 --- a/src/components/piano-roll/PianoRollContent.test.tsx +++ b/src/components/piano-roll/PianoRollContent.test.tsx @@ -4,6 +4,7 @@ import { render, screen } from '@testing-library/react'; import PianoRollContent from './PianoRollContent'; import { createMockMidiRegion } from '../../test/utils/mock-data'; import type { KeySignature } from '../../core/KGProject'; +import { parseSheetQuantization } from './sheetNotation'; vi.mock('../../stores/projectStore', () => ({ useProjectStore: (selector: (state: { isRecording: boolean; recordingNotes: [] }) => unknown) => ( @@ -48,6 +49,7 @@ 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: () =>
})); describe('PianoRollContent', () => { const baseProps = { @@ -106,4 +108,21 @@ describe('PianoRollContent', () => { expect(screen.getByTestId('piano-roll-content-single')).toBeInTheDocument(); expect(screen.queryByTestId('automation-lane')).not.toBeInTheDocument(); }); + + it('renders sheet mode without piano keys or automation lane', () => { + render( + + ); + + expect(screen.getByTestId('sheet-music-view')).toBeInTheDocument(); + expect(screen.queryByTestId('piano-keys')).not.toBeInTheDocument(); + expect(screen.queryByTestId('automation-lane')).not.toBeInTheDocument(); + }); }); diff --git a/src/components/piano-roll/PianoRollContent.tsx b/src/components/piano-roll/PianoRollContent.tsx index fa3ce4f..c1b2161 100644 --- a/src/components/piano-roll/PianoRollContent.tsx +++ b/src/components/piano-roll/PianoRollContent.tsx @@ -17,6 +17,11 @@ import { velocityToColor } from '../../util/velocityColor'; import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil'; import PianoRollAutomationLane from './PianoRollAutomationLane'; import type { PianoRollAutomationType } from './pianoRollAutomation'; +import type { SheetMeasureMetric, SheetQuantization } from './sheetNotationTypes'; +import type { InstrumentType } from '../../core/track/KGMidiTrack'; +import SheetMusicView from './SheetMusicView'; + +const NOOP_SHEET_METRICS_CHANGE = (_metrics: SheetMeasureMetric[]) => {}; interface PianoRollContentProps { contentRef: React.MutableRefObject; @@ -44,6 +49,11 @@ interface PianoRollContentProps { automationEnabled?: boolean; automationType?: PianoRollAutomationType; automationRedrawVersion?: number; + sheetMusicViewEnabled?: boolean; + sheetQuantization?: SheetQuantization; + sheetKeySignature?: KeySignature; + sheetInstrument?: InstrumentType; + onSheetMeasureMetricsChange?: (metrics: SheetMeasureMetric[]) => void; } const PianoRollContent: React.FC = ({ @@ -72,9 +82,14 @@ const PianoRollContent: React.FC = ({ automationEnabled = false, automationType = 'pitch-bend', automationRedrawVersion = 0, + sheetMusicViewEnabled = false, + sheetQuantization, + sheetKeySignature = 'C major', + sheetInstrument = 'acoustic_grand_piano', + onSheetMeasureMetricsChange, }) => { const isSpectrogram = mode === 'spectrogram'; - const showAutomationLane = automationEnabled && !isSpectrogram; + const showAutomationLane = automationEnabled && !isSpectrogram && !sheetMusicViewEnabled; const [spectrogramLoading, setSpectrogramLoading] = useState(false); const [noteScrollLeft, setNoteScrollLeft] = useState(0); const handleSpectrogramLoadingChange = useCallback((loading: boolean) => { @@ -191,7 +206,7 @@ const PianoRollContent: React.FC = ({ // Memoize the notes rendering to prevent unnecessary recalculations const memoizedNotes = useMemo(() => { - if (isSpectrogram || !activeRegion) return null; + if (isSpectrogram || sheetMusicViewEnabled || !activeRegion) return null; if (DEBUG_MODE.PIANO_ROLL) { console.log(`Rendering notes for region: ${activeRegion.getId()}`); @@ -268,7 +283,7 @@ const PianoRollContent: React.FC = ({ /> ); }); - }, [mode, activeRegion, noteUpdateCounter, resizingNoteId, draggingNoteId, tempNoteStyles, selectedNoteIds, selectionBoxRender, tracks]); + }, [mode, activeRegion, noteUpdateCounter, resizingNoteId, draggingNoteId, tempNoteStyles, selectedNoteIds, selectionBoxRender, tracks, sheetMusicViewEnabled]); const recordingNoteOverlays = useMemo(() => { if (!isRecording || !activeRegion || recordingNotes.length === 0) return null; @@ -291,6 +306,19 @@ const PianoRollContent: React.FC = ({ )); }, [isRecording, recordingNotes, activeRegion]); + useEffect(() => { + if (!sheetMusicViewEnabled) { + return; + } + + const container = noteScrollRef.current; + if (!container) { + return; + } + + container.scrollTop = 0; + }, [noteScrollRef, sheetMusicViewEnabled]); + return (
= ({ ref={noteScrollRef} onScroll={(event) => setNoteScrollLeft(event.currentTarget.scrollLeft)} > - -
- - {} : handleGridDoubleClick} - onClick={isSpectrogram ? () => {} : handleCombinedClick} - onMouseDown={isSpectrogram ? () => {} : handleBackgroundMouseDown} - isBoxSelecting={isSpectrogram ? false : isBoxSelectingRef.current} - selectionBox={isSpectrogram ? { startX: 0, startY: 0, endX: 0, endY: 0 } : selectionBoxRef.current} - regionStartBeat={activeRegion?.getStartFromBeat() || 0} - selectedMode={selectedMode} - keySignature={keySignature} - chordGuide={chordGuide} - audioRegion={audioRegion} - trackId={trackId} - projectName={projectName} - bpm={bpm} - spectrogramThresholdDb={spectrogramThresholdDb} - spectrogramPower={spectrogramPower} - spectrogramHeightResolution={spectrogramHeightResolution} - pianoRollZoom={pianoRollZoom} - onSpectrogramLoadingChange={handleSpectrogramLoadingChange} - > - {memoizedNotes} - {!isSpectrogram && recordingNoteOverlays} - + {!sheetMusicViewEnabled && ( + + )} +
+ {!sheetMusicViewEnabled && } + {sheetMusicViewEnabled && activeRegion && sheetQuantization ? ( + + ) : ( + {} : handleGridDoubleClick} + onClick={isSpectrogram ? () => {} : handleCombinedClick} + onMouseDown={isSpectrogram ? () => {} : handleBackgroundMouseDown} + isBoxSelecting={isSpectrogram ? false : isBoxSelectingRef.current} + selectionBox={isSpectrogram ? { startX: 0, startY: 0, endX: 0, endY: 0 } : selectionBoxRef.current} + regionStartBeat={activeRegion?.getStartFromBeat() || 0} + selectedMode={selectedMode} + keySignature={keySignature} + chordGuide={chordGuide} + audioRegion={audioRegion} + trackId={trackId} + projectName={projectName} + bpm={bpm} + spectrogramThresholdDb={spectrogramThresholdDb} + spectrogramPower={spectrogramPower} + spectrogramHeightResolution={spectrogramHeightResolution} + pianoRollZoom={pianoRollZoom} + onSpectrogramLoadingChange={handleSpectrogramLoadingChange} + > + {memoizedNotes} + {!isSpectrogram && recordingNoteOverlays} + + )}
diff --git a/src/components/piano-roll/PianoRollToolbar.test.tsx b/src/components/piano-roll/PianoRollToolbar.test.tsx index ac9fba1..6975055 100644 --- a/src/components/piano-roll/PianoRollToolbar.test.tsx +++ b/src/components/piano-roll/PianoRollToolbar.test.tsx @@ -40,6 +40,11 @@ vi.mock('../../core/KGCore', () => ({ describe('PianoRollToolbar', () => { const baseProps = { + sheetMusicViewEnabled: false, + onSheetMusicViewToggle: vi.fn(), + sheetQuantization: '16,48', + onSheetQuantizationChange: vi.fn(), + sheetQuantizationOptions: ['16,48', '32,96'], activeTool: 'pointer' as const, onToolSelect: vi.fn(), quantPosition: '1/8', @@ -72,6 +77,7 @@ describe('PianoRollToolbar', () => { expect(screen.getByRole('button', { name: 'Toggle automation lane' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Pitch Bend/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sheet Music View' })).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Toggle automation lane' })); expect(onAutomationToggle).toHaveBeenCalledTimes(1); @@ -109,4 +115,19 @@ describe('PianoRollToolbar', () => { expect(screen.queryByRole('button', { name: 'Toggle automation lane' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Pitch Bend/i })).not.toBeInTheDocument(); }); + + it('shows only the sheet controls when sheet mode is enabled', () => { + render( + + ); + + expect(screen.getByRole('button', { name: 'Sheet Music View' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /16,48/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Pointer Tool' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Pitch Bend/i })).not.toBeInTheDocument(); + }); }); diff --git a/src/components/piano-roll/PianoRollToolbar.tsx b/src/components/piano-roll/PianoRollToolbar.tsx index 6f13429..ae411bc 100644 --- a/src/components/piano-roll/PianoRollToolbar.tsx +++ b/src/components/piano-roll/PianoRollToolbar.tsx @@ -16,6 +16,11 @@ const POWER_OPTIONS = [ ]; interface PianoRollToolbarProps { + sheetMusicViewEnabled?: boolean; + onSheetMusicViewToggle?: () => void; + sheetQuantization?: string; + onSheetQuantizationChange?: (value: string) => void; + sheetQuantizationOptions?: string[]; activeTool: 'pointer' | 'pencil'; onToolSelect: (tool: 'pointer' | 'pencil') => void; quantPosition: string; @@ -43,6 +48,11 @@ interface PianoRollToolbarProps { } const PianoRollToolbar: React.FC = ({ + sheetMusicViewEnabled = false, + onSheetMusicViewToggle, + sheetQuantization = '16,48', + onSheetQuantizationChange, + sheetQuantizationOptions = [], activeTool, onToolSelect, quantPosition, @@ -68,9 +78,8 @@ const PianoRollToolbar: React.FC = ({ onAutomationToggle, onAutomationTypeChange, }) => { - const isSpectrogram = mode === 'spectrogram'; - const showMidiControls = mode !== 'spectrogram'; // midi-edit and hybrid - const showSpecControls = mode === 'spectrogram' || mode === 'hybrid'; + const showMidiControls = mode !== 'spectrogram' && !sheetMusicViewEnabled; // midi-edit and hybrid + const showSpecControls = !sheetMusicViewEnabled && (mode === 'spectrogram' || mode === 'hybrid'); const [showZoomSlider, setShowZoomSlider] = React.useState(false); const zoomSliderRef = React.useRef(null); @@ -90,6 +99,14 @@ const PianoRollToolbar: React.FC = ({
{showMidiControls && (
+
)} + {sheetMusicViewEnabled && ( +
+ +
+ )} +
+ {sheetMusicViewEnabled && ( + onSheetQuantizationChange?.(value)} + label="Sheet Quant." + buttonClassName="sheet-quantization" + showValueAsLabel={true} + /> + )} {showMidiControls && ( <> ; + onMetricsChange: (metrics: SheetMeasureMetric[]) => void; +} + +interface RenderedSheetEvent { + barIndex: number; + eventIndex: number; + startBeat: number; + endBeat: number; + keys: string[]; + tieStart: boolean; + tieEnd: boolean; + tieLeftX: number; + tieRightX: number; + y: number; +} + +interface SheetTiePath { + id: string; + d: string; +} + +const MIN_MEASURE_WIDTH = 200; +const EVENT_WIDTH = 28; +const STAFF_HEIGHT = 132; +const FIRST_MEASURE_MODIFIER_WIDTH = 72; +const SheetMusicView: React.FC = ({ + activeRegion, + timeSignature, + keySignature, + instrument, + quantization, + noteScrollRef, + onMetricsChange, +}) => { + const setPlayheadPosition = useProjectStore(state => state.setPlayheadPosition); + const requestMainContentScroll = useProjectStore(state => state.requestMainContentScroll); + const [metrics, setMetrics] = useState([]); + const [tiePaths, setTiePaths] = useState([]); + const headerRef = useRef(null); + const measureHostRefs = useRef>([]); + 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]); + + const measureModels = useMemo(() => { + if (!activeRegion) { + return []; + } + + return buildSheetMeasureModels({ + region: activeRegion, + timeSignature, + quantization, + }); + }, [activeRegion, timeSignature, quantization]); + const measureWidths = useMemo( + () => measureModels.map((measure, index) => ( + Math.max( + MIN_MEASURE_WIDTH, + 140 + measure.events.length * EVENT_WIDTH + (index === 0 ? FIRST_MEASURE_MODIFIER_WIDTH : 0) + ) + )), + [measureModels] + ); + + const clef = useMemo(() => { + if (!activeRegion) { + return 'treble'; + } + + return resolveSheetClef(activeRegion.getNotes(), instrument, true); + }, [activeRegion, instrument]); + const drawSignature = useMemo(() => JSON.stringify({ + regionId: activeRegion?.getId() ?? null, + regionName: activeRegion?.getName() ?? null, + regionStartBeat: activeRegion?.getStartFromBeat() ?? null, + regionLength: activeRegion?.getLength() ?? null, + bars: measureModels.length, + clef, + instrument, + keySignature, + quantization: quantization.raw, + numerator: timeSignature.numerator, + denominator: timeSignature.denominator, + measureWidths, + eventCounts: measureModels.map((measure) => measure.events.length), + }), [activeRegion, clef, instrument, keySignature, measureModels, measureWidths, quantization.raw, timeSignature]); + + useEffect(() => { + if (!activeRegion) { + lastDrawSignatureRef.current = null; + setMetrics((current) => (current.length === 0 ? current : [])); + setTiePaths((current) => (current.length === 0 ? current : [])); + onMetricsChange([]); + return; + } + + if (lastDrawSignatureRef.current === drawSignature) { + return; + } + + const nextMetrics = buildSheetMeasureMetrics(measureWidths, timeSignature.numerator); + const renderedEvents: RenderedSheetEvent[] = []; + + measureModels.forEach((measure, index) => { + const host = measureHostRefs.current[index]; + if (!host) { + return; + } + + host.replaceChildren(); + host.style.width = `${measureWidths[index]}px`; + host.style.height = `${STAFF_HEIGHT}px`; + + const width = measureWidths[index]; + const renderer = new Renderer(host, Renderer.Backends.SVG); + renderer.resize(width, STAFF_HEIGHT); + const context = renderer.getContext(); + const showLeadingModifiers = index === 0; + const staveX = showLeadingModifiers ? 8 : 0; + const staveWidth = Math.max(0, width - staveX); + const stave = new Stave(staveX, 10, staveWidth); + stave.setBegBarType(showLeadingModifiers ? BarlineType.SINGLE : BarlineType.NONE); + stave.setEndBarType(BarlineType.SINGLE); + if (showLeadingModifiers) { + stave.addClef(clef); + stave.addKeySignature(vexKeySignature); + stave.addTimeSignature(`${timeSignature.numerator}/${timeSignature.denominator}`); + } + stave.setContext(context).draw(); + + const notes = measure.events.map(event => createStaveNote(event, clef)); + const voice = new Voice({ + numBeats: timeSignature.numerator, + beatValue: timeSignature.denominator, + }); + voice.setStrict(false); + voice.addTickables(notes); + Accidental.applyAccidentals([voice], vexKeySignature); + const beams = Beam.generateBeams(notes.filter(note => !note.isRest())); + new Formatter().joinVoices([voice]).formatToStave([voice], stave, { stave }); + voice.draw(context, stave); + beams.forEach((beam) => beam.setContext(context).draw()); + + measure.events.forEach((event, eventIndex) => { + const note = notes[eventIndex]; + if (event.isRest || !note) { + return; + } + + renderedEvents.push({ + barIndex: measure.barIndex, + eventIndex, + startBeat: event.startBeat, + endBeat: event.endBeat, + keys: [...event.keys], + tieStart: event.tieStart, + tieEnd: event.tieEnd, + tieLeftX: note.getTieLeftX() + nextMetrics[index].leftPx, + tieRightX: note.getTieRightX() + nextMetrics[index].leftPx, + y: note.getYs()[0] ?? 0, + }); + }); + + const svg = host.querySelector('svg'); + if (svg instanceof SVGElement) { + svg.style.display = 'block'; + svg.style.width = `${width}px`; + svg.style.height = `${STAFF_HEIGHT}px`; + } + }); + + const nextTiePaths = buildTiePaths(renderedEvents, nextMetrics); + lastDrawSignatureRef.current = drawSignature; + setMetrics((current) => { + if ( + current.length === nextMetrics.length && + current.every((metric, index) => ( + metric.barIndex === nextMetrics[index].barIndex && + metric.startBeat === nextMetrics[index].startBeat && + metric.endBeat === nextMetrics[index].endBeat && + metric.leftPx === nextMetrics[index].leftPx && + metric.widthPx === nextMetrics[index].widthPx + )) + ) { + return current; + } + + return nextMetrics; + }); + setTiePaths((current) => ( + current.length === nextTiePaths.length && + current.every((path, index) => path.id === nextTiePaths[index].id && path.d === nextTiePaths[index].d) + ? current + : nextTiePaths + )); + onMetricsChange(nextMetrics); + }, [activeRegion, clef, drawSignature, instrument, measureModels, measureWidths, onMetricsChange, quantization.raw, timeSignature]); + + const handleHeaderClick = (event: React.MouseEvent) => { + if (!activeRegion || !headerRef.current || metrics.length === 0) { + return; + } + + const rect = headerRef.current.getBoundingClientRect(); + const relativeX = event.clientX - rect.left + (noteScrollRef.current?.scrollLeft ?? 0); + const metric = metrics.find(candidate => ( + relativeX >= candidate.leftPx && relativeX <= candidate.leftPx + candidate.widthPx + )); + + if (!metric) { + 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; + + setPlayheadPosition(absoluteBeat); + requestMainContentScroll(absoluteBeat); + }; + + return ( +
+
+ {measureModels.map((measure, index) => ( +
+ {startingBarNumber + measure.barIndex} +
+ ))} +
+
+
+ Sheet music view is under development and may not fully reflect the exact musical notation. +
+
+ sum + width, 0)} + height={STAFF_HEIGHT} + viewBox={`0 0 ${measureWidths.reduce((sum, width) => sum + width, 0)} ${STAFF_HEIGHT}`} + aria-hidden="true" + > + {tiePaths.map((tiePath) => ( + + ))} + + + {measureModels.map((measure, index) => ( +
+
{ + measureHostRefs.current[index] = element; + }} + /> +
+ ))} +
+
+
+ ); +}; + +interface SheetMusicPlayheadProps { + activeRegion: KGMidiRegion | null; + metrics: SheetMeasureMetric[]; +} + +const SheetMusicPlayhead: React.FC = memo(({ activeRegion, metrics }) => { + const playheadPosition = useProjectStore(state => state.playheadPosition); + + const playheadPixel = useMemo(() => { + if (!activeRegion) { + return 0; + } + + return getSheetPlayheadPixel( + Math.max(0, playheadPosition - activeRegion.getStartFromBeat()), + metrics + ); + }, [activeRegion, metrics, playheadPosition]); + + return ; +}); + +const arePropsEqual = (previous: SheetMusicViewProps, next: SheetMusicViewProps) => { + return ( + previous.activeRegion?.getId() === next.activeRegion?.getId() && + previous.activeRegion?.getName() === next.activeRegion?.getName() && + previous.activeRegion?.getLength() === next.activeRegion?.getLength() && + previous.activeRegion?.getStartFromBeat() === next.activeRegion?.getStartFromBeat() && + previous.instrument === next.instrument && + previous.keySignature === next.keySignature && + previous.quantization.raw === next.quantization.raw && + previous.timeSignature.numerator === next.timeSignature.numerator && + previous.timeSignature.denominator === next.timeSignature.denominator && + previous.noteScrollRef === next.noteScrollRef && + previous.onMetricsChange === next.onMetricsChange + ); +}; + +function createStaveNote( + event: SheetMeasureModel['events'][number], + clef: SheetClef +): StaveNote { + const durationSpec = resolveDurationSpec(event.endBeat - event.startBeat, event.isRest); + const note = new StaveNote({ + clef, + keys: event.keys, + duration: durationSpec.duration, + }); + + for (let dotIndex = 0; dotIndex < durationSpec.dots; dotIndex += 1) { + Dot.buildAndAttach([note], { all: true }); + } + + return note; +} + +export default memo(SheetMusicView, arePropsEqual); + +function buildTiePaths(events: RenderedSheetEvent[], metrics: SheetMeasureMetric[]): SheetTiePath[] { + const byStartBeat = new Map(); + + events.forEach((event) => { + const existing = byStartBeat.get(event.startBeat) ?? []; + existing.push(event); + byStartBeat.set(event.startBeat, existing); + }); + + return events + .filter((event) => event.tieEnd) + .map((event) => { + const nextCandidates = byStartBeat.get(event.endBeat) ?? []; + const nextEvent = nextCandidates.find((candidate) => ( + candidate.tieStart && + candidate.barIndex === event.barIndex + 1 && + candidate.keys.join(',') === event.keys.join(',') + )); + + if (!nextEvent) { + return null; + } + + const currentMetric = metrics[event.barIndex]; + const nextMetric = metrics[nextEvent.barIndex]; + if (!currentMetric || !nextMetric) { + return null; + } + + const startX = Math.min(event.tieRightX, currentMetric.leftPx + currentMetric.widthPx - 4); + const endX = Math.max(nextEvent.tieLeftX, nextMetric.leftPx + 4); + const y = Math.max(event.y, nextEvent.y) + 10; + const span = Math.max(endX - startX, 16); + const controlY = y + Math.min(12, span * 0.18); + const innerY = y + Math.min(8, span * 0.12); + const d = [ + `M ${startX} ${y}`, + `C ${startX + span * 0.25} ${controlY} ${endX - span * 0.25} ${controlY} ${endX} ${y}`, + `C ${endX - span * 0.25} ${innerY} ${startX + span * 0.25} ${innerY} ${startX} ${y}`, + 'Z', + ].join(' '); + + return { + id: `${event.barIndex}-${event.eventIndex}-${nextEvent.barIndex}-${nextEvent.eventIndex}`, + d, + }; + }) + .filter((path): path is SheetTiePath => Boolean(path)); +} diff --git a/src/components/piano-roll/sheetNotation.test.ts b/src/components/piano-roll/sheetNotation.test.ts new file mode 100644 index 0000000..8f4e7c4 --- /dev/null +++ b/src/components/piano-roll/sheetNotation.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { createMockMidiNote, createMockMidiRegion } from '../../test/utils/mock-data'; +import { + buildSheetMeasureMetrics, + buildSheetMeasureModels, + getSheetPlayheadPixel, + getSheetQuantizationOptions, + isDrumInstrument, + parseSheetQuantization, + projectKeySignatureToVexFlow, + resolveDurationSpec, + resolveSheetClef, +} from './sheetNotation'; + +describe('sheetNotation', () => { + it('parses all supported quantization values', () => { + getSheetQuantizationOptions().forEach((value) => { + const parsed = parseSheetQuantization(value); + expect(parsed.raw).toBe(value); + expect(parsed.stepBeats).toBeGreaterThan(0); + }); + }); + + it('splits notes that cross barlines and inserts rests', () => { + const region = createMockMidiRegion({ + length: 8, + notes: [ + createMockMidiNote({ startBeat: 0, endBeat: 5, pitch: 60 }), + createMockMidiNote({ startBeat: 6, endBeat: 7, pitch: 64, id: 'note-2' }), + ], + }); + + const measures = buildSheetMeasureModels({ + region, + timeSignature: { numerator: 4, denominator: 4 }, + quantization: parseSheetQuantization('16,48'), + }); + + expect(measures).toHaveLength(2); + expect(measures[0].events.some(event => event.tieEnd)).toBe(true); + expect(measures[1].events.some(event => event.tieStart)).toBe(true); + expect(measures[1].events.some(event => event.isRest)).toBe(true); + }); + + it('selects clef from note range and falls back for drum instruments', () => { + expect(resolveSheetClef([createMockMidiNote({ pitch: 76 })], 'acoustic_grand_piano')).toBe('treble'); + expect(resolveSheetClef([createMockMidiNote({ pitch: 40 })], 'acoustic_grand_piano')).toBe('bass'); + expect(isDrumInstrument('standard')).toBe(true); + expect(resolveSheetClef([createMockMidiNote({ pitch: 38 })], 'standard', false)).toBe('treble'); + }); + + it('maps playhead position through variable-width bars', () => { + const metrics = buildSheetMeasureMetrics([120, 240], 4); + + expect(getSheetPlayheadPixel(0, metrics)).toBe(0); + expect(getSheetPlayheadPixel(2, metrics)).toBe(60); + expect(getSheetPlayheadPixel(5, metrics)).toBe(180); + expect(getSheetPlayheadPixel(8, metrics)).toBe(360); + }); + + it('supports dotted durations used by sheet display', () => { + expect(resolveDurationSpec(1.5, false)).toEqual({ duration: 'q', dots: 1 }); + expect(resolveDurationSpec(1.5, true)).toEqual({ duration: 'qr', dots: 1 }); + expect(resolveDurationSpec(3, false)).toEqual({ duration: 'h', dots: 1 }); + }); + + it('maps project key signatures to vexflow key specs', () => { + expect(projectKeySignatureToVexFlow('C major')).toBe('C'); + expect(projectKeySignatureToVexFlow('C# minor')).toBe('C#m'); + expect(projectKeySignatureToVexFlow('F# major')).toBe('F#'); + }); + + it('keeps bar-aligned quarter notes in the correct measure model', () => { + const region = createMockMidiRegion({ + length: 8, + notes: [ + createMockMidiNote({ startBeat: 0, endBeat: 1, pitch: 64, id: 'n1' }), + createMockMidiNote({ startBeat: 1, endBeat: 2, pitch: 64, id: 'n2' }), + createMockMidiNote({ startBeat: 2, endBeat: 3, pitch: 65, id: 'n3' }), + createMockMidiNote({ startBeat: 3, endBeat: 4, pitch: 67, id: 'n4' }), + createMockMidiNote({ startBeat: 4, endBeat: 5, pitch: 67, id: 'n5' }), + ], + }); + + const measures = buildSheetMeasureModels({ + region, + timeSignature: { numerator: 4, denominator: 4 }, + quantization: parseSheetQuantization('16,48'), + }); + + 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]); + }); +}); diff --git a/src/components/piano-roll/sheetNotation.ts b/src/components/piano-roll/sheetNotation.ts new file mode 100644 index 0000000..5d986e7 --- /dev/null +++ b/src/components/piano-roll/sheetNotation.ts @@ -0,0 +1,325 @@ +import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants'; +import type { KeySignature } from '../../core/KGProject'; +import type { KGMidiNote } from '../../core/midi/KGMidiNote'; +import type { KGMidiRegion } from '../../core/region/KGMidiRegion'; +import type { InstrumentType } from '../../core/track/KGMidiTrack'; +import type { + SheetDisplayEvent, + SheetMeasureMetric, + SheetMeasureModel, + SheetQuantization, +} from './sheetNotationTypes'; + +const SHEET_QUANTIZATION_OPTIONS = [ + '4', '4,3', '4,6', '4,12', + '8', '8,6', '8,12', '8,24', + '16', '16,12', '16,24', '16,48', + '32', '32,24', '32,48', '32,96', + '64', '64,48', '64,96', '64,192', + '128', '128,96', '128,192', '128,384', +] as const; + +const EPSILON = 1e-6; + +export type SheetClef = 'treble' | 'bass' | 'percussion'; + +export interface BuildSheetNotationOptions { + region: KGMidiRegion; + timeSignature: { numerator: number; denominator: number }; + quantization: SheetQuantization; +} + +interface WorkingEvent { + keys: string[]; + startBeat: number; + endBeat: number; + isRest: boolean; +} + +export function getSheetQuantizationOptions(): string[] { + return [...SHEET_QUANTIZATION_OPTIONS]; +} + +export function projectKeySignatureToVexFlow(keySignature: KeySignature): string { + const [tonic, quality] = keySignature.split(' '); + return quality === 'minor' ? `${tonic}m` : tonic; +} + +export function parseSheetQuantization(value: string): SheetQuantization { + const [primaryText, subdivisionText] = value.split(','); + const primary = Number.parseInt(primaryText, 10); + const subdivision = Number.parseInt(subdivisionText ?? primaryText, 10); + + if (!Number.isFinite(primary) || primary <= 0 || !Number.isFinite(subdivision) || subdivision <= 0) { + throw new Error(`Invalid sheet quantization value: ${value}`); + } + + return { + raw: value, + primary, + subdivision, + stepBeats: 4 / subdivision, + }; +} + +export function isDrumInstrument(instrument: InstrumentType): boolean { + const key = String(instrument); + return key === 'standard' || FLUIDR3_INSTRUMENT_MAP[key]?.group === 'PERCUSSION_KIT'; +} + +export function resolveSheetClef( + notes: KGMidiNote[], + instrument: InstrumentType, + supportsPercussion = true +): SheetClef { + if (isDrumInstrument(instrument)) { + return supportsPercussion ? 'percussion' : 'treble'; + } + + if (notes.length === 0) { + return 'treble'; + } + + const averagePitch = notes.reduce((sum, note) => sum + note.getPitch(), 0) / notes.length; + return averagePitch >= 60 ? 'treble' : 'bass'; +} + +export function buildSheetMeasureMetrics(widths: number[], beatsPerBar: number): SheetMeasureMetric[] { + let leftPx = 0; + return widths.map((widthPx, index) => { + const metric: SheetMeasureMetric = { + barIndex: index, + startBeat: index * beatsPerBar, + endBeat: (index + 1) * beatsPerBar, + leftPx, + widthPx, + }; + leftPx += widthPx; + return metric; + }); +} + +export function getSheetPlayheadPixel( + regionRelativeBeat: number, + metrics: SheetMeasureMetric[] +): number { + if (metrics.length === 0) { + return 0; + } + + if (regionRelativeBeat <= metrics[0].startBeat) { + return metrics[0].leftPx; + } + + const lastMetric = metrics[metrics.length - 1]; + if (regionRelativeBeat >= lastMetric.endBeat) { + return lastMetric.leftPx + lastMetric.widthPx; + } + + const activeMetric = metrics.find(metric => regionRelativeBeat >= metric.startBeat && regionRelativeBeat < metric.endBeat); + if (!activeMetric) { + return 0; + } + + const span = Math.max(activeMetric.endBeat - activeMetric.startBeat, EPSILON); + const progress = (regionRelativeBeat - activeMetric.startBeat) / span; + return activeMetric.leftPx + activeMetric.widthPx * progress; +} + +export function resolveDurationSpec(durationBeats: number, isRest: boolean): { duration: string; dots: number } { + const withRest = (value: string) => (isRest ? `${value}r` : value); + const options = [ + { beats: 6, duration: 'w', dots: 1 }, + { beats: 4, duration: 'w', dots: 0 }, + { beats: 3, duration: 'h', dots: 1 }, + { beats: 2, duration: 'h', dots: 0 }, + { beats: 1.5, duration: 'q', dots: 1 }, + { beats: 1, duration: 'q', dots: 0 }, + { beats: 0.75, duration: '8', dots: 1 }, + { beats: 0.5, duration: '8', dots: 0 }, + { beats: 0.375, duration: '16', dots: 1 }, + { beats: 0.25, duration: '16', dots: 0 }, + { beats: 0.1875, duration: '32', dots: 1 }, + { beats: 0.125, duration: '32', dots: 0 }, + { beats: 0.09375, duration: '64', dots: 1 }, + { beats: 0.0625, duration: '64', dots: 0 }, + ]; + + const match = options.find(option => Math.abs(durationBeats - option.beats) < EPSILON); + if (match) { + return { duration: withRest(match.duration), dots: match.dots }; + } + + if (durationBeats >= 4 - EPSILON) return { duration: withRest('w'), dots: 0 }; + if (durationBeats >= 2 - EPSILON) return { duration: withRest('h'), dots: 0 }; + if (durationBeats >= 1 - EPSILON) return { duration: withRest('q'), dots: 0 }; + if (durationBeats >= 0.5 - EPSILON) return { duration: withRest('8'), dots: 0 }; + if (durationBeats >= 0.25 - EPSILON) return { duration: withRest('16'), dots: 0 }; + if (durationBeats >= 0.125 - EPSILON) return { duration: withRest('32'), dots: 0 }; + return { duration: withRest('64'), dots: 0 }; +} + +export function buildSheetMeasureModels({ + region, + 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 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, + events: [], + })); + + splitEvents.forEach(event => { + const barIndex = Math.min(measures.length - 1, Math.max(0, Math.floor(event.startBeat / beatsPerBar))); + measures[barIndex].events.push(event); + }); + + measures.forEach((measure) => { + if (measure.events.length === 0) { + measure.events.push({ + keys: ['b/4'], + startBeat: measure.startBeat, + endBeat: measure.endBeat, + isRest: true, + tieStart: false, + tieEnd: false, + }); + } + }); + + return measures; +} + +function normalizeNotes(notes: KGMidiNote[], stepBeats: number, measureEndBeat: number): WorkingEvent[] { + const clippedNotes = notes + .map(note => ({ + keys: [midiPitchToVexKey(note.getPitch())], + startBeat: quantizeBeat(note.getStartBeat(), stepBeats), + endBeat: quantizeBeat(note.getEndBeat(), stepBeats), + isRest: false, + })) + .map(note => ({ + ...note, + startBeat: clampBeat(note.startBeat, 0, measureEndBeat), + endBeat: clampBeat(Math.max(note.endBeat, note.startBeat + stepBeats), 0, measureEndBeat), + })) + .filter(note => note.endBeat - note.startBeat > EPSILON) + .sort((a, b) => { + if (a.startBeat !== b.startBeat) return a.startBeat - b.startBeat; + if (a.endBeat !== b.endBeat) return a.endBeat - b.endBeat; + return a.keys[0].localeCompare(b.keys[0]); + }); + + const merged: WorkingEvent[] = []; + + for (const note of clippedNotes) { + const previous = merged[merged.length - 1]; + if ( + previous && + !previous.isRest && + Math.abs(previous.startBeat - note.startBeat) < EPSILON && + Math.abs(previous.endBeat - note.endBeat) < EPSILON + ) { + previous.keys.push(...note.keys); + continue; + } + + merged.push(note); + } + + for (let index = 0; index < merged.length - 1; index += 1) { + const current = merged[index]; + const next = merged[index + 1]; + if (current.endBeat > next.startBeat + EPSILON) { + current.endBeat = Math.max(current.startBeat + stepBeats, next.startBeat); + } + } + + return merged.filter(note => note.endBeat - note.startBeat > EPSILON); +} + +function insertRests(events: WorkingEvent[], measureEndBeat: number): WorkingEvent[] { + if (events.length === 0) { + return [{ keys: ['b/4'], startBeat: 0, endBeat: measureEndBeat, isRest: true }]; + } + + const result: WorkingEvent[] = []; + let cursorBeat = 0; + + for (const event of events) { + if (event.startBeat > cursorBeat + EPSILON) { + result.push({ + keys: ['b/4'], + startBeat: cursorBeat, + endBeat: event.startBeat, + isRest: true, + }); + } + + result.push(event); + cursorBeat = event.endBeat; + } + + if (cursorBeat < measureEndBeat - EPSILON) { + result.push({ + keys: ['b/4'], + startBeat: cursorBeat, + endBeat: measureEndBeat, + isRest: true, + }); + } + + return result; +} + +function splitAcrossBars(events: WorkingEvent[], beatsPerBar: number): SheetDisplayEvent[] { + const result: SheetDisplayEvent[] = []; + + for (const event of events) { + let segmentStart = event.startBeat; + const eventEnd = event.endBeat; + + while (segmentStart < eventEnd - EPSILON) { + const currentBar = Math.floor(segmentStart / beatsPerBar); + const barEnd = (currentBar + 1) * beatsPerBar; + const segmentEnd = Math.min(eventEnd, barEnd); + + result.push({ + keys: [...event.keys], + startBeat: segmentStart, + endBeat: segmentEnd, + isRest: event.isRest, + tieStart: !event.isRest && segmentStart > event.startBeat + EPSILON, + tieEnd: !event.isRest && segmentEnd < eventEnd - EPSILON, + }); + + segmentStart = segmentEnd; + } + } + + return result; +} + +function quantizeBeat(beat: number, stepBeats: number): number { + return Math.round(beat / stepBeats) * stepBeats; +} + +function clampBeat(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function midiPitchToVexKey(pitch: number): string { + const semitone = ((pitch % 12) + 12) % 12; + const octave = Math.floor(pitch / 12) - 1; + const names = ['c', 'c#', 'd', 'd#', 'e', 'f', 'f#', 'g', 'g#', 'a', 'a#', 'b']; + return `${names[semitone]}/${octave}`; +} diff --git a/src/components/piano-roll/sheetNotationTypes.ts b/src/components/piano-roll/sheetNotationTypes.ts new file mode 100644 index 0000000..9638efe --- /dev/null +++ b/src/components/piano-roll/sheetNotationTypes.ts @@ -0,0 +1,30 @@ +export interface SheetMeasureMetric { + barIndex: number; + startBeat: number; + endBeat: number; + leftPx: number; + widthPx: number; +} + +export interface SheetQuantization { + raw: string; + primary: number; + subdivision: number; + stepBeats: number; +} + +export interface SheetDisplayEvent { + keys: string[]; + startBeat: number; + endBeat: number; + isRest: boolean; + tieStart: boolean; + tieEnd: boolean; +} + +export interface SheetMeasureModel { + barIndex: number; + startBeat: number; + endBeat: number; + events: SheetDisplayEvent[]; +} diff --git a/src/core/state/KGPianoRollState.ts b/src/core/state/KGPianoRollState.ts index 7afe3c5..acf899a 100644 --- a/src/core/state/KGPianoRollState.ts +++ b/src/core/state/KGPianoRollState.ts @@ -15,6 +15,8 @@ export class KGPianoRollState { private currentMode: string = "ionian"; // Default mode private automationViewEnabled: boolean = false; private currentAutomationType: string = "pitch-bend"; + private sheetMusicViewEnabled: boolean = false; + private sheetQuantization: string = '16,48'; // Chord guide state private currentSuitableChords: Record = {}; // Map of chord symbols to note names (e.g., {"I": ["C", "E", "G"]}) @@ -83,6 +85,22 @@ export class KGPianoRollState { this.currentAutomationType = type; } + public getSheetMusicViewEnabled(): boolean { + return this.sheetMusicViewEnabled; + } + + public setSheetMusicViewEnabled(enabled: boolean): void { + this.sheetMusicViewEnabled = enabled; + } + + public getSheetQuantization(): string { + return this.sheetQuantization; + } + + public setSheetQuantization(value: string): void { + this.sheetQuantization = value; + } + public getCurrentSuitableChords(): Record { return this.currentSuitableChords; } From 578be7b7260fda1348bcdb5af9c1a252e2388395 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sun, 10 May 2026 13:11:10 -0700 Subject: [PATCH 2/4] feat: added track scope mode for sheet music view; adjusted viewport behavior when switching between piano roll view and sheet music view --- src/components/piano-roll/PianoRoll.css | 21 +- src/components/piano-roll/PianoRoll.test.ts | 262 ++++++++++++ src/components/piano-roll/PianoRoll.tsx | 372 +++++++++++++----- .../piano-roll/PianoRollContent.test.tsx | 20 +- .../piano-roll/PianoRollContent.tsx | 7 + .../piano-roll/PianoRollToolbar.test.tsx | 41 ++ .../piano-roll/PianoRollToolbar.tsx | 19 +- src/components/piano-roll/SheetMusicView.tsx | 72 +++- .../piano-roll/sheetNotation.test.ts | 50 ++- src/components/piano-roll/sheetNotation.ts | 112 +++++- src/core/state/KGPianoRollState.ts | 9 + 11 files changed, 850 insertions(+), 135 deletions(-) create mode 100644 src/components/piano-roll/PianoRoll.test.ts 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; } From 8fa2c16395f870f13b0b9242a4b9f0ed78613a6b Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sun, 10 May 2026 13:25:12 -0700 Subject: [PATCH 3/4] feat: added `save as` option when renaming the project --- src/components/Toolbar.tsx | 52 +++++++++++++++++++----- src/components/common/DialogProvider.css | 11 +++++ src/components/common/DialogProvider.tsx | 46 +++++++++++++++------ src/core/io/KGProjectStorage.ts | 14 +++++++ src/util/dialogUtil.ts | 15 +++++++ 5 files changed, 115 insertions(+), 23 deletions(-) diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 34f83be..af37e84 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -36,7 +36,7 @@ import { clearChatHistoryAndUI } from '../util/chatUtil'; import PianoIcon from './common/icons/PianoIcon'; import MetronomeIcon from './common/icons/MetronomeIcon'; import { ConfigManager } from '../core/config/ConfigManager'; -import { showAlert, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil'; +import { showAlert, showChoice, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil'; const Toolbar: React.FC = () => { const { @@ -112,8 +112,28 @@ const Toolbar: React.FC = () => { return; } - // Conflict check: only relevant when targeting a different OPFS folder - if (newName !== savedProjectName) { + // If the name hasn't changed from the saved state, just save without prompting + if (newName === savedProjectName) { + setProjectName(newName); + await saveProject(newName, savedProjectName, setStatus, (finalName) => { + setSavedProjectName(finalName); + if (finalName !== newName) setProjectName(finalName); + }); + return; + } + + // Ask whether the user wants to rename or save as a copy + const choice = await showChoice( + "Would you like to rename this project, or save it as a new copy?", + [ + { label: 'Save as Copy', value: 'saveas' }, + { label: 'Rename', value: 'rename' }, + ] + ); + if (!choice) return; + + if (choice === 'rename') { + // Conflict check: only relevant when targeting a different OPFS folder const storage = KGProjectStorage.getInstance(); const exists = await storage.exists(newName); if (exists) { @@ -121,7 +141,6 @@ const Toolbar: React.FC = () => { `Project "${newName}" already exists. Do you want to overwrite it?` ); if (!confirmed) return; - // Confirmed: update in-memory name then save immediately, overwriting the existing project setProjectName(newName); await saveProject(newName, savedProjectName, setStatus, (finalName) => { setSavedProjectName(finalName); @@ -129,14 +148,25 @@ const Toolbar: React.FC = () => { }, true /* forceOverwrite */); return; } + setProjectName(newName); + await saveProject(newName, savedProjectName, setStatus, (finalName) => { + setSavedProjectName(finalName); + if (finalName !== newName) setProjectName(finalName); + }); + } else { + // Save as Copy: save current state under a unique new name, then switch to it + const storage = KGProjectStorage.getInstance(); + const finalName = await storage.resolveUniqueName(newName); + try { + await storage.saveAs(savedProjectName, finalName, KGCore.instance().getCurrentProject()); + setProjectName(finalName); + setSavedProjectName(finalName); + setStatus(`Saved as "${finalName}"`); + } catch (error) { + console.error('Error saving project as copy:', error); + await showAlert(`An error occurred while saving: ${error}`); + } } - - // Name is available — update and save immediately - setProjectName(newName); - await saveProject(newName, savedProjectName, setStatus, (finalName) => { - setSavedProjectName(finalName); - if (finalName !== newName) setProjectName(finalName); - }); }; // Common project loading logic extracted for reuse diff --git a/src/components/common/DialogProvider.css b/src/components/common/DialogProvider.css index 4fba3e8..2ecc648 100644 --- a/src/components/common/DialogProvider.css +++ b/src/components/common/DialogProvider.css @@ -183,4 +183,15 @@ .dialog-btn-cancel:hover { background-color: #4a4a4a; color: #e0e0e0; +} + +.dialog-btn-secondary { + background-color: transparent; + color: #5a9fd4; + border: 1px solid #5a9fd4; +} + +.dialog-btn-secondary:hover { + background-color: rgba(90, 159, 212, 0.12); + transform: translateY(-1px); } \ No newline at end of file diff --git a/src/components/common/DialogProvider.tsx b/src/components/common/DialogProvider.tsx index eadeffb..b0c8199 100644 --- a/src/components/common/DialogProvider.tsx +++ b/src/components/common/DialogProvider.tsx @@ -2,14 +2,15 @@ import React, { useState, useCallback, useRef } from 'react'; import './DialogProvider.css'; import { FaTimes } from 'react-icons/fa'; import { registerDialogFns } from '../../util/dialogUtil'; -import type { ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil'; +import type { ChoiceOption, ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil'; interface DialogInfo { - type: 'alert' | 'confirm' | 'prompt' | 'timesig'; + type: 'alert' | 'confirm' | 'prompt' | 'timesig' | 'choice'; message: string; options?: ConfirmOptions | PromptOptions; defaultValue?: string; defaultTimeSig?: TimeSigResult; + choices?: ChoiceOption[]; } const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { @@ -53,6 +54,13 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = }); }, []); + const openChoice = useCallback((message: string, choices: ChoiceOption[]): Promise => { + return new Promise((resolve) => { + resolveRef.current = resolve; + setDialog({ type: 'choice', message, choices }); + }); + }, []); + const close = useCallback((value: unknown) => { pendingValueRef.current = value; setIsClosing(true); @@ -77,7 +85,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = const registered = useRef(false); if (!registered.current) { registered.current = true; - registerDialogFns(openAlert, openConfirm, openPrompt, openTimeSig); + registerDialogFns(openAlert, openConfirm, openPrompt, openTimeSig, openChoice); } if (!dialog) { @@ -87,6 +95,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = const isAlert = dialog.type === 'alert'; const isPrompt = dialog.type === 'prompt'; const isTimeSig = dialog.type === 'timesig'; + const isChoice = dialog.type === 'choice'; const promptOptions = isPrompt ? (dialog.options as PromptOptions | undefined) : undefined; const title = isAlert ? 'Notice' : isTimeSig ? 'Time Signature' : isPrompt ? 'Input' : 'Confirm'; @@ -97,11 +106,11 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = const handleOverlayClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget && mouseDownOnOverlay.current) { - close(isAlert ? undefined : (isPrompt || isTimeSig) ? null : false); + close(isAlert ? undefined : (isPrompt || isTimeSig || isChoice) ? null : false); } }; - const handleCancel = () => close(isAlert ? undefined : (isPrompt || isTimeSig) ? null : false); + const handleCancel = () => close(isAlert ? undefined : (isPrompt || isTimeSig || isChoice) ? null : false); const handleConfirm = () => { if (isAlert) { close(undefined); return; } @@ -182,13 +191,26 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = {(dialog.options as ConfirmOptions | PromptOptions | undefined)?.cancelLabel ?? 'Cancel'} )} - + {isChoice ? ( + dialog.choices?.map((choice, i) => ( + + )) + ) : ( + + )}
diff --git a/src/core/io/KGProjectStorage.ts b/src/core/io/KGProjectStorage.ts index 7bdac74..8349d1e 100644 --- a/src/core/io/KGProjectStorage.ts +++ b/src/core/io/KGProjectStorage.ts @@ -371,6 +371,20 @@ export class KGProjectStorage { } } + /** + * Save the current in-memory project under a new name without removing the source folder. + * Used for "Save as Copy": the original project remains intact in OPFS. + */ + public async saveAs(sourceName: string, targetName: string, data: KGProject): Promise { + this.ensureInitialized(); + + await this.save(targetName, data, false); + + if (await this.exists(sourceName)) { + await this.copyMediaFiles(sourceName, targetName); + } + } + /** * Export a project folder as a zip Blob (.kgstudio bundle). * Includes project.json, meta.json, and all files in media/. diff --git a/src/util/dialogUtil.ts b/src/util/dialogUtil.ts index c9b84a8..ac40880 100644 --- a/src/util/dialogUtil.ts +++ b/src/util/dialogUtil.ts @@ -14,21 +14,29 @@ export interface TimeSigResult { denominator: number; } +export interface ChoiceOption { + label: string; + value: string; +} + let _showAlertFn: ((message: string) => Promise) | null = null; let _showConfirmFn: ((message: string, options?: ConfirmOptions) => Promise) | null = null; let _showPromptFn: ((message: string, defaultValue?: string, options?: PromptOptions) => Promise) | null = null; let _showTimeSigFn: ((message: string, defaultValue?: TimeSigResult) => Promise) | null = null; +let _showChoiceFn: ((message: string, choices: ChoiceOption[]) => Promise) | null = null; export function registerDialogFns( alertFn: (message: string) => Promise, confirmFn: (message: string, options?: ConfirmOptions) => Promise, promptFn: (message: string, defaultValue?: string, options?: PromptOptions) => Promise, timeSigFn: (message: string, defaultValue?: TimeSigResult) => Promise, + choiceFn?: (message: string, choices: ChoiceOption[]) => Promise, ) { _showAlertFn = alertFn; _showConfirmFn = confirmFn; _showPromptFn = promptFn; _showTimeSigFn = timeSigFn; + if (choiceFn) _showChoiceFn = choiceFn; } export function showAlert(message: string): Promise { @@ -53,6 +61,13 @@ export function showPrompt(message: string, defaultValue?: string, options?: Pro return _showPromptFn(message, defaultValue, options); } +export function showChoice(message: string, choices: ChoiceOption[]): Promise { + if (!_showChoiceFn) { + return Promise.resolve(window.confirm(message) ? choices[0]?.value ?? null : null); + } + return _showChoiceFn(message, choices); +} + export function showTimeSigPrompt(message: string, defaultValue?: TimeSigResult): Promise { if (!_showTimeSigFn) { const raw = window.prompt(message, defaultValue ? `${defaultValue.numerator}/${defaultValue.denominator}` : '4/4'); From 1a84199025b0ecec6da1b7e7dd6e6cef0a80a2bc Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sun, 10 May 2026 13:33:39 -0700 Subject: [PATCH 4/4] docs: updated README.md and LICENSE --- LICENSE | 29 ++++++++++++++++++++++++++++- README.md | 4 +++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index 4c514fd..6f20539 100644 --- a/LICENSE +++ b/LICENSE @@ -121,7 +121,34 @@ Who knows, maybe I'll kick start this project again? ;) --- -### 3. System Prompt Structure (based on Cline) +### 3. VexFlow + +``` +VexFlow - A JavaScript library for rendering music notation. +Copyright (c) 2010 Mohit Muthanna Cheppudira + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +``` + +--- + +### 4. System Prompt Structure (based on Cline) Portions of the system prompt structure are derived from the Cline project, licensed under the Apache License, Version 2.0. diff --git a/README.md b/README.md index 240b0c5..be1435c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ K.G.Studio is a lightweight, modern DAW that runs entirely in the browser with * ## Latest Updates +- **2026.05.10**: Added **staff notation (sheet music) view** — the piano roll now offers a full standard notation mode. Switch between Piano Roll and Sheet Music views using the toggle in the piano roll toolbar. In sheet music mode, notes are engraved via VexFlow with automatic clef selection (treble or bass) based on the active instrument, key signature rendering, automatic beam grouping, ties across bar lines, and configurable quantization for note-value resolution. Enable **Track Scope** to render all MIDI regions on the track as a continuous score rather than a single isolated region. + - **2026.05.09**: Added **audio recording** — record directly from your microphone into an audio track. A live waveform preview grows in real time as you record, and the region is committed to the timeline as a standard audio region when you stop. Added **audio I/O device selection** in Settings so you can choose your preferred microphone input and audio output device. - **2026.05.08**: Added **MIDI automation** — draw and edit pitch bend and MIDI CC curves (CC1 Modulation, CC2 Breath, CC7 Volume, CC11 Expression, CC64 Sustain) in an editable automation lane below the piano grid. Added **track-level automation**: each track now has a dedicated automation panel where you can view and edit the same curves directly on the timeline. Real-time MIDI controller input (pitch wheel, CC pedals) is recorded and played back with per-lane interpolation. Added the **Event List Panel** — a tabbed sidebar (Notes / Pitch Bend / Controller) for inspecting and inline-editing all events in the active MIDI region. Added **region multi-select** with lasso and bulk move/resize, and **merge MIDI regions**. @@ -383,4 +385,4 @@ Licensed under the Apache License, Version 2.0, with additional terms (see `LICE - No patent applications using this software or assets - Attribution required when used in public/commercial products (“Powered by K.G.Studio”) -Third‑party notices (e.g., FluidR3_GM SoundFont, midi‑js‑soundfonts, and prompt structure notes) are included in `LICENSE`. +Third‑party notices (e.g., FluidR3_GM SoundFont, midi‑js‑soundfonts, VexFlow, and prompt structure notes) are included in `LICENSE`.