feat: added sheet music (stuff notation) view to the piano roll window MIDI mode

This commit is contained in:
Xiaohan-Tian
2026-05-09 23:50:02 -07:00
parent 841575e01f
commit d69b3924e5
14 changed files with 1440 additions and 80 deletions
+9 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "K.G.Studio", "name": "K.G.Studio",
"version": "0.12.0-build.20260430", "version": "0.15.0-build.20260510",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "K.G.Studio", "name": "K.G.Studio",
"version": "0.12.0-build.20260430", "version": "0.15.0-build.20260510",
"dependencies": { "dependencies": {
"@breezystack/lamejs": "^1.2.7", "@breezystack/lamejs": "^1.2.7",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
@@ -22,6 +22,7 @@
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tone": "^15.1.22", "tone": "^15.1.22",
"vexflow": "^5.0.0",
"zustand": "^5.0.6" "zustand": "^5.0.6"
}, },
"devDependencies": { "devDependencies": {
@@ -13734,6 +13735,12 @@
"spdx-expression-parse": "^3.0.0" "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": { "node_modules/vfile": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+1
View File
@@ -28,6 +28,7 @@
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tone": "^15.1.22", "tone": "^15.1.22",
"vexflow": "^5.0.0",
"zustand": "^5.0.6" "zustand": "^5.0.6"
}, },
"devDependencies": { "devDependencies": {
+8 -3
View File
@@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import { KGCore } from '../../core/KGCore';
import { useProjectStore } from '../../stores/projectStore'; import { useProjectStore } from '../../stores/projectStore';
interface PlayheadProps { interface PlayheadProps {
@@ -7,13 +6,19 @@ interface PlayheadProps {
context: 'main-grid' | 'piano-roll'; context: 'main-grid' | 'piano-roll';
/** For piano roll context, the region start beat offset */ /** For piano roll context, the region start beat offset */
regionStartBeat?: number; regionStartBeat?: number;
/** Optional exact pixel override for variable-width layouts */
pixelPositionOverride?: number;
} }
const Playhead: React.FC<PlayheadProps> = ({ context, regionStartBeat = 0 }) => { const Playhead: React.FC<PlayheadProps> = ({ context, pixelPositionOverride }) => {
const { timeSignature, playheadPosition } = useProjectStore(); const { timeSignature, playheadPosition } = useProjectStore();
// Calculate the pixel position based on context // Calculate the pixel position based on context
const getPixelPosition = (): number => { const getPixelPosition = (): number => {
if (typeof pixelPositionOverride === 'number') {
return pixelPositionOverride;
}
if (context === 'main-grid') { if (context === 'main-grid') {
// In main grid, convert beats to bars, then bars to pixels // In main grid, convert beats to bars, then bars to pixels
const beatsPerBar = timeSignature.numerator; const beatsPerBar = timeSignature.numerator;
@@ -79,4 +84,4 @@ const Playhead: React.FC<PlayheadProps> = ({ context, regionStartBeat = 0 }) =>
); );
}; };
export default Playhead; export default Playhead;
+125
View File
@@ -152,9 +152,16 @@
} }
.piano-roll-toolbar .tool-button svg { .piano-roll-toolbar .tool-button svg {
font-size: 14px;
transition: color 0.18s ease, fill 0.18s ease; 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 { .piano-roll-toolbar .tool-button:hover {
--toolbar-button-bg: #3a3a3a; --toolbar-button-bg: #3a3a3a;
--toolbar-button-fg: #e0e0e0; --toolbar-button-fg: #e0e0e0;
@@ -397,6 +404,124 @@
width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width)); 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 { .piano-keys-container {
width: var(--region-piano-key-width); width: var(--region-piano-key-width);
flex-shrink: 0; flex-shrink: 0;
+264 -42
View File
@@ -1,6 +1,5 @@
import React, { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo } from 'react'; import React, { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo } from 'react';
import './PianoRoll.css'; import './PianoRoll.css';
import type { MouseEvent } from 'react';
import { useProjectStore } from '../../stores/projectStore'; import { useProjectStore } from '../../stores/projectStore';
import { FaGripLines } from 'react-icons/fa'; import { FaGripLines } from 'react-icons/fa';
import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import { KGMidiRegion } from '../../core/region/KGMidiRegion';
@@ -12,6 +11,7 @@ import NoteAttributeBar from './NoteAttributeBar';
import PianoRollContent from './PianoRollContent'; import PianoRollContent from './PianoRollContent';
import { KGCore } from '../../core/KGCore'; import { KGCore } from '../../core/KGCore';
import { KGMidiNote } from '../../core/midi/KGMidiNote'; import { KGMidiNote } from '../../core/midi/KGMidiNote';
import { KGMidiTrack, type InstrumentType } from '../../core/track/KGMidiTrack';
import { KGPianoRollState } from '../../core/state/KGPianoRollState'; import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import { ConfigManager } from '../../core/config/ConfigManager'; import { ConfigManager } from '../../core/config/ConfigManager';
import { beatsToBar } from '../../util/midiUtil'; import { beatsToBar } from '../../util/midiUtil';
@@ -23,6 +23,23 @@ import {
type SpectrogramHeightResolution, type SpectrogramHeightResolution,
} from '../../util/spectrogramUtil'; } from '../../util/spectrogramUtil';
import type { PianoRollAutomationType } from './pianoRollAutomation'; 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 { interface PianoRollProps {
onClose: () => void; onClose: () => void;
@@ -62,6 +79,9 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const [pianoRollZoom, setPianoRollZoom] = useState<number>(1); const [pianoRollZoom, setPianoRollZoom] = useState<number>(1);
const [automationEnabled, setAutomationEnabled] = useState(false); const [automationEnabled, setAutomationEnabled] = useState(false);
const [automationType, setAutomationType] = useState<PianoRollAutomationType>('pitch-bend'); const [automationType, setAutomationType] = useState<PianoRollAutomationType>('pitch-bend');
const [sheetMusicViewEnabled, setSheetMusicViewEnabled] = useState(false);
const [sheetQuantization, setSheetQuantization] = useState('16,48');
const [sheetMeasureMetrics, setSheetMeasureMetrics] = useState<SheetMeasureMetric[]>([]);
// Quantization state // Quantization state
const [quantPosition, setQuantPosition] = useState<string>('1/8'); const [quantPosition, setQuantPosition] = useState<string>('1/8');
@@ -88,6 +108,20 @@ const PianoRoll: React.FC<PianoRollProps> = ({
() => activeRegion?.getNotes().filter(n => selectedNoteIds.includes(n.getId())) ?? [], () => activeRegion?.getNotes().filter(n => selectedNoteIds.includes(n.getId())) ?? [],
[activeRegion, selectedNoteIds] [activeRegion, selectedNoteIds]
); );
const parentMidiTrack = useMemo(() => {
if (!activeRegion) {
return null;
}
return tracks.find(track => track.getId().toString() === activeRegion.getTrackId()) ?? null;
}, [activeRegion, tracks]);
const activeInstrument = useMemo<InstrumentType>(() => (
parentMidiTrack instanceof KGMidiTrack ? parentMidiTrack.getInstrument() : 'acoustic_grand_piano'
), [parentMidiTrack]);
const parsedSheetQuantization = useMemo(
() => parseSheetQuantization(sheetQuantization),
[sheetQuantization]
);
const pianoRollRef = useRef<HTMLDivElement>(null); const pianoRollRef = useRef<HTMLDivElement>(null);
const pianoRollContentRef = useRef<HTMLDivElement>(null); const pianoRollContentRef = useRef<HTMLDivElement>(null);
@@ -99,6 +133,9 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const pianoRollExpectedScrollLeftRef = useRef<number>(-1); const pianoRollExpectedScrollLeftRef = useRef<number>(-1);
const pianoRollIsPlayingRef = useRef(false); const pianoRollIsPlayingRef = useRef(false);
const pendingZoomAnchorBeatRef = useRef<number | null>(null); const pendingZoomAnchorBeatRef = useRef<number | null>(null);
const pendingModeSwitchAnchorBeatRef = useRef<number | null>(null);
const previousSheetMusicViewEnabledRef = useRef<boolean>(false);
const previousActiveRegionIdRef = useRef<string | null>(null);
// Ref for storing the setNoteUpdateCounter function // Ref for storing the setNoteUpdateCounter function
const triggerNoteUpdateRef = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null); const triggerNoteUpdateRef = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null);
@@ -205,6 +242,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
setActiveTool(currentTool); setActiveTool(currentTool);
setAutomationEnabled(pianoRollState.getAutomationViewEnabled()); setAutomationEnabled(pianoRollState.getAutomationViewEnabled());
setAutomationType(pianoRollState.getCurrentAutomationType() as PianoRollAutomationType); setAutomationType(pianoRollState.getCurrentAutomationType() as PianoRollAutomationType);
setSheetMusicViewEnabled(pianoRollState.getSheetMusicViewEnabled());
setSheetQuantization(pianoRollState.getSheetQuantization());
if (DEBUG_MODE.PIANO_ROLL) { if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Synced piano roll state on mount - snap: ${currentSnap}, tool: ${currentTool}`); console.log(`Synced piano roll state on mount - snap: ${currentSnap}, tool: ${currentTool}`);
@@ -703,35 +742,80 @@ const PianoRoll: React.FC<PianoRollProps> = ({
KGPianoRollState.instance().setCurrentAutomationType(value); 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 // Calculate C4 position and scroll to it when piano roll opens
useEffect(() => { useEffect(() => {
if (pianoRollNoteScrollRef.current) { centerPianoRollOnDefaultVerticalPosition();
// Calculate position of C4 }, [centerPianoRollOnDefaultVerticalPosition]);
// 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
// Calculate from the bottom: useEffect(() => {
// - Octaves 0-3 = 4 octaves = 4 * 12 * piano key height const previous = previousSheetMusicViewEnabledRef.current;
// - 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
// Total height of all notes (8 octaves * 12 notes * piano key height) if (previous !== sheetMusicViewEnabled && !sheetMusicViewEnabled) {
const totalHeight = 8 * 12 * keyHeight; centerPianoRollOnDefaultVerticalPosition();
// 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);
} }
}, []);
previousSheetMusicViewEnabledRef.current = sheetMusicViewEnabled;
}, [centerPianoRollOnDefaultVerticalPosition, sheetMusicViewEnabled]);
// Sync isPlayingRef for use inside scroll event closure // Sync isPlayingRef for use inside scroll event closure
useEffect(() => { useEffect(() => {
@@ -762,15 +846,24 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const container = pianoRollNoteScrollRef.current; const container = pianoRollNoteScrollRef.current;
if (!container) return; if (!container) return;
const beatWidth = parseInt( const playheadPixel = sheetMusicViewEnabled && activeRegion
getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width') ? getSheetPlayheadPixel(
) || 40; Math.max(0, playheadPosition - activeRegion.getStartFromBeat()),
const playheadPixel = playheadPosition * beatWidth; 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) // Center the playhead in the visible grid area (excluding the 60px sticky piano keys panel)
const keysWidth = parseInt( const keysWidth = sheetMusicViewEnabled
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width') ? 0
) || 60; : (parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
) || 60);
const targetScrollLeft = playheadPixel - (container.clientWidth - keysWidth) / 2; const targetScrollLeft = playheadPixel - (container.clientWidth - keysWidth) / 2;
const clampedScrollLeft = Math.max( const clampedScrollLeft = Math.max(
0, 0,
@@ -779,7 +872,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
pianoRollExpectedScrollLeftRef.current = clampedScrollLeft; pianoRollExpectedScrollLeftRef.current = clampedScrollLeft;
container.scrollLeft = clampedScrollLeft; container.scrollLeft = clampedScrollLeft;
}, [playheadPosition, isPlaying, autoScrollEnabled]); }, [playheadPosition, isPlaying, autoScrollEnabled, sheetMusicViewEnabled, sheetMeasureMetrics, activeRegion]);
// Handle scroll requests from main content bar numbers clicks // Handle scroll requests from main content bar numbers clicks
useEffect(() => { useEffect(() => {
@@ -788,15 +881,24 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const container = pianoRollNoteScrollRef.current; const container = pianoRollNoteScrollRef.current;
if (!container) return; if (!container) return;
const beatWidth = parseInt( const playheadPixel = sheetMusicViewEnabled && activeRegion
getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width') ? getSheetPlayheadPixel(
) || 40; Math.max(0, pianoRollScrollRequest - activeRegion.getStartFromBeat()),
const playheadPixel = pianoRollScrollRequest * beatWidth; 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) // Center the playhead in the visible grid area (excluding the 60px sticky piano keys panel)
const keysWidth = parseInt( const keysWidth = sheetMusicViewEnabled
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width') ? 0
) || 60; : (parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
) || 60);
const targetScrollLeft = playheadPixel - (container.clientWidth - keysWidth) / 2; const targetScrollLeft = playheadPixel - (container.clientWidth - keysWidth) / 2;
const clampedScrollLeft = Math.max( const clampedScrollLeft = Math.max(
0, 0,
@@ -807,7 +909,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Clear the request after handling // Clear the request after handling
useProjectStore.setState({ pianoRollScrollRequest: null }); useProjectStore.setState({ pianoRollScrollRequest: null });
}, [pianoRollScrollRequest]); }, [pianoRollScrollRequest, sheetMusicViewEnabled, sheetMeasureMetrics, activeRegion]);
// Update --region-grid-beat-width when zoom changes and preserve the centered beat position. // Update --region-grid-beat-width when zoom changes and preserve the centered beat position.
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -843,7 +945,17 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Scroll horizontally to the active region's starting bar // Scroll horizontally to the active region's starting bar
useEffect(() => { 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 // Get the starting beat of the region
const startBeat = activeRegion.getStartFromBeat(); const startBeat = activeRegion.getStartFromBeat();
@@ -865,9 +977,34 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Scroll to the calculated position // Scroll to the calculated position
pianoRollNoteScrollRef.current.scrollLeft = Math.max(0, scrollPosition); pianoRollNoteScrollRef.current.scrollLeft = Math.max(0, scrollPosition);
previousActiveRegionIdRef.current = activeRegion.getId();
} }
}, [activeRegion, timeSignature]); }, [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) // Add keyboard event listener for piano roll hotkeys (snapping and quantization)
useEffect(() => { useEffect(() => {
const handlePianoRollKeyDown = (event: KeyboardEvent) => { const handlePianoRollKeyDown = (event: KeyboardEvent) => {
@@ -1065,6 +1202,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
/> />
<PianoRollToolbar <PianoRollToolbar
sheetMusicViewEnabled={sheetMusicViewEnabled}
onSheetMusicViewToggle={handleSheetMusicViewToggle}
sheetQuantization={sheetQuantization}
onSheetQuantizationChange={handleSheetQuantizationChange}
sheetQuantizationOptions={getSheetQuantizationOptions()}
activeTool={activeTool} activeTool={activeTool}
onToolSelect={handleToolSelect} onToolSelect={handleToolSelect}
quantPosition={quantPosition} quantPosition={quantPosition}
@@ -1119,6 +1261,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
automationEnabled={automationEnabled} automationEnabled={automationEnabled}
automationType={automationType} automationType={automationType}
automationRedrawVersion={automationRedrawVersion} automationRedrawVersion={automationRedrawVersion}
sheetMusicViewEnabled={sheetMusicViewEnabled}
sheetQuantization={parsedSheetQuantization}
sheetKeySignature={keySignature}
sheetInstrument={activeInstrument}
onSheetMeasureMetricsChange={handleSheetMeasureMetricsChange}
/> />
<div <div
@@ -1132,3 +1279,78 @@ const PianoRoll: React.FC<PianoRollProps> = ({
}; };
export default PianoRoll; 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));
}
@@ -4,6 +4,7 @@ import { render, screen } from '@testing-library/react';
import PianoRollContent from './PianoRollContent'; import PianoRollContent from './PianoRollContent';
import { createMockMidiRegion } from '../../test/utils/mock-data'; import { createMockMidiRegion } from '../../test/utils/mock-data';
import type { KeySignature } from '../../core/KGProject'; import type { KeySignature } from '../../core/KGProject';
import { parseSheetQuantization } from './sheetNotation';
vi.mock('../../stores/projectStore', () => ({ vi.mock('../../stores/projectStore', () => ({
useProjectStore: (selector: (state: { isRecording: boolean; recordingNotes: [] }) => unknown) => ( useProjectStore: (selector: (state: { isRecording: boolean; recordingNotes: [] }) => unknown) => (
@@ -48,6 +49,7 @@ vi.mock('./PianoKeys', () => ({ default: () => <div data-testid="piano-keys" />
vi.mock('./PianoGrid', () => ({ default: ({ children }: { children?: React.ReactNode }) => <div data-testid="piano-grid">{children}</div> })); vi.mock('./PianoGrid', () => ({ default: ({ children }: { children?: React.ReactNode }) => <div data-testid="piano-grid">{children}</div> }));
vi.mock('./PianoNote', () => ({ default: () => <div data-testid="piano-note" /> })); vi.mock('./PianoNote', () => ({ default: () => <div data-testid="piano-note" /> }));
vi.mock('./PianoRollAutomationLane', () => ({ default: () => <div data-testid="automation-lane" /> })); vi.mock('./PianoRollAutomationLane', () => ({ default: () => <div data-testid="automation-lane" /> }));
vi.mock('./SheetMusicView', () => ({ default: () => <div data-testid="sheet-music-view" /> }));
describe('PianoRollContent', () => { describe('PianoRollContent', () => {
const baseProps = { const baseProps = {
@@ -106,4 +108,21 @@ describe('PianoRollContent', () => {
expect(screen.getByTestId('piano-roll-content-single')).toBeInTheDocument(); expect(screen.getByTestId('piano-roll-content-single')).toBeInTheDocument();
expect(screen.queryByTestId('automation-lane')).not.toBeInTheDocument(); expect(screen.queryByTestId('automation-lane')).not.toBeInTheDocument();
}); });
it('renders sheet mode without piano keys or automation lane', () => {
render(
<PianoRollContent
{...baseProps}
mode="midi-edit"
automationEnabled={true}
automationType="cc-7"
sheetMusicViewEnabled={true}
sheetQuantization={parseSheetQuantization('16,48')}
/>
);
expect(screen.getByTestId('sheet-music-view')).toBeInTheDocument();
expect(screen.queryByTestId('piano-keys')).not.toBeInTheDocument();
expect(screen.queryByTestId('automation-lane')).not.toBeInTheDocument();
});
}); });
+72 -30
View File
@@ -17,6 +17,11 @@ import { velocityToColor } from '../../util/velocityColor';
import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil'; import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil';
import PianoRollAutomationLane from './PianoRollAutomationLane'; import PianoRollAutomationLane from './PianoRollAutomationLane';
import type { PianoRollAutomationType } from './pianoRollAutomation'; 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 { interface PianoRollContentProps {
contentRef: React.MutableRefObject<HTMLDivElement | null>; contentRef: React.MutableRefObject<HTMLDivElement | null>;
@@ -44,6 +49,11 @@ interface PianoRollContentProps {
automationEnabled?: boolean; automationEnabled?: boolean;
automationType?: PianoRollAutomationType; automationType?: PianoRollAutomationType;
automationRedrawVersion?: number; automationRedrawVersion?: number;
sheetMusicViewEnabled?: boolean;
sheetQuantization?: SheetQuantization;
sheetKeySignature?: KeySignature;
sheetInstrument?: InstrumentType;
onSheetMeasureMetricsChange?: (metrics: SheetMeasureMetric[]) => void;
} }
const PianoRollContent: React.FC<PianoRollContentProps> = ({ const PianoRollContent: React.FC<PianoRollContentProps> = ({
@@ -72,9 +82,14 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
automationEnabled = false, automationEnabled = false,
automationType = 'pitch-bend', automationType = 'pitch-bend',
automationRedrawVersion = 0, automationRedrawVersion = 0,
sheetMusicViewEnabled = false,
sheetQuantization,
sheetKeySignature = 'C major',
sheetInstrument = 'acoustic_grand_piano',
onSheetMeasureMetricsChange,
}) => { }) => {
const isSpectrogram = mode === 'spectrogram'; const isSpectrogram = mode === 'spectrogram';
const showAutomationLane = automationEnabled && !isSpectrogram; const showAutomationLane = automationEnabled && !isSpectrogram && !sheetMusicViewEnabled;
const [spectrogramLoading, setSpectrogramLoading] = useState(false); const [spectrogramLoading, setSpectrogramLoading] = useState(false);
const [noteScrollLeft, setNoteScrollLeft] = useState(0); const [noteScrollLeft, setNoteScrollLeft] = useState(0);
const handleSpectrogramLoadingChange = useCallback((loading: boolean) => { const handleSpectrogramLoadingChange = useCallback((loading: boolean) => {
@@ -191,7 +206,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
// Memoize the notes rendering to prevent unnecessary recalculations // Memoize the notes rendering to prevent unnecessary recalculations
const memoizedNotes = useMemo(() => { const memoizedNotes = useMemo(() => {
if (isSpectrogram || !activeRegion) return null; if (isSpectrogram || sheetMusicViewEnabled || !activeRegion) return null;
if (DEBUG_MODE.PIANO_ROLL) { if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Rendering notes for region: ${activeRegion.getId()}`); console.log(`Rendering notes for region: ${activeRegion.getId()}`);
@@ -268,7 +283,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
/> />
); );
}); });
}, [mode, activeRegion, noteUpdateCounter, resizingNoteId, draggingNoteId, tempNoteStyles, selectedNoteIds, selectionBoxRender, tracks]); }, [mode, activeRegion, noteUpdateCounter, resizingNoteId, draggingNoteId, tempNoteStyles, selectedNoteIds, selectionBoxRender, tracks, sheetMusicViewEnabled]);
const recordingNoteOverlays = useMemo(() => { const recordingNoteOverlays = useMemo(() => {
if (!isRecording || !activeRegion || recordingNotes.length === 0) return null; if (!isRecording || !activeRegion || recordingNotes.length === 0) return null;
@@ -291,6 +306,19 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
)); ));
}, [isRecording, recordingNotes, activeRegion]); }, [isRecording, recordingNotes, activeRegion]);
useEffect(() => {
if (!sheetMusicViewEnabled) {
return;
}
const container = noteScrollRef.current;
if (!container) {
return;
}
container.scrollTop = 0;
}, [noteScrollRef, sheetMusicViewEnabled]);
return ( return (
<div className="piano-roll-content-outer"> <div className="piano-roll-content-outer">
<div <div
@@ -304,33 +332,47 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
ref={noteScrollRef} ref={noteScrollRef}
onScroll={(event) => setNoteScrollLeft(event.currentTarget.scrollLeft)} onScroll={(event) => setNoteScrollLeft(event.currentTarget.scrollLeft)}
> >
<PianoGridHeader maxBars={maxBars} timeSignature={timeSignature} /> {!sheetMusicViewEnabled && (
<div className="piano-roll-body"> <PianoGridHeader maxBars={maxBars} timeSignature={timeSignature} />
<PianoKeys activeRegion={activeRegion} /> )}
<PianoGrid <div className={`piano-roll-body ${sheetMusicViewEnabled ? 'sheet-music-body' : ''}`}>
gridRef={pianoGridRef} {!sheetMusicViewEnabled && <PianoKeys activeRegion={activeRegion} />}
onDoubleClick={isSpectrogram ? () => {} : handleGridDoubleClick} {sheetMusicViewEnabled && activeRegion && sheetQuantization ? (
onClick={isSpectrogram ? () => {} : handleCombinedClick} <SheetMusicView
onMouseDown={isSpectrogram ? () => {} : handleBackgroundMouseDown} activeRegion={activeRegion}
isBoxSelecting={isSpectrogram ? false : isBoxSelectingRef.current} timeSignature={timeSignature}
selectionBox={isSpectrogram ? { startX: 0, startY: 0, endX: 0, endY: 0 } : selectionBoxRef.current} keySignature={sheetKeySignature}
regionStartBeat={activeRegion?.getStartFromBeat() || 0} instrument={sheetInstrument}
selectedMode={selectedMode} quantization={sheetQuantization}
keySignature={keySignature} noteScrollRef={noteScrollRef}
chordGuide={chordGuide} onMetricsChange={onSheetMeasureMetricsChange ?? NOOP_SHEET_METRICS_CHANGE}
audioRegion={audioRegion} />
trackId={trackId} ) : (
projectName={projectName} <PianoGrid
bpm={bpm} gridRef={pianoGridRef}
spectrogramThresholdDb={spectrogramThresholdDb} onDoubleClick={isSpectrogram ? () => {} : handleGridDoubleClick}
spectrogramPower={spectrogramPower} onClick={isSpectrogram ? () => {} : handleCombinedClick}
spectrogramHeightResolution={spectrogramHeightResolution} onMouseDown={isSpectrogram ? () => {} : handleBackgroundMouseDown}
pianoRollZoom={pianoRollZoom} isBoxSelecting={isSpectrogram ? false : isBoxSelectingRef.current}
onSpectrogramLoadingChange={handleSpectrogramLoadingChange} selectionBox={isSpectrogram ? { startX: 0, startY: 0, endX: 0, endY: 0 } : selectionBoxRef.current}
> regionStartBeat={activeRegion?.getStartFromBeat() || 0}
{memoizedNotes} selectedMode={selectedMode}
{!isSpectrogram && recordingNoteOverlays} keySignature={keySignature}
</PianoGrid> chordGuide={chordGuide}
audioRegion={audioRegion}
trackId={trackId}
projectName={projectName}
bpm={bpm}
spectrogramThresholdDb={spectrogramThresholdDb}
spectrogramPower={spectrogramPower}
spectrogramHeightResolution={spectrogramHeightResolution}
pianoRollZoom={pianoRollZoom}
onSpectrogramLoadingChange={handleSpectrogramLoadingChange}
>
{memoizedNotes}
{!isSpectrogram && recordingNoteOverlays}
</PianoGrid>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -40,6 +40,11 @@ vi.mock('../../core/KGCore', () => ({
describe('PianoRollToolbar', () => { describe('PianoRollToolbar', () => {
const baseProps = { const baseProps = {
sheetMusicViewEnabled: false,
onSheetMusicViewToggle: vi.fn(),
sheetQuantization: '16,48',
onSheetQuantizationChange: vi.fn(),
sheetQuantizationOptions: ['16,48', '32,96'],
activeTool: 'pointer' as const, activeTool: 'pointer' as const,
onToolSelect: vi.fn(), onToolSelect: vi.fn(),
quantPosition: '1/8', quantPosition: '1/8',
@@ -72,6 +77,7 @@ describe('PianoRollToolbar', () => {
expect(screen.getByRole('button', { name: 'Toggle automation lane' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Toggle automation lane' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Pitch Bend/i })).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' })); fireEvent.click(screen.getByRole('button', { name: 'Toggle automation lane' }));
expect(onAutomationToggle).toHaveBeenCalledTimes(1); 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: 'Toggle automation lane' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Pitch Bend/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Pitch Bend/i })).not.toBeInTheDocument();
}); });
it('shows only the sheet controls when sheet mode is enabled', () => {
render(
<PianoRollToolbar
{...baseProps}
sheetMusicViewEnabled={true}
mode="midi-edit"
/>
);
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();
});
}); });
+43 -3
View File
@@ -16,6 +16,11 @@ const POWER_OPTIONS = [
]; ];
interface PianoRollToolbarProps { interface PianoRollToolbarProps {
sheetMusicViewEnabled?: boolean;
onSheetMusicViewToggle?: () => void;
sheetQuantization?: string;
onSheetQuantizationChange?: (value: string) => void;
sheetQuantizationOptions?: string[];
activeTool: 'pointer' | 'pencil'; activeTool: 'pointer' | 'pencil';
onToolSelect: (tool: 'pointer' | 'pencil') => void; onToolSelect: (tool: 'pointer' | 'pencil') => void;
quantPosition: string; quantPosition: string;
@@ -43,6 +48,11 @@ interface PianoRollToolbarProps {
} }
const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
sheetMusicViewEnabled = false,
onSheetMusicViewToggle,
sheetQuantization = '16,48',
onSheetQuantizationChange,
sheetQuantizationOptions = [],
activeTool, activeTool,
onToolSelect, onToolSelect,
quantPosition, quantPosition,
@@ -68,9 +78,8 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
onAutomationToggle, onAutomationToggle,
onAutomationTypeChange, onAutomationTypeChange,
}) => { }) => {
const isSpectrogram = mode === 'spectrogram'; const showMidiControls = mode !== 'spectrogram' && !sheetMusicViewEnabled; // midi-edit and hybrid
const showMidiControls = mode !== 'spectrogram'; // midi-edit and hybrid const showSpecControls = !sheetMusicViewEnabled && (mode === 'spectrogram' || mode === 'hybrid');
const showSpecControls = mode === 'spectrogram' || mode === 'hybrid';
const [showZoomSlider, setShowZoomSlider] = React.useState(false); const [showZoomSlider, setShowZoomSlider] = React.useState(false);
const zoomSliderRef = React.useRef<HTMLDivElement>(null); const zoomSliderRef = React.useRef<HTMLDivElement>(null);
@@ -90,6 +99,14 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
<div className="piano-roll-toolbar"> <div className="piano-roll-toolbar">
{showMidiControls && ( {showMidiControls && (
<div className="toolbar-left"> <div className="toolbar-left">
<button
className={`tool-button sheet-mode-toggle ${sheetMusicViewEnabled ? 'active' : ''}`}
onClick={() => onSheetMusicViewToggle?.()}
title="Sheet Music View"
aria-label="Sheet Music View"
>
</button>
<button <button
className={`tool-button ${activeTool === 'pointer' ? 'active' : ''}`} className={`tool-button ${activeTool === 'pointer' ? 'active' : ''}`}
onClick={() => onToolSelect('pointer')} onClick={() => onToolSelect('pointer')}
@@ -148,7 +165,30 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
</div> </div>
)} )}
{sheetMusicViewEnabled && (
<div className="toolbar-left">
<button
className={`tool-button sheet-mode-toggle ${sheetMusicViewEnabled ? 'active' : ''}`}
onClick={() => onSheetMusicViewToggle?.()}
title="Sheet Music View"
aria-label="Sheet Music View"
>
</button>
</div>
)}
<div className="toolbar-right"> <div className="toolbar-right">
{sheetMusicViewEnabled && (
<KGDropdown
options={sheetQuantizationOptions}
value={sheetQuantization}
onChange={(value) => onSheetQuantizationChange?.(value)}
label="Sheet Quant."
buttonClassName="sheet-quantization"
showValueAsLabel={true}
/>
)}
{showMidiControls && ( {showMidiControls && (
<> <>
<KGDropdown <KGDropdown
@@ -0,0 +1,411 @@
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
import { Accidental, BarlineType, Beam, Dot, Formatter, Renderer, Stave, StaveNote, Voice } from 'vexflow';
import { Playhead } from '../common';
import { useProjectStore } from '../../stores/projectStore';
import type { KeySignature } from '../../core/KGProject';
import type { KGMidiRegion } from '../../core/region/KGMidiRegion';
import type { InstrumentType } from '../../core/track/KGMidiTrack';
import type { SheetMeasureMetric, SheetMeasureModel, SheetQuantization } from './sheetNotationTypes';
import {
buildSheetMeasureMetrics,
buildSheetMeasureModels,
getSheetPlayheadPixel,
projectKeySignatureToVexFlow,
resolveDurationSpec,
resolveSheetClef,
type SheetClef,
} from './sheetNotation';
interface SheetMusicViewProps {
activeRegion: KGMidiRegion | null;
timeSignature: { numerator: number; denominator: number };
keySignature: KeySignature;
instrument: InstrumentType;
quantization: SheetQuantization;
noteScrollRef: React.MutableRefObject<HTMLDivElement | null>;
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<SheetMusicViewProps> = ({
activeRegion,
timeSignature,
keySignature,
instrument,
quantization,
noteScrollRef,
onMetricsChange,
}) => {
const setPlayheadPosition = useProjectStore(state => state.setPlayheadPosition);
const requestMainContentScroll = useProjectStore(state => state.requestMainContentScroll);
const [metrics, setMetrics] = useState<SheetMeasureMetric[]>([]);
const [tiePaths, setTiePaths] = useState<SheetTiePath[]>([]);
const headerRef = useRef<HTMLDivElement | null>(null);
const measureHostRefs = useRef<Array<HTMLDivElement | null>>([]);
const lastDrawSignatureRef = useRef<string | null>(null);
const vexKeySignature = useMemo(() => projectKeySignatureToVexFlow(keySignature), [keySignature]);
const startingBarNumber = useMemo(() => (
activeRegion ? Math.floor(activeRegion.getStartFromBeat() / timeSignature.numerator) + 1 : 1
), [activeRegion, timeSignature.numerator]);
const measureModels = useMemo<SheetMeasureModel[]>(() => {
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<SheetClef>(() => {
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<HTMLDivElement>) => {
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 (
<div className="sheet-music-view" data-testid="sheet-music-view">
<div className="sheet-music-header" ref={headerRef} onClick={handleHeaderClick}>
{measureModels.map((measure, index) => (
<div
key={`sheet-header-${measure.barIndex}`}
className="sheet-music-bar-number"
style={{ width: metrics[index]?.widthPx ?? measureWidths[index] ?? MIN_MEASURE_WIDTH }}
>
{startingBarNumber + measure.barIndex}
</div>
))}
</div>
<div className="sheet-music-strip">
<div className="sheet-music-notice">
Sheet music view is under development and may not fully reflect the exact musical notation.
</div>
<div className="sheet-music-measures">
<svg
className="sheet-music-ties"
width={measureWidths.reduce((sum, width) => sum + width, 0)}
height={STAFF_HEIGHT}
viewBox={`0 0 ${measureWidths.reduce((sum, width) => sum + width, 0)} ${STAFF_HEIGHT}`}
aria-hidden="true"
>
{tiePaths.map((tiePath) => (
<path key={tiePath.id} d={tiePath.d} className="sheet-music-tie-path" />
))}
</svg>
<SheetMusicPlayhead activeRegion={activeRegion} metrics={metrics} />
{measureModels.map((measure, index) => (
<div
key={`sheet-measure-${measure.barIndex}`}
className="sheet-music-measure"
style={{
width: measureWidths[index],
height: STAFF_HEIGHT,
position: 'relative',
}}
>
<div
className="sheet-music-measure-host"
ref={(element) => {
measureHostRefs.current[index] = element;
}}
/>
</div>
))}
</div>
</div>
</div>
);
};
interface SheetMusicPlayheadProps {
activeRegion: KGMidiRegion | null;
metrics: SheetMeasureMetric[];
}
const SheetMusicPlayhead: React.FC<SheetMusicPlayheadProps> = 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 <Playhead context="piano-roll" pixelPositionOverride={playheadPixel} />;
});
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<number, RenderedSheetEvent[]>();
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));
}
@@ -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]);
});
});
+325
View File
@@ -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}`;
}
@@ -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[];
}
+18
View File
@@ -15,6 +15,8 @@ export class KGPianoRollState {
private currentMode: string = "ionian"; // Default mode private currentMode: string = "ionian"; // Default mode
private automationViewEnabled: boolean = false; private automationViewEnabled: boolean = false;
private currentAutomationType: string = "pitch-bend"; private currentAutomationType: string = "pitch-bend";
private sheetMusicViewEnabled: boolean = false;
private sheetQuantization: string = '16,48';
// Chord guide state // Chord guide state
private currentSuitableChords: Record<string, string[]> = {}; // Map of chord symbols to note names (e.g., {"I": ["C", "E", "G"]}) private currentSuitableChords: Record<string, string[]> = {}; // Map of chord symbols to note names (e.g., {"I": ["C", "E", "G"]})
@@ -83,6 +85,22 @@ export class KGPianoRollState {
this.currentAutomationType = type; 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<string, string[]> { public getCurrentSuitableChords(): Record<string, string[]> {
return this.currentSuitableChords; return this.currentSuitableChords;
} }