Merge pull request #40 from KGAudioLab/feat/2026-05-09-staff-notation

Feat/2026 05 09 staff notation
This commit is contained in:
Xiaohan-Tian
2026-05-10 13:34:05 -07:00
committed by GitHub
22 changed files with 2305 additions and 109 deletions
+28 -1
View File
@@ -121,7 +121,34 @@ Who knows, maybe I'll kick start this project again? ;)
---
### 3. System Prompt Structure (based on Cline)
### 3. VexFlow
```
VexFlow - A JavaScript library for rendering music notation.
Copyright (c) 2010 Mohit Muthanna Cheppudira
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```
---
### 4. System Prompt Structure (based on Cline)
Portions of the system prompt structure are derived from the Cline project,
licensed under the Apache License, Version 2.0.
+3 -1
View File
@@ -23,6 +23,8 @@ K.G.Studio is a lightweight, modern DAW that runs entirely in the browser with *
## Latest Updates
- **2026.05.10**: Added **staff notation (sheet music) view** — the piano roll now offers a full standard notation mode. Switch between Piano Roll and Sheet Music views using the toggle in the piano roll toolbar. In sheet music mode, notes are engraved via VexFlow with automatic clef selection (treble or bass) based on the active instrument, key signature rendering, automatic beam grouping, ties across bar lines, and configurable quantization for note-value resolution. Enable **Track Scope** to render all MIDI regions on the track as a continuous score rather than a single isolated region.
- **2026.05.09**: Added **audio recording** — record directly from your microphone into an audio track. A live waveform preview grows in real time as you record, and the region is committed to the timeline as a standard audio region when you stop. Added **audio I/O device selection** in Settings so you can choose your preferred microphone input and audio output device.
- **2026.05.08**: Added **MIDI automation** — draw and edit pitch bend and MIDI CC curves (CC1 Modulation, CC2 Breath, CC7 Volume, CC11 Expression, CC64 Sustain) in an editable automation lane below the piano grid. Added **track-level automation**: each track now has a dedicated automation panel where you can view and edit the same curves directly on the timeline. Real-time MIDI controller input (pitch wheel, CC pedals) is recorded and played back with per-lane interpolation. Added the **Event List Panel** — a tabbed sidebar (Notes / Pitch Bend / Controller) for inspecting and inline-editing all events in the active MIDI region. Added **region multi-select** with lasso and bulk move/resize, and **merge MIDI regions**.
@@ -383,4 +385,4 @@ Licensed under the Apache License, Version 2.0, with additional terms (see `LICE
- No patent applications using this software or assets
- Attribution required when used in public/commercial products (“Powered by K.G.Studio”)
Thirdparty notices (e.g., FluidR3_GM SoundFont, midijssoundfonts, and prompt structure notes) are included in `LICENSE`.
Thirdparty notices (e.g., FluidR3_GM SoundFont, midijssoundfonts, VexFlow, and prompt structure notes) are included in `LICENSE`.
+9 -2
View File
@@ -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",
+1
View File
@@ -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": {
+41 -11
View File
@@ -36,7 +36,7 @@ import { clearChatHistoryAndUI } from '../util/chatUtil';
import PianoIcon from './common/icons/PianoIcon';
import MetronomeIcon from './common/icons/MetronomeIcon';
import { ConfigManager } from '../core/config/ConfigManager';
import { showAlert, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil';
import { showAlert, showChoice, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil';
const Toolbar: React.FC = () => {
const {
@@ -112,8 +112,28 @@ const Toolbar: React.FC = () => {
return;
}
// Conflict check: only relevant when targeting a different OPFS folder
if (newName !== savedProjectName) {
// If the name hasn't changed from the saved state, just save without prompting
if (newName === savedProjectName) {
setProjectName(newName);
await saveProject(newName, savedProjectName, setStatus, (finalName) => {
setSavedProjectName(finalName);
if (finalName !== newName) setProjectName(finalName);
});
return;
}
// Ask whether the user wants to rename or save as a copy
const choice = await showChoice(
"Would you like to rename this project, or save it as a new copy?",
[
{ label: 'Save as Copy', value: 'saveas' },
{ label: 'Rename', value: 'rename' },
]
);
if (!choice) return;
if (choice === 'rename') {
// Conflict check: only relevant when targeting a different OPFS folder
const storage = KGProjectStorage.getInstance();
const exists = await storage.exists(newName);
if (exists) {
@@ -121,7 +141,6 @@ const Toolbar: React.FC = () => {
`Project "${newName}" already exists. Do you want to overwrite it?`
);
if (!confirmed) return;
// Confirmed: update in-memory name then save immediately, overwriting the existing project
setProjectName(newName);
await saveProject(newName, savedProjectName, setStatus, (finalName) => {
setSavedProjectName(finalName);
@@ -129,14 +148,25 @@ const Toolbar: React.FC = () => {
}, true /* forceOverwrite */);
return;
}
setProjectName(newName);
await saveProject(newName, savedProjectName, setStatus, (finalName) => {
setSavedProjectName(finalName);
if (finalName !== newName) setProjectName(finalName);
});
} else {
// Save as Copy: save current state under a unique new name, then switch to it
const storage = KGProjectStorage.getInstance();
const finalName = await storage.resolveUniqueName(newName);
try {
await storage.saveAs(savedProjectName, finalName, KGCore.instance().getCurrentProject());
setProjectName(finalName);
setSavedProjectName(finalName);
setStatus(`Saved as "${finalName}"`);
} catch (error) {
console.error('Error saving project as copy:', error);
await showAlert(`An error occurred while saving: ${error}`);
}
}
// Name is available — update and save immediately
setProjectName(newName);
await saveProject(newName, savedProjectName, setStatus, (finalName) => {
setSavedProjectName(finalName);
if (finalName !== newName) setProjectName(finalName);
});
};
// Common project loading logic extracted for reuse
+11
View File
@@ -183,4 +183,15 @@
.dialog-btn-cancel:hover {
background-color: #4a4a4a;
color: #e0e0e0;
}
.dialog-btn-secondary {
background-color: transparent;
color: #5a9fd4;
border: 1px solid #5a9fd4;
}
.dialog-btn-secondary:hover {
background-color: rgba(90, 159, 212, 0.12);
transform: translateY(-1px);
}
+34 -12
View File
@@ -2,14 +2,15 @@ import React, { useState, useCallback, useRef } from 'react';
import './DialogProvider.css';
import { FaTimes } from 'react-icons/fa';
import { registerDialogFns } from '../../util/dialogUtil';
import type { ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';
import type { ChoiceOption, ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';
interface DialogInfo {
type: 'alert' | 'confirm' | 'prompt' | 'timesig';
type: 'alert' | 'confirm' | 'prompt' | 'timesig' | 'choice';
message: string;
options?: ConfirmOptions | PromptOptions;
defaultValue?: string;
defaultTimeSig?: TimeSigResult;
choices?: ChoiceOption[];
}
const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@@ -53,6 +54,13 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
});
}, []);
const openChoice = useCallback((message: string, choices: ChoiceOption[]): Promise<string | null> => {
return new Promise<string | null>((resolve) => {
resolveRef.current = resolve;
setDialog({ type: 'choice', message, choices });
});
}, []);
const close = useCallback((value: unknown) => {
pendingValueRef.current = value;
setIsClosing(true);
@@ -77,7 +85,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
const registered = useRef(false);
if (!registered.current) {
registered.current = true;
registerDialogFns(openAlert, openConfirm, openPrompt, openTimeSig);
registerDialogFns(openAlert, openConfirm, openPrompt, openTimeSig, openChoice);
}
if (!dialog) {
@@ -87,6 +95,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
const isAlert = dialog.type === 'alert';
const isPrompt = dialog.type === 'prompt';
const isTimeSig = dialog.type === 'timesig';
const isChoice = dialog.type === 'choice';
const promptOptions = isPrompt ? (dialog.options as PromptOptions | undefined) : undefined;
const title = isAlert ? 'Notice' : isTimeSig ? 'Time Signature' : isPrompt ? 'Input' : 'Confirm';
@@ -97,11 +106,11 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget && mouseDownOnOverlay.current) {
close(isAlert ? undefined : (isPrompt || isTimeSig) ? null : false);
close(isAlert ? undefined : (isPrompt || isTimeSig || isChoice) ? null : false);
}
};
const handleCancel = () => close(isAlert ? undefined : (isPrompt || isTimeSig) ? null : false);
const handleCancel = () => close(isAlert ? undefined : (isPrompt || isTimeSig || isChoice) ? null : false);
const handleConfirm = () => {
if (isAlert) { close(undefined); return; }
@@ -182,13 +191,26 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
{(dialog.options as ConfirmOptions | PromptOptions | undefined)?.cancelLabel ?? 'Cancel'}
</button>
)}
<button
className="dialog-btn dialog-btn-primary"
onClick={handleConfirm}
autoFocus={!isPrompt && !isTimeSig}
>
{isAlert ? 'OK' : ((dialog.options as ConfirmOptions | PromptOptions | undefined)?.confirmLabel ?? (isPrompt || isTimeSig ? 'OK' : 'Yes'))}
</button>
{isChoice ? (
dialog.choices?.map((choice, i) => (
<button
key={choice.value}
className={`dialog-btn ${i === (dialog.choices!.length - 1) ? 'dialog-btn-primary' : 'dialog-btn-secondary'}`}
onClick={() => close(choice.value)}
autoFocus={i === dialog.choices!.length - 1}
>
{choice.label}
</button>
))
) : (
<button
className="dialog-btn dialog-btn-primary"
onClick={handleConfirm}
autoFocus={!isPrompt && !isTimeSig}
>
{isAlert ? 'OK' : ((dialog.options as ConfirmOptions | PromptOptions | undefined)?.confirmLabel ?? (isPrompt || isTimeSig ? 'OK' : 'Yes'))}
</button>
)}
</div>
</div>
</div>
+8 -3
View File
@@ -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;
@@ -79,4 +84,4 @@ const Playhead: React.FC<PlayheadProps> = ({ context, regionStartBeat = 0 }) =>
);
};
export default Playhead;
export default Playhead;
+145 -1
View File
@@ -152,9 +152,35 @@
}
.piano-roll-toolbar .tool-button svg {
font-size: 14px;
transition: color 0.18s ease, fill 0.18s ease;
}
.piano-roll-toolbar .sheet-track-scope-toggle {
width: 20px;
min-width: 20px;
height: 20px;
}
.piano-roll-toolbar .sheet-track-scope-toggle .sheet-track-scope-icon {
width: 12px;
height: 12px;
stroke-width: 2.5;
overflow: visible;
}
.piano-roll-toolbar .piano-roll-tool-icon {
width: 12px;
height: 12px;
overflow: visible;
}
.piano-roll-toolbar .tool-button.sheet-mode-toggle,
.piano-roll-toolbar .tool-button:not(.icon-only) {
font-size: 13px;
line-height: 1;
}
.piano-roll-toolbar .tool-button:hover {
--toolbar-button-bg: #3a3a3a;
--toolbar-button-fg: #e0e0e0;
@@ -397,6 +423,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;
@@ -658,4 +802,4 @@
color: #e0e0e0;
min-width: 20px;
text-align: center;
}
}
+262
View File
@@ -0,0 +1,262 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../stores/projectStore', () => ({
useProjectStore: Object.assign(
(selector: (state: Record<string, unknown>) => unknown) => selector({
maxBars: 8,
tracks: [],
updateTrack: vi.fn(),
timeSignature: { numerator: 4, denominator: 4 },
showChatBox: false,
showKGOnePanel: false,
showEventListPanel: false,
showInstrumentSelection: false,
keySignature: 'C major',
selectedMode: 'ionian',
setSelectedMode: vi.fn(),
playheadPosition: 0,
isPlaying: false,
autoScrollEnabled: false,
bpm: 120,
pianoRollScrollRequest: null,
selectedNoteIds: [],
automationRedrawVersion: 0,
}),
{
getState: () => ({
setAutoScrollEnabled: vi.fn(),
}),
setState: vi.fn(),
}
),
}));
import {
createPendingModeSwitchRequest,
getRegionPlayheadRelation,
getScrollLeftForViewportRequest,
} from './PianoRoll';
import type { SheetMeasureMetric } from './sheetNotationTypes';
function createContainer({ clientWidth, scrollWidth }: { clientWidth: number; scrollWidth: number }): HTMLDivElement {
return {
clientWidth,
scrollWidth,
} as HTMLDivElement;
}
describe('PianoRoll viewport switch helpers', () => {
beforeEach(() => {
document.documentElement.style.setProperty('--region-grid-beat-width', '40px');
document.documentElement.style.setProperty('--region-piano-key-width', '60px');
});
it('classifies playhead position relative to the active region', () => {
expect(getRegionPlayheadRelation(15, 16, 24)).toBe('before');
expect(getRegionPlayheadRelation(20, 16, 24)).toBe('inside');
expect(getRegionPlayheadRelation(25, 16, 24)).toBe('after');
});
it('centers an in-region playhead when switching to region-scope sheet view', () => {
const request = createPendingModeSwitchRequest({
playheadBeat: 20,
regionStartBeat: 16,
regionEndBeat: 24,
sourceSheetMusicViewEnabled: false,
destinationSheetMusicViewEnabled: true,
destinationSheetMusicTrackScopeEnabled: false,
});
expect(request).toMatchObject({
alignment: 'center',
anchorBeat: 20,
clampScope: 'region',
});
const metrics: SheetMeasureMetric[] = [
{ barIndex: 0, startBeat: 0, endBeat: 4, leftPx: 0, widthPx: 200 },
{ barIndex: 1, startBeat: 4, endBeat: 8, leftPx: 200, widthPx: 200 },
];
const scrollLeft = getScrollLeftForViewportRequest({
request,
container: createContainer({ clientWidth: 200, scrollWidth: 400 }),
sheetMeasureMetrics: metrics,
activeRegionStartBeat: 16,
activeRegionEndBeat: 24,
songEndBeat: 32,
});
expect(scrollLeft).toBe(100);
});
it('snaps to the region start when the playhead is before the active region', () => {
const request = createPendingModeSwitchRequest({
playheadBeat: 12,
regionStartBeat: 16,
regionEndBeat: 24,
sourceSheetMusicViewEnabled: true,
destinationSheetMusicViewEnabled: false,
destinationSheetMusicTrackScopeEnabled: false,
});
expect(request).toMatchObject({
alignment: 'region-start',
anchorBeat: 16,
clampScope: 'region',
});
const scrollLeft = getScrollLeftForViewportRequest({
request,
container: createContainer({ clientWidth: 260, scrollWidth: 2000 }),
sheetMeasureMetrics: [],
activeRegionStartBeat: 16,
activeRegionEndBeat: 24,
songEndBeat: 32,
});
expect(scrollLeft).toBe(640);
});
it('snaps to the region end when the playhead is after the active region', () => {
const request = createPendingModeSwitchRequest({
playheadBeat: 28,
regionStartBeat: 16,
regionEndBeat: 24,
sourceSheetMusicViewEnabled: true,
destinationSheetMusicViewEnabled: true,
destinationSheetMusicTrackScopeEnabled: false,
});
expect(request).toMatchObject({
alignment: 'region-end',
anchorBeat: 24,
clampScope: 'region',
});
const metrics: SheetMeasureMetric[] = [
{ barIndex: 0, startBeat: 0, endBeat: 4, leftPx: 0, widthPx: 200 },
{ barIndex: 1, startBeat: 4, endBeat: 8, leftPx: 200, widthPx: 200 },
];
const scrollLeft = getScrollLeftForViewportRequest({
request,
container: createContainer({ clientWidth: 200, scrollWidth: 400 }),
sheetMeasureMetrics: metrics,
activeRegionStartBeat: 16,
activeRegionEndBeat: 24,
songEndBeat: 32,
});
expect(scrollLeft).toBe(200);
});
it('uses the track-scope special case only when entering sheet music from piano roll', () => {
const specialCaseRequest = createPendingModeSwitchRequest({
playheadBeat: 28,
regionStartBeat: 16,
regionEndBeat: 24,
sourceSheetMusicViewEnabled: false,
destinationSheetMusicViewEnabled: true,
destinationSheetMusicTrackScopeEnabled: true,
});
const regularTrackScopeRequest = createPendingModeSwitchRequest({
playheadBeat: 28,
regionStartBeat: 16,
regionEndBeat: 24,
sourceSheetMusicViewEnabled: true,
destinationSheetMusicViewEnabled: true,
destinationSheetMusicTrackScopeEnabled: true,
});
expect(specialCaseRequest).toMatchObject({
alignment: 'center',
anchorBeat: 28,
clampScope: 'track',
});
expect(regularTrackScopeRequest).toMatchObject({
alignment: 'region-end',
anchorBeat: 24,
clampScope: 'region',
});
});
it('treats sheet-music to piano-roll switches as region-scoped even when source sheet view was track-scoped', () => {
const request = createPendingModeSwitchRequest({
playheadBeat: 20,
regionStartBeat: 16,
regionEndBeat: 24,
sourceSheetMusicViewEnabled: true,
destinationSheetMusicViewEnabled: false,
destinationSheetMusicTrackScopeEnabled: false,
});
expect(request).toMatchObject({
alignment: 'center',
anchorBeat: 20,
clampScope: 'region',
});
});
it('clamps centered piano-roll scroll at the start and end of the active region', () => {
const container = createContainer({ clientWidth: 260, scrollWidth: 2000 });
const startClamp = getScrollLeftForViewportRequest({
request: {
sourceSheetMusicViewEnabled: false,
destinationSheetMusicViewEnabled: false,
destinationSheetMusicTrackScopeEnabled: false,
alignment: 'center',
anchorBeat: 16,
clampScope: 'region',
},
container,
sheetMeasureMetrics: [],
activeRegionStartBeat: 16,
activeRegionEndBeat: 24,
songEndBeat: 32,
});
const endClamp = getScrollLeftForViewportRequest({
request: {
sourceSheetMusicViewEnabled: false,
destinationSheetMusicViewEnabled: false,
destinationSheetMusicTrackScopeEnabled: false,
alignment: 'center',
anchorBeat: 24,
clampScope: 'region',
},
container,
sheetMeasureMetrics: [],
activeRegionStartBeat: 16,
activeRegionEndBeat: 24,
songEndBeat: 32,
});
expect(startClamp).toBe(640);
expect(endClamp).toBe(760);
});
it('centers the playhead in track-scope sheet view with song-bound clamping', () => {
const request = createPendingModeSwitchRequest({
playheadBeat: 14,
regionStartBeat: 16,
regionEndBeat: 24,
sourceSheetMusicViewEnabled: false,
destinationSheetMusicViewEnabled: true,
destinationSheetMusicTrackScopeEnabled: true,
});
const metrics: SheetMeasureMetric[] = [
{ barIndex: 0, startBeat: 0, endBeat: 4, leftPx: 0, widthPx: 200 },
{ barIndex: 1, startBeat: 4, endBeat: 8, leftPx: 200, widthPx: 200 },
{ barIndex: 2, startBeat: 8, endBeat: 12, leftPx: 400, widthPx: 200 },
{ barIndex: 3, startBeat: 12, endBeat: 16, leftPx: 600, widthPx: 200 },
];
const scrollLeft = getScrollLeftForViewportRequest({
request,
container: createContainer({ clientWidth: 200, scrollWidth: 800 }),
sheetMeasureMetrics: metrics,
activeRegionStartBeat: 16,
activeRegionEndBeat: 24,
songEndBeat: 16,
});
expect(scrollLeft).toBe(600);
});
});
+458 -42
View File
@@ -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,39 @@ import {
type SpectrogramHeightResolution,
} from '../../util/spectrogramUtil';
import type { PianoRollAutomationType } from './pianoRollAutomation';
import type { SheetMeasureMetric } from './sheetNotationTypes';
import { getSheetPlayheadPixel, getSheetQuantizationOptions, parseSheetQuantization } from './sheetNotation';
type RegionPlayheadRelation = 'before' | 'inside' | 'after';
type ViewportSwitchAlignment = 'center' | 'region-start' | 'region-end';
type ViewportClampScope = 'region' | 'track';
interface PendingModeSwitchRequest {
sourceSheetMusicViewEnabled: boolean;
destinationSheetMusicViewEnabled: boolean;
destinationSheetMusicTrackScopeEnabled: boolean;
alignment: ViewportSwitchAlignment;
anchorBeat: number;
clampScope: ViewportClampScope;
}
interface ModeSwitchRequestOptions {
playheadBeat: number;
regionStartBeat: number;
regionEndBeat: number;
sourceSheetMusicViewEnabled: boolean;
destinationSheetMusicViewEnabled: boolean;
destinationSheetMusicTrackScopeEnabled: boolean;
}
interface ScrollLeftForViewportRequestOptions {
request: PendingModeSwitchRequest;
container: HTMLDivElement;
sheetMeasureMetrics: SheetMeasureMetric[];
activeRegionStartBeat: number;
activeRegionEndBeat: number;
songEndBeat: number;
}
interface PianoRollProps {
onClose: () => void;
@@ -62,6 +95,10 @@ 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 [sheetMusicTrackScopeEnabled, setSheetMusicTrackScopeEnabled] = useState(false);
const [sheetQuantization, setSheetQuantization] = useState('16,48');
const [sheetMeasureMetrics, setSheetMeasureMetrics] = useState<SheetMeasureMetric[]>([]);
// Quantization state
const [quantPosition, setQuantPosition] = useState<string>('1/8');
@@ -88,6 +125,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 +150,9 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const pianoRollExpectedScrollLeftRef = useRef<number>(-1);
const pianoRollIsPlayingRef = useRef(false);
const pendingZoomAnchorBeatRef = useRef<number | null>(null);
const pendingModeSwitchRequestRef = useRef<PendingModeSwitchRequest | 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 +259,9 @@ const PianoRoll: React.FC<PianoRollProps> = ({
setActiveTool(currentTool);
setAutomationEnabled(pianoRollState.getAutomationViewEnabled());
setAutomationType(pianoRollState.getCurrentAutomationType() as PianoRollAutomationType);
setSheetMusicViewEnabled(pianoRollState.getSheetMusicViewEnabled());
setSheetMusicTrackScopeEnabled(pianoRollState.getSheetMusicTrackScopeEnabled());
setSheetQuantization(pianoRollState.getSheetQuantization());
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Synced piano roll state on mount - snap: ${currentSnap}, tool: ${currentTool}`);
@@ -703,35 +760,101 @@ const PianoRoll: React.FC<PianoRollProps> = ({
KGPianoRollState.instance().setCurrentAutomationType(value);
}, []);
const handleSheetMusicViewToggle = useCallback(() => {
if (activeRegion) {
pendingModeSwitchRequestRef.current = createPendingModeSwitchRequest({
playheadBeat: playheadPosition,
regionStartBeat: activeRegion.getStartFromBeat(),
regionEndBeat: activeRegion.getStartFromBeat() + activeRegion.getLength(),
sourceSheetMusicViewEnabled: sheetMusicViewEnabled,
destinationSheetMusicViewEnabled: !sheetMusicViewEnabled,
destinationSheetMusicTrackScopeEnabled: !sheetMusicViewEnabled && sheetMusicTrackScopeEnabled,
});
} else {
pendingModeSwitchRequestRef.current = null;
}
setSheetMusicViewEnabled(current => {
const next = !current;
KGPianoRollState.instance().setSheetMusicViewEnabled(next);
return next;
});
}, [activeRegion, playheadPosition, sheetMusicTrackScopeEnabled, sheetMusicViewEnabled]);
const handleSheetQuantizationChange = useCallback((value: string) => {
setSheetQuantization(value);
KGPianoRollState.instance().setSheetQuantization(value);
}, []);
const handleSheetMusicTrackScopeToggle = useCallback(() => {
if (activeRegion) {
pendingModeSwitchRequestRef.current = createPendingModeSwitchRequest({
playheadBeat: playheadPosition,
regionStartBeat: activeRegion.getStartFromBeat(),
regionEndBeat: activeRegion.getStartFromBeat() + activeRegion.getLength(),
sourceSheetMusicViewEnabled: sheetMusicViewEnabled,
destinationSheetMusicViewEnabled: sheetMusicViewEnabled,
destinationSheetMusicTrackScopeEnabled: !sheetMusicTrackScopeEnabled,
});
} else {
pendingModeSwitchRequestRef.current = null;
}
setSheetMusicTrackScopeEnabled(current => {
const next = !current;
KGPianoRollState.instance().setSheetMusicTrackScopeEnabled(next);
return next;
});
}, [activeRegion, playheadPosition, sheetMusicTrackScopeEnabled, sheetMusicViewEnabled]);
const handleSheetMeasureMetricsChange = useCallback((metrics: SheetMeasureMetric[]) => {
setSheetMeasureMetrics((current) => {
if (
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 +885,26 @@ 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(
sheetMusicTrackScopeEnabled
? Math.max(0, playheadPosition)
: 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 +913,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
pianoRollExpectedScrollLeftRef.current = clampedScrollLeft;
container.scrollLeft = clampedScrollLeft;
}, [playheadPosition, isPlaying, autoScrollEnabled]);
}, [playheadPosition, isPlaying, autoScrollEnabled, sheetMusicViewEnabled, sheetMusicTrackScopeEnabled, sheetMeasureMetrics, activeRegion]);
// Handle scroll requests from main content bar numbers clicks
useEffect(() => {
@@ -788,15 +922,26 @@ 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(
sheetMusicTrackScopeEnabled
? Math.max(0, pianoRollScrollRequest)
: 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 +952,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Clear the request after handling
useProjectStore.setState({ pianoRollScrollRequest: null });
}, [pianoRollScrollRequest]);
}, [pianoRollScrollRequest, sheetMusicViewEnabled, sheetMusicTrackScopeEnabled, sheetMeasureMetrics, activeRegion]);
// Update --region-grid-beat-width when zoom changes and preserve the centered beat position.
useLayoutEffect(() => {
@@ -843,7 +988,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 (pendingModeSwitchRequestRef.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 +1020,35 @@ 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 request = pendingModeSwitchRequestRef.current;
const container = pianoRollNoteScrollRef.current;
if (!request || !container || !activeRegion) {
return;
}
if (request.destinationSheetMusicViewEnabled && sheetMeasureMetrics.length === 0) {
return;
}
const targetScrollLeft = getScrollLeftForViewportRequest({
request,
container,
sheetMeasureMetrics,
activeRegionStartBeat: activeRegion.getStartFromBeat(),
activeRegionEndBeat: activeRegion.getStartFromBeat() + activeRegion.getLength(),
songEndBeat: maxBars * timeSignature.numerator,
});
pianoRollExpectedScrollLeftRef.current = targetScrollLeft;
container.scrollLeft = targetScrollLeft;
pendingModeSwitchRequestRef.current = null;
}, [activeRegion, maxBars, sheetMeasureMetrics, sheetMusicTrackScopeEnabled, sheetMusicViewEnabled, timeSignature.numerator]);
// Add keyboard event listener for piano roll hotkeys (snapping and quantization)
useEffect(() => {
const handlePianoRollKeyDown = (event: KeyboardEvent) => {
@@ -1065,6 +1246,13 @@ const PianoRoll: React.FC<PianoRollProps> = ({
/>
<PianoRollToolbar
sheetMusicViewEnabled={sheetMusicViewEnabled}
onSheetMusicViewToggle={handleSheetMusicViewToggle}
sheetMusicTrackScopeEnabled={sheetMusicTrackScopeEnabled}
onSheetMusicTrackScopeToggle={handleSheetMusicTrackScopeToggle}
sheetQuantization={sheetQuantization}
onSheetQuantizationChange={handleSheetQuantizationChange}
sheetQuantizationOptions={getSheetQuantizationOptions()}
activeTool={activeTool}
onToolSelect={handleToolSelect}
quantPosition={quantPosition}
@@ -1119,6 +1307,12 @@ const PianoRoll: React.FC<PianoRollProps> = ({
automationEnabled={automationEnabled}
automationType={automationType}
automationRedrawVersion={automationRedrawVersion}
sheetMusicViewEnabled={sheetMusicViewEnabled}
sheetMusicTrackScopeEnabled={sheetMusicTrackScopeEnabled}
sheetQuantization={parsedSheetQuantization}
sheetKeySignature={keySignature}
sheetInstrument={activeInstrument}
onSheetMeasureMetricsChange={handleSheetMeasureMetricsChange}
/>
<div
@@ -1132,3 +1326,225 @@ const PianoRoll: React.FC<PianoRollProps> = ({
};
export default PianoRoll;
export function getRegionPlayheadRelation(
playheadBeat: number,
regionStartBeat: number,
regionEndBeat: number
): RegionPlayheadRelation {
if (playheadBeat < regionStartBeat) {
return 'before';
}
if (playheadBeat > regionEndBeat) {
return 'after';
}
return 'inside';
}
export function createPendingModeSwitchRequest({
playheadBeat,
regionStartBeat,
regionEndBeat,
sourceSheetMusicViewEnabled,
destinationSheetMusicViewEnabled,
destinationSheetMusicTrackScopeEnabled,
}: ModeSwitchRequestOptions): PendingModeSwitchRequest {
const relation = getRegionPlayheadRelation(playheadBeat, regionStartBeat, regionEndBeat);
const enteringTrackScopeSheet = (
!sourceSheetMusicViewEnabled &&
destinationSheetMusicViewEnabled &&
destinationSheetMusicTrackScopeEnabled
);
if (relation === 'inside') {
return {
sourceSheetMusicViewEnabled,
destinationSheetMusicViewEnabled,
destinationSheetMusicTrackScopeEnabled,
alignment: 'center',
anchorBeat: playheadBeat,
clampScope: destinationSheetMusicTrackScopeEnabled ? 'track' : 'region',
};
}
if (enteringTrackScopeSheet) {
return {
sourceSheetMusicViewEnabled,
destinationSheetMusicViewEnabled,
destinationSheetMusicTrackScopeEnabled,
alignment: 'center',
anchorBeat: playheadBeat,
clampScope: 'track',
};
}
return {
sourceSheetMusicViewEnabled,
destinationSheetMusicViewEnabled,
destinationSheetMusicTrackScopeEnabled,
alignment: relation === 'before' ? 'region-start' : 'region-end',
anchorBeat: relation === 'before' ? regionStartBeat : regionEndBeat,
clampScope: 'region',
};
}
function getHorizontalViewportMetrics(container: HTMLDivElement, sheetMusicViewEnabled: boolean): {
visibleWidth: number;
keysWidth: number;
} {
const keysWidth = sheetMusicViewEnabled
? 0
: (parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
) || 60);
return {
visibleWidth: Math.max(0, container.clientWidth - keysWidth),
keysWidth,
};
}
function getPixelForAbsoluteBeat(
beat: number,
sheetMusicViewEnabled: boolean,
sheetMusicTrackScopeEnabled: boolean,
sheetMeasureMetrics: SheetMeasureMetric[],
activeRegionStartBeat: number
): number {
if (sheetMusicViewEnabled) {
return getSheetPlayheadPixel(
sheetMusicTrackScopeEnabled
? Math.max(0, beat)
: Math.max(0, beat - activeRegionStartBeat),
sheetMeasureMetrics
);
}
const beatWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')
) || 40;
return beat * beatWidth;
}
function getScopeBoundsInPixels(
request: PendingModeSwitchRequest,
sheetMeasureMetrics: SheetMeasureMetric[],
activeRegionStartBeat: number,
activeRegionEndBeat: number,
songEndBeat: number
): { startPx: number; endPx: number } {
if (request.destinationSheetMusicViewEnabled) {
if (request.clampScope === 'track') {
return {
startPx: getSheetPlayheadPixel(0, sheetMeasureMetrics),
endPx: getSheetPlayheadPixel(songEndBeat, sheetMeasureMetrics),
};
}
return {
startPx: getSheetPlayheadPixel(0, sheetMeasureMetrics),
endPx: getSheetPlayheadPixel(activeRegionEndBeat - activeRegionStartBeat, sheetMeasureMetrics),
};
}
const beatWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')
) || 40;
if (request.clampScope === 'track') {
return {
startPx: 0,
endPx: songEndBeat * beatWidth,
};
}
return {
startPx: activeRegionStartBeat * beatWidth,
endPx: activeRegionEndBeat * beatWidth,
};
}
function clampScrollLeftToContainer(container: HTMLDivElement, scrollLeft: number): number {
return Math.max(0, Math.min(scrollLeft, container.scrollWidth - container.clientWidth));
}
function getCenteredScrollLeft({
pixelPosition,
visibleWidth,
scopeStartPx,
scopeEndPx,
container,
}: {
pixelPosition: number;
visibleWidth: number;
scopeStartPx: number;
scopeEndPx: number;
container: HTMLDivElement;
}): number {
const unclamped = pixelPosition - visibleWidth / 2;
const maxScopeScrollLeft = Math.max(scopeStartPx, scopeEndPx - visibleWidth);
const clampedToScope = Math.max(scopeStartPx, Math.min(unclamped, maxScopeScrollLeft));
return clampScrollLeftToContainer(container, clampedToScope);
}
function getRegionEndAlignedScrollLeft({
visibleWidth,
scopeEndPx,
container,
}: {
visibleWidth: number;
scopeEndPx: number;
container: HTMLDivElement;
}): number {
return clampScrollLeftToContainer(container, Math.max(0, scopeEndPx - visibleWidth));
}
export function getScrollLeftForViewportRequest({
request,
container,
sheetMeasureMetrics,
activeRegionStartBeat,
activeRegionEndBeat,
songEndBeat,
}: ScrollLeftForViewportRequestOptions): number {
const { visibleWidth } = getHorizontalViewportMetrics(
container,
request.destinationSheetMusicViewEnabled
);
const pixelPosition = getPixelForAbsoluteBeat(
request.anchorBeat,
request.destinationSheetMusicViewEnabled,
request.destinationSheetMusicTrackScopeEnabled,
sheetMeasureMetrics,
activeRegionStartBeat
);
const { startPx, endPx } = getScopeBoundsInPixels(
request,
sheetMeasureMetrics,
activeRegionStartBeat,
activeRegionEndBeat,
songEndBeat
);
if (request.alignment === 'region-start') {
return clampScrollLeftToContainer(container, startPx);
}
if (request.alignment === 'region-end') {
return getRegionEndAlignedScrollLeft({
visibleWidth,
scopeEndPx: endPx,
container,
});
}
return getCenteredScrollLeft({
pixelPosition,
visibleWidth,
scopeStartPx: startPx,
scopeEndPx: endPx,
container,
});
}
@@ -1,9 +1,10 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import PianoRollContent from './PianoRollContent';
import { createMockMidiRegion } from '../../test/utils/mock-data';
import type { KeySignature } from '../../core/KGProject';
import { parseSheetQuantization } from './sheetNotation';
vi.mock('../../stores/projectStore', () => ({
useProjectStore: (selector: (state: { isRecording: boolean; recordingNotes: [] }) => unknown) => (
@@ -48,6 +49,14 @@ 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" /> }));
const sheetMusicViewSpy = vi.fn();
vi.mock('./SheetMusicView', () => ({
default: (props: unknown) => {
sheetMusicViewSpy(props);
return <div data-testid="sheet-music-view" />;
},
}));
describe('PianoRollContent', () => {
const baseProps = {
@@ -65,6 +74,10 @@ describe('PianoRollContent', () => {
bpm: 120,
};
beforeEach(() => {
sheetMusicViewSpy.mockClear();
});
it('keeps the single-pane layout when automation is disabled', () => {
render(
<PianoRollContent
@@ -106,4 +119,26 @@ 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}
sheetMusicTrackScopeEnabled={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();
expect(sheetMusicViewSpy).toHaveBeenCalledWith(expect.objectContaining({
sheetMusicTrackScopeEnabled: true,
maxBars: 8,
}));
});
});
+79 -30
View File
@@ -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,12 @@ interface PianoRollContentProps {
automationEnabled?: boolean;
automationType?: PianoRollAutomationType;
automationRedrawVersion?: number;
sheetMusicViewEnabled?: boolean;
sheetMusicTrackScopeEnabled?: boolean;
sheetQuantization?: SheetQuantization;
sheetKeySignature?: KeySignature;
sheetInstrument?: InstrumentType;
onSheetMeasureMetricsChange?: (metrics: SheetMeasureMetric[]) => void;
}
const PianoRollContent: React.FC<PianoRollContentProps> = ({
@@ -72,9 +83,15 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
automationEnabled = false,
automationType = 'pitch-bend',
automationRedrawVersion = 0,
sheetMusicViewEnabled = false,
sheetMusicTrackScopeEnabled = 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 +208,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 +285,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 +308,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 +334,52 @@ 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}
midiRegions={tracks.filter(track => track.getId().toString() === activeRegion?.getTrackId()).flatMap(track => (
track.getRegions().filter(region => region instanceof KGMidiRegion)
)) as KGMidiRegion[]}
maxBars={maxBars}
sheetMusicTrackScopeEnabled={sheetMusicTrackScopeEnabled}
timeSignature={timeSignature}
keySignature={sheetKeySignature}
instrument={sheetInstrument}
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,13 @@ vi.mock('../../core/KGCore', () => ({
describe('PianoRollToolbar', () => {
const baseProps = {
sheetMusicViewEnabled: false,
onSheetMusicViewToggle: vi.fn(),
sheetMusicTrackScopeEnabled: false,
onSheetMusicTrackScopeToggle: vi.fn(),
sheetQuantization: '16,48',
onSheetQuantizationChange: vi.fn(),
sheetQuantizationOptions: ['16,48', '32,96'],
activeTool: 'pointer' as const,
onToolSelect: vi.fn(),
quantPosition: '1/8',
@@ -72,6 +79,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 +117,58 @@ 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.getByRole('button', { name: 'Show Entire Track' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Pointer Tool' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Pitch Bend/i })).not.toBeInTheDocument();
});
it('toggles the full-track sheet scope button', () => {
const onSheetMusicTrackScopeToggle = vi.fn();
render(
<PianoRollToolbar
{...baseProps}
sheetMusicViewEnabled={true}
mode="hybrid"
onSheetMusicTrackScopeToggle={onSheetMusicTrackScopeToggle}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Show Entire Track' }));
expect(onSheetMusicTrackScopeToggle).toHaveBeenCalledTimes(1);
});
it('hides the full-track sheet scope button outside sheet mode and spectrogram mode', () => {
const { rerender } = render(
<PianoRollToolbar
{...baseProps}
sheetMusicViewEnabled={false}
mode="midi-edit"
/>
);
expect(screen.queryByRole('button', { name: 'Show Entire Track' })).not.toBeInTheDocument();
rerender(
<PianoRollToolbar
{...baseProps}
sheetMusicViewEnabled={true}
mode="spectrogram"
/>
);
expect(screen.queryByRole('button', { name: 'Show Entire Track' })).not.toBeInTheDocument();
});
});
+60 -5
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { FaMousePointer, FaPencilAlt } from 'react-icons/fa';
import { TbArrowBarToUp } from 'react-icons/tb';
import { KGDropdown } from '../common';
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import { KGCore } from '../../core/KGCore';
@@ -16,6 +17,13 @@ const POWER_OPTIONS = [
];
interface PianoRollToolbarProps {
sheetMusicViewEnabled?: boolean;
onSheetMusicViewToggle?: () => void;
sheetMusicTrackScopeEnabled?: boolean;
onSheetMusicTrackScopeToggle?: () => void;
sheetQuantization?: string;
onSheetQuantizationChange?: (value: string) => void;
sheetQuantizationOptions?: string[];
activeTool: 'pointer' | 'pencil';
onToolSelect: (tool: 'pointer' | 'pencil') => void;
quantPosition: string;
@@ -43,6 +51,13 @@ interface PianoRollToolbarProps {
}
const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
sheetMusicViewEnabled = false,
onSheetMusicViewToggle,
sheetMusicTrackScopeEnabled = false,
onSheetMusicTrackScopeToggle,
sheetQuantization = '16,48',
onSheetQuantizationChange,
sheetQuantizationOptions = [],
activeTool,
onToolSelect,
quantPosition,
@@ -68,9 +83,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,19 +104,27 @@ 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')}
title="Pointer Tool"
>
<FaMousePointer />
<FaMousePointer className="piano-roll-tool-icon" />
</button>
<button
className={`tool-button ${activeTool === 'pencil' ? 'active' : ''}`}
onClick={() => onToolSelect('pencil')}
title="Pencil Tool"
>
<FaPencilAlt />
<FaPencilAlt className="piano-roll-tool-icon" />
</button>
{showAutomationControls && (
<div className="piano-roll-automation-toolbar-group">
@@ -148,7 +170,40 @@ 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>
{mode !== 'spectrogram' && (
<button
className={`tool-button icon-only sheet-track-scope-toggle ${sheetMusicTrackScopeEnabled ? 'active' : ''}`}
onClick={() => onSheetMusicTrackScopeToggle?.()}
title={sheetMusicTrackScopeEnabled ? 'Show Active Region Only' : 'Show Entire Track'}
aria-label={sheetMusicTrackScopeEnabled ? 'Show Active Region Only' : 'Show Entire Track'}
>
<TbArrowBarToUp className="sheet-track-scope-icon" strokeWidth={2.5} />
</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,441 @@
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,
getSheetBeatAtPixel,
buildSheetMeasureModels,
getSheetPlayheadPixel,
projectKeySignatureToVexFlow,
resolveDurationSpec,
resolveSheetClef,
type SheetClef,
} from './sheetNotation';
interface SheetMusicViewProps {
activeRegion: KGMidiRegion | null;
midiRegions: KGMidiRegion[];
maxBars: number;
sheetMusicTrackScopeEnabled: boolean;
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,
midiRegions,
maxBars,
sheetMusicTrackScopeEnabled,
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(() => (
sheetMusicTrackScopeEnabled
? 1
: (activeRegion ? Math.floor(activeRegion.getStartFromBeat() / timeSignature.numerator) + 1 : 1)
), [activeRegion, sheetMusicTrackScopeEnabled, timeSignature.numerator]);
const measureModels = useMemo<SheetMeasureModel[]>(() => {
if (!activeRegion) {
return [];
}
return buildSheetMeasureModels({
scope: sheetMusicTrackScopeEnabled ? 'track' : 'region',
region: activeRegion,
regions: midiRegions,
projectMaxBars: maxBars,
timeSignature,
quantization,
});
}, [activeRegion, maxBars, midiRegions, quantization, sheetMusicTrackScopeEnabled, timeSignature]);
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';
}
const notes = sheetMusicTrackScopeEnabled
? midiRegions.flatMap(region => region.getNotes())
: activeRegion.getNotes();
return resolveSheetClef(notes, instrument, true);
}, [activeRegion, instrument, midiRegions, sheetMusicTrackScopeEnabled]);
const drawSignature = useMemo(() => JSON.stringify({
regionId: activeRegion?.getId() ?? null,
regionIds: midiRegions.map(region => region.getId()),
scope: sheetMusicTrackScopeEnabled ? 'track' : 'region',
maxBars,
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, maxBars, measureModels, measureWidths, midiRegions, quantization.raw, sheetMusicTrackScopeEnabled, 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(measureModels, measureWidths);
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 targetBeat = getSheetBeatAtPixel(relativeX, metrics);
const absoluteBeat = sheetMusicTrackScopeEnabled
? targetBeat
: activeRegion.getStartFromBeat() + targetBeat;
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}
sheetMusicTrackScopeEnabled={sheetMusicTrackScopeEnabled}
/>
{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[];
sheetMusicTrackScopeEnabled: boolean;
}
const SheetMusicPlayhead: React.FC<SheetMusicPlayheadProps> = memo(({
activeRegion,
metrics,
sheetMusicTrackScopeEnabled,
}) => {
const playheadPosition = useProjectStore(state => state.playheadPosition);
const playheadPixel = useMemo(() => {
if (!activeRegion) {
return 0;
}
return getSheetPlayheadPixel(
sheetMusicTrackScopeEnabled
? Math.max(0, playheadPosition)
: Math.max(0, playheadPosition - activeRegion.getStartFromBeat()),
metrics
);
}, [activeRegion, metrics, playheadPosition, sheetMusicTrackScopeEnabled]);
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.midiRegions.length === next.midiRegions.length &&
previous.midiRegions.every((region, index) => region.getId() === next.midiRegions[index]?.getId()) &&
previous.maxBars === next.maxBars &&
previous.sheetMusicTrackScopeEnabled === next.sheetMusicTrackScopeEnabled &&
previous.instrument === next.instrument &&
previous.keySignature === next.keySignature &&
previous.quantization.raw === next.quantization.raw &&
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,142 @@
import { describe, expect, it } from 'vitest';
import { createMockMidiNote, createMockMidiRegion } from '../../test/utils/mock-data';
import {
buildSheetMeasureMetrics,
getSheetBeatAtPixel,
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([
{ barIndex: 0, startBeat: 0, endBeat: 4, events: [] },
{ barIndex: 1, startBeat: 4, endBeat: 8, events: [] },
], [120, 240]);
expect(getSheetPlayheadPixel(0, metrics)).toBe(0);
expect(getSheetPlayheadPixel(2, metrics)).toBe(60);
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]);
});
it('builds a full-track sheet timeline with rests across empty bars and gaps', () => {
const firstRegion = createMockMidiRegion({
id: 'region-a',
startFromBeat: 4,
length: 4,
notes: [createMockMidiNote({ id: 'a1', startBeat: 0, endBeat: 1, pitch: 60 })],
});
const secondRegion = createMockMidiRegion({
id: 'region-b',
startFromBeat: 12,
length: 4,
notes: [createMockMidiNote({ id: 'b1', startBeat: 0, endBeat: 1, pitch: 64 })],
});
const measures = buildSheetMeasureModels({
scope: 'track',
region: firstRegion,
regions: [secondRegion, firstRegion],
projectMaxBars: 6,
timeSignature: { numerator: 4, denominator: 4 },
quantization: parseSheetQuantization('16,48'),
});
expect(measures).toHaveLength(6);
expect(measures[0].startBeat).toBe(0);
expect(measures[5].endBeat).toBe(24);
expect(measures[0].events.every(event => event.isRest)).toBe(true);
expect(measures[1].events.some(event => !event.isRest && event.startBeat === 4)).toBe(true);
expect(measures[2].events.every(event => event.isRest)).toBe(true);
expect(measures[3].events.some(event => !event.isRest && event.startBeat === 12)).toBe(true);
expect(measures[4].events.every(event => event.isRest)).toBe(true);
expect(measures[5].events.every(event => event.isRest)).toBe(true);
});
it('maps absolute track beats through sheet metrics for full-track mode', () => {
const metrics = buildSheetMeasureMetrics([
{ barIndex: 0, startBeat: 0, endBeat: 4, events: [] },
{ barIndex: 1, startBeat: 4, endBeat: 8, events: [] },
], [120, 240]);
expect(getSheetPlayheadPixel(5, metrics)).toBe(180);
expect(getSheetBeatAtPixel(180, metrics)).toBe(5);
});
});
+399
View File
@@ -0,0 +1,399 @@
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 {
scope?: 'region' | 'track';
region: KGMidiRegion;
regions?: KGMidiRegion[];
projectMaxBars?: number;
timeSignature: { numerator: number; denominator: number };
quantization: SheetQuantization;
}
interface WorkingEvent {
keys: string[];
startBeat: number;
endBeat: number;
isRest: boolean;
}
interface NormalizedNoteInput {
pitch: number;
startBeat: number;
endBeat: number;
}
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(measures: SheetMeasureModel[], widths: number[]): SheetMeasureMetric[] {
let leftPx = 0;
return measures.map((measure, index) => {
const widthPx = widths[index] ?? 0;
const metric: SheetMeasureMetric = {
barIndex: measure.barIndex,
startBeat: measure.startBeat,
endBeat: measure.endBeat,
leftPx,
widthPx,
};
leftPx += widthPx;
return metric;
});
}
export function getSheetPlayheadPixel(
sheetBeat: number,
metrics: SheetMeasureMetric[]
): number {
if (metrics.length === 0) {
return 0;
}
if (sheetBeat <= metrics[0].startBeat) {
return metrics[0].leftPx;
}
const lastMetric = metrics[metrics.length - 1];
if (sheetBeat >= lastMetric.endBeat) {
return lastMetric.leftPx + lastMetric.widthPx;
}
const activeMetric = metrics.find(metric => sheetBeat >= metric.startBeat && sheetBeat < metric.endBeat);
if (!activeMetric) {
return 0;
}
const span = Math.max(activeMetric.endBeat - activeMetric.startBeat, EPSILON);
const progress = (sheetBeat - activeMetric.startBeat) / span;
return activeMetric.leftPx + activeMetric.widthPx * progress;
}
export function getSheetBeatAtPixel(
pixel: number,
metrics: SheetMeasureMetric[]
): number {
if (metrics.length === 0) {
return 0;
}
const firstMetric = metrics[0];
if (pixel <= firstMetric.leftPx) {
return firstMetric.startBeat;
}
const lastMetric = metrics[metrics.length - 1];
if (pixel >= lastMetric.leftPx + lastMetric.widthPx) {
return lastMetric.endBeat;
}
const activeMetric = metrics.find(metric => pixel >= metric.leftPx && pixel < metric.leftPx + metric.widthPx);
if (!activeMetric) {
return lastMetric.endBeat;
}
const progress = activeMetric.widthPx > 0 ? (pixel - activeMetric.leftPx) / activeMetric.widthPx : 0;
return activeMetric.startBeat + progress * (activeMetric.endBeat - activeMetric.startBeat);
}
export function resolveDurationSpec(durationBeats: number, isRest: boolean): { duration: string; dots: number } {
const withRest = (value: string) => (isRest ? `${value}r` : value);
const options = [
{ 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({
scope = 'region',
region,
regions = [region],
projectMaxBars,
timeSignature,
quantization,
}: BuildSheetNotationOptions): SheetMeasureModel[] {
const beatsPerBar = timeSignature.numerator;
const isTrackScope = scope === 'track';
const timelineStartBeat = isTrackScope ? 0 : 0;
const measureCount = isTrackScope
? Math.max(1, projectMaxBars ?? 1)
: Math.max(1, Math.ceil(region.getLength() / beatsPerBar));
const measureEndBeat = isTrackScope
? measureCount * beatsPerBar
: measureCount * beatsPerBar;
const noteInputs = isTrackScope
? collectTrackScopeNotes(regions)
: region.getNotes().map(note => ({
pitch: note.getPitch(),
startBeat: note.getStartBeat(),
endBeat: note.getEndBeat(),
}));
const workingEvents = normalizeNotes(noteInputs, quantization.stepBeats, measureEndBeat);
const withRests = insertRests(workingEvents, measureEndBeat);
const splitEvents = splitAcrossBars(withRests, beatsPerBar);
const measures: SheetMeasureModel[] = Array.from({ length: measureCount }, (_, barIndex) => ({
barIndex,
startBeat: timelineStartBeat + barIndex * beatsPerBar,
endBeat: timelineStartBeat + (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: NormalizedNoteInput[], stepBeats: number, measureEndBeat: number): WorkingEvent[] {
const clippedNotes = notes
.map(note => ({
keys: [midiPitchToVexKey(note.pitch)],
startBeat: quantizeBeat(note.startBeat, stepBeats),
endBeat: quantizeBeat(note.endBeat, 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 collectTrackScopeNotes(regions: KGMidiRegion[]): NormalizedNoteInput[] {
return [...regions]
.sort((left, right) => {
if (left.getStartFromBeat() !== right.getStartFromBeat()) {
return left.getStartFromBeat() - right.getStartFromBeat();
}
return left.getId().localeCompare(right.getId());
})
.flatMap(region => {
const regionStart = region.getStartFromBeat();
// Overlapping regions are flattened in deterministic start-beat/id order so
// the existing note normalization path can resolve collisions consistently.
return region.getNotes().map(note => ({
pitch: note.getPitch(),
startBeat: regionStart + note.getStartBeat(),
endBeat: regionStart + note.getEndBeat(),
}));
});
}
function insertRests(events: WorkingEvent[], measureEndBeat: number): WorkingEvent[] {
if (events.length === 0) {
return [{ keys: ['b/4'], startBeat: 0, endBeat: measureEndBeat, isRest: true }];
}
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[];
}
+14
View File
@@ -371,6 +371,20 @@ export class KGProjectStorage {
}
}
/**
* Save the current in-memory project under a new name without removing the source folder.
* Used for "Save as Copy": the original project remains intact in OPFS.
*/
public async saveAs(sourceName: string, targetName: string, data: KGProject): Promise<void> {
this.ensureInitialized();
await this.save(targetName, data, false);
if (await this.exists(sourceName)) {
await this.copyMediaFiles(sourceName, targetName);
}
}
/**
* Export a project folder as a zip Blob (.kgstudio bundle).
* Includes project.json, meta.json, and all files in media/.
+27
View File
@@ -15,6 +15,9 @@ export class KGPianoRollState {
private currentMode: string = "ionian"; // Default mode
private automationViewEnabled: boolean = false;
private currentAutomationType: string = "pitch-bend";
private sheetMusicViewEnabled: boolean = false;
private sheetMusicTrackScopeEnabled: boolean = false;
private sheetQuantization: string = '16,48';
// Chord guide state
private currentSuitableChords: Record<string, string[]> = {}; // Map of chord symbols to note names (e.g., {"I": ["C", "E", "G"]})
@@ -83,6 +86,30 @@ export class KGPianoRollState {
this.currentAutomationType = type;
}
public getSheetMusicViewEnabled(): boolean {
return this.sheetMusicViewEnabled;
}
public setSheetMusicViewEnabled(enabled: boolean): void {
this.sheetMusicViewEnabled = enabled;
}
public getSheetMusicTrackScopeEnabled(): boolean {
return this.sheetMusicTrackScopeEnabled;
}
public setSheetMusicTrackScopeEnabled(enabled: boolean): void {
this.sheetMusicTrackScopeEnabled = enabled;
}
public getSheetQuantization(): string {
return this.sheetQuantization;
}
public setSheetQuantization(value: string): void {
this.sheetQuantization = value;
}
public getCurrentSuitableChords(): Record<string, string[]> {
return this.currentSuitableChords;
}
+15
View File
@@ -14,21 +14,29 @@ export interface TimeSigResult {
denominator: number;
}
export interface ChoiceOption {
label: string;
value: string;
}
let _showAlertFn: ((message: string) => Promise<void>) | null = null;
let _showConfirmFn: ((message: string, options?: ConfirmOptions) => Promise<boolean>) | null = null;
let _showPromptFn: ((message: string, defaultValue?: string, options?: PromptOptions) => Promise<string | null>) | null = null;
let _showTimeSigFn: ((message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>) | null = null;
let _showChoiceFn: ((message: string, choices: ChoiceOption[]) => Promise<string | null>) | null = null;
export function registerDialogFns(
alertFn: (message: string) => Promise<void>,
confirmFn: (message: string, options?: ConfirmOptions) => Promise<boolean>,
promptFn: (message: string, defaultValue?: string, options?: PromptOptions) => Promise<string | null>,
timeSigFn: (message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>,
choiceFn?: (message: string, choices: ChoiceOption[]) => Promise<string | null>,
) {
_showAlertFn = alertFn;
_showConfirmFn = confirmFn;
_showPromptFn = promptFn;
_showTimeSigFn = timeSigFn;
if (choiceFn) _showChoiceFn = choiceFn;
}
export function showAlert(message: string): Promise<void> {
@@ -53,6 +61,13 @@ export function showPrompt(message: string, defaultValue?: string, options?: Pro
return _showPromptFn(message, defaultValue, options);
}
export function showChoice(message: string, choices: ChoiceOption[]): Promise<string | null> {
if (!_showChoiceFn) {
return Promise.resolve(window.confirm(message) ? choices[0]?.value ?? null : null);
}
return _showChoiceFn(message, choices);
}
export function showTimeSigPrompt(message: string, defaultValue?: TimeSigResult): Promise<TimeSigResult | null> {
if (!_showTimeSigFn) {
const raw = window.prompt(message, defaultValue ? `${defaultValue.numerator}/${defaultValue.denominator}` : '4/4');