event.stopPropagation()}
+ onClick={(event) => event.stopPropagation()}
+ onDoubleClick={(event) => event.stopPropagation()}
>
{
+ const baseRegion = new KGChordRegion('chord-1', 'global-chord', 3, 'Cmaj7', 0, 4);
+
+ beforeEach(() => {
+ document.documentElement.style.setProperty('--track-grid-bar-width', '40');
+ });
+
+ it('opens the chord popup for an existing region', () => {
+ const onOpenPopup = vi.fn();
+
+ render(
+
+ );
+
+ fireEvent.doubleClick(screen.getByText('Cmaj7'));
+ expect(onOpenPopup).toHaveBeenCalledWith('chord-1');
+ });
+
+ it('creates a new region at a bar-aligned beat on empty-lane double click and modifier click', () => {
+ const onCreateAtBeat = vi.fn();
+
+ const { container } = render(
+
+ );
+
+ const lane = container.querySelector('.global-chord-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 });
+ fireEvent.mouseDown(lane, { clientX: 81, clientY: 10, ctrlKey: true, button: 0 });
+
+ expect(onCreateAtBeat).toHaveBeenNthCalledWith(1, 16);
+ expect(onCreateAtBeat).toHaveBeenNthCalledWith(2, 8);
+ });
+
+ it('snaps drag moves to whole beats', async () => {
+ const onMoveRegion = vi.fn();
+
+ render(
+
+ );
+
+ const region = screen.getByText('Cmaj7').closest('.global-chord-region') as HTMLDivElement;
+ vi.spyOn(region, 'getBoundingClientRect').mockReturnValue({
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ right: 30,
+ bottom: 24,
+ width: 30,
+ height: 24,
+ toJSON: () => ({}),
+ });
+ fireEvent.mouseDown(region, { clientX: 15, clientY: 10, button: 0 });
+ fireEvent.mouseMove(window, { clientX: 32, clientY: 10 });
+ await waitFor(() => expect(region.style.left).toBe('20px'));
+ fireEvent.mouseUp(window, { clientX: 32, clientY: 10 });
+
+ expect(onMoveRegion).toHaveBeenCalledWith('chord-1', 2);
+ });
+});
diff --git a/src/components/global-track/GlobalChordLane.tsx b/src/components/global-track/GlobalChordLane.tsx
new file mode 100644
index 0000000..8de3611
--- /dev/null
+++ b/src/components/global-track/GlobalChordLane.tsx
@@ -0,0 +1,365 @@
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { KGChordRegion } from '../../core/region/KGChordRegion';
+import type { RegionClickOptions } from '../interfaces';
+import { isModifierKeyPressed } from '../../util/osUtil';
+import { TOOLBAR_CONSTANTS } from '../../constants';
+import FloatingPopup from '../common/FloatingPopup';
+import ChordPickerPopup from '../ChordPickerPopup';
+
+interface GlobalChordLaneProps {
+ chordRegions: KGChordRegion[];
+ maxBars: number;
+ barWidthMultiplier: number;
+ timeSignature: { numerator: number; denominator: number };
+ selectedRegionIds: string[];
+ popupRegionId: string | null;
+ onClosePopup: () => void;
+ onSelectRegion: (regionId: string, options?: RegionClickOptions) => void;
+ onCreateAtBeat: (startBeat: number) => void;
+ onMoveRegion: (regionId: string, startBeat: number) => void;
+ onResizeRegion: (regionId: string, edge: 'start' | 'end', beat: number) => void;
+ onChangeChord: (regionId: string, symbol: string) => void;
+ onOpenPopup: (regionId: string) => void;
+ onTabNavigate: (regionId: string, direction: 'forward' | 'backward') => void;
+}
+
+type ResizeEdge = 'start' | 'end' | null;
+
+const REGION_EDGE_HITBOX_PX = 8;
+const DRAG_THRESHOLD_PX = 4;
+
+const GlobalChordLane: React.FC
= ({
+ chordRegions,
+ maxBars,
+ barWidthMultiplier,
+ timeSignature,
+ selectedRegionIds,
+ popupRegionId,
+ onClosePopup,
+ onSelectRegion,
+ onCreateAtBeat,
+ onMoveRegion,
+ onResizeRegion,
+ onChangeChord,
+ onOpenPopup,
+ onTabNavigate,
+}) => {
+ const laneRef = useRef(null);
+ const [previewBeats, setPreviewBeats] = useState>({});
+ const [hoverEdges, setHoverEdges] = useState>({});
+ const [isModifierPressed, setIsModifierPressed] = useState(false);
+ const interactionRef = useRef<{
+ mode: 'drag' | 'resize' | null;
+ regionId: string;
+ initialMouseX: number;
+ initialStartBeat: number;
+ initialLength: number;
+ resizeEdge: ResizeEdge;
+ moved: boolean;
+ } | null>(null);
+
+ const totalBeats = maxBars * timeSignature.numerator;
+ const beatWidth = useMemo(() => {
+ const barWidth = TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * barWidthMultiplier;
+ return barWidth / 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));
+ const beatsPerBar = timeSignature.numerator;
+
+ const getBeatFromClientX = (clientX: number, mode: 'start' | 'end' = 'start') => {
+ if (!laneRef.current) {
+ return 0;
+ }
+
+ const rect = laneRef.current.getBoundingClientRect();
+ const relativeX = clientX - rect.left;
+ const rawBeat = relativeX / beatWidth;
+ return mode === 'end'
+ ? clampEndBeat(Math.round(rawBeat))
+ : clampStartBeat(Math.round(rawBeat));
+ };
+
+ const getBarSnappedBeatFromClientX = (clientX: number) => {
+ const beat = getBeatFromClientX(clientX);
+ return clampStartBeat(Math.floor(beat / beatsPerBar) * beatsPerBar);
+ };
+
+ const getRenderedBeatState = (region: KGChordRegion) => (
+ previewBeats[region.getId()] ?? {
+ startBeat: region.getStartFromBeat(),
+ length: region.getLength(),
+ }
+ );
+
+ 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;
+ };
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (isModifierKeyPressed(event)) {
+ setIsModifierPressed(true);
+ }
+ };
+
+ 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);
+ };
+ }, []);
+
+ 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 === 'drag') {
+ const beatDelta = Math.round(deltaX / beatWidth);
+ const nextStartBeat = clampStartBeat(interaction.initialStartBeat + beatDelta);
+ setPreviewBeats({
+ [interaction.regionId]: {
+ startBeat: nextStartBeat,
+ length: interaction.initialLength,
+ },
+ });
+ return;
+ }
+
+ if (interaction.mode === 'resize') {
+ const desiredBeat = getBeatFromClientX(
+ event.clientX,
+ interaction.resizeEdge === 'end' ? 'end' : 'start'
+ );
+
+ if (interaction.resizeEdge === 'start') {
+ const nextStartBeat = Math.min(desiredBeat, interaction.initialStartBeat + interaction.initialLength - 1);
+ const endBeat = interaction.initialStartBeat + interaction.initialLength;
+ setPreviewBeats({
+ [interaction.regionId]: {
+ startBeat: nextStartBeat,
+ length: Math.max(1, endBeat - nextStartBeat),
+ },
+ });
+ return;
+ }
+
+ setPreviewBeats({
+ [interaction.regionId]: {
+ startBeat: interaction.initialStartBeat,
+ length: Math.max(1, desiredBeat - interaction.initialStartBeat),
+ },
+ });
+ }
+ };
+
+ const handleMouseUp = (event: MouseEvent) => {
+ if (!interactionRef.current) {
+ return;
+ }
+
+ const interaction = interactionRef.current;
+ interactionRef.current = null;
+
+ if (!interaction.moved) {
+ setPreviewBeats({});
+ onSelectRegion(interaction.regionId, { shiftKey: event.shiftKey });
+ return;
+ }
+
+ const preview = previewBeats[interaction.regionId];
+ setPreviewBeats({});
+
+ if (!preview) {
+ return;
+ }
+
+ if (interaction.mode === 'drag') {
+ onMoveRegion(interaction.regionId, preview.startBeat);
+ return;
+ }
+
+ if (interaction.mode === 'resize' && interaction.resizeEdge) {
+ const beat = interaction.resizeEdge === 'start'
+ ? preview.startBeat
+ : preview.startBeat + preview.length;
+ onResizeRegion(interaction.regionId, interaction.resizeEdge, beat);
+ }
+ };
+
+ window.addEventListener('mousemove', handleMouseMove);
+ window.addEventListener('mouseup', handleMouseUp);
+ return () => {
+ window.removeEventListener('mousemove', handleMouseMove);
+ window.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, [beatWidth, onMoveRegion, onResizeRegion, onSelectRegion, previewBeats]);
+
+ const handleLaneMouseDown = (event: React.MouseEvent) => {
+ if (event.button !== 0) {
+ return;
+ }
+ if (!(event.target instanceof HTMLElement)) {
+ return;
+ }
+ if (event.target.closest('.global-chord-region')) {
+ return;
+ }
+ if (!isModifierKeyPressed(event)) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ onCreateAtBeat(getBarSnappedBeatFromClientX(event.clientX));
+ };
+
+ const handleLaneDoubleClick = (event: React.MouseEvent) => {
+ if (!(event.target instanceof HTMLElement)) {
+ return;
+ }
+ if (event.target.closest('.global-chord-region')) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ onCreateAtBeat(getBarSnappedBeatFromClientX(event.clientX));
+ };
+
+ return (
+
+ {chordRegions.map(region => {
+ const { startBeat, length } = getRenderedBeatState(region);
+ const isSelected = selectedRegionIds.includes(region.getId());
+ const left = startBeat * beatWidth;
+ const width = Math.max(beatWidth, length * beatWidth);
+
+ return (
+
{
+ event.preventDefault();
+ event.stopPropagation();
+ onSelectRegion(region.getId(), { shiftKey: false });
+ onOpenPopup(region.getId());
+ }}
+ onMouseMove={(event) => {
+ if (interactionRef.current?.regionId === region.getId()) {
+ return;
+ }
+
+ const resizeEdge = getResizeEdgeFromMouseEvent(event);
+ setHoverEdges((current) => (
+ current[region.getId()] === resizeEdge
+ ? current
+ : {
+ ...current,
+ [region.getId()]: resizeEdge,
+ }
+ ));
+ }}
+ onMouseLeave={() => {
+ setHoverEdges((current) => {
+ if (!current[region.getId()]) {
+ return current;
+ }
+
+ return {
+ ...current,
+ [region.getId()]: null,
+ };
+ });
+ }}
+ onMouseDown={(event) => {
+ if (event.button !== 0) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ const resizeEdge = getResizeEdgeFromMouseEvent(event);
+ interactionRef.current = {
+ mode: resizeEdge ? 'resize' : 'drag',
+ regionId: region.getId(),
+ initialMouseX: event.clientX,
+ initialStartBeat: region.getStartFromBeat(),
+ initialLength: region.getLength(),
+ resizeEdge,
+ moved: false,
+ };
+ }}
+ onClick={(event) => {
+ event.stopPropagation();
+ onSelectRegion(region.getId(), { shiftKey: event.shiftKey });
+ }}
+ >
+ {region.getSymbol()}
+ }
+ >
+ onChangeChord(region.getId(), symbol)}
+ onTabNavigate={(direction) => onTabNavigate(region.getId(), direction)}
+ />
+
+
+ );
+ })}
+
+ );
+};
+
+export default GlobalChordLane;
diff --git a/src/core/KGProject.ts b/src/core/KGProject.ts
index bb0b2d2..5a7ad45 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 = 14;
+ public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 15;
@Expose()
@Type(() => KGTrack, {
diff --git a/src/core/commands/global-region/CreateChordRegionCommand.ts b/src/core/commands/global-region/CreateChordRegionCommand.ts
new file mode 100644
index 0000000..ecbfe51
--- /dev/null
+++ b/src/core/commands/global-region/CreateChordRegionCommand.ts
@@ -0,0 +1,68 @@
+import { KGCommand } from '../KGCommand';
+import { KGCore } from '../../KGCore';
+import { GlobalTrackType } from '../../global-track';
+import { KGChordRegion } from '../../region/KGChordRegion';
+import { generateUniqueId } from '../../../util/miscUtil';
+import { findGlobalTrackByType, findNonOverlappingNeighborBounds, getSongEndBeat } from '../../../util/globalTrackUtil';
+
+export class CreateChordRegionCommand extends KGCommand {
+ private readonly startBeat: number;
+ private readonly preferredLength: number;
+ private readonly symbol: string;
+ private readonly regionId: string;
+ private createdRegion: KGChordRegion | null = null;
+
+ constructor(startBeat: number, preferredLength: number, symbol: string = 'C', regionId?: string) {
+ super();
+ this.startBeat = startBeat;
+ this.preferredLength = preferredLength;
+ this.symbol = symbol;
+ this.regionId = regionId ?? generateUniqueId('KGChordRegion');
+ }
+
+ execute(): void {
+ const project = KGCore.instance().getCurrentProject();
+ const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
+ if (!chordTrack) {
+ throw new Error('Chord global track not found');
+ }
+
+ const { maxEndBeat } = findNonOverlappingNeighborBounds(project, GlobalTrackType.Chord, null, this.startBeat);
+ const songEndBeat = getSongEndBeat(project);
+ const allowedEndBeat = Math.min(maxEndBeat, songEndBeat);
+ const targetEndBeat = Math.min(this.startBeat + this.preferredLength, allowedEndBeat);
+ const length = Math.max(1, targetEndBeat - this.startBeat);
+
+ this.createdRegion = new KGChordRegion(
+ this.regionId,
+ chordTrack.getId(),
+ chordTrack.getTrackIndex(),
+ this.symbol,
+ this.startBeat,
+ length
+ );
+
+ chordTrack.setRegions(
+ [...chordTrack.getRegions(), this.createdRegion]
+ .sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat())
+ );
+ }
+
+ undo(): void {
+ const project = KGCore.instance().getCurrentProject();
+ const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
+ if (!chordTrack) {
+ throw new Error('Chord global track not found during undo');
+ }
+
+ chordTrack.removeRegion(this.regionId);
+ }
+
+ getDescription(): string {
+ return `Create chord "${this.symbol}"`;
+ }
+
+ public getCreatedRegion(): KGChordRegion | null {
+ return this.createdRegion;
+ }
+}
diff --git a/src/core/commands/global-region/DeleteGlobalRegionCommand.ts b/src/core/commands/global-region/DeleteGlobalRegionCommand.ts
index 674fddf..c622547 100644
--- a/src/core/commands/global-region/DeleteGlobalRegionCommand.ts
+++ b/src/core/commands/global-region/DeleteGlobalRegionCommand.ts
@@ -49,7 +49,7 @@ export class DeleteGlobalRegionCommand extends KGCommand {
}
getDescription(): string {
- return `Delete marker "${this.deletedRegion?.getName() ?? this.regionId}"`;
+ return `Delete global region "${this.deletedRegion?.getName() ?? this.regionId}"`;
}
}
@@ -104,6 +104,6 @@ export class DeleteMultipleGlobalRegionsCommand extends KGCommand {
}
getDescription(): string {
- return this.regionIds.length === 1 ? 'Delete marker' : `Delete ${this.regionIds.length} markers`;
+ return this.regionIds.length === 1 ? 'Delete global region' : `Delete ${this.regionIds.length} global regions`;
}
}
diff --git a/src/core/commands/global-region/GlobalChordCommands.test.ts b/src/core/commands/global-region/GlobalChordCommands.test.ts
new file mode 100644
index 0000000..b03480d
--- /dev/null
+++ b/src/core/commands/global-region/GlobalChordCommands.test.ts
@@ -0,0 +1,107 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { KGCore } from '../../KGCore';
+import { KGProject } from '../../KGProject';
+import { GlobalTrackType } from '../../global-track';
+import { KGChordRegion } from '../../region/KGChordRegion';
+import { CreateChordRegionCommand } from './CreateChordRegionCommand';
+import { InsertChordRegionAtBeatCommand } from './InsertChordRegionAtBeatCommand';
+import { MoveGlobalRegionCommand } from './MoveGlobalRegionCommand';
+import { ResizeGlobalRegionCommand } from './ResizeGlobalRegionCommand';
+import { UpdateChordRegionCommand } from './UpdateChordRegionCommand';
+
+describe('global chord region commands', () => {
+ beforeEach(() => {
+ const project = new KGProject('Chords', 8, 0, 120);
+ const mockCore = KGCore.instance() as unknown as {
+ getCurrentProject: ReturnType;
+ getSelectedItems: ReturnType;
+ removeSelectedItem?: ReturnType;
+ };
+
+ mockCore.getCurrentProject.mockReturnValue(project);
+ mockCore.getSelectedItems.mockReturnValue([]);
+ if (!mockCore.removeSelectedItem) {
+ mockCore.removeSelectedItem = vi.fn();
+ } else {
+ mockCore.removeSelectedItem.mockReset();
+ }
+ });
+
+ const getChordTrack = () => {
+ const chordTrack = KGCore.instance().getCurrentProject().getGlobalTracks()
+ .find(track => track.getType() === GlobalTrackType.Chord);
+
+ if (!chordTrack) {
+ throw new Error('Chord track missing in test setup');
+ }
+
+ return chordTrack;
+ };
+
+ it('creates a chord region with a default one-bar length clamped by the next region', () => {
+ const chordTrack = getChordTrack();
+ chordTrack.addRegion(new KGChordRegion('existing', chordTrack.getId(), chordTrack.getTrackIndex(), 'Fmaj7', 6, 2));
+
+ const command = new CreateChordRegionCommand(4, 4, 'Cmaj7');
+ command.execute();
+
+ const created = command.getCreatedRegion();
+ expect(created?.getStartFromBeat()).toBe(4);
+ expect(created?.getLength()).toBe(2);
+ });
+
+ it('moves and resizes chord regions with beat snapping and no overlap', () => {
+ const chordTrack = getChordTrack();
+ const region = new KGChordRegion('middle', chordTrack.getId(), chordTrack.getTrackIndex(), 'Dm7', 4, 2);
+ chordTrack.setRegions([
+ new KGChordRegion('left', chordTrack.getId(), chordTrack.getTrackIndex(), 'C', 0, 4),
+ region,
+ new KGChordRegion('right', chordTrack.getId(), chordTrack.getTrackIndex(), 'G7', 10, 2),
+ ]);
+
+ const moveCommand = new MoveGlobalRegionCommand('middle', 9);
+ moveCommand.execute();
+ expect(region.getStartFromBeat()).toBe(8);
+
+ const resizeCommand = new ResizeGlobalRegionCommand('middle', 'end', 12);
+ resizeCommand.execute();
+ expect(region.getLength()).toBe(2);
+
+ const resizeMinCommand = new ResizeGlobalRegionCommand('middle', 'start', 9);
+ resizeMinCommand.execute();
+ expect(region.getStartFromBeat()).toBe(9);
+ expect(region.getLength()).toBe(1);
+ });
+
+ it('updates chord symbols with undo support', () => {
+ const chordTrack = getChordTrack();
+ const region = new KGChordRegion('chord', chordTrack.getId(), chordTrack.getTrackIndex(), 'C', 0, 4);
+ chordTrack.setRegions([region]);
+
+ const command = new UpdateChordRegionCommand('chord', 'Bm7b5');
+ command.execute();
+ expect(region.getSymbol()).toBe('Bm7b5');
+
+ command.undo();
+ expect(region.getSymbol()).toBe('C');
+ });
+
+ it('inserts a new chord inside an existing region and shortens the original', () => {
+ const chordTrack = getChordTrack();
+ const region = new KGChordRegion('chord', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 8);
+ chordTrack.setRegions([region]);
+
+ const command = new InsertChordRegionAtBeatCommand(3, 'C');
+ command.execute();
+
+ const created = command.getCreatedRegion();
+ expect(created).not.toBeNull();
+ expect(region.getLength()).toBe(3);
+ expect(created?.getStartFromBeat()).toBe(3);
+ expect(created?.getLength()).toBe(5);
+
+ command.undo();
+ expect(chordTrack.getRegions()).toHaveLength(1);
+ expect(region.getLength()).toBe(8);
+ });
+});
diff --git a/src/core/commands/global-region/InsertChordRegionAtBeatCommand.ts b/src/core/commands/global-region/InsertChordRegionAtBeatCommand.ts
new file mode 100644
index 0000000..5542705
--- /dev/null
+++ b/src/core/commands/global-region/InsertChordRegionAtBeatCommand.ts
@@ -0,0 +1,86 @@
+import { KGCommand } from '../KGCommand';
+import { KGCore } from '../../KGCore';
+import { GlobalTrackType } from '../../global-track';
+import { KGChordRegion } from '../../region/KGChordRegion';
+import { generateUniqueId } from '../../../util/miscUtil';
+import { findChordRegionAtBeat, findGlobalTrackByType } from '../../../util/globalTrackUtil';
+
+export class InsertChordRegionAtBeatCommand extends KGCommand {
+ private readonly insertBeat: number;
+ private readonly symbol: string;
+ private readonly regionId: string;
+ private createdRegion: KGChordRegion | null = null;
+ private targetRegionId: string | null = null;
+ private originalTargetLength = 0;
+
+ constructor(insertBeat: number, symbol: string = 'C', regionId?: string) {
+ super();
+ this.insertBeat = insertBeat;
+ this.symbol = symbol;
+ this.regionId = regionId ?? generateUniqueId('KGChordRegion');
+ }
+
+ execute(): void {
+ const project = KGCore.instance().getCurrentProject();
+ const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
+ if (!chordTrack) {
+ throw new Error('Chord global track not found');
+ }
+
+ const occupiedRegion = findChordRegionAtBeat(project, this.insertBeat);
+ if (!occupiedRegion) {
+ throw new Error(`No chord region found at beat ${this.insertBeat}`);
+ }
+
+ const regionStart = occupiedRegion.getStartFromBeat();
+ const regionEnd = regionStart + occupiedRegion.getLength();
+ if (this.insertBeat <= regionStart || this.insertBeat >= regionEnd) {
+ throw new Error(`Cannot insert chord at beat ${this.insertBeat} without shrinking region below minimum length`);
+ }
+
+ this.targetRegionId = occupiedRegion.getId();
+ this.originalTargetLength = occupiedRegion.getLength();
+ occupiedRegion.setLength(this.insertBeat - regionStart);
+
+ this.createdRegion = new KGChordRegion(
+ this.regionId,
+ chordTrack.getId(),
+ chordTrack.getTrackIndex(),
+ this.symbol,
+ this.insertBeat,
+ regionEnd - this.insertBeat
+ );
+
+ chordTrack.setRegions(
+ [...chordTrack.getRegions(), this.createdRegion]
+ .sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat())
+ );
+ }
+
+ undo(): void {
+ const project = KGCore.instance().getCurrentProject();
+ const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
+ if (!chordTrack || !this.targetRegionId || !this.createdRegion) {
+ throw new Error('Cannot undo inserted chord region');
+ }
+
+ chordTrack.removeRegion(this.createdRegion.getId());
+ const targetRegion = chordTrack.getRegions().find((region): region is KGChordRegion => (
+ region instanceof KGChordRegion && region.getId() === this.targetRegionId
+ ));
+ if (!targetRegion) {
+ throw new Error(`Chord region ${this.targetRegionId} not found during undo`);
+ }
+
+ targetRegion.setLength(this.originalTargetLength);
+ chordTrack.setRegions([...chordTrack.getRegions()].sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat()));
+ }
+
+ getDescription(): string {
+ return `Insert chord "${this.symbol}"`;
+ }
+
+ public getCreatedRegion(): KGChordRegion | null {
+ return this.createdRegion;
+ }
+}
diff --git a/src/core/commands/global-region/MoveGlobalRegionCommand.ts b/src/core/commands/global-region/MoveGlobalRegionCommand.ts
index a2b2a3b..0b0c12a 100644
--- a/src/core/commands/global-region/MoveGlobalRegionCommand.ts
+++ b/src/core/commands/global-region/MoveGlobalRegionCommand.ts
@@ -2,7 +2,7 @@ import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGGlobalRegion } from '../../region/KGGlobalRegion';
import { GlobalTrackType } from '../../global-track';
-import { findGlobalTrackContainingRegion, findMarkerNeighborBounds } from '../../../util/globalTrackUtil';
+import { findGlobalTrackContainingRegion, findNonOverlappingNeighborBounds } from '../../../util/globalTrackUtil';
export class MoveGlobalRegionCommand extends KGCommand {
private readonly regionId: string;
@@ -26,12 +26,17 @@ export class MoveGlobalRegionCommand extends KGCommand {
this.targetRegion = result.region;
this.originalStartBeat = result.region.getStartFromBeat();
- if (result.track.getType() !== GlobalTrackType.Marker) {
+ if (result.track.getType() !== GlobalTrackType.Marker && result.track.getType() !== GlobalTrackType.Chord) {
result.region.setStartFromBeat(this.desiredStartBeat);
return;
}
- const { minStartBeat, maxEndBeat } = findMarkerNeighborBounds(project, this.regionId, this.desiredStartBeat);
+ const { minStartBeat, maxEndBeat } = findNonOverlappingNeighborBounds(
+ project,
+ result.track.getType() as GlobalTrackType.Marker | GlobalTrackType.Chord,
+ this.regionId,
+ this.desiredStartBeat
+ );
const maxStartBeat = Math.max(minStartBeat, maxEndBeat - result.region.getLength());
const clampedStartBeat = Math.max(minStartBeat, Math.min(this.desiredStartBeat, maxStartBeat));
result.region.setStartFromBeat(clampedStartBeat);
diff --git a/src/core/commands/global-region/ResizeGlobalRegionCommand.ts b/src/core/commands/global-region/ResizeGlobalRegionCommand.ts
index f0294bf..6c0cada 100644
--- a/src/core/commands/global-region/ResizeGlobalRegionCommand.ts
+++ b/src/core/commands/global-region/ResizeGlobalRegionCommand.ts
@@ -1,7 +1,7 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { GlobalTrackType } from '../../global-track';
-import { findGlobalTrackContainingRegion, findMarkerNeighborBounds, getSongEndBeat } from '../../../util/globalTrackUtil';
+import { findGlobalTrackContainingRegion, findNonOverlappingNeighborBounds, getSongEndBeat } from '../../../util/globalTrackUtil';
import { KGGlobalRegion } from '../../region/KGGlobalRegion';
export type GlobalRegionResizeEdge = 'start' | 'end';
@@ -32,12 +32,17 @@ export class ResizeGlobalRegionCommand extends KGCommand {
this.originalStartBeat = result.region.getStartFromBeat();
this.originalLength = result.region.getLength();
- if (result.track.getType() !== GlobalTrackType.Marker) {
+ if (result.track.getType() !== GlobalTrackType.Marker && result.track.getType() !== GlobalTrackType.Chord) {
return;
}
const originalEndBeat = this.originalStartBeat + this.originalLength;
- const { minStartBeat, maxEndBeat } = findMarkerNeighborBounds(project, this.regionId, this.originalStartBeat);
+ const { minStartBeat, maxEndBeat } = findNonOverlappingNeighborBounds(
+ project,
+ result.track.getType() as GlobalTrackType.Marker | GlobalTrackType.Chord,
+ this.regionId,
+ this.originalStartBeat
+ );
const songEndBeat = getSongEndBeat(project);
const absoluteMaxEndBeat = Math.min(maxEndBeat, songEndBeat);
diff --git a/src/core/commands/global-region/UpdateChordRegionCommand.ts b/src/core/commands/global-region/UpdateChordRegionCommand.ts
new file mode 100644
index 0000000..3a92ffe
--- /dev/null
+++ b/src/core/commands/global-region/UpdateChordRegionCommand.ts
@@ -0,0 +1,39 @@
+import { KGCommand } from '../KGCommand';
+import { KGCore } from '../../KGCore';
+import { findGlobalTrackContainingRegion } from '../../../util/globalTrackUtil';
+import { KGChordRegion } from '../../region/KGChordRegion';
+
+export class UpdateChordRegionCommand extends KGCommand {
+ private readonly regionId: string;
+ private readonly nextSymbol: string;
+ private previousSymbol: string | null = null;
+
+ constructor(regionId: string, nextSymbol: string) {
+ super();
+ this.regionId = regionId;
+ this.nextSymbol = nextSymbol;
+ }
+
+ execute(): void {
+ const result = findGlobalTrackContainingRegion(KGCore.instance().getCurrentProject(), this.regionId);
+ if (!result || !(result.region instanceof KGChordRegion)) {
+ throw new Error(`Chord region with ID ${this.regionId} not found`);
+ }
+
+ this.previousSymbol = result.region.getSymbol();
+ result.region.setSymbol(this.nextSymbol);
+ }
+
+ undo(): void {
+ const result = findGlobalTrackContainingRegion(KGCore.instance().getCurrentProject(), this.regionId);
+ if (!result || !(result.region instanceof KGChordRegion) || this.previousSymbol === null) {
+ throw new Error(`Chord region with ID ${this.regionId} not found during undo`);
+ }
+
+ result.region.setSymbol(this.previousSymbol);
+ }
+
+ getDescription(): string {
+ return `Change chord to "${this.nextSymbol}"`;
+ }
+}
diff --git a/src/core/commands/index.ts b/src/core/commands/index.ts
index 1c2fcc3..2fe9532 100644
--- a/src/core/commands/index.ts
+++ b/src/core/commands/index.ts
@@ -36,6 +36,8 @@ export { MergeMidiRegionsCommand } from './region/MergeMidiRegionsCommand';
// Global region commands
export { CreateGlobalMarkerRegionCommand } from './global-region/CreateGlobalMarkerRegionCommand';
+export { CreateChordRegionCommand } from './global-region/CreateChordRegionCommand';
+export { InsertChordRegionAtBeatCommand } from './global-region/InsertChordRegionAtBeatCommand';
export { CreateKeySignatureRegionCommand } from './global-region/CreateKeySignatureRegionCommand';
export { CreateTempoRegionCommand } from './global-region/CreateTempoRegionCommand';
export { MoveGlobalRegionCommand } from './global-region/MoveGlobalRegionCommand';
@@ -44,6 +46,7 @@ export { ResizeKeySignatureRegionCommand } from './global-region/ResizeKeySignat
export { ResizeTempoRegionCommand } from './global-region/ResizeTempoRegionCommand';
export { DeleteGlobalRegionCommand, DeleteMultipleGlobalRegionsCommand } from './global-region/DeleteGlobalRegionCommand';
export { UpdateGlobalRegionTextCommand } from './global-region/UpdateGlobalRegionTextCommand';
+export { UpdateChordRegionCommand } from './global-region/UpdateChordRegionCommand';
export { DeleteKeySignatureRegionCommand, DeleteMultipleKeySignatureRegionsCommand } from './global-region/DeleteKeySignatureRegionCommand';
export { UpdateKeySignatureRegionCommand } from './global-region/UpdateKeySignatureRegionCommand';
export { DeleteTempoRegionCommand, DeleteMultipleTempoRegionsCommand } from './global-region/DeleteTempoRegionCommand';
diff --git a/src/core/global-track/KGGlobalTrack.ts b/src/core/global-track/KGGlobalTrack.ts
index c6d549b..9d63d16 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 { KGChordRegion } from '../region/KGChordRegion';
import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
import { KGMarkerRegion } from '../region/KGMarkerRegion';
import { KGTempoRegion } from '../region/KGTempoRegion';
@@ -36,6 +37,7 @@ export class KGGlobalTrack {
{ value: KGMarkerRegion, name: 'KGMarkerRegion' },
{ value: KGTempoRegion, name: 'KGTempoRegion' },
{ value: KGKeySignatureRegion, name: 'KGKeySignatureRegion' },
+ { value: KGChordRegion, name: 'KGChordRegion' },
],
},
})
diff --git a/src/core/io/KGProjectStorage.test.ts b/src/core/io/KGProjectStorage.test.ts
index 17c1fc0..af8dba8 100644
--- a/src/core/io/KGProjectStorage.test.ts
+++ b/src/core/io/KGProjectStorage.test.ts
@@ -4,6 +4,7 @@ import { KGProject } from '../KGProject';
import { GlobalTrackType } from '../global-track';
import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
import { KGMarkerRegion } from '../region/KGMarkerRegion';
+import { KGChordRegion } from '../region/KGChordRegion';
import { KGTrack } from '../track/KGTrack';
// --- OPFS mock infrastructure ---
@@ -199,6 +200,24 @@ describe('KGProjectStorage', () => {
expect((loadedSignatureTrack?.getRegions()[0] as KGKeySignatureRegion).getStartBar()).toBe(4);
});
+ it('preserves chord regions when saving and loading', async () => {
+ const project = createTestProject('Chord Song');
+ const chordTrack = project.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Chord);
+
+ expect(chordTrack).toBeDefined();
+ chordTrack?.addRegion(new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Bm7b5', 5, 3));
+
+ await storage.save('Chord Song', project);
+
+ const loaded = await storage.load('Chord Song');
+ const loadedChordTrack = loaded?.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Chord);
+
+ expect(loadedChordTrack).toBeDefined();
+ expect(loadedChordTrack?.getRegions()).toHaveLength(1);
+ expect(loadedChordTrack?.getRegions()[0]).toBeInstanceOf(KGChordRegion);
+ expect((loadedChordTrack?.getRegions()[0] as KGChordRegion).getSymbol()).toBe('Bm7b5');
+ });
+
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 47311b7..09e4fad 100644
--- a/src/core/project-upgrader/KGProjectUpgrader.ts
+++ b/src/core/project-upgrader/KGProjectUpgrader.ts
@@ -13,6 +13,7 @@ import { upgradeToV11 } from './upgradeToV11';
import { upgradeToV12 } from './upgradeToV12';
import { upgradeToV13 } from './upgradeToV13';
import { upgradeToV14 } from './upgradeToV14';
+import { upgradeToV15 } from './upgradeToV15';
/**
* Upgrade the given project to the latest structure version, one version at a time.
@@ -88,6 +89,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
workingProject = upgradeToV14(workingProject);
break;
}
+ case 15: {
+ workingProject = upgradeToV15(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/upgradeToV15.test.ts b/src/core/project-upgrader/upgradeToV15.test.ts
new file mode 100644
index 0000000..aac8da6
--- /dev/null
+++ b/src/core/project-upgrader/upgradeToV15.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+import { KGProject } from '../KGProject';
+import { upgradeProjectToLatest } from './KGProjectUpgrader';
+import { upgradeToV15 } from './upgradeToV15';
+
+describe('upgradeToV15', () => {
+ it('marks projects as upgraded and preserves default global tracks', () => {
+ const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 1, [], 14, 1, []);
+
+ upgradeToV15(project);
+
+ expect(project.getProjectStructureVersion()).toBe(15);
+ 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, [], 14, 1, []);
+
+ const upgraded = upgradeProjectToLatest(project);
+
+ expect(upgraded.getProjectStructureVersion()).toBe(KGProject.CURRENT_PROJECT_STRUCTURE_VERSION);
+ });
+});
diff --git a/src/core/project-upgrader/upgradeToV15.ts b/src/core/project-upgrader/upgradeToV15.ts
new file mode 100644
index 0000000..a78cd56
--- /dev/null
+++ b/src/core/project-upgrader/upgradeToV15.ts
@@ -0,0 +1,12 @@
+import { KGProject } from '../KGProject';
+import { ensureDefaultGlobalTracks } from '../../util/globalTrackUtil';
+
+export function upgradeToV15(project: KGProject): KGProject {
+ try {
+ ensureDefaultGlobalTracks(project);
+ } finally {
+ project.setProjectStructureVersion(15);
+ }
+
+ return project;
+}
diff --git a/src/core/region/KGChordRegion.ts b/src/core/region/KGChordRegion.ts
new file mode 100644
index 0000000..d544606
--- /dev/null
+++ b/src/core/region/KGChordRegion.ts
@@ -0,0 +1,37 @@
+import { Expose } from 'class-transformer';
+import { KGGlobalRegion } from './KGGlobalRegion';
+
+export class KGChordRegion extends KGGlobalRegion {
+ @Expose()
+ protected override __type: string = 'KGChordRegion';
+
+ @Expose()
+ private symbol: string = 'C';
+
+ constructor(
+ id: string,
+ trackId: string,
+ trackIndex: number,
+ symbol: string,
+ startFromBeat: number = 0,
+ length: number = 0
+ ) {
+ super(id, trackId, trackIndex, symbol, startFromBeat, length);
+ this.__type = 'KGChordRegion';
+ this.symbol = symbol;
+ super.setName(symbol);
+ }
+
+ public getSymbol(): string {
+ return this.symbol;
+ }
+
+ public setSymbol(symbol: string): void {
+ this.symbol = symbol;
+ super.setName(symbol);
+ }
+
+ public override getCurrentType(): string {
+ return 'KGChordRegion';
+ }
+}
diff --git a/src/util/chordUtil.test.ts b/src/util/chordUtil.test.ts
new file mode 100644
index 0000000..9dba207
--- /dev/null
+++ b/src/util/chordUtil.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest';
+import { buildChordSymbol, formatChordSymbolForDisplay, getChordMidiPitches, getChordPitchClasses, parseChordSymbol } from './chordUtil';
+
+describe('chordUtil', () => {
+ it('parses half-diminished chords into the popup descriptor shape', () => {
+ const parsed = parseChordSymbol('Bm7b5');
+
+ expect(parsed).not.toBeNull();
+ expect(parsed?.root).toBe('B');
+ expect(parsed?.quality).toBe('dim');
+ expect(parsed?.extensions).toContain('b5');
+ expect(parsed?.extensions).toContain('7');
+ expect(parsed?.symbol).toBe('Bm7b5');
+ });
+
+ it('preserves enharmonic root spelling in the canonical symbol', () => {
+ expect(buildChordSymbol({
+ root: 'Bb',
+ quality: 'maj',
+ extensions: ['7'],
+ })).toBe('Bb7');
+
+ expect(buildChordSymbol({
+ root: 'A#',
+ quality: 'maj',
+ extensions: ['7'],
+ })).toBe('A#7');
+ });
+
+ it('rejects unsupported symbols deterministically', () => {
+ expect(parseChordSymbol('C/E')).toBeNull();
+ expect(parseChordSymbol('not-a-chord')).toBeNull();
+ });
+
+ it('derives stable pitch classes and midi pitches from the stored symbol', () => {
+ expect(getChordPitchClasses('Bm7b5')).toEqual([11, 2, 5, 9]);
+ expect(getChordMidiPitches('Bm7b5', 59)).toEqual([59, 62, 65, 69]);
+ });
+
+ it('formats the preview using standard chord display conventions', () => {
+ expect(formatChordSymbolForDisplay('Bm7b5')).toBe('Bm7(♭5)');
+ expect(formatChordSymbolForDisplay('Bbmaj7#11')).toBe('B♭maj7(♯11)');
+ });
+});
diff --git a/src/util/chordUtil.ts b/src/util/chordUtil.ts
new file mode 100644
index 0000000..f8607da
--- /dev/null
+++ b/src/util/chordUtil.ts
@@ -0,0 +1,550 @@
+import { Chord, Interval, Note } from 'tonal';
+
+export type ChordQuality = 'maj' | 'min' | 'sus2' | 'sus4' | 'power' | 'aug' | 'dim';
+export type ChordExtension =
+ | 'b5'
+ | '#5'
+ | '6'
+ | '7'
+ | 'maj7'
+ | 'b9'
+ | '9'
+ | '#9'
+ | '11'
+ | '#11'
+ | 'b13'
+ | '13';
+
+export interface ChordDescriptor {
+ root: string;
+ quality: ChordQuality;
+ extensions: ChordExtension[];
+ symbol: string;
+}
+
+const ROOT_PATTERN = /^[A-G](?:#|b)?$/;
+const EXTENSION_ORDER: ChordExtension[] = ['b5', '#5', '6', '7', 'maj7', 'b9', '9', '#9', '11', '#11', 'b13', '13'];
+const REMAINING_EXTENSION_ORDER: ChordExtension[] = ['b5', '#5', 'b9', '9', '#9', '11', '#11', 'b13', '13'];
+const ADD_EXTENSION_ORDER: ChordExtension[] = ['b9', '9', '#9', '11', '#11', 'b13', '13'];
+const CUSTOM_TOKENS = [
+ 'maj7#5',
+ 'm7b5',
+ 'sus2',
+ 'sus4',
+ 'aug',
+ 'dim',
+ 'maj7',
+ 'add#11',
+ 'addb13',
+ 'add13',
+ 'add11',
+ 'add#9',
+ 'addb9',
+ 'add9',
+ '#11',
+ 'b13',
+ '#9',
+ 'b9',
+ '9',
+ '13',
+ '11',
+ '#5',
+ 'b5',
+ '7',
+ '6',
+ 'm',
+ '5',
+] as const;
+
+function normalizeRoot(root: string): string | null {
+ const normalized = root.trim();
+ if (!ROOT_PATTERN.test(normalized)) {
+ return null;
+ }
+
+ const parsed = Note.get(normalized);
+ return parsed.empty || parsed.acc.length > 1 ? null : parsed.pc;
+}
+
+function sortExtensions(extensions: Iterable): ChordExtension[] {
+ const unique = new Set(extensions);
+ return EXTENSION_ORDER.filter(extension => unique.has(extension));
+}
+
+function hasExtension(descriptor: Pick, extension: ChordExtension): boolean {
+ return descriptor.extensions.includes(extension);
+}
+
+function getDescriptorIntervals(descriptor: Pick): string[] {
+ const intervals = ['1P'];
+
+ switch (descriptor.quality) {
+ case 'maj':
+ intervals.push('3M', '5P');
+ break;
+ case 'min':
+ intervals.push('3m', '5P');
+ break;
+ case 'sus2':
+ intervals.push('2M', '5P');
+ break;
+ case 'sus4':
+ intervals.push('4P', '5P');
+ break;
+ case 'power':
+ intervals.push('5P');
+ break;
+ case 'aug':
+ intervals.push('3M', '5A');
+ break;
+ case 'dim':
+ intervals.push('3m', '5d');
+ break;
+ }
+
+ for (const extension of descriptor.extensions) {
+ switch (extension) {
+ case 'b5':
+ if (!intervals.includes('5d')) {
+ intervals.push('5d');
+ }
+ break;
+ case '#5':
+ if (!intervals.includes('5A')) {
+ intervals.push('5A');
+ }
+ break;
+ case '6':
+ intervals.push('6M');
+ break;
+ case '7':
+ intervals.push('7m');
+ break;
+ case 'maj7':
+ intervals.push('7M');
+ break;
+ case 'b9':
+ intervals.push('9m');
+ break;
+ case '9':
+ intervals.push('9M');
+ break;
+ case '#9':
+ intervals.push('9A');
+ break;
+ case '11':
+ intervals.push('11P');
+ break;
+ case '#11':
+ intervals.push('11A');
+ break;
+ case 'b13':
+ intervals.push('13m');
+ break;
+ case '13':
+ intervals.push('13M');
+ break;
+ }
+ }
+
+ return Array.from(new Set(intervals));
+}
+
+function parseIntervalsToDescriptor(root: string, intervals: string[]): ChordDescriptor | null {
+ const intervalSet = new Set(intervals);
+ let quality: ChordQuality | null = null;
+
+ if (intervalSet.has('5A')) {
+ quality = 'aug';
+ } else if (intervalSet.has('2M') && !intervalSet.has('3m') && !intervalSet.has('3M') && !intervalSet.has('4P') && intervalSet.has('5P')) {
+ quality = 'sus2';
+ } else if (intervalSet.has('4P') && !intervalSet.has('3m') && !intervalSet.has('3M') && intervalSet.has('5P')) {
+ quality = 'sus4';
+ } else if (intervalSet.has('3m') && intervalSet.has('5d')) {
+ quality = 'dim';
+ } else if (intervalSet.has('3m')) {
+ quality = 'min';
+ } else if (intervalSet.has('3M')) {
+ quality = 'maj';
+ } else if (intervalSet.has('5P')) {
+ quality = 'power';
+ }
+
+ if (!quality) {
+ return null;
+ }
+
+ if (intervalSet.has('7d') || intervalSet.has('3A') || intervalSet.has('4d')) {
+ return null;
+ }
+
+ const extensions = new Set();
+ if (intervalSet.has('5d')) {
+ extensions.add('b5');
+ }
+ if (intervalSet.has('5A')) {
+ extensions.add('#5');
+ }
+ if (intervalSet.has('6M')) {
+ extensions.add('6');
+ }
+ if (intervalSet.has('7m')) {
+ extensions.add('7');
+ }
+ if (intervalSet.has('7M')) {
+ extensions.add('maj7');
+ }
+ if (intervalSet.has('9m') || intervalSet.has('2m')) {
+ extensions.add('b9');
+ }
+ if (intervalSet.has('9M') || (intervalSet.has('2M') && quality !== 'sus2')) {
+ extensions.add('9');
+ }
+ if (intervalSet.has('9A')) {
+ extensions.add('#9');
+ }
+ if (intervalSet.has('11P') && quality !== 'sus4') {
+ extensions.add('11');
+ }
+ if (intervalSet.has('11A')) {
+ extensions.add('#11');
+ }
+ if (intervalSet.has('13m') || intervalSet.has('6m')) {
+ extensions.add('b13');
+ }
+ if (intervalSet.has('13M')) {
+ extensions.add('13');
+ }
+
+ const descriptor: ChordDescriptor = {
+ root,
+ quality,
+ extensions: sortExtensions(extensions),
+ symbol: '',
+ };
+
+ const symbol = buildChordSymbol(descriptor);
+ if (!symbol) {
+ return null;
+ }
+
+ return {
+ ...descriptor,
+ symbol,
+ };
+}
+
+function parseCustomChordSymbol(symbol: string): ChordDescriptor | null {
+ const trimmed = symbol.trim();
+ const rootMatch = trimmed.match(/^([A-G](?:#|b)?)(.*)$/);
+ if (!rootMatch) {
+ return null;
+ }
+
+ const root = normalizeRoot(rootMatch[1]);
+ if (!root) {
+ return null;
+ }
+
+ let remainder = rootMatch[2];
+ let quality: ChordQuality = 'maj';
+ const extensions = new Set();
+
+ if (remainder.startsWith('m7b5')) {
+ quality = 'dim';
+ extensions.add('b5');
+ extensions.add('7');
+ remainder = remainder.slice(4);
+ } else if (remainder.startsWith('maj7#5')) {
+ quality = 'aug';
+ extensions.add('#5');
+ extensions.add('maj7');
+ remainder = remainder.slice(6);
+ } else if (remainder.startsWith('7#5')) {
+ quality = 'aug';
+ extensions.add('#5');
+ extensions.add('7');
+ remainder = remainder.slice(3);
+ } else if (remainder.startsWith('sus2')) {
+ quality = 'sus2';
+ remainder = remainder.slice(4);
+ } else if (remainder.startsWith('sus4')) {
+ quality = 'sus4';
+ remainder = remainder.slice(4);
+ } else if (remainder.startsWith('aug')) {
+ quality = 'aug';
+ remainder = remainder.slice(3);
+ } else if (remainder.startsWith('dim')) {
+ quality = 'dim';
+ remainder = remainder.slice(3);
+ } else if (remainder.startsWith('m')) {
+ quality = 'min';
+ remainder = remainder.slice(1);
+ } else if (remainder.startsWith('5')) {
+ quality = 'power';
+ remainder = remainder.slice(1);
+ }
+
+ if (remainder.startsWith('maj7')) {
+ extensions.add('maj7');
+ remainder = remainder.slice(4);
+ } else if (remainder.startsWith('7')) {
+ extensions.add('7');
+ remainder = remainder.slice(1);
+ } else if (remainder.startsWith('6')) {
+ extensions.add('6');
+ remainder = remainder.slice(1);
+ }
+
+ while (remainder.length > 0) {
+ const nextToken = CUSTOM_TOKENS.find(token => remainder.startsWith(token));
+ if (!nextToken) {
+ return null;
+ }
+
+ switch (nextToken) {
+ case 'b5':
+ extensions.add('b5');
+ break;
+ case '#5':
+ extensions.add('#5');
+ break;
+ case 'b9':
+ case 'addb9':
+ extensions.add('b9');
+ break;
+ case '9':
+ case 'add9':
+ extensions.add('9');
+ break;
+ case '#9':
+ case 'add#9':
+ extensions.add('#9');
+ break;
+ case '11':
+ case 'add11':
+ extensions.add('11');
+ break;
+ case '#11':
+ case 'add#11':
+ extensions.add('#11');
+ break;
+ case 'b13':
+ case 'addb13':
+ extensions.add('b13');
+ break;
+ case '13':
+ case 'add13':
+ extensions.add('13');
+ break;
+ default:
+ return null;
+ }
+
+ remainder = remainder.slice(nextToken.length);
+ }
+
+ const descriptor: ChordDescriptor = {
+ root,
+ quality,
+ extensions: sortExtensions(extensions),
+ symbol: '',
+ };
+
+ const canonical = buildChordSymbol(descriptor);
+ if (!canonical) {
+ return null;
+ }
+
+ return {
+ ...descriptor,
+ symbol: canonical,
+ };
+}
+
+function tokenizeTonalIntervals(intervals: string[]): string[] | null {
+ const normalized = intervals
+ .map(interval => interval.replace(/^(\d+)([PmMdA])$/, '$1$2').replace(/^(\d+)([dm])$/, '$1$2'))
+ .filter(Boolean);
+
+ if (normalized.length === 0) {
+ return null;
+ }
+
+ return normalized;
+}
+
+export function buildChordSymbol(descriptor: Pick): string | null {
+ const root = normalizeRoot(descriptor.root);
+ if (!root) {
+ return null;
+ }
+
+ const extensions = sortExtensions(descriptor.extensions);
+ const has = (extension: ChordExtension) => extensions.includes(extension);
+ const hasSeventh = has('7') || has('maj7');
+ const remainingExtensions = new Set(extensions);
+
+ let symbol = root;
+
+ if (descriptor.quality === 'dim' && has('7')) {
+ symbol += 'm7b5';
+ remainingExtensions.delete('7');
+ remainingExtensions.delete('b5');
+ } else if (descriptor.quality === 'aug' && has('7')) {
+ symbol += '7#5';
+ remainingExtensions.delete('7');
+ remainingExtensions.delete('#5');
+ } else if (descriptor.quality === 'aug' && has('maj7')) {
+ symbol += 'maj7#5';
+ remainingExtensions.delete('maj7');
+ remainingExtensions.delete('#5');
+ } else {
+ switch (descriptor.quality) {
+ case 'maj':
+ break;
+ case 'min':
+ symbol += 'm';
+ break;
+ case 'sus2':
+ symbol += 'sus2';
+ break;
+ case 'sus4':
+ symbol += 'sus4';
+ break;
+ case 'power':
+ symbol += '5';
+ break;
+ case 'aug':
+ symbol += 'aug';
+ remainingExtensions.delete('#5');
+ break;
+ case 'dim':
+ symbol += 'dim';
+ remainingExtensions.delete('b5');
+ break;
+ }
+
+ if (has('maj7')) {
+ symbol += 'maj7';
+ remainingExtensions.delete('maj7');
+ } else if (has('7')) {
+ symbol += '7';
+ remainingExtensions.delete('7');
+ } else if (has('6')) {
+ symbol += '6';
+ remainingExtensions.delete('6');
+ }
+ }
+
+ for (const extension of REMAINING_EXTENSION_ORDER) {
+ if (!remainingExtensions.has(extension)) {
+ continue;
+ }
+
+ if (ADD_EXTENSION_ORDER.includes(extension) && !hasSeventh) {
+ symbol += `add${extension}`;
+ } else {
+ symbol += extension;
+ }
+ }
+
+ return symbol;
+}
+
+export function parseChordSymbol(symbol: string): ChordDescriptor | null {
+ const trimmed = symbol.trim();
+ if (!trimmed) {
+ return null;
+ }
+
+ const tonalChord = Chord.get(trimmed);
+ if (tonalChord && !tonalChord.empty && tonalChord.tonic && !tonalChord.bass && !tonalChord.root) {
+ const root = normalizeRoot(tonalChord.tonic);
+ const intervals = tokenizeTonalIntervals(tonalChord.intervals);
+ if (root && intervals) {
+ const parsed = parseIntervalsToDescriptor(root, intervals);
+ if (parsed) {
+ return parsed;
+ }
+ }
+ }
+
+ return parseCustomChordSymbol(trimmed);
+}
+
+export function getChordPitchClasses(symbol: string): number[] {
+ const descriptor = parseChordSymbol(symbol);
+ if (!descriptor) {
+ return [];
+ }
+
+ const root = Note.get(descriptor.root);
+ if (root.empty || root.chroma === undefined) {
+ return [];
+ }
+
+ return getDescriptorIntervals(descriptor).map((interval) => {
+ const semitones = Interval.semitones(interval);
+ return (root.chroma + semitones + 120) % 12;
+ });
+}
+
+export function getChordMidiPitches(symbol: string, rootMidi: number): number[] {
+ const descriptor = parseChordSymbol(symbol);
+ if (!descriptor) {
+ return [];
+ }
+
+ return getDescriptorIntervals(descriptor).map((interval) => rootMidi + Interval.semitones(interval));
+}
+
+export function formatChordSymbolForDisplay(symbol: string): string {
+ const descriptor = parseChordSymbol(symbol);
+ if (!descriptor) {
+ return symbol;
+ }
+
+ const { root, quality, extensions } = descriptor;
+ const accidentalDisplay = (value: string) => value.replace(/b/g, '♭').replace(/#/g, '♯');
+ const baseQuality = (() => {
+ switch (quality) {
+ case 'maj':
+ return '';
+ case 'min':
+ return 'm';
+ case 'sus2':
+ return 'sus2';
+ case 'sus4':
+ return 'sus4';
+ case 'power':
+ return '5';
+ case 'aug':
+ return 'aug';
+ case 'dim':
+ return 'm';
+ }
+ })();
+
+ const inlineExtensions: string[] = [];
+ const parentheticalExtensions: string[] = [];
+
+ for (const extension of extensions) {
+ if (extension === '7' || extension === 'maj7' || extension === '6') {
+ inlineExtensions.push(extension);
+ continue;
+ }
+
+ parentheticalExtensions.push(accidentalDisplay(extension));
+ }
+
+ const inlineText = inlineExtensions.join('');
+ const parentheticalText = parentheticalExtensions.length > 0
+ ? `(${parentheticalExtensions.join(', ')})`
+ : '';
+
+ return `${accidentalDisplay(root)}${baseQuality}${inlineText}${parentheticalText}`;
+}
+
+export const CHORD_QUALITY_OPTIONS: ChordQuality[] = ['maj', 'min', 'sus2', 'sus4', 'power', 'aug', 'dim'];
+export const CHORD_EXTENSION_OPTIONS: ChordExtension[] = EXTENSION_ORDER;
diff --git a/src/util/globalTrackUtil.ts b/src/util/globalTrackUtil.ts
index cacbc02..41d82a7 100644
--- a/src/util/globalTrackUtil.ts
+++ b/src/util/globalTrackUtil.ts
@@ -6,6 +6,7 @@ import {
createDefaultGlobalTracks,
} from '../core/global-track';
import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
+import { KGChordRegion } from '../core/region/KGChordRegion';
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
import { KGAudioRegion } from '../core/region/KGAudioRegion';
import { KGTempoRegion } from '../core/region/KGTempoRegion';
@@ -59,14 +60,23 @@ export function findMarkerNeighborBounds(
regionId: string | null,
proposedStartBeat: number
): { minStartBeat: number; maxEndBeat: number; nextStartBeat: number | null } {
- const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
+ return findNonOverlappingNeighborBounds(project, GlobalTrackType.Marker, regionId, proposedStartBeat);
+}
+
+export function findNonOverlappingNeighborBounds(
+ project: KGProject,
+ trackType: GlobalTrackType.Marker | GlobalTrackType.Chord,
+ regionId: string | null,
+ proposedStartBeat: number
+): { minStartBeat: number; maxEndBeat: number; nextStartBeat: number | null } {
+ const track = findGlobalTrackByType(project, trackType);
const songEndBeat = getSongEndBeat(project);
- if (!markerTrack) {
+ if (!track) {
return { minStartBeat: 0, maxEndBeat: songEndBeat, nextStartBeat: null };
}
- const otherRegions = markerTrack.getRegions()
+ const otherRegions = track.getRegions()
.filter(region => region.getId() !== regionId)
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
@@ -88,6 +98,17 @@ export function findMarkerNeighborBounds(
return { minStartBeat, maxEndBeat, nextStartBeat };
}
+export function findChordRegionAtBeat(project: KGProject, beat: number): KGChordRegion | null {
+ const track = findGlobalTrackByType(project, GlobalTrackType.Chord);
+ if (!track) {
+ return null;
+ }
+
+ return track.getRegions()
+ .filter((region): region is KGChordRegion => region instanceof KGChordRegion)
+ .find(region => beat >= region.getStartFromBeat() && beat < region.getStartFromBeat() + region.getLength()) ?? null;
+}
+
export function getSongEndBar(project: KGProject): number {
return project.getMaxBars();
}