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] 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; }