Merge pull request #40 from KGAudioLab/feat/2026-05-09-staff-notation
Feat/2026 05 09 staff notation
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -23,6 +23,8 @@ K.G.Studio is a lightweight, modern DAW that runs entirely in the browser with *
|
||||
|
||||
## Latest Updates
|
||||
|
||||
- **2026.05.10**: Added **staff notation (sheet music) view** — the piano roll now offers a full standard notation mode. Switch between Piano Roll and Sheet Music views using the toggle in the piano roll toolbar. In sheet music mode, notes are engraved via VexFlow with automatic clef selection (treble or bass) based on the active instrument, key signature rendering, automatic beam grouping, ties across bar lines, and configurable quantization for note-value resolution. Enable **Track Scope** to render all MIDI regions on the track as a continuous score rather than a single isolated region.
|
||||
|
||||
- **2026.05.09**: Added **audio recording** — record directly from your microphone into an audio track. A live waveform preview grows in real time as you record, and the region is committed to the timeline as a standard audio region when you stop. Added **audio I/O device selection** in Settings so you can choose your preferred microphone input and audio output device.
|
||||
|
||||
- **2026.05.08**: Added **MIDI automation** — draw and edit pitch bend and MIDI CC curves (CC1 Modulation, CC2 Breath, CC7 Volume, CC11 Expression, CC64 Sustain) in an editable automation lane below the piano grid. Added **track-level automation**: each track now has a dedicated automation panel where you can view and edit the same curves directly on the timeline. Real-time MIDI controller input (pitch wheel, CC pedals) is recorded and played back with per-lane interpolation. Added the **Event List Panel** — a tabbed sidebar (Notes / Pitch Bend / Controller) for inspecting and inline-editing all events in the active MIDI region. Added **region multi-select** with lasso and bulk move/resize, and **merge MIDI regions**.
|
||||
@@ -383,4 +385,4 @@ Licensed under the Apache License, Version 2.0, with additional terms (see `LICE
|
||||
- No patent applications using this software or assets
|
||||
- Attribution required when used in public/commercial products (“Powered by K.G.Studio”)
|
||||
|
||||
Third‑party notices (e.g., FluidR3_GM SoundFont, midi‑js‑soundfonts, and prompt structure notes) are included in `LICENSE`.
|
||||
Third‑party notices (e.g., FluidR3_GM SoundFont, midi‑js‑soundfonts, VexFlow, and prompt structure notes) are included in `LICENSE`.
|
||||
|
||||
Generated
+9
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "K.G.Studio",
|
||||
"version": "0.12.0-build.20260430",
|
||||
"version": "0.15.0-build.20260510",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "K.G.Studio",
|
||||
"version": "0.12.0-build.20260430",
|
||||
"version": "0.15.0-build.20260510",
|
||||
"dependencies": {
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"class-transformer": "^0.5.1",
|
||||
@@ -22,6 +22,7 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tone": "^15.1.22",
|
||||
"vexflow": "^5.0.0",
|
||||
"zustand": "^5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -13734,6 +13735,12 @@
|
||||
"spdx-expression-parse": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vexflow": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vexflow/-/vexflow-5.0.0.tgz",
|
||||
"integrity": "sha512-rjB7TV4ygKE5Fl3W5OlG+0dHv22CFufUJdMG6oNgvcn0zp34u+sOboZsadQXnF1O3tZ3myXThaUIaLkJlpNM2Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vfile": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tone": "^15.1.22",
|
||||
"vexflow": "^5.0.0",
|
||||
"zustand": "^5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+41
-11
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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/.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user