feat: added sheet music (stuff notation) view to the piano roll window MIDI mode
This commit is contained in:
Generated
+9
-2
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<PlayheadProps> = ({ context, regionStartBeat = 0 }) => {
|
||||
const Playhead: React.FC<PlayheadProps> = ({ 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PianoRollProps> = ({
|
||||
const [pianoRollZoom, setPianoRollZoom] = useState<number>(1);
|
||||
const [automationEnabled, setAutomationEnabled] = useState(false);
|
||||
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
|
||||
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, 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 pianoRollContentRef = useRef<HTMLDivElement>(null);
|
||||
@@ -99,6 +133,9 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
const pianoRollExpectedScrollLeftRef = useRef<number>(-1);
|
||||
const pianoRollIsPlayingRef = useRef(false);
|
||||
const pendingZoomAnchorBeatRef = useRef<number | null>(null);
|
||||
const pendingModeSwitchAnchorBeatRef = useRef<number | null>(null);
|
||||
const previousSheetMusicViewEnabledRef = useRef<boolean>(false);
|
||||
const previousActiveRegionIdRef = useRef<string | null>(null);
|
||||
|
||||
// Ref for storing the setNoteUpdateCounter function
|
||||
const triggerNoteUpdateRef = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null);
|
||||
@@ -205,6 +242,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
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<PianoRollProps> = ({
|
||||
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<PianoRollProps> = ({
|
||||
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<PianoRollProps> = ({
|
||||
|
||||
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<PianoRollProps> = ({
|
||||
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<PianoRollProps> = ({
|
||||
|
||||
// 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<PianoRollProps> = ({
|
||||
|
||||
// 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<PianoRollProps> = ({
|
||||
|
||||
// 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<PianoRollProps> = ({
|
||||
/>
|
||||
|
||||
<PianoRollToolbar
|
||||
sheetMusicViewEnabled={sheetMusicViewEnabled}
|
||||
onSheetMusicViewToggle={handleSheetMusicViewToggle}
|
||||
sheetQuantization={sheetQuantization}
|
||||
onSheetQuantizationChange={handleSheetQuantizationChange}
|
||||
sheetQuantizationOptions={getSheetQuantizationOptions()}
|
||||
activeTool={activeTool}
|
||||
onToolSelect={handleToolSelect}
|
||||
quantPosition={quantPosition}
|
||||
@@ -1119,6 +1261,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
automationEnabled={automationEnabled}
|
||||
automationType={automationType}
|
||||
automationRedrawVersion={automationRedrawVersion}
|
||||
sheetMusicViewEnabled={sheetMusicViewEnabled}
|
||||
sheetQuantization={parsedSheetQuantization}
|
||||
sheetKeySignature={keySignature}
|
||||
sheetInstrument={activeInstrument}
|
||||
onSheetMeasureMetricsChange={handleSheetMeasureMetricsChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -1132,3 +1279,78 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
};
|
||||
|
||||
export default PianoRoll;
|
||||
|
||||
function getVisibleCenterBeat({
|
||||
container,
|
||||
sheetMusicViewEnabled,
|
||||
sheetMeasureMetrics,
|
||||
activeRegionStartBeat,
|
||||
}: VisibleCenterBeatOptions): number {
|
||||
if (sheetMusicViewEnabled) {
|
||||
const centerPixel = container.scrollLeft + container.clientWidth / 2;
|
||||
return activeRegionStartBeat + getAbsoluteBeatForSheetPixel(centerPixel, sheetMeasureMetrics);
|
||||
}
|
||||
|
||||
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 { 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: () => <div data-testid="piano-keys" />
|
||||
vi.mock('./PianoGrid', () => ({ default: ({ children }: { children?: React.ReactNode }) => <div data-testid="piano-grid">{children}</div> }));
|
||||
vi.mock('./PianoNote', () => ({ default: () => <div data-testid="piano-note" /> }));
|
||||
vi.mock('./PianoRollAutomationLane', () => ({ default: () => <div data-testid="automation-lane" /> }));
|
||||
vi.mock('./SheetMusicView', () => ({ default: () => <div data-testid="sheet-music-view" /> }));
|
||||
|
||||
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(
|
||||
<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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<HTMLDivElement | null>;
|
||||
@@ -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<PianoRollContentProps> = ({
|
||||
@@ -72,9 +82,14 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
||||
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<PianoRollContentProps> = ({
|
||||
|
||||
// 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<PianoRollContentProps> = ({
|
||||
/>
|
||||
);
|
||||
});
|
||||
}, [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<PianoRollContentProps> = ({
|
||||
));
|
||||
}, [isRecording, recordingNotes, activeRegion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sheetMusicViewEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = noteScrollRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.scrollTop = 0;
|
||||
}, [noteScrollRef, sheetMusicViewEnabled]);
|
||||
|
||||
return (
|
||||
<div className="piano-roll-content-outer">
|
||||
<div
|
||||
@@ -304,33 +332,47 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
||||
ref={noteScrollRef}
|
||||
onScroll={(event) => setNoteScrollLeft(event.currentTarget.scrollLeft)}
|
||||
>
|
||||
<PianoGridHeader maxBars={maxBars} timeSignature={timeSignature} />
|
||||
<div className="piano-roll-body">
|
||||
<PianoKeys activeRegion={activeRegion} />
|
||||
<PianoGrid
|
||||
gridRef={pianoGridRef}
|
||||
onDoubleClick={isSpectrogram ? () => {} : 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}
|
||||
</PianoGrid>
|
||||
{!sheetMusicViewEnabled && (
|
||||
<PianoGridHeader maxBars={maxBars} timeSignature={timeSignature} />
|
||||
)}
|
||||
<div className={`piano-roll-body ${sheetMusicViewEnabled ? 'sheet-music-body' : ''}`}>
|
||||
{!sheetMusicViewEnabled && <PianoKeys activeRegion={activeRegion} />}
|
||||
{sheetMusicViewEnabled && activeRegion && sheetQuantization ? (
|
||||
<SheetMusicView
|
||||
activeRegion={activeRegion}
|
||||
timeSignature={timeSignature}
|
||||
keySignature={sheetKeySignature}
|
||||
instrument={sheetInstrument}
|
||||
quantization={sheetQuantization}
|
||||
noteScrollRef={noteScrollRef}
|
||||
onMetricsChange={onSheetMeasureMetricsChange ?? NOOP_SHEET_METRICS_CHANGE}
|
||||
/>
|
||||
) : (
|
||||
<PianoGrid
|
||||
gridRef={pianoGridRef}
|
||||
onDoubleClick={isSpectrogram ? () => {} : 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}
|
||||
</PianoGrid>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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(
|
||||
<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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<PianoRollToolbarProps> = ({
|
||||
sheetMusicViewEnabled = false,
|
||||
onSheetMusicViewToggle,
|
||||
sheetQuantization = '16,48',
|
||||
onSheetQuantizationChange,
|
||||
sheetQuantizationOptions = [],
|
||||
activeTool,
|
||||
onToolSelect,
|
||||
quantPosition,
|
||||
@@ -68,9 +78,8 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
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<HTMLDivElement>(null);
|
||||
@@ -90,6 +99,14 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
<div className="piano-roll-toolbar">
|
||||
{showMidiControls && (
|
||||
<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
|
||||
className={`tool-button ${activeTool === 'pointer' ? 'active' : ''}`}
|
||||
onClick={() => onToolSelect('pointer')}
|
||||
@@ -148,7 +165,30 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
||||
</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">
|
||||
{sheetMusicViewEnabled && (
|
||||
<KGDropdown
|
||||
options={sheetQuantizationOptions}
|
||||
value={sheetQuantization}
|
||||
onChange={(value) => onSheetQuantizationChange?.(value)}
|
||||
label="Sheet Quant."
|
||||
buttonClassName="sheet-quantization"
|
||||
showValueAsLabel={true}
|
||||
/>
|
||||
)}
|
||||
{showMidiControls && (
|
||||
<>
|
||||
<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]);
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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<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;
|
||||
}
|
||||
|
||||
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[]> {
|
||||
return this.currentSuitableChords;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user