setEditingKeySignatureRegionId(null)}
+ onSelectRegion={selectGlobalRegion}
+ onCreateAtBar={createKeySignatureAtBar}
+ onResizeRegion={resizeKeySignatureRegion}
+ onChangeKeySignature={updateKeySignatureRegion}
+ onOpenPicker={beginEditingKeySignatureRegion}
+ />
) : (
({
isReservedProjectName: vi.fn(() => false),
RESERVED_PROJECT_NAME: 'Untitled Project',
}));
-vi.mock('../core/KGCore', () => ({ KGCore: { instance: vi.fn(() => ({ getCurrentProject: vi.fn(() => ({ getTracks: () => [] })) })) } }));
+vi.mock('../core/KGCore', () => ({ KGCore: { instance: vi.fn(() => ({ getCurrentProject: vi.fn(() => ({ getTracks: () => [] })), executeCommand: executeCommandMock })) } }));
vi.mock('../core/midi-input/KGMidiInput', () => ({ KGMidiInput: { instance: vi.fn(() => ({ getConnectedInputCount: () => 0 })) } }));
vi.mock('../core/region/KGMidiRegion', () => ({ KGMidiRegion: class {} }));
vi.mock('../core/track/KGAudioTrack', () => ({ KGAudioTrack: class {} }));
@@ -207,7 +211,11 @@ describe('Toolbar settings side-panel behavior', () => {
storeState.showKGOnePanel = true;
storeState.showEventListPanel = false;
storeState.keySignature = 'C major';
+ storeState.playheadPosition = 0;
+ storeState.globalTracks = createDefaultGlobalTracks();
storeState.setKeySignature.mockClear();
+ storeState.refreshProjectState.mockClear();
+ executeCommandMock.mockClear();
});
it('suppresses active styling for side-panel buttons while Settings is visible', () => {
@@ -273,4 +281,24 @@ describe('Toolbar settings side-panel behavior', () => {
expect(storeState.setKeySignature).toHaveBeenCalledWith('E minor');
expect(storeState.setStatus).toHaveBeenCalledWith('Key signature changed to E minor');
});
+
+ it('shows the effective region key at the playhead and updates that region instead of the project default', () => {
+ const signatureTrack = storeState.globalTracks.find(track => track.getType() === 'signature');
+ signatureTrack?.setRegions([
+ new KGKeySignatureRegion('sig-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 2, 4, 4),
+ ]);
+ storeState.playheadPosition = 8;
+
+ render();
+
+ expect(screen.getByRole('button', { name: /choose key signature, current G major/i })).toHaveTextContent('G major');
+
+ fireEvent.click(screen.getByRole('button', { name: /choose key signature/i }));
+ fireEvent.click(screen.getByRole('button', { name: 'Select E minor' }));
+
+ expect(executeCommandMock).toHaveBeenCalledTimes(1);
+ expect(storeState.setKeySignature).not.toHaveBeenCalled();
+ expect(storeState.refreshProjectState).toHaveBeenCalled();
+ expect(storeState.setStatus).toHaveBeenCalledWith('Key signature changed to E minor');
+ });
});
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx
index 00bd63c..99a28cd 100644
--- a/src/components/Toolbar.tsx
+++ b/src/components/Toolbar.tsx
@@ -15,7 +15,9 @@ import {
FaCog, FaMagnet, FaCut, FaCircle, FaCompress
} from 'react-icons/fa';
import { KGProject, type KeySignature } from '../core/KGProject';
+import { GlobalTrackType } from '../core/global-track';
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
+import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
import { KGAudioTrack } from '../core/track/KGAudioTrack';
import { plainToInstance } from 'class-transformer';
@@ -36,6 +38,7 @@ import PianoIcon from './common/icons/PianoIcon';
import MetronomeIcon from './common/icons/MetronomeIcon';
import { mergeSelectedMidiRegions, splitSelectedRegionAtPlayhead } from '../util/regionEditUtil';
import { showAlert, showChoice, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil';
+import { UpdateKeySignatureRegionCommand } from '../core/commands';
const Toolbar: React.FC = () => {
const {
@@ -47,6 +50,7 @@ const Toolbar: React.FC = () => {
maxBars, setMaxBars,
barWidthMultiplier, setBarWidthMultiplier,
isLooping, toggleLoop,
+ globalTracks,
canUndo, canRedo, undoDescription, redoDescription, undo, redo,
toggleChatBox, toggleSettings, toggleKGOnePanel, toggleEventListPanel, activateSidePanel, showKGOnePanel, showEventListPanel, showChatBox, showSettings, cleanupProjectState, toggleMetronome, isMetronomeEnabled,
isRecording, startRecording, stopRecording,
@@ -66,6 +70,16 @@ const Toolbar: React.FC = () => {
// State for key signature dropdown
const [showKeySignatureDropdown, setShowKeySignatureDropdown] = React.useState(false);
+ const signatureTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Signature) ?? null;
+ const signatureRegions = (signatureTrack?.getRegions() ?? [])
+ .filter((region): region is KGKeySignatureRegion => region instanceof KGKeySignatureRegion)
+ .sort((left, right) => left.getStartBar() - right.getStartBar());
+ const playheadBar = Math.floor(playheadPosition / timeSignature.numerator);
+ const activeKeySignatureRegion = signatureRegions.find(
+ region => playheadBar >= region.getStartBar() && playheadBar < region.getEndBar()
+ ) ?? null;
+ const displayedKeySignature = activeKeySignatureRegion?.getKeySignature() ?? keySignature;
+
// State for export dropdown
const [showExportDropdown, setShowExportDropdown] = React.useState(false);
@@ -691,10 +705,18 @@ const Toolbar: React.FC = () => {
const handleKeySignatureChange = (newKeySignature: string) => {
if (DEBUG_MODE.TOOLBAR) {
- console.log("Key signature changed from", keySignature, "to", newKeySignature);
+ console.log("Key signature changed from", displayedKeySignature, "to", newKeySignature);
}
- setKeySignature(newKeySignature as KeySignature);
+ if (activeKeySignatureRegion) {
+ KGCore.instance().executeCommand(new UpdateKeySignatureRegionCommand(
+ activeKeySignatureRegion.getId(),
+ newKeySignature as KeySignature
+ ));
+ refreshProjectState();
+ } else {
+ setKeySignature(newKeySignature as KeySignature);
+ }
setStatus(`Key signature changed to ${newKeySignature}`);
setShowKeySignatureDropdown(false);
};
@@ -1167,13 +1189,13 @@ const Toolbar: React.FC = () => {
onClick={() => setShowKeySignatureDropdown((current) => !current)}
aria-haspopup="dialog"
aria-expanded={showKeySignatureDropdown}
- aria-label={`Choose key signature, current ${keySignature}`}
+ aria-label={`Choose key signature, current ${displayedKeySignature}`}
>
- {keySignature}
+ {displayedKeySignature}
)}
>
-
+
diff --git a/src/components/common/FloatingPopup.tsx b/src/components/common/FloatingPopup.tsx
index c746d8b..0e98476 100644
--- a/src/components/common/FloatingPopup.tsx
+++ b/src/components/common/FloatingPopup.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import { createPortal } from 'react-dom';
import './FloatingPopup.css';
type FloatingPopupPlacement = 'bottom' | 'top' | 'left' | 'right';
@@ -14,6 +15,7 @@ interface FloatingPopupProps {
contentClassName?: string;
panelClassName?: string;
arrowClassName?: string;
+ renderInPortal?: boolean;
}
const FloatingPopup: React.FC = ({
@@ -27,8 +29,63 @@ const FloatingPopup: React.FC = ({
contentClassName = '',
panelClassName = '',
arrowClassName = '',
+ renderInPortal = false,
}) => {
const rootRef = React.useRef(null);
+ const triggerRef = React.useRef(null);
+ const [portalStyle, setPortalStyle] = React.useState(undefined);
+
+ React.useLayoutEffect(() => {
+ if (!isOpen || !renderInPortal || !triggerRef.current) {
+ return;
+ }
+
+ const updatePortalStyle = () => {
+ if (!triggerRef.current) {
+ return;
+ }
+
+ const rect = triggerRef.current.getBoundingClientRect();
+ const centerX = rect.left + rect.width / 2;
+ const placementOffset = 14;
+
+ if (placement === 'bottom') {
+ setPortalStyle({
+ position: 'fixed',
+ top: `${rect.bottom + placementOffset}px`,
+ left: `${centerX}px`,
+ transform: 'translateX(-50%)',
+ });
+ return;
+ }
+
+ if (placement === 'top') {
+ setPortalStyle({
+ position: 'fixed',
+ bottom: `${window.innerHeight - rect.top + placementOffset}px`,
+ left: `${centerX}px`,
+ transform: 'translateX(-50%)',
+ });
+ return;
+ }
+
+ setPortalStyle({
+ position: 'fixed',
+ top: `${rect.bottom + placementOffset}px`,
+ left: `${centerX}px`,
+ transform: 'translateX(-50%)',
+ });
+ };
+
+ updatePortalStyle();
+ window.addEventListener('resize', updatePortalStyle);
+ window.addEventListener('scroll', updatePortalStyle, true);
+
+ return () => {
+ window.removeEventListener('resize', updatePortalStyle);
+ window.removeEventListener('scroll', updatePortalStyle, true);
+ };
+ }, [isOpen, placement, renderInPortal]);
React.useEffect(() => {
if (!isOpen) {
@@ -56,28 +113,31 @@ const FloatingPopup: React.FC = ({
};
}, [isOpen, onClose]);
+ const popupSurface = isOpen ? (
+
+ ) : null;
+
return (
-
+
{trigger}
- {isOpen && (
-
- )}
+ {renderInPortal && popupSurface ? createPortal(popupSurface, document.body) : popupSurface}
);
};
diff --git a/src/components/global-track/GlobalKeySignatureLane.test.tsx b/src/components/global-track/GlobalKeySignatureLane.test.tsx
new file mode 100644
index 0000000..d39353c
--- /dev/null
+++ b/src/components/global-track/GlobalKeySignatureLane.test.tsx
@@ -0,0 +1,74 @@
+import React from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import GlobalKeySignatureLane from './GlobalKeySignatureLane';
+import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
+
+describe('GlobalKeySignatureLane', () => {
+ const baseRegion = new KGKeySignatureRegion('sig-1', 'global-signature', 2, 'C major', 0, 4, 4);
+
+ beforeEach(() => {
+ document.documentElement.style.setProperty('--track-grid-bar-width', '40');
+ });
+
+ it('opens the key signature picker for an existing region', () => {
+ const onOpenPicker = vi.fn();
+
+ render(
+
+ );
+
+ fireEvent.doubleClick(screen.getByText('C major'));
+ expect(onOpenPicker).toHaveBeenCalledWith('sig-1');
+ });
+
+ it('creates a new region at a bar-aligned position on empty-lane double click', () => {
+ const onCreateAtBar = vi.fn();
+
+ const { container } = render(
+
+ );
+
+ const lane = container.querySelector('.global-key-signature-lane') as HTMLDivElement;
+ vi.spyOn(lane, 'getBoundingClientRect').mockReturnValue({
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ right: 320,
+ bottom: 24,
+ width: 320,
+ height: 24,
+ toJSON: () => ({}),
+ });
+
+ fireEvent.doubleClick(lane, { clientX: 159, clientY: 10 });
+ expect(onCreateAtBar).toHaveBeenCalledWith(3);
+ });
+});
diff --git a/src/components/global-track/GlobalKeySignatureLane.tsx b/src/components/global-track/GlobalKeySignatureLane.tsx
new file mode 100644
index 0000000..66a48a6
--- /dev/null
+++ b/src/components/global-track/GlobalKeySignatureLane.tsx
@@ -0,0 +1,364 @@
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import type { KeySignature } from '../../core/KGProject';
+import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
+import type { RegionClickOptions } from '../interfaces';
+import { isModifierKeyPressed } from '../../util/osUtil';
+import { TOOLBAR_CONSTANTS } from '../../constants';
+import FloatingPopup from '../common/FloatingPopup';
+import KeySignaturePickerPopup from '../KeySignaturePickerPopup';
+
+interface GlobalKeySignatureLaneProps {
+ signatureRegions: KGKeySignatureRegion[];
+ maxBars: number;
+ barWidthMultiplier: number;
+ timeSignature: { numerator: number; denominator: number };
+ selectedRegionIds: string[];
+ pickerRegionId: string | null;
+ onClosePicker: () => void;
+ onSelectRegion: (regionId: string, options?: RegionClickOptions) => void;
+ onCreateAtBar: (startBar: number) => void;
+ onResizeRegion: (regionId: string, edge: 'start' | 'end', bar: number) => void;
+ onChangeKeySignature: (regionId: string, keySignature: KeySignature) => void;
+ onOpenPicker: (regionId: string) => void;
+}
+
+type ResizeEdge = 'start' | 'end' | null;
+
+const REGION_EDGE_HITBOX_PX = 8;
+const DRAG_THRESHOLD_PX = 4;
+
+const GlobalKeySignatureLane: React.FC
= ({
+ signatureRegions,
+ maxBars,
+ barWidthMultiplier,
+ timeSignature,
+ selectedRegionIds,
+ pickerRegionId,
+ onClosePicker,
+ onSelectRegion,
+ onCreateAtBar,
+ onResizeRegion,
+ onChangeKeySignature,
+ onOpenPicker,
+}) => {
+ const laneRef = useRef(null);
+ const [previewBars, setPreviewBars] = useState>({});
+ const [hoverEdges, setHoverEdges] = useState>({});
+ const [isModifierPressed, setIsModifierPressed] = useState(false);
+ const interactionRef = useRef<{
+ mode: 'resize' | null;
+ regionId: string;
+ resizeEdge: ResizeEdge;
+ initialMouseX: number;
+ moved: boolean;
+ } | null>(null);
+
+ const barWidth = useMemo(
+ () => TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * barWidthMultiplier,
+ [barWidthMultiplier]
+ );
+
+ const regionOrder = useMemo(
+ () => signatureRegions.map(region => region.getId()),
+ [signatureRegions]
+ );
+
+ const getRegionIndex = (regionId: string) => regionOrder.findIndex(candidateId => candidateId === regionId);
+ const canResizeEdge = (regionId: string, edge: 'start' | 'end') => {
+ const regionIndex = getRegionIndex(regionId);
+ if (regionIndex === -1) {
+ return false;
+ }
+
+ if (edge === 'start') {
+ return regionIndex > 0;
+ }
+
+ return regionIndex < signatureRegions.length - 1;
+ };
+
+ const getRenderedBarState = (region: KGKeySignatureRegion) => (
+ previewBars[region.getId()] ?? {
+ startBar: region.getStartBar(),
+ lengthBars: region.getLengthBars(),
+ }
+ );
+
+ const getResizeEdgeFromMouseEvent = (event: React.MouseEvent): ResizeEdge => {
+ const rect = event.currentTarget.getBoundingClientRect();
+ const offsetX = event.clientX - rect.left;
+
+ if (offsetX <= REGION_EDGE_HITBOX_PX) {
+ return 'start';
+ }
+
+ if (rect.width - offsetX <= REGION_EDGE_HITBOX_PX) {
+ return 'end';
+ }
+
+ return null;
+ };
+
+ const getBarFromClientX = (clientX: number) => {
+ if (!laneRef.current) {
+ return 0;
+ }
+
+ const rect = laneRef.current.getBoundingClientRect();
+ const relativeX = clientX - rect.left;
+ return Math.max(0, Math.min(maxBars, Math.round(relativeX / barWidth)));
+ };
+
+ const getCreateBarFromClientX = (clientX: number) => {
+ if (!laneRef.current) {
+ return 0;
+ }
+
+ const rect = laneRef.current.getBoundingClientRect();
+ const relativeX = clientX - rect.left;
+ return Math.max(0, Math.min(maxBars - 1, Math.floor(relativeX / barWidth)));
+ };
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (isModifierKeyPressed(event)) {
+ setIsModifierPressed(true);
+ }
+
+ if (event.key === 'Escape') {
+ onClosePicker();
+ }
+ };
+
+ const handleKeyUp = (event: KeyboardEvent) => {
+ if (!isModifierKeyPressed(event)) {
+ setIsModifierPressed(false);
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ window.addEventListener('keyup', handleKeyUp);
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ window.removeEventListener('keyup', handleKeyUp);
+ };
+ }, [onClosePicker]);
+
+ useEffect(() => {
+ const handleMouseMove = (event: MouseEvent) => {
+ if (!interactionRef.current) {
+ return;
+ }
+
+ const interaction = interactionRef.current;
+ const deltaX = event.clientX - interaction.initialMouseX;
+ if (Math.abs(deltaX) >= DRAG_THRESHOLD_PX) {
+ interaction.moved = true;
+ }
+
+ if (interaction.mode !== 'resize' || !interaction.resizeEdge) {
+ return;
+ }
+
+ const targetRegion = signatureRegions.find(region => region.getId() === interaction.regionId);
+ if (!targetRegion) {
+ return;
+ }
+
+ const targetIndex = getRegionIndex(interaction.regionId);
+ const desiredBoundaryBar = getBarFromClientX(event.clientX);
+
+ if (interaction.resizeEdge === 'start' && targetIndex > 0) {
+ const previousRegion = signatureRegions[targetIndex - 1];
+ const targetEndBar = targetRegion.getEndBar();
+ const clampedBoundaryBar = Math.max(
+ previousRegion.getStartBar() + 1,
+ Math.min(desiredBoundaryBar, targetEndBar - 1)
+ );
+
+ setPreviewBars({
+ [previousRegion.getId()]: {
+ startBar: previousRegion.getStartBar(),
+ lengthBars: clampedBoundaryBar - previousRegion.getStartBar(),
+ },
+ [targetRegion.getId()]: {
+ startBar: clampedBoundaryBar,
+ lengthBars: targetEndBar - clampedBoundaryBar,
+ },
+ });
+ return;
+ }
+
+ if (interaction.resizeEdge === 'end' && targetIndex < signatureRegions.length - 1) {
+ const nextRegion = signatureRegions[targetIndex + 1];
+ const nextEndBar = nextRegion.getEndBar();
+ const clampedBoundaryBar = Math.max(
+ targetRegion.getStartBar() + 1,
+ Math.min(desiredBoundaryBar, nextEndBar - 1)
+ );
+
+ setPreviewBars({
+ [targetRegion.getId()]: {
+ startBar: targetRegion.getStartBar(),
+ lengthBars: clampedBoundaryBar - targetRegion.getStartBar(),
+ },
+ [nextRegion.getId()]: {
+ startBar: clampedBoundaryBar,
+ lengthBars: nextEndBar - clampedBoundaryBar,
+ },
+ });
+ }
+ };
+
+ const handleMouseUp = (event: MouseEvent) => {
+ if (!interactionRef.current) {
+ return;
+ }
+
+ const interaction = interactionRef.current;
+ interactionRef.current = null;
+ const resizeEdge = interaction.resizeEdge;
+ const shouldResize = interaction.moved && resizeEdge !== null;
+ setPreviewBars({});
+
+ if (!shouldResize) {
+ onSelectRegion(interaction.regionId, { shiftKey: event.shiftKey });
+ return;
+ }
+
+ onResizeRegion(interaction.regionId, resizeEdge, getBarFromClientX(event.clientX));
+ };
+
+ window.addEventListener('mousemove', handleMouseMove);
+ window.addEventListener('mouseup', handleMouseUp);
+ return () => {
+ window.removeEventListener('mousemove', handleMouseMove);
+ window.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, [getBarFromClientX, onResizeRegion, onSelectRegion, signatureRegions]);
+
+ const handleLaneMouseDown = (event: React.MouseEvent) => {
+ if (event.button !== 0) {
+ return;
+ }
+
+ if (!(event.target instanceof HTMLElement) || event.target.closest('.global-key-signature-region')) {
+ return;
+ }
+
+ if (!isModifierKeyPressed(event)) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ onCreateAtBar(getCreateBarFromClientX(event.clientX));
+ };
+
+ const handleLaneDoubleClick = (event: React.MouseEvent) => {
+ if (!(event.target instanceof HTMLElement) || event.target.closest('.global-key-signature-region')) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ onCreateAtBar(getCreateBarFromClientX(event.clientX));
+ };
+
+ return (
+ <>
+
+ {signatureRegions.map(region => {
+ const rendered = getRenderedBarState(region);
+ const isSelected = selectedRegionIds.includes(region.getId());
+ const left = rendered.startBar * barWidth;
+ const clampedEndBar = Math.max(rendered.startBar, Math.min(rendered.startBar + rendered.lengthBars, maxBars));
+ const widthBars = Math.max(0, clampedEndBar - rendered.startBar);
+ const width = Math.max(barWidth, widthBars * barWidth);
+
+ if (rendered.startBar >= maxBars || widthBars <= 0) {
+ return null;
+ }
+
+ return (
+
setHoverEdges(prev => ({ ...prev, [region.getId()]: null }))}
+ onMouseMove={(event) => {
+ const nextEdge = getResizeEdgeFromMouseEvent(event);
+ const normalizedEdge = nextEdge && canResizeEdge(region.getId(), nextEdge) ? nextEdge : null;
+ setHoverEdges(prev => ({ ...prev, [region.getId()]: normalizedEdge }));
+ }}
+ onMouseLeave={() => setHoverEdges(prev => ({ ...prev, [region.getId()]: null }))}
+ onMouseDown={(event) => {
+ if (event.button !== 0) {
+ return;
+ }
+
+ const nextEdge = getResizeEdgeFromMouseEvent(event);
+ const normalizedEdge = nextEdge && canResizeEdge(region.getId(), nextEdge) ? nextEdge : null;
+ if (!normalizedEdge) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ interactionRef.current = {
+ mode: 'resize',
+ regionId: region.getId(),
+ resizeEdge: normalizedEdge,
+ initialMouseX: event.clientX,
+ moved: false,
+ };
+ }}
+ onClick={(event) => {
+ event.stopPropagation();
+ onSelectRegion(region.getId(), { shiftKey: event.shiftKey });
+ }}
+ onDoubleClick={(event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ onOpenPicker(region.getId());
+ }}
+ >
+ {region.getKeySignature()}
+
+ )}
+ >
+ onChangeKeySignature(region.getId(), keySignature)}
+ />
+
+
+ );
+ })}
+
+ >
+ );
+};
+
+export default GlobalKeySignatureLane;
diff --git a/src/components/global-track/GlobalMarkerLane.tsx b/src/components/global-track/GlobalMarkerLane.tsx
index e85d30f..2a4d837 100644
--- a/src/components/global-track/GlobalMarkerLane.tsx
+++ b/src/components/global-track/GlobalMarkerLane.tsx
@@ -2,10 +2,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { KGMarkerRegion } from '../../core/region/KGMarkerRegion';
import type { RegionClickOptions } from '../interfaces';
import { isModifierKeyPressed } from '../../util/osUtil';
+import { TOOLBAR_CONSTANTS } from '../../constants';
interface GlobalMarkerLaneProps {
markerRegions: KGMarkerRegion[];
maxBars: number;
+ barWidthMultiplier: number;
timeSignature: { numerator: number; denominator: number };
selectedRegionIds: string[];
editingRegionId: string | null;
@@ -28,6 +30,7 @@ const DRAG_THRESHOLD_PX = 4;
const GlobalMarkerLane: React.FC = ({
markerRegions,
maxBars,
+ barWidthMultiplier,
timeSignature,
selectedRegionIds,
editingRegionId,
@@ -57,11 +60,9 @@ const GlobalMarkerLane: React.FC = ({
const totalBeats = maxBars * timeSignature.numerator;
const beatWidth = useMemo(() => {
- const barWidth = parseInt(
- getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width')
- ) || 40;
+ const barWidth = TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * barWidthMultiplier;
return barWidth / timeSignature.numerator;
- }, [timeSignature.numerator]);
+ }, [barWidthMultiplier, timeSignature.numerator]);
const clampStartBeat = (value: number) => Math.max(0, Math.min(totalBeats - 1, value));
const clampEndBeat = (value: number) => Math.max(1, Math.min(totalBeats, value));
diff --git a/src/components/piano-roll/SheetMusicView.test.tsx b/src/components/piano-roll/SheetMusicView.test.tsx
index 789073a..eda0754 100644
--- a/src/components/piano-roll/SheetMusicView.test.tsx
+++ b/src/components/piano-roll/SheetMusicView.test.tsx
@@ -5,20 +5,29 @@ import SheetMusicView from './SheetMusicView';
import { getSheetPlayheadPixel, parseSheetQuantization } from './sheetNotation';
import type { SheetMeasureMetric } from './sheetNotationTypes';
import { createMockMidiNote, createMockMidiRegion } from '../../test/utils/mock-data';
+import { createDefaultGlobalTracks } from '../../core/global-track';
+import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
const setPlayheadPosition = vi.fn();
const requestMainContentScroll = vi.fn();
+const vexflowMocks = vi.hoisted(() => ({
+ addKeySignatureMock: vi.fn(),
+ applyAccidentalsMock: vi.fn(),
+}));
+const storeState = {
+ playheadPosition: 0,
+ setPlayheadPosition,
+ requestMainContentScroll,
+ globalTracks: createDefaultGlobalTracks(),
+};
vi.mock('../../stores/projectStore', () => ({
useProjectStore: (selector: (state: {
playheadPosition: number;
setPlayheadPosition: typeof setPlayheadPosition;
requestMainContentScroll: typeof requestMainContentScroll;
- }) => unknown) => selector({
- playheadPosition: 0,
- setPlayheadPosition,
- requestMainContentScroll,
- }),
+ globalTracks: typeof storeState.globalTracks;
+ }) => unknown) => selector(storeState),
}));
vi.mock('../common', () => ({
@@ -66,6 +75,7 @@ vi.mock('vexflow', () => {
}
addKeySignature() {
+ vexflowMocks.addKeySignatureMock(...arguments);
return this;
}
@@ -143,7 +153,7 @@ vi.mock('vexflow', () => {
}
return {
- Accidental: { applyAccidentals: vi.fn() },
+ Accidental: { applyAccidentals: vexflowMocks.applyAccidentalsMock },
BarlineType: { SINGLE: 1, NONE: 0 },
Beam: MockBeam,
Dot: { buildAndAttach: vi.fn() },
@@ -169,6 +179,9 @@ describe('SheetMusicView', () => {
setPlayheadPosition.mockClear();
requestMainContentScroll.mockClear();
onMetricsChange.mockClear();
+ vexflowMocks.addKeySignatureMock.mockClear();
+ vexflowMocks.applyAccidentalsMock.mockClear();
+ storeState.globalTracks = createDefaultGlobalTracks();
});
it('maps header clicks in region scope without adding scroll offset', () => {
@@ -299,4 +312,64 @@ describe('SheetMusicView', () => {
expect(screen.getByTestId('playhead')).toBeInTheDocument();
});
+
+ it('renders sheet measures using effective key signatures from the global signature track', () => {
+ const activeRegion = createMockMidiRegion({
+ startFromBeat: 0,
+ length: 12,
+ notes: [createMockMidiNote({ startBeat: 0, endBeat: 1, pitch: 60 })],
+ });
+ const signatureTrack = storeState.globalTracks.find(track => track.getType() === 'signature');
+ signatureTrack?.setRegions([
+ new KGKeySignatureRegion('sig-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 1, 2, 4),
+ ]);
+
+ render(
+
+ );
+
+ expect(vexflowMocks.addKeySignatureMock).toHaveBeenCalledWith('C');
+ expect(vexflowMocks.addKeySignatureMock).toHaveBeenCalledWith('G', 'C');
+ expect(vexflowMocks.applyAccidentalsMock).toHaveBeenCalledWith(expect.any(Array), 'C');
+ expect(vexflowMocks.applyAccidentalsMock).toHaveBeenCalledWith(expect.any(Array), 'G');
+ });
+
+ it('widens a measure when a key change header is inserted', () => {
+ const activeRegion = createMockMidiRegion({
+ startFromBeat: 0,
+ length: 12,
+ notes: [],
+ });
+ const signatureTrack = storeState.globalTracks.find(track => track.getType() === 'signature');
+ signatureTrack?.setRegions([
+ new KGKeySignatureRegion('sig-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 1, 2, 4),
+ ]);
+
+ render(
+
+ );
+
+ const metrics = getLatestMetrics();
+ expect(metrics[1].widthPx).toBeGreaterThan(metrics[2].widthPx);
+ });
});
diff --git a/src/components/piano-roll/SheetMusicView.tsx b/src/components/piano-roll/SheetMusicView.tsx
index 8329ba0..b4cc636 100644
--- a/src/components/piano-roll/SheetMusicView.tsx
+++ b/src/components/piano-roll/SheetMusicView.tsx
@@ -3,6 +3,8 @@ import { Accidental, BarlineType, Beam, Dot, Formatter, Renderer, Stave, StaveNo
import { Playhead } from '../common';
import { useProjectStore } from '../../stores/projectStore';
import type { KeySignature } from '../../core/KGProject';
+import { GlobalTrackType } from '../../core/global-track';
+import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
import type { KGMidiRegion } from '../../core/region/KGMidiRegion';
import type { InstrumentType } from '../../core/track/KGMidiTrack';
import type { SheetMeasureMetric, SheetMeasureModel, SheetQuantization } from './sheetNotationTypes';
@@ -11,6 +13,7 @@ import {
getSheetBeatAtPixel,
buildSheetMeasureModels,
getSheetPlayheadPixel,
+ getSheetKeySignatureChangeModifierWidth,
projectKeySignatureToVexFlow,
resolveDurationSpec,
resolveSheetClef,
@@ -64,12 +67,28 @@ const SheetMusicView: React.FC = ({
}) => {
const setPlayheadPosition = useProjectStore(state => state.setPlayheadPosition);
const requestMainContentScroll = useProjectStore(state => state.requestMainContentScroll);
+ const globalTracks = useProjectStore(state => state.globalTracks);
const [metrics, setMetrics] = useState([]);
const [tiePaths, setTiePaths] = useState([]);
const headerRef = useRef(null);
const measureHostRefs = useRef>([]);
const lastDrawSignatureRef = useRef(null);
- const vexKeySignature = useMemo(() => projectKeySignatureToVexFlow(keySignature), [keySignature]);
+ const signatureRegions = useMemo(() => {
+ const signatureTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Signature);
+ if (!signatureTrack) {
+ return [] as KGKeySignatureRegion[];
+ }
+
+ return signatureTrack.getRegions()
+ .filter((region): region is KGKeySignatureRegion => region instanceof KGKeySignatureRegion)
+ .sort((left, right) => left.getStartBar() - right.getStartBar());
+ }, [globalTracks]);
+ const resolveEffectiveKeySignatureAtBar = useMemo(
+ () => (barIndex: number): KeySignature => (
+ signatureRegions.find(region => barIndex >= region.getStartBar() && barIndex < region.getEndBar())?.getKeySignature() ?? keySignature
+ ),
+ [keySignature, signatureRegions]
+ );
const startingBarNumber = useMemo(() => (
sheetMusicTrackScopeEnabled
? 1
@@ -88,13 +107,24 @@ const SheetMusicView: React.FC = ({
projectMaxBars: maxBars,
timeSignature,
quantization,
+ defaultKeySignature: keySignature,
+ resolveKeySignatureAtBar: resolveEffectiveKeySignatureAtBar,
});
- }, [activeRegion, maxBars, midiRegions, quantization, sheetMusicTrackScopeEnabled, timeSignature]);
+ }, [activeRegion, keySignature, maxBars, midiRegions, quantization, resolveEffectiveKeySignatureAtBar, 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)
+ 140 +
+ measure.events.length * EVENT_WIDTH +
+ (
+ index === 0
+ ? FIRST_MEASURE_MODIFIER_WIDTH
+ : getSheetKeySignatureChangeModifierWidth(
+ measure.keySignature,
+ measureModels[index - 1]?.keySignature ?? null
+ )
+ )
)
)),
[measureModels]
@@ -119,6 +149,7 @@ const SheetMusicView: React.FC = ({
clef,
instrument,
keySignature,
+ measureKeySignatures: measureModels.map((measure) => measure.keySignature),
quantization: quantization.raw,
numerator: timeSignature.numerator,
denominator: timeSignature.denominator,
@@ -157,6 +188,8 @@ const SheetMusicView: React.FC = ({
renderer.resize(width, STAFF_HEIGHT);
const context = renderer.getContext();
const showLeadingModifiers = index === 0;
+ const measureVexKeySignature = projectKeySignatureToVexFlow(measure.keySignature);
+ const previousMeasureKeySignature = index > 0 ? measureModels[index - 1]?.keySignature : null;
const staveX = showLeadingModifiers ? 8 : 0;
const staveWidth = Math.max(0, width - staveX);
const stave = new Stave(staveX, 10, staveWidth);
@@ -164,8 +197,13 @@ const SheetMusicView: React.FC = ({
stave.setEndBarType(BarlineType.SINGLE);
if (showLeadingModifiers) {
stave.addClef(clef);
- stave.addKeySignature(vexKeySignature);
+ stave.addKeySignature(measureVexKeySignature);
stave.addTimeSignature(`${timeSignature.numerator}/${timeSignature.denominator}`);
+ } else if (measure.keySignature !== previousMeasureKeySignature) {
+ stave.addKeySignature(
+ measureVexKeySignature,
+ previousMeasureKeySignature ? projectKeySignatureToVexFlow(previousMeasureKeySignature) : undefined
+ );
}
stave.setContext(context).draw();
@@ -176,7 +214,7 @@ const SheetMusicView: React.FC = ({
});
voice.setStrict(false);
voice.addTickables(notes);
- Accidental.applyAccidentals([voice], vexKeySignature);
+ Accidental.applyAccidentals([voice], measureVexKeySignature);
const beams = Beam.generateBeams(notes.filter(note => !note.isRest()));
new Formatter().joinVoices([voice]).formatToStave([voice], stave, { stave });
voice.draw(context, stave);
diff --git a/src/components/piano-roll/sheetNotation.test.ts b/src/components/piano-roll/sheetNotation.test.ts
index 36a6f78..78c5a4b 100644
--- a/src/components/piano-roll/sheetNotation.test.ts
+++ b/src/components/piano-roll/sheetNotation.test.ts
@@ -5,6 +5,7 @@ import {
getSheetBeatAtPixel,
buildSheetMeasureModels,
getSheetPlayheadPixel,
+ getSheetKeySignatureChangeModifierWidth,
getSheetQuantizationOptions,
isDrumInstrument,
parseSheetQuantization,
@@ -52,8 +53,8 @@ describe('sheetNotation', () => {
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: [] },
+ { barIndex: 0, absoluteBarIndex: 0, startBeat: 0, endBeat: 4, keySignature: 'C major', events: [] },
+ { barIndex: 1, absoluteBarIndex: 1, startBeat: 4, endBeat: 8, keySignature: 'C major', events: [] },
], [120, 240]);
expect(getSheetPlayheadPixel(0, metrics)).toBe(0);
@@ -74,6 +75,13 @@ describe('sheetNotation', () => {
expect(projectKeySignatureToVexFlow('F# major')).toBe('F#');
});
+ it('estimates extra width for cancelled naturals and new accidentals on key changes', () => {
+ expect(getSheetKeySignatureChangeModifierWidth('C major', 'G major')).toBeGreaterThan(0);
+ expect(getSheetKeySignatureChangeModifierWidth('G major', 'C major')).toBeGreaterThan(0);
+ expect(getSheetKeySignatureChangeModifierWidth('D major', 'G major')).toBeGreaterThan(0);
+ expect(getSheetKeySignatureChangeModifierWidth('C major', 'C major')).toBe(0);
+ });
+
it('keeps bar-aligned quarter notes in the correct measure model', () => {
const region = createMockMidiRegion({
length: 8,
@@ -132,11 +140,30 @@ describe('sheetNotation', () => {
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: [] },
+ { barIndex: 0, absoluteBarIndex: 0, startBeat: 0, endBeat: 4, keySignature: 'C major', events: [] },
+ { barIndex: 1, absoluteBarIndex: 1, startBeat: 4, endBeat: 8, keySignature: 'C major', events: [] },
], [120, 240]);
expect(getSheetPlayheadPixel(5, metrics)).toBe(180);
expect(getSheetBeatAtPixel(180, metrics)).toBe(5);
});
+
+ it('attaches effective key signatures to sheet measures', () => {
+ const region = createMockMidiRegion({
+ startFromBeat: 4,
+ length: 12,
+ notes: [createMockMidiNote({ startBeat: 0, endBeat: 1, pitch: 60 })],
+ });
+
+ const measures = buildSheetMeasureModels({
+ region,
+ timeSignature: { numerator: 4, denominator: 4 },
+ quantization: parseSheetQuantization('16,48'),
+ defaultKeySignature: 'C major',
+ resolveKeySignatureAtBar: (barIndex) => (barIndex >= 2 ? 'G major' : 'C major'),
+ });
+
+ expect(measures.map((measure) => measure.absoluteBarIndex)).toEqual([1, 2, 3]);
+ expect(measures.map((measure) => measure.keySignature)).toEqual(['C major', 'G major', 'G major']);
+ });
});
diff --git a/src/components/piano-roll/sheetNotation.ts b/src/components/piano-roll/sheetNotation.ts
index 8c6a5ed..ec66440 100644
--- a/src/components/piano-roll/sheetNotation.ts
+++ b/src/components/piano-roll/sheetNotation.ts
@@ -1,3 +1,4 @@
+import { KEY_SIGNATURE_MAP } from '../../constants/coreConstants';
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
import type { KeySignature } from '../../core/KGProject';
import type { KGMidiNote } from '../../core/midi/KGMidiNote';
@@ -30,6 +31,8 @@ export interface BuildSheetNotationOptions {
projectMaxBars?: number;
timeSignature: { numerator: number; denominator: number };
quantization: SheetQuantization;
+ defaultKeySignature?: KeySignature;
+ resolveKeySignatureAtBar?: (barIndex: number) => KeySignature;
}
interface WorkingEvent {
@@ -54,6 +57,32 @@ export function projectKeySignatureToVexFlow(keySignature: KeySignature): string
return quality === 'minor' ? `${tonic}m` : tonic;
}
+export function getSheetKeySignatureChangeModifierWidth(
+ keySignature: KeySignature,
+ previousKeySignature: KeySignature | null
+): number {
+ if (!previousKeySignature || previousKeySignature === keySignature) {
+ return 0;
+ }
+
+ const currentEntry = KEY_SIGNATURE_MAP[keySignature];
+ const previousEntry = KEY_SIGNATURE_MAP[previousKeySignature];
+ const currentCount = currentEntry.accidentals.length;
+ const previousCount = previousEntry.accidentals.length;
+ const differentTypes = (
+ (currentEntry.sharps > 0 && previousEntry.flats > 0) ||
+ (currentEntry.flats > 0 && previousEntry.sharps > 0)
+ );
+ const cancelledNaturals = differentTypes
+ ? previousCount
+ : Math.max(0, previousCount - currentCount);
+ const glyphCount = cancelledNaturals + currentCount;
+
+ // Roughly matches the added horizontal space VexFlow needs for
+ // naturals followed by the new key signature accidentals.
+ return 24 + glyphCount * 12;
+}
+
export function parseSheetQuantization(value: string): SheetQuantization {
const [primaryText, subdivisionText] = value.split(',');
const primary = Number.parseInt(primaryText, 10);
@@ -203,10 +232,13 @@ export function buildSheetMeasureModels({
projectMaxBars,
timeSignature,
quantization,
+ defaultKeySignature = 'C major',
+ resolveKeySignatureAtBar,
}: BuildSheetNotationOptions): SheetMeasureModel[] {
const beatsPerBar = timeSignature.numerator;
const isTrackScope = scope === 'track';
const timelineStartBeat = isTrackScope ? 0 : 0;
+ const regionStartBar = Math.floor(region.getStartFromBeat() / beatsPerBar);
const measureCount = isTrackScope
? Math.max(1, projectMaxBars ?? 1)
: Math.max(1, Math.ceil(region.getLength() / beatsPerBar));
@@ -226,8 +258,10 @@ export function buildSheetMeasureModels({
const measures: SheetMeasureModel[] = Array.from({ length: measureCount }, (_, barIndex) => ({
barIndex,
+ absoluteBarIndex: isTrackScope ? barIndex : regionStartBar + barIndex,
startBeat: timelineStartBeat + barIndex * beatsPerBar,
endBeat: timelineStartBeat + (barIndex + 1) * beatsPerBar,
+ keySignature: resolveKeySignatureAtBar?.(isTrackScope ? barIndex : regionStartBar + barIndex) ?? defaultKeySignature,
events: [],
}));
diff --git a/src/components/piano-roll/sheetNotationTypes.ts b/src/components/piano-roll/sheetNotationTypes.ts
index 9638efe..5410e1c 100644
--- a/src/components/piano-roll/sheetNotationTypes.ts
+++ b/src/components/piano-roll/sheetNotationTypes.ts
@@ -1,3 +1,5 @@
+import type { KeySignature } from '../../core/KGProject';
+
export interface SheetMeasureMetric {
barIndex: number;
startBeat: number;
@@ -24,7 +26,9 @@ export interface SheetDisplayEvent {
export interface SheetMeasureModel {
barIndex: number;
+ absoluteBarIndex: number;
startBeat: number;
endBeat: number;
+ keySignature: KeySignature;
events: SheetDisplayEvent[];
}
diff --git a/src/core/KGProject.ts b/src/core/KGProject.ts
index 8d3600a..bb0b2d2 100644
--- a/src/core/KGProject.ts
+++ b/src/core/KGProject.ts
@@ -58,7 +58,7 @@ export class KGProject {
@WithDefault(0)
private projectStructureVersion: number = 0;
- public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 13;
+ public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 14;
@Expose()
@Type(() => KGTrack, {
diff --git a/src/core/commands/global-region/CreateKeySignatureRegionCommand.ts b/src/core/commands/global-region/CreateKeySignatureRegionCommand.ts
new file mode 100644
index 0000000..f9d3f75
--- /dev/null
+++ b/src/core/commands/global-region/CreateKeySignatureRegionCommand.ts
@@ -0,0 +1,113 @@
+import { KGCommand } from '../KGCommand';
+import { KGCore } from '../../KGCore';
+import type { KeySignature } from '../../KGProject';
+import { GlobalTrackType } from '../../global-track';
+import { KGKeySignatureRegion } from '../../region/KGKeySignatureRegion';
+import { generateUniqueId } from '../../../util/miscUtil';
+import {
+ cloneKeySignatureRegions,
+ findGlobalTrackByType,
+ findKeySignatureRegionAtBar,
+ getSongEndBar,
+ getSortedKeySignatureRegions,
+} from '../../../util/globalTrackUtil';
+
+export class CreateKeySignatureRegionCommand extends KGCommand {
+ private readonly startBar: number;
+ private readonly regionId: string;
+ private createdRegion: KGKeySignatureRegion | null = null;
+ private previousRegions: KGKeySignatureRegion[] = [];
+
+ constructor(startBar: number, regionId?: string) {
+ super();
+ this.startBar = startBar;
+ this.regionId = regionId ?? generateUniqueId('KGKeySignatureRegion');
+ }
+
+ execute(): void {
+ const project = KGCore.instance().getCurrentProject();
+ const beatsPerBar = project.getTimeSignature().numerator;
+ const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
+ if (!track) {
+ throw new Error('Signature global track not found');
+ }
+
+ const existingRegions = getSortedKeySignatureRegions(track, beatsPerBar);
+ this.previousRegions = cloneKeySignatureRegions(existingRegions, beatsPerBar);
+
+ const songEndBar = getSongEndBar(project);
+ const clampedStartBar = Math.max(0, Math.min(this.startBar, Math.max(0, songEndBar - 1)));
+
+ if (existingRegions.length === 0) {
+ const nextRegions: KGKeySignatureRegion[] = [];
+
+ if (clampedStartBar > 0) {
+ nextRegions.push(new KGKeySignatureRegion(
+ generateUniqueId('KGKeySignatureRegion'),
+ track.getId(),
+ track.getTrackIndex(),
+ project.getKeySignature(),
+ 0,
+ clampedStartBar,
+ beatsPerBar
+ ));
+ }
+
+ this.createdRegion = new KGKeySignatureRegion(
+ this.regionId,
+ track.getId(),
+ track.getTrackIndex(),
+ project.getKeySignature(),
+ clampedStartBar,
+ Math.max(1, songEndBar - clampedStartBar),
+ beatsPerBar
+ );
+ nextRegions.push(this.createdRegion);
+ track.setRegions(nextRegions);
+ return;
+ }
+
+ const containingRegion = findKeySignatureRegionAtBar(project, clampedStartBar);
+ if (!containingRegion) {
+ throw new Error(`No key signature region covers bar ${clampedStartBar}`);
+ }
+
+ const regionStartBar = containingRegion.getStartBar();
+ const regionEndBar = containingRegion.getEndBar();
+ if (clampedStartBar <= regionStartBar || clampedStartBar >= regionEndBar) {
+ throw new Error(`Bar ${clampedStartBar} is not a valid split point`);
+ }
+
+ containingRegion.setLengthBars(clampedStartBar - regionStartBar, beatsPerBar);
+ this.createdRegion = new KGKeySignatureRegion(
+ this.regionId,
+ track.getId(),
+ track.getTrackIndex(),
+ containingRegion.getKeySignature(),
+ clampedStartBar,
+ regionEndBar - clampedStartBar,
+ beatsPerBar
+ );
+
+ track.setRegions([...existingRegions, this.createdRegion].sort((left, right) => left.getStartBar() - right.getStartBar()));
+ }
+
+ undo(): void {
+ const project = KGCore.instance().getCurrentProject();
+ const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
+ if (!track) {
+ throw new Error('Signature global track not found during undo');
+ }
+
+ const beatsPerBar = project.getTimeSignature().numerator;
+ track.setRegions(cloneKeySignatureRegions(this.previousRegions, beatsPerBar));
+ }
+
+ getDescription(): string {
+ return `Create key signature change at bar ${this.startBar + 1}`;
+ }
+
+ public getCreatedRegion(): KGKeySignatureRegion | null {
+ return this.createdRegion;
+ }
+}
diff --git a/src/core/commands/global-region/DeleteKeySignatureRegionCommand.ts b/src/core/commands/global-region/DeleteKeySignatureRegionCommand.ts
new file mode 100644
index 0000000..b6c1c51
--- /dev/null
+++ b/src/core/commands/global-region/DeleteKeySignatureRegionCommand.ts
@@ -0,0 +1,145 @@
+import { KGCommand } from '../KGCommand';
+import { KGCore } from '../../KGCore';
+import { GlobalTrackType } from '../../global-track';
+import { KGKeySignatureRegion } from '../../region/KGKeySignatureRegion';
+import {
+ cloneKeySignatureRegions,
+ findGlobalTrackByType,
+ getSortedKeySignatureRegions,
+} from '../../../util/globalTrackUtil';
+
+export class DeleteKeySignatureRegionCommand extends KGCommand {
+ private readonly regionId: string;
+ private previousRegions: KGKeySignatureRegion[] = [];
+ private deletedKeySignature = '';
+
+ constructor(regionId: string) {
+ super();
+ this.regionId = regionId;
+ }
+
+ execute(): void {
+ const project = KGCore.instance().getCurrentProject();
+ const beatsPerBar = project.getTimeSignature().numerator;
+ const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
+ if (!track) {
+ throw new Error('Signature global track not found');
+ }
+
+ const regions = getSortedKeySignatureRegions(track, beatsPerBar);
+ this.previousRegions = cloneKeySignatureRegions(regions, beatsPerBar);
+
+ const targetIndex = regions.findIndex(region => region.getId() === this.regionId);
+ if (targetIndex === -1) {
+ throw new Error(`Key signature region with ID ${this.regionId} not found`);
+ }
+
+ const targetRegion = regions[targetIndex];
+ this.deletedKeySignature = targetRegion.getKeySignature();
+
+ if (regions.length === 1) {
+ track.setRegions([]);
+ return;
+ }
+
+ const nextRegions = [...regions];
+ const deletedLengthBars = targetRegion.getLengthBars();
+
+ if (targetIndex === 0) {
+ const nextRegion = nextRegions[1];
+ nextRegion.setBarRange(0, nextRegion.getLengthBars() + deletedLengthBars, beatsPerBar);
+ nextRegions.splice(0, 1);
+ track.setRegions(nextRegions);
+ return;
+ }
+
+ const previousRegion = nextRegions[targetIndex - 1];
+ previousRegion.setLengthBars(previousRegion.getLengthBars() + deletedLengthBars, beatsPerBar);
+ nextRegions.splice(targetIndex, 1);
+ track.setRegions(nextRegions);
+ }
+
+ undo(): void {
+ const project = KGCore.instance().getCurrentProject();
+ const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
+ if (!track) {
+ throw new Error('Signature global track not found during undo');
+ }
+
+ const beatsPerBar = project.getTimeSignature().numerator;
+ track.setRegions(cloneKeySignatureRegions(this.previousRegions, beatsPerBar));
+ }
+
+ getDescription(): string {
+ return `Delete key signature "${this.deletedKeySignature || this.regionId}"`;
+ }
+}
+
+export class DeleteMultipleKeySignatureRegionsCommand extends KGCommand {
+ private readonly regionIds: string[];
+ private previousRegions: KGKeySignatureRegion[] = [];
+
+ constructor(regionIds: string[]) {
+ super();
+ this.regionIds = regionIds;
+ }
+
+ execute(): void {
+ const project = KGCore.instance().getCurrentProject();
+ const beatsPerBar = project.getTimeSignature().numerator;
+ const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
+ if (!track) {
+ throw new Error('Signature global track not found');
+ }
+
+ const regions = getSortedKeySignatureRegions(track, beatsPerBar);
+ this.previousRegions = cloneKeySignatureRegions(regions, beatsPerBar);
+
+ const remainingIds = new Set(this.regionIds);
+ let workingRegions = cloneKeySignatureRegions(regions, beatsPerBar);
+
+ for (const regionId of this.regionIds) {
+ const targetIndex = workingRegions.findIndex(region => region.getId() === regionId);
+ if (targetIndex === -1) {
+ continue;
+ }
+
+ const deletedRegion = workingRegions[targetIndex];
+ const deletedLengthBars = deletedRegion.getLengthBars();
+ remainingIds.delete(regionId);
+
+ if (workingRegions.length === 1) {
+ workingRegions = [];
+ continue;
+ }
+
+ if (targetIndex === 0) {
+ const nextRegion = workingRegions[1];
+ nextRegion.setBarRange(0, nextRegion.getLengthBars() + deletedLengthBars, beatsPerBar);
+ workingRegions.splice(0, 1);
+ continue;
+ }
+
+ const previousRegion = workingRegions[targetIndex - 1];
+ previousRegion.setLengthBars(previousRegion.getLengthBars() + deletedLengthBars, beatsPerBar);
+ workingRegions.splice(targetIndex, 1);
+ }
+
+ track.setRegions(workingRegions);
+ }
+
+ undo(): void {
+ const project = KGCore.instance().getCurrentProject();
+ const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
+ if (!track) {
+ throw new Error('Signature global track not found during undo');
+ }
+
+ const beatsPerBar = project.getTimeSignature().numerator;
+ track.setRegions(cloneKeySignatureRegions(this.previousRegions, beatsPerBar));
+ }
+
+ getDescription(): string {
+ return this.regionIds.length === 1 ? 'Delete key signature' : `Delete ${this.regionIds.length} key signatures`;
+ }
+}
diff --git a/src/core/commands/global-region/GlobalKeySignatureCommands.test.ts b/src/core/commands/global-region/GlobalKeySignatureCommands.test.ts
new file mode 100644
index 0000000..357b6fe
--- /dev/null
+++ b/src/core/commands/global-region/GlobalKeySignatureCommands.test.ts
@@ -0,0 +1,140 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { KGCore } from '../../KGCore';
+import { KGProject } from '../../KGProject';
+import { GlobalTrackType } from '../../global-track';
+import { KGKeySignatureRegion } from '../../region/KGKeySignatureRegion';
+import { CreateKeySignatureRegionCommand } from './CreateKeySignatureRegionCommand';
+import { DeleteKeySignatureRegionCommand } from './DeleteKeySignatureRegionCommand';
+import { ResizeKeySignatureRegionCommand } from './ResizeKeySignatureRegionCommand';
+import { UpdateKeySignatureRegionCommand } from './UpdateKeySignatureRegionCommand';
+
+describe('global key signature region commands', () => {
+ beforeEach(() => {
+ const project = new KGProject('Signatures', 8, 0, 120);
+ const mockCore = KGCore.instance() as unknown as {
+ getCurrentProject: ReturnType;
+ };
+
+ mockCore.getCurrentProject.mockReturnValue(project);
+ });
+
+ const getSignatureTrack = () => {
+ const signatureTrack = KGCore.instance().getCurrentProject().getGlobalTracks()
+ .find(track => track.getType() === GlobalTrackType.Signature);
+
+ if (!signatureTrack) {
+ throw new Error('Signature track missing in test setup');
+ }
+
+ return signatureTrack;
+ };
+
+ it('creates the first explicit region by splitting the project default coverage', () => {
+ const command = new CreateKeySignatureRegionCommand(3);
+ command.execute();
+
+ const signatureTrack = getSignatureTrack();
+ const regions = signatureTrack.getRegions() as KGKeySignatureRegion[];
+
+ expect(regions).toHaveLength(2);
+ expect(regions[0].getStartBar()).toBe(0);
+ expect(regions[0].getLengthBars()).toBe(3);
+ expect(regions[0].getKeySignature()).toBe('C major');
+ expect(regions[1].getStartBar()).toBe(3);
+ expect(regions[1].getLengthBars()).toBe(5);
+ expect(regions[1].getKeySignature()).toBe('C major');
+ });
+
+ it('creates additional regions by splitting the covered span and inheriting the key', () => {
+ const signatureTrack = getSignatureTrack();
+ signatureTrack.setRegions([
+ new KGKeySignatureRegion('left', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 8, 4),
+ ]);
+
+ const command = new CreateKeySignatureRegionCommand(5);
+ command.execute();
+
+ const regions = signatureTrack.getRegions() as KGKeySignatureRegion[];
+ expect(regions).toHaveLength(2);
+ expect(regions[0].getLengthBars()).toBe(5);
+ expect(regions[1].getStartBar()).toBe(5);
+ expect(regions[1].getLengthBars()).toBe(3);
+ expect(regions[1].getKeySignature()).toBe('C major');
+ });
+
+ it('resizes a shared boundary and keeps the track gapless', () => {
+ const signatureTrack = getSignatureTrack();
+ signatureTrack.setRegions([
+ new KGKeySignatureRegion('left', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 4, 4),
+ new KGKeySignatureRegion('right', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 4, 4, 4),
+ ]);
+
+ const command = new ResizeKeySignatureRegionCommand('left', 'end', 6);
+ command.execute();
+
+ const regions = signatureTrack.getRegions() as KGKeySignatureRegion[];
+ expect(regions[0].getLengthBars()).toBe(6);
+ expect(regions[1].getStartBar()).toBe(6);
+ expect(regions[1].getLengthBars()).toBe(2);
+ });
+
+ it('deletes a middle region by extending the previous region', () => {
+ const signatureTrack = getSignatureTrack();
+ signatureTrack.setRegions([
+ new KGKeySignatureRegion('first', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 2, 4),
+ new KGKeySignatureRegion('middle', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 2, 3, 4),
+ new KGKeySignatureRegion('last', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'D major', 5, 3, 4),
+ ]);
+
+ const command = new DeleteKeySignatureRegionCommand('middle');
+ command.execute();
+
+ const regions = signatureTrack.getRegions() as KGKeySignatureRegion[];
+ expect(regions).toHaveLength(2);
+ expect(regions[0].getLengthBars()).toBe(5);
+ expect(regions[1].getStartBar()).toBe(5);
+ });
+
+ it('deletes the first region by extending the next region leftward', () => {
+ const signatureTrack = getSignatureTrack();
+ signatureTrack.setRegions([
+ new KGKeySignatureRegion('first', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 2, 4),
+ new KGKeySignatureRegion('next', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 2, 6, 4),
+ ]);
+
+ const command = new DeleteKeySignatureRegionCommand('first');
+ command.execute();
+
+ const regions = signatureTrack.getRegions() as KGKeySignatureRegion[];
+ expect(regions).toHaveLength(1);
+ expect(regions[0].getStartBar()).toBe(0);
+ expect(regions[0].getLengthBars()).toBe(8);
+ });
+
+ it('allows deleting the last remaining region', () => {
+ const signatureTrack = getSignatureTrack();
+ signatureTrack.setRegions([
+ new KGKeySignatureRegion('only', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 8, 4),
+ ]);
+
+ const command = new DeleteKeySignatureRegionCommand('only');
+ command.execute();
+
+ expect(signatureTrack.getRegions()).toHaveLength(0);
+ command.undo();
+ expect(signatureTrack.getRegions()).toHaveLength(1);
+ });
+
+ it('updates the region key signature with undo support', () => {
+ const signatureTrack = getSignatureTrack();
+ signatureTrack.setRegions([
+ new KGKeySignatureRegion('region', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 8, 4),
+ ]);
+
+ const command = new UpdateKeySignatureRegionCommand('region', 'G major');
+ command.execute();
+ expect((signatureTrack.getRegions()[0] as KGKeySignatureRegion).getKeySignature()).toBe('G major');
+ command.undo();
+ expect((signatureTrack.getRegions()[0] as KGKeySignatureRegion).getKeySignature()).toBe('C major');
+ });
+});
diff --git a/src/core/commands/global-region/ResizeKeySignatureRegionCommand.ts b/src/core/commands/global-region/ResizeKeySignatureRegionCommand.ts
new file mode 100644
index 0000000..7642c1b
--- /dev/null
+++ b/src/core/commands/global-region/ResizeKeySignatureRegionCommand.ts
@@ -0,0 +1,88 @@
+import { KGCommand } from '../KGCommand';
+import { KGCore } from '../../KGCore';
+import { GlobalTrackType } from '../../global-track';
+import { KGKeySignatureRegion } from '../../region/KGKeySignatureRegion';
+import type { GlobalRegionResizeEdge } from './ResizeGlobalRegionCommand';
+import {
+ cloneKeySignatureRegions,
+ findGlobalTrackByType,
+ getSortedKeySignatureRegions,
+} from '../../../util/globalTrackUtil';
+
+export class ResizeKeySignatureRegionCommand extends KGCommand {
+ private readonly regionId: string;
+ private readonly edge: GlobalRegionResizeEdge;
+ private readonly desiredBar: number;
+ private previousRegions: KGKeySignatureRegion[] = [];
+
+ constructor(regionId: string, edge: GlobalRegionResizeEdge, desiredBar: number) {
+ super();
+ this.regionId = regionId;
+ this.edge = edge;
+ this.desiredBar = desiredBar;
+ }
+
+ execute(): void {
+ const project = KGCore.instance().getCurrentProject();
+ const beatsPerBar = project.getTimeSignature().numerator;
+ const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
+ if (!track) {
+ throw new Error('Signature global track not found');
+ }
+
+ const regions = getSortedKeySignatureRegions(track, beatsPerBar);
+ this.previousRegions = cloneKeySignatureRegions(regions, beatsPerBar);
+
+ const targetIndex = regions.findIndex(region => region.getId() === this.regionId);
+ if (targetIndex === -1) {
+ throw new Error(`Key signature region with ID ${this.regionId} not found`);
+ }
+
+ const targetRegion = regions[targetIndex];
+ if (this.edge === 'start') {
+ if (targetIndex === 0) {
+ return;
+ }
+
+ const previousRegion = regions[targetIndex - 1];
+ const targetEndBar = targetRegion.getEndBar();
+ const clampedBoundaryBar = Math.max(
+ previousRegion.getStartBar() + 1,
+ Math.min(this.desiredBar, targetEndBar - 1)
+ );
+
+ previousRegion.setLengthBars(clampedBoundaryBar - previousRegion.getStartBar(), beatsPerBar);
+ targetRegion.setBarRange(clampedBoundaryBar, targetEndBar - clampedBoundaryBar, beatsPerBar);
+ return;
+ }
+
+ if (targetIndex === regions.length - 1) {
+ return;
+ }
+
+ const nextRegion = regions[targetIndex + 1];
+ const nextRegionEndBar = nextRegion.getEndBar();
+ const clampedBoundaryBar = Math.max(
+ targetRegion.getStartBar() + 1,
+ Math.min(this.desiredBar, nextRegionEndBar - 1)
+ );
+
+ targetRegion.setLengthBars(clampedBoundaryBar - targetRegion.getStartBar(), beatsPerBar);
+ nextRegion.setBarRange(clampedBoundaryBar, nextRegionEndBar - clampedBoundaryBar, beatsPerBar);
+ }
+
+ undo(): void {
+ const project = KGCore.instance().getCurrentProject();
+ const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
+ if (!track) {
+ throw new Error('Signature global track not found during undo');
+ }
+
+ const beatsPerBar = project.getTimeSignature().numerator;
+ track.setRegions(cloneKeySignatureRegions(this.previousRegions, beatsPerBar));
+ }
+
+ getDescription(): string {
+ return `Resize key signature boundary for "${this.regionId}"`;
+ }
+}
diff --git a/src/core/commands/global-region/UpdateKeySignatureRegionCommand.ts b/src/core/commands/global-region/UpdateKeySignatureRegionCommand.ts
new file mode 100644
index 0000000..9b6e9c9
--- /dev/null
+++ b/src/core/commands/global-region/UpdateKeySignatureRegionCommand.ts
@@ -0,0 +1,40 @@
+import { KGCommand } from '../KGCommand';
+import { KGCore } from '../../KGCore';
+import type { KeySignature } from '../../KGProject';
+import { findGlobalTrackContainingRegion } from '../../../util/globalTrackUtil';
+import { KGKeySignatureRegion } from '../../region/KGKeySignatureRegion';
+
+export class UpdateKeySignatureRegionCommand extends KGCommand {
+ private readonly regionId: string;
+ private readonly nextKeySignature: KeySignature;
+ private previousKeySignature: KeySignature | null = null;
+
+ constructor(regionId: string, nextKeySignature: KeySignature) {
+ super();
+ this.regionId = regionId;
+ this.nextKeySignature = nextKeySignature;
+ }
+
+ execute(): void {
+ const result = findGlobalTrackContainingRegion(KGCore.instance().getCurrentProject(), this.regionId);
+ if (!result || !(result.region instanceof KGKeySignatureRegion)) {
+ throw new Error(`Key signature region with ID ${this.regionId} not found`);
+ }
+
+ this.previousKeySignature = result.region.getKeySignature();
+ result.region.setKeySignature(this.nextKeySignature);
+ }
+
+ undo(): void {
+ const result = findGlobalTrackContainingRegion(KGCore.instance().getCurrentProject(), this.regionId);
+ if (!result || !(result.region instanceof KGKeySignatureRegion) || !this.previousKeySignature) {
+ throw new Error(`Key signature region with ID ${this.regionId} not found during undo`);
+ }
+
+ result.region.setKeySignature(this.previousKeySignature);
+ }
+
+ getDescription(): string {
+ return `Change key signature to "${this.nextKeySignature}"`;
+ }
+}
diff --git a/src/core/commands/index.ts b/src/core/commands/index.ts
index 1136188..8943933 100644
--- a/src/core/commands/index.ts
+++ b/src/core/commands/index.ts
@@ -36,10 +36,14 @@ export { MergeMidiRegionsCommand } from './region/MergeMidiRegionsCommand';
// Global region commands
export { CreateGlobalMarkerRegionCommand } from './global-region/CreateGlobalMarkerRegionCommand';
+export { CreateKeySignatureRegionCommand } from './global-region/CreateKeySignatureRegionCommand';
export { MoveGlobalRegionCommand } from './global-region/MoveGlobalRegionCommand';
export { ResizeGlobalRegionCommand, type GlobalRegionResizeEdge } from './global-region/ResizeGlobalRegionCommand';
+export { ResizeKeySignatureRegionCommand } from './global-region/ResizeKeySignatureRegionCommand';
export { DeleteGlobalRegionCommand, DeleteMultipleGlobalRegionsCommand } from './global-region/DeleteGlobalRegionCommand';
export { UpdateGlobalRegionTextCommand } from './global-region/UpdateGlobalRegionTextCommand';
+export { DeleteKeySignatureRegionCommand, DeleteMultipleKeySignatureRegionsCommand } from './global-region/DeleteKeySignatureRegionCommand';
+export { UpdateKeySignatureRegionCommand } from './global-region/UpdateKeySignatureRegionCommand';
// Note commands
export { CreateNoteCommand } from './note/CreateNoteCommand';
diff --git a/src/core/global-track/KGGlobalTrack.ts b/src/core/global-track/KGGlobalTrack.ts
index 4838feb..5a6834a 100644
--- a/src/core/global-track/KGGlobalTrack.ts
+++ b/src/core/global-track/KGGlobalTrack.ts
@@ -1,5 +1,6 @@
import { Expose, Type } from 'class-transformer';
import { KGGlobalRegion } from '../region/KGGlobalRegion';
+import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
import { KGMarkerRegion } from '../region/KGMarkerRegion';
export enum GlobalTrackType {
@@ -32,6 +33,7 @@ export class KGGlobalTrack {
subTypes: [
{ value: KGGlobalRegion, name: 'KGGlobalRegion' },
{ value: KGMarkerRegion, name: 'KGMarkerRegion' },
+ { value: KGKeySignatureRegion, name: 'KGKeySignatureRegion' },
],
},
})
diff --git a/src/core/global-track/KGSignatureTrack.ts b/src/core/global-track/KGSignatureTrack.ts
index bf2dbb5..5378398 100644
--- a/src/core/global-track/KGSignatureTrack.ts
+++ b/src/core/global-track/KGSignatureTrack.ts
@@ -5,7 +5,7 @@ export class KGSignatureTrack extends KGGlobalTrack {
@Expose()
protected override __type: string = 'KGSignatureTrack';
- constructor(id: string = 'global-signature', trackIndex: number = 2, name: string = 'Signature') {
+ constructor(id: string = 'global-signature', trackIndex: number = 2, name: string = 'Key Signature') {
super(id, trackIndex, GlobalTrackType.Signature, name, []);
this.__type = 'KGSignatureTrack';
}
diff --git a/src/core/io/KGProjectStorage.test.ts b/src/core/io/KGProjectStorage.test.ts
index e0df5dd..17c1fc0 100644
--- a/src/core/io/KGProjectStorage.test.ts
+++ b/src/core/io/KGProjectStorage.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage';
import { KGProject } from '../KGProject';
import { GlobalTrackType } from '../global-track';
+import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
import { KGMarkerRegion } from '../region/KGMarkerRegion';
import { KGTrack } from '../track/KGTrack';
@@ -179,6 +180,25 @@ describe('KGProjectStorage', () => {
expect(loadedMarkerTrack?.getRegions()[0].getName()).toBe('Intro');
});
+ it('preserves key signature regions when saving and loading', async () => {
+ const project = createTestProject('Signature Song');
+ const signatureTrack = project.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Signature);
+
+ expect(signatureTrack).toBeDefined();
+ signatureTrack?.addRegion(new KGKeySignatureRegion('signature-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 4, 12, 4));
+
+ await storage.save('Signature Song', project);
+
+ const loaded = await storage.load('Signature Song');
+ const loadedSignatureTrack = loaded?.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Signature);
+
+ expect(loadedSignatureTrack).toBeDefined();
+ expect(loadedSignatureTrack?.getRegions()).toHaveLength(1);
+ expect(loadedSignatureTrack?.getRegions()[0]).toBeInstanceOf(KGKeySignatureRegion);
+ expect((loadedSignatureTrack?.getRegions()[0] as KGKeySignatureRegion).getKeySignature()).toBe('G major');
+ expect((loadedSignatureTrack?.getRegions()[0] as KGKeySignatureRegion).getStartBar()).toBe(4);
+ });
+
it('creates meta.json and media/ directory on save', async () => {
const project = createTestProject('My Song');
await storage.save('My Song', project);
diff --git a/src/core/project-upgrader/KGProjectUpgrader.ts b/src/core/project-upgrader/KGProjectUpgrader.ts
index 54286ce..47311b7 100644
--- a/src/core/project-upgrader/KGProjectUpgrader.ts
+++ b/src/core/project-upgrader/KGProjectUpgrader.ts
@@ -12,6 +12,7 @@ import { upgradeToV10 } from './upgradeToV10';
import { upgradeToV11 } from './upgradeToV11';
import { upgradeToV12 } from './upgradeToV12';
import { upgradeToV13 } from './upgradeToV13';
+import { upgradeToV14 } from './upgradeToV14';
/**
* Upgrade the given project to the latest structure version, one version at a time.
@@ -83,6 +84,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
workingProject = upgradeToV13(workingProject);
break;
}
+ case 14: {
+ workingProject = upgradeToV14(workingProject);
+ break;
+ }
default: {
// If an upgrader is missing, throw to prevent loading incompatible structures
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
diff --git a/src/core/project-upgrader/upgradeToV13.test.ts b/src/core/project-upgrader/upgradeToV13.test.ts
index 086bbb1..fd79615 100644
--- a/src/core/project-upgrader/upgradeToV13.test.ts
+++ b/src/core/project-upgrader/upgradeToV13.test.ts
@@ -18,6 +18,7 @@ describe('upgradeToV13', () => {
GlobalTrackType.Signature,
GlobalTrackType.Chord,
]);
+ expect(project.getGlobalTracks()[2].getName()).toBe('Key Signature');
});
it('runs through the main upgrader path', () => {
diff --git a/src/core/project-upgrader/upgradeToV14.test.ts b/src/core/project-upgrader/upgradeToV14.test.ts
new file mode 100644
index 0000000..c407e22
--- /dev/null
+++ b/src/core/project-upgrader/upgradeToV14.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+import { KGProject } from '../KGProject';
+import { upgradeProjectToLatest } from './KGProjectUpgrader';
+import { upgradeToV14 } from './upgradeToV14';
+
+describe('upgradeToV14', () => {
+ it('marks projects as upgraded even when no signature regions exist yet', () => {
+ const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 1, [], 13, 1, []);
+
+ upgradeToV14(project);
+
+ expect(project.getProjectStructureVersion()).toBe(14);
+ expect(project.getGlobalTracks()).toHaveLength(4);
+ });
+
+ it('runs through the main upgrader path', () => {
+ const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 1, [], 13, 1, []);
+
+ const upgraded = upgradeProjectToLatest(project);
+
+ expect(upgraded.getProjectStructureVersion()).toBe(KGProject.CURRENT_PROJECT_STRUCTURE_VERSION);
+ });
+});
diff --git a/src/core/project-upgrader/upgradeToV14.ts b/src/core/project-upgrader/upgradeToV14.ts
new file mode 100644
index 0000000..8bcce64
--- /dev/null
+++ b/src/core/project-upgrader/upgradeToV14.ts
@@ -0,0 +1,20 @@
+import { KGProject } from '../KGProject';
+import { GlobalTrackType } from '../global-track';
+import { ensureDefaultGlobalTracks, getSortedKeySignatureRegions } from '../../util/globalTrackUtil';
+
+export function upgradeToV14(project: KGProject): KGProject {
+ try {
+ ensureDefaultGlobalTracks(project);
+
+ const signatureTrack = project.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Signature);
+ if (signatureTrack) {
+ const beatsPerBar = project.getTimeSignature().numerator;
+ const regions = getSortedKeySignatureRegions(signatureTrack, beatsPerBar);
+ signatureTrack.setRegions(regions);
+ }
+ } finally {
+ project.setProjectStructureVersion(14);
+ }
+
+ return project;
+}
diff --git a/src/core/region/KGKeySignatureRegion.ts b/src/core/region/KGKeySignatureRegion.ts
new file mode 100644
index 0000000..287264c
--- /dev/null
+++ b/src/core/region/KGKeySignatureRegion.ts
@@ -0,0 +1,87 @@
+import { Expose } from 'class-transformer';
+import type { KeySignature } from '../KGProject';
+import { KGGlobalRegion } from './KGGlobalRegion';
+
+export class KGKeySignatureRegion extends KGGlobalRegion {
+ @Expose()
+ protected override __type: string = 'KGKeySignatureRegion';
+
+ @Expose()
+ private keySignature: KeySignature = 'C major';
+
+ @Expose()
+ private startBar: number = 0;
+
+ @Expose()
+ private lengthBars: number = 1;
+
+ constructor(
+ id: string,
+ trackId: string,
+ trackIndex: number,
+ keySignature: KeySignature,
+ startBar: number = 0,
+ lengthBars: number = 1,
+ beatsPerBar: number = 4
+ ) {
+ super(id, trackId, trackIndex, keySignature, startBar * beatsPerBar, lengthBars * beatsPerBar);
+ this.__type = 'KGKeySignatureRegion';
+ this.keySignature = keySignature;
+ this.startBar = startBar;
+ this.lengthBars = lengthBars;
+ this.syncBeatsFromBars(beatsPerBar);
+ super.setName(keySignature);
+ }
+
+ public getKeySignature(): KeySignature {
+ return this.keySignature;
+ }
+
+ public setKeySignature(keySignature: KeySignature): void {
+ this.keySignature = keySignature;
+ super.setName(keySignature);
+ }
+
+ public getStartBar(): number {
+ return this.startBar;
+ }
+
+ public getLengthBars(): number {
+ return this.lengthBars;
+ }
+
+ public getEndBar(): number {
+ return this.startBar + this.lengthBars;
+ }
+
+ public setStartBar(startBar: number, beatsPerBar: number): void {
+ this.startBar = startBar;
+ this.syncBeatsFromBars(beatsPerBar);
+ }
+
+ public setLengthBars(lengthBars: number, beatsPerBar: number): void {
+ this.lengthBars = lengthBars;
+ this.syncBeatsFromBars(beatsPerBar);
+ }
+
+ public setBarRange(startBar: number, lengthBars: number, beatsPerBar: number): void {
+ this.startBar = startBar;
+ this.lengthBars = lengthBars;
+ this.syncBeatsFromBars(beatsPerBar);
+ }
+
+ public syncBeatsFromBars(beatsPerBar: number): void {
+ super.setStartFromBeat(this.startBar * beatsPerBar);
+ super.setLength(this.lengthBars * beatsPerBar);
+ }
+
+ public syncBarsFromBeats(beatsPerBar: number): void {
+ this.startBar = Math.floor(this.getStartFromBeat() / beatsPerBar);
+ this.lengthBars = Math.max(1, Math.round(this.getLength() / beatsPerBar));
+ super.setName(this.keySignature);
+ }
+
+ public override getCurrentType(): string {
+ return 'KGKeySignatureRegion';
+ }
+}
diff --git a/src/util/globalTrackUtil.ts b/src/util/globalTrackUtil.ts
index 3a38a0d..69f3b9f 100644
--- a/src/util/globalTrackUtil.ts
+++ b/src/util/globalTrackUtil.ts
@@ -1,10 +1,12 @@
import { KGProject } from '../core/KGProject';
+import type { KeySignature } from '../core/KGProject';
import {
GlobalTrackType,
KGGlobalTrack,
createDefaultGlobalTracks,
} from '../core/global-track';
import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
+import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
export const DEFAULT_MARKER_REGION_NAME = 'Marker';
@@ -83,3 +85,55 @@ export function findMarkerNeighborBounds(
return { minStartBeat, maxEndBeat, nextStartBeat };
}
+
+export function getSongEndBar(project: KGProject): number {
+ return project.getMaxBars();
+}
+
+export function getSortedKeySignatureRegions(track: KGGlobalTrack, beatsPerBar: number): KGKeySignatureRegion[] {
+ return track.getRegions()
+ .filter((region): region is KGKeySignatureRegion => region instanceof KGKeySignatureRegion)
+ .map((region) => {
+ region.syncBarsFromBeats(beatsPerBar);
+ region.syncBeatsFromBars(beatsPerBar);
+ return region;
+ })
+ .sort((left, right) => left.getStartBar() - right.getStartBar());
+}
+
+export function cloneKeySignatureRegions(regions: KGKeySignatureRegion[], beatsPerBar: number): KGKeySignatureRegion[] {
+ return regions.map(region => new KGKeySignatureRegion(
+ region.getId(),
+ region.getTrackId(),
+ region.getTrackIndex(),
+ region.getKeySignature(),
+ region.getStartBar(),
+ region.getLengthBars(),
+ beatsPerBar
+ ));
+}
+
+export function findKeySignatureRegionAtBar(project: KGProject, bar: number): KGKeySignatureRegion | null {
+ const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
+ if (!track) {
+ return null;
+ }
+
+ const beatsPerBar = project.getTimeSignature().numerator;
+ return getSortedKeySignatureRegions(track, beatsPerBar)
+ .find(region => bar >= region.getStartBar() && bar < region.getEndBar()) ?? null;
+}
+
+export function findKeySignatureRegionAtBeat(project: KGProject, beat: number): KGKeySignatureRegion | null {
+ const beatsPerBar = project.getTimeSignature().numerator;
+ const bar = Math.floor(beat / beatsPerBar);
+ return findKeySignatureRegionAtBar(project, bar);
+}
+
+export function getEffectiveKeySignatureAtBeat(project: KGProject, beat: number): KeySignature {
+ return findKeySignatureRegionAtBeat(project, beat)?.getKeySignature() ?? project.getKeySignature();
+}
+
+export function getClampedKeySignatureRegionEndBar(region: KGKeySignatureRegion, maxBars: number): number {
+ return Math.max(region.getStartBar(), Math.min(region.getEndBar(), maxBars));
+}