diff --git a/src/components/ChordPickerPopup.test.tsx b/src/components/ChordPickerPopup.test.tsx
index aa7c97a..e21add8 100644
--- a/src/components/ChordPickerPopup.test.tsx
+++ b/src/components/ChordPickerPopup.test.tsx
@@ -58,6 +58,20 @@ describe('ChordPickerPopup', () => {
expect(screen.getByRole('button', { name: 'dim7' }).className).toContain('selected');
});
+ it('preserves suspended seventh chords when parsing text input', () => {
+ const onChange = vi.fn();
+
+ render();
+
+ const input = screen.getByRole('textbox');
+ fireEvent.change(input, { target: { value: 'E7sus4' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+
+ expect(onChange).toHaveBeenCalledWith('E7sus4');
+ expect(screen.getByRole('button', { name: 'Sus4' }).className).toContain('selected');
+ expect(screen.getByRole('button', { name: '7' }).className).toContain('selected');
+ });
+
it('intercepts tab and delegates popup bar navigation', () => {
const onTabNavigate = vi.fn();
diff --git a/src/components/MainContent.css b/src/components/MainContent.css
index 46e964a..685b2d4 100644
--- a/src/components/MainContent.css
+++ b/src/components/MainContent.css
@@ -26,7 +26,7 @@
background-color: #2d2d2d;
border-bottom: 1px solid #3a3a3a;
border-right: 1px solid #3a3a3a;
- z-index: 1002;
+ z-index: 1005;
/* Higher than other elements to ensure it's always visible */
display: flex;
flex-direction: row;
@@ -107,7 +107,7 @@
display: flex;
border-bottom: 1px solid #3a3a3a;
background-color: #2d2d2d;
- z-index: 20;
+ z-index: 1004;
margin-left: var(--track-info-panel-width);
/* Offset for info-container */
width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width));
diff --git a/src/components/piano-roll/PianoGridHeader.test.tsx b/src/components/piano-roll/PianoGridHeader.test.tsx
new file mode 100644
index 0000000..35375e5
--- /dev/null
+++ b/src/components/piano-roll/PianoGridHeader.test.tsx
@@ -0,0 +1,121 @@
+import React from 'react';
+import { fireEvent, render } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import PianoGridHeader from './PianoGridHeader';
+
+const setPlayheadPosition = vi.fn();
+const requestMainContentScroll = vi.fn();
+const getPlayheadPosition = vi.fn(() => 0);
+
+const storeState = {
+ setPlayheadPosition,
+ requestMainContentScroll,
+};
+
+vi.mock('../../stores/projectStore', () => ({
+ useProjectStore: () => storeState,
+}));
+
+vi.mock('../../core/KGCore', () => ({
+ KGCore: {
+ instance: () => ({
+ getPlayheadPosition,
+ }),
+ },
+}));
+
+function renderHeader({
+ hasPianoKeys = true,
+ scrollLeft = 0,
+ paddingLeft = hasPianoKeys ? '60px' : '0px',
+}: {
+ hasPianoKeys?: boolean;
+ scrollLeft?: number;
+ paddingLeft?: string;
+} = {}) {
+ const view = render(
+
+ );
+
+ const scrollContainer = view.container.querySelector('.piano-roll-note-scroll') as HTMLDivElement;
+ const header = view.container.querySelector('.piano-grid-header') as HTMLDivElement;
+
+ Object.defineProperty(scrollContainer, 'scrollLeft', {
+ configurable: true,
+ value: scrollLeft,
+ writable: true,
+ });
+
+ header.style.paddingLeft = paddingLeft;
+
+ Object.defineProperty(header, 'getBoundingClientRect', {
+ configurable: true,
+ value: () => ({
+ left: 100,
+ top: 0,
+ right: 600,
+ bottom: 20,
+ width: 500,
+ height: 20,
+ x: 100,
+ y: 0,
+ toJSON: () => ({}),
+ }),
+ });
+
+ return { ...view, header };
+}
+
+describe('PianoGridHeader', () => {
+ beforeEach(() => {
+ document.documentElement.style.setProperty('--region-grid-beat-width', '40px');
+ setPlayheadPosition.mockClear();
+ requestMainContentScroll.mockClear();
+ getPlayheadPosition.mockClear();
+ getPlayheadPosition.mockReturnValue(0);
+ });
+
+ it('seeks to the expected beat without horizontal scroll', () => {
+ const { header } = renderHeader();
+
+ fireEvent.click(header, { clientX: 280 });
+
+ expect(setPlayheadPosition).toHaveBeenCalledTimes(1);
+ expect(setPlayheadPosition).toHaveBeenCalledWith(3);
+ expect(requestMainContentScroll).toHaveBeenCalledTimes(1);
+ expect(requestMainContentScroll).toHaveBeenCalledWith(3);
+ });
+
+ it('includes the note scroll offset when seeking after horizontal scroll', () => {
+ const { header } = renderHeader({ scrollLeft: 160 });
+
+ fireEvent.click(header, { clientX: 280 });
+
+ expect(setPlayheadPosition).toHaveBeenCalledTimes(1);
+ expect(setPlayheadPosition).toHaveBeenCalledWith(7);
+ expect(requestMainContentScroll).toHaveBeenCalledTimes(1);
+ expect(requestMainContentScroll).toHaveBeenCalledWith(7);
+ });
+
+ it('does not subtract a gutter when piano keys are hidden', () => {
+ const { header } = renderHeader({ hasPianoKeys: false });
+
+ fireEvent.click(header, { clientX: 180 });
+
+ expect(setPlayheadPosition).toHaveBeenCalledTimes(1);
+ expect(setPlayheadPosition).toHaveBeenCalledWith(2);
+ expect(requestMainContentScroll).toHaveBeenCalledWith(2);
+ });
+
+ it('uses the same corrected math on mousedown for drag-to-seek', () => {
+ const { header } = renderHeader({ scrollLeft: 80 });
+
+ fireEvent.mouseDown(header, { button: 0, clientX: 280 });
+
+ expect(setPlayheadPosition).toHaveBeenCalledTimes(1);
+ expect(setPlayheadPosition).toHaveBeenCalledWith(5);
+ expect(requestMainContentScroll).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/piano-roll/PianoGridHeader.tsx b/src/components/piano-roll/PianoGridHeader.tsx
index 702a0ff..a8f9c4b 100644
--- a/src/components/piano-roll/PianoGridHeader.tsx
+++ b/src/components/piano-roll/PianoGridHeader.tsx
@@ -26,20 +26,16 @@ const PianoGridHeader: React.FC = ({
// Utility function to calculate playhead position from mouse coordinates
const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => {
if (!headerElementRef.current) return null;
-
- const rect = headerElementRef.current.getBoundingClientRect();
+
+ const headerElement = headerElementRef.current;
+ const rect = headerElement.getBoundingClientRect();
const relativeX = clientX - rect.left;
-
- // Account for the piano keys width offset
- const pianoKeysWidth = hasPianoKeys
- ? (parseInt(
- getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
- ) || 60)
- : 0;
-
- const adjustedX = relativeX - pianoKeysWidth;
-
- // If the click is in the piano keys area (left side), ignore it
+ const scrollContainer = headerElement.closest('.piano-roll-note-scroll') as HTMLElement | null;
+ const scrollLeft = scrollContainer?.scrollLeft ?? 0;
+ const leftGutter = parseFloat(getComputedStyle(headerElement).paddingLeft) || 0;
+ const adjustedX = relativeX + scrollLeft - leftGutter;
+
+ // Ignore clicks inside the visual left gutter reserved for piano keys.
if (adjustedX < 0) {
return null;
}
diff --git a/src/components/track/TrackInfoItem.test.tsx b/src/components/track/TrackInfoItem.test.tsx
index d962264..c9a8e54 100644
--- a/src/components/track/TrackInfoItem.test.tsx
+++ b/src/components/track/TrackInfoItem.test.tsx
@@ -1,8 +1,10 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import TrackInfoItem from './TrackInfoItem';
import { KGAudioTrack } from '../../core/track/KGAudioTrack';
+import { showConfirm } from '../../util/dialogUtil';
+import { translate } from '../../i18n/translate';
const storeState = {
selectedTrackId: null as string | null,
@@ -24,8 +26,31 @@ vi.mock('../../stores/projectStore', () => ({
),
}));
+vi.mock('../../i18n/useI18n', async () => {
+ const { translate } = await import('../../i18n/translate');
+ return {
+ useI18n: () => ({
+ t: (key: string, params?: Record) => translate(key, params, 'zh_cn'),
+ }),
+ };
+});
+
vi.mock('../common/KGDropdown', () => ({
- default: () => null,
+ default: ({ options, isOpen, onChange }: { options: Array; isOpen?: boolean; onChange: (value: string) => void }) => (
+ isOpen ? (
+
+ {options.map((option) => {
+ const value = typeof option === 'string' ? option : option.value;
+ const label = typeof option === 'string' ? option : option.label;
+ return (
+
+ );
+ })}
+
+ ) : null
+ ),
}));
vi.mock('../common/FileImportModal', () => ({
@@ -49,6 +74,7 @@ describe('TrackInfoItem audio import', () => {
storeState.toggleInstrumentSelectionForTrack.mockReset();
storeState.importAudioToTrack.mockReset();
storeState.setTrackAutomationView.mockReset();
+ vi.mocked(showConfirm).mockReset();
});
it('advertises m4a support in the track audio import modal', () => {
@@ -72,4 +98,33 @@ describe('TrackInfoItem audio import', () => {
expect(fileImportModalProps?.acceptedTypes).toEqual(['.wav', '.mp3', '.ogg', '.flac', '.aac', '.m4a']);
});
+
+ it('uses the localized delete-track confirmation message', () => {
+ const audioTrack = new KGAudioTrack('钢琴', 1);
+ audioTrack.setTrackIndex(0);
+ storeState.tracks = [audioTrack];
+
+ vi.mocked(showConfirm).mockResolvedValue(false);
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: '更多操作' }));
+ fireEvent.click(screen.getByRole('button', { name: '删除轨道' }));
+
+ expect(showConfirm).toHaveBeenCalledWith(
+ translate('track.controls.settings.deleteTrackConfirm', { name: '钢琴' }, 'zh_cn')
+ );
+ });
});
diff --git a/src/components/track/TrackInfoItem.tsx b/src/components/track/TrackInfoItem.tsx
index 0b49058..579aa27 100644
--- a/src/components/track/TrackInfoItem.tsx
+++ b/src/components/track/TrackInfoItem.tsx
@@ -360,7 +360,9 @@ const TrackInfoItem: React.FC = ({
// Handle settings action
const handleSettingsAction = async (action: string) => {
if (action === 'Delete Track') {
- const confirmed = await showConfirm(`Are you sure you want to delete track "${track.getName()}"?`);
+ const confirmed = await showConfirm(
+ t('track.controls.settings.deleteTrackConfirm', { name: track.getName() })
+ );
if (confirmed) {
try {
if (DEBUG_MODE.TRACK_INFO) {
@@ -379,7 +381,7 @@ const TrackInfoItem: React.FC = ({
setShowSettingsDropdown(false);
} catch (error) {
console.error('Failed to delete track:', error);
- await showAlert('Failed to delete track. Please try again.');
+ await showAlert(t('track.controls.settings.deleteTrackError'));
}
}
}
diff --git a/src/i18n/messages/en_us.ts b/src/i18n/messages/en_us.ts
index f2597b9..c631f81 100644
--- a/src/i18n/messages/en_us.ts
+++ b/src/i18n/messages/en_us.ts
@@ -539,6 +539,8 @@ export const enUsMessages: TranslationMessages = {
'track.controls.automation.pan': 'Pan',
'track.controls.moreActions': 'More actions',
'track.controls.settings.deleteTrack': 'Delete Track',
+ 'track.controls.settings.deleteTrackConfirm': 'Are you sure you want to delete track "{name}"?',
+ 'track.controls.settings.deleteTrackError': 'Failed to delete track. Please try again.',
'toolbar.keySignatureChooser': 'Choose key signature, current {value}',
'toolbar.export.label': 'Export',
'toolbar.importProject.title': 'Import Project',
diff --git a/src/i18n/messages/fr_fr.ts b/src/i18n/messages/fr_fr.ts
index 01c0627..c40e5ba 100644
--- a/src/i18n/messages/fr_fr.ts
+++ b/src/i18n/messages/fr_fr.ts
@@ -423,6 +423,8 @@ export const frFrMessages: TranslationMessages = {
'track.controls.automation.pan': 'Panoramique',
'track.controls.moreActions': 'Autres actions',
'track.controls.settings.deleteTrack': 'Supprimer la piste',
+ 'track.controls.settings.deleteTrackConfirm': 'Voulez-vous vraiment supprimer la piste « {name} » ?',
+ 'track.controls.settings.deleteTrackError': 'Impossible de supprimer la piste. Veuillez réessayer.',
'toolbar.keySignatureChooser': 'Choisir l\'armure, actuelle : {value}',
'toolbar.export.label': 'Exporter',
'toolbar.importProject.title': 'Importer un projet',
diff --git a/src/i18n/messages/zh_cn.ts b/src/i18n/messages/zh_cn.ts
index 7c24976..18244b4 100644
--- a/src/i18n/messages/zh_cn.ts
+++ b/src/i18n/messages/zh_cn.ts
@@ -537,6 +537,8 @@ export const zhCnMessages: TranslationMessages = {
'track.controls.automation.pan': '声像',
'track.controls.moreActions': '更多操作',
'track.controls.settings.deleteTrack': '删除轨道',
+ 'track.controls.settings.deleteTrackConfirm': '确定要删除轨道“{name}”吗?',
+ 'track.controls.settings.deleteTrackError': '删除轨道失败。请重试。',
'toolbar.keySignatureChooser': '选择调号,当前为 {value}',
'toolbar.export.label': '导出',
'toolbar.importProject.title': '导入项目',
diff --git a/src/i18n/messages/zh_hk.ts b/src/i18n/messages/zh_hk.ts
index 1356242..4b10f37 100644
--- a/src/i18n/messages/zh_hk.ts
+++ b/src/i18n/messages/zh_hk.ts
@@ -537,6 +537,8 @@ export const zhHkMessages: TranslationMessages = {
'track.controls.automation.pan': '聲像',
'track.controls.moreActions': '更多操作',
'track.controls.settings.deleteTrack': '刪除音軌',
+ 'track.controls.settings.deleteTrackConfirm': '確定要刪除音軌「{name}」嗎?',
+ 'track.controls.settings.deleteTrackError': '刪除音軌失敗。請再試一次。',
'toolbar.keySignatureChooser': '選擇調號,當前為 {value}',
'toolbar.export.label': '匯出',
'toolbar.importProject.title': '匯入專案',
diff --git a/src/util/chordRegionImportUtil.ts b/src/util/chordRegionImportUtil.ts
index 71939f6..02a6643 100644
--- a/src/util/chordRegionImportUtil.ts
+++ b/src/util/chordRegionImportUtil.ts
@@ -1,4 +1,4 @@
-import { Chord, Note } from 'tonal';
+import { Note } from 'tonal';
import { KGChordRegion } from '../core/region/KGChordRegion';
import { getChordMidiPitches, parseChordSymbol } from './chordUtil';
@@ -59,11 +59,6 @@ export function convertChordSymbolToMidiPitches(symbol: string): number[] | null
return null;
}
- const tonalChord = Chord.get(descriptor.symbol);
- if (tonalChord.empty) {
- return null;
- }
-
const rootMidi = getBaseRootMidi(descriptor.symbol);
if (rootMidi === null) {
return null;
diff --git a/src/util/chordUtil.test.ts b/src/util/chordUtil.test.ts
index 1182ef8..403ba8e 100644
--- a/src/util/chordUtil.test.ts
+++ b/src/util/chordUtil.test.ts
@@ -39,6 +39,24 @@ describe('chordUtil', () => {
})).toBe('A#7');
});
+ it('canonicalizes extended seventh-family chords using standard shorthand', () => {
+ expect(parseChordSymbol('Am9')?.symbol).toBe('Am9');
+ expect(parseChordSymbol('Dm11')?.symbol).toBe('Dm11');
+ expect(parseChordSymbol('G13')?.symbol).toBe('G13');
+ expect(parseChordSymbol('Bbmaj9')?.symbol).toBe('Bbmaj9');
+ expect(parseChordSymbol('E7sus4')?.symbol).toBe('E7sus4');
+ expect(buildChordSymbol({
+ root: 'C',
+ quality: 'sus4',
+ extensions: ['9'],
+ })).toBe('Csus4add9');
+ expect(buildChordSymbol({
+ root: 'E',
+ quality: 'sus4',
+ extensions: ['7'],
+ })).toBe('E7sus4');
+ });
+
it('rejects unsupported symbols deterministically', () => {
expect(parseChordSymbol('C/E')).toBeNull();
expect(parseChordSymbol('not-a-chord')).toBeNull();
@@ -52,6 +70,10 @@ describe('chordUtil', () => {
it('formats the preview using standard chord display conventions', () => {
expect(formatChordSymbolForDisplay('Bm7b5')).toBe('Bm7(♭5)');
+ expect(formatChordSymbolForDisplay('Am9')).toBe('Am9');
+ expect(formatChordSymbolForDisplay('Dm11')).toBe('Dm11');
+ expect(formatChordSymbolForDisplay('G13')).toBe('G13');
+ expect(formatChordSymbolForDisplay('Bbmaj9')).toBe('B♭maj9');
expect(formatChordSymbolForDisplay('Bbmaj7#11')).toBe('B♭maj7(♯11)');
expect(formatChordSymbolForDisplay('G#dim7')).toBe('G♯dim7');
});
@@ -68,9 +90,17 @@ describe('chordUtil', () => {
expect(convertChordSymbolToMidiPitches('Am')).toEqual([45, 57, 60, 64]);
expect(convertChordSymbolToMidiPitches('Dm')).toEqual([50, 62, 65, 69]);
expect(convertChordSymbolToMidiPitches('E7')).toEqual([52, 64, 68, 71, 74]);
+ expect(convertChordSymbolToMidiPitches('E7sus4')).toEqual([52, 64, 69, 71, 74]);
expect(convertChordSymbolToMidiPitches('Bm7b5')).toEqual([47, 59, 62, 65, 69]);
});
+ it('maps extended chords into MIDI pitches without relying on tonal round-tripping', () => {
+ expect(convertChordSymbolToMidiPitches('Am9')).toEqual([45, 57, 60, 64, 67, 71]);
+ expect(convertChordSymbolToMidiPitches('Dm9')).toEqual([50, 62, 65, 69, 72, 76]);
+ expect(convertChordSymbolToMidiPitches('G13')).toEqual([43, 55, 59, 62, 65, 69, 76]);
+ expect(convertChordSymbolToMidiPitches('Bbmaj9')).toEqual([46, 58, 62, 65, 69, 72]);
+ });
+
it('builds a multi-region import plan using timeline-relative note placement', () => {
const chordA = new KGChordRegion('chord-1', 'global-chord', 3, 'C', 8, 4);
const chordB = new KGChordRegion('chord-2', 'global-chord', 3, 'F', 12, 2);
@@ -133,6 +163,68 @@ describe('chordUtil', () => {
]);
});
+ it('builds an import plan for a suspended dominant chord', () => {
+ const chord = new KGChordRegion('chord-1', 'global-chord', 3, 'E7sus4', 0, 4);
+ const result = buildChordRegionImportPlan([chord]);
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) {
+ return;
+ }
+
+ expect(result.plan.sourceRegionIds).toEqual(['chord-1']);
+ expect(result.plan.notes).toEqual([
+ { startBeat: 0, endBeat: 4, pitch: 52, velocity: 127 },
+ { startBeat: 0, endBeat: 4, pitch: 64, velocity: 127 },
+ { startBeat: 0, endBeat: 4, pitch: 69, velocity: 127 },
+ { startBeat: 0, endBeat: 4, pitch: 71, velocity: 127 },
+ { startBeat: 0, endBeat: 4, pitch: 74, velocity: 127 },
+ ]);
+ });
+
+ it('builds an import plan for extended chord progressions', () => {
+ const chordA = new KGChordRegion('chord-1', 'global-chord', 3, 'Am9', 0, 4);
+ const chordB = new KGChordRegion('chord-2', 'global-chord', 3, 'Dm9', 4, 4);
+ const chordC = new KGChordRegion('chord-3', 'global-chord', 3, 'G13', 8, 4);
+ const chordD = new KGChordRegion('chord-4', 'global-chord', 3, 'Bbmaj9', 12, 4);
+ const result = buildChordRegionImportPlan([chordD, chordB, chordA, chordC]);
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) {
+ return;
+ }
+
+ expect(result.plan.sourceRegionIds).toEqual(['chord-1', 'chord-2', 'chord-3', 'chord-4']);
+ expect(result.plan.lengthInBeats).toBe(16);
+ expect(result.plan.notes).toEqual([
+ { startBeat: 0, endBeat: 4, pitch: 45, velocity: 127 },
+ { startBeat: 0, endBeat: 4, pitch: 57, velocity: 127 },
+ { startBeat: 0, endBeat: 4, pitch: 60, velocity: 127 },
+ { startBeat: 0, endBeat: 4, pitch: 64, velocity: 127 },
+ { startBeat: 0, endBeat: 4, pitch: 67, velocity: 127 },
+ { startBeat: 0, endBeat: 4, pitch: 71, velocity: 127 },
+ { startBeat: 4, endBeat: 8, pitch: 50, velocity: 127 },
+ { startBeat: 4, endBeat: 8, pitch: 62, velocity: 127 },
+ { startBeat: 4, endBeat: 8, pitch: 65, velocity: 127 },
+ { startBeat: 4, endBeat: 8, pitch: 69, velocity: 127 },
+ { startBeat: 4, endBeat: 8, pitch: 72, velocity: 127 },
+ { startBeat: 4, endBeat: 8, pitch: 76, velocity: 127 },
+ { startBeat: 8, endBeat: 12, pitch: 43, velocity: 127 },
+ { startBeat: 8, endBeat: 12, pitch: 55, velocity: 127 },
+ { startBeat: 8, endBeat: 12, pitch: 59, velocity: 127 },
+ { startBeat: 8, endBeat: 12, pitch: 62, velocity: 127 },
+ { startBeat: 8, endBeat: 12, pitch: 65, velocity: 127 },
+ { startBeat: 8, endBeat: 12, pitch: 69, velocity: 127 },
+ { startBeat: 8, endBeat: 12, pitch: 76, velocity: 127 },
+ { startBeat: 12, endBeat: 16, pitch: 46, velocity: 127 },
+ { startBeat: 12, endBeat: 16, pitch: 58, velocity: 127 },
+ { startBeat: 12, endBeat: 16, pitch: 62, velocity: 127 },
+ { startBeat: 12, endBeat: 16, pitch: 65, velocity: 127 },
+ { startBeat: 12, endBeat: 16, pitch: 69, velocity: 127 },
+ { startBeat: 12, endBeat: 16, pitch: 72, velocity: 127 },
+ ]);
+ });
+
it('returns structured failure metadata for unsupported symbols', () => {
const badChord = new KGChordRegion('chord-1', 'global-chord', 3, 'not-a-chord', 0, 4);
const result = buildChordRegionImportPlan([badChord]);
diff --git a/src/util/chordUtil.ts b/src/util/chordUtil.ts
index b0ff446..6923adb 100644
--- a/src/util/chordUtil.ts
+++ b/src/util/chordUtil.ts
@@ -77,6 +77,76 @@ function hasExtension(descriptor: Pick, extension
return descriptor.extensions.includes(extension);
}
+interface CollapsedNaturalExtension {
+ suffix: string;
+ consumed: ChordExtension[];
+}
+
+function getSuspendedSeventhSuffix(
+ quality: ChordQuality,
+ extensions: ChordExtension[],
+): string | null {
+ if (!extensions.includes('7')) {
+ return null;
+ }
+
+ if (quality === 'sus2') {
+ return '7sus2';
+ }
+
+ if (quality === 'sus4') {
+ return '7sus4';
+ }
+
+ return null;
+}
+
+function getCollapsedNaturalExtension(
+ quality: ChordQuality,
+ extensions: ChordExtension[],
+): CollapsedNaturalExtension | null {
+ const has = (extension: ChordExtension) => extensions.includes(extension);
+ const consume = (...consumed: ChordExtension[]): ChordExtension[] => consumed.filter(has);
+
+ if (quality === 'maj' && has('maj7')) {
+ if (has('13')) {
+ return { suffix: 'maj13', consumed: consume('maj7', '9', '11', '13') };
+ }
+ if (has('11')) {
+ return { suffix: 'maj11', consumed: consume('maj7', '9', '11') };
+ }
+ if (has('9')) {
+ return { suffix: 'maj9', consumed: consume('maj7', '9') };
+ }
+ }
+
+ if (quality === 'maj' && has('7')) {
+ if (has('13')) {
+ return { suffix: '13', consumed: consume('7', '9', '11', '13') };
+ }
+ if (has('11')) {
+ return { suffix: '11', consumed: consume('7', '9', '11') };
+ }
+ if (has('9')) {
+ return { suffix: '9', consumed: consume('7', '9') };
+ }
+ }
+
+ if (quality === 'min' && has('7')) {
+ if (has('13')) {
+ return { suffix: 'm13', consumed: consume('7', '9', '11', '13') };
+ }
+ if (has('11')) {
+ return { suffix: 'm11', consumed: consume('7', '9', '11') };
+ }
+ if (has('9')) {
+ return { suffix: 'm9', consumed: consume('7', '9') };
+ }
+ }
+
+ return null;
+}
+
function getDescriptorIntervals(descriptor: Pick): string[] {
const intervals = ['1P'];
@@ -258,7 +328,15 @@ function parseCustomChordSymbol(symbol: string): ChordDescriptor | null {
let quality: ChordQuality = 'maj';
const extensions = new Set();
- if (remainder.startsWith('m7b5')) {
+ if (remainder.startsWith('7sus2')) {
+ quality = 'sus2';
+ extensions.add('7');
+ remainder = remainder.slice(5);
+ } else if (remainder.startsWith('7sus4')) {
+ quality = 'sus4';
+ extensions.add('7');
+ remainder = remainder.slice(5);
+ } else if (remainder.startsWith('m7b5')) {
quality = 'dim';
extensions.add('b5');
extensions.add('7');
@@ -417,43 +495,55 @@ export function buildChordSymbol(descriptor: Pick remainingExtensions.delete(extension));
+ } else {
+ const suspendedSeventhSuffix = getSuspendedSeventhSuffix(descriptor.quality, extensions);
+ if (suspendedSeventhSuffix) {
+ symbol += suspendedSeventhSuffix;
+ remainingExtensions.delete('7');
+ } 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('dim7')) {
- symbol += 'dim7';
- remainingExtensions.delete('dim7');
- } else 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');
+ if (has('dim7')) {
+ symbol += 'dim7';
+ remainingExtensions.delete('dim7');
+ } else 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');
+ }
+ }
}
}
@@ -530,7 +620,17 @@ export function formatChordSymbolForDisplay(symbol: string): string {
if (quality === 'dim' && extensions.includes('dim7')) {
return `${accidentalDisplay(root)}dim7`;
}
+ const collapsedNaturalExtension = getCollapsedNaturalExtension(quality, extensions);
+ const consumedCollapsedExtensions = new Set(collapsedNaturalExtension?.consumed ?? []);
+ const suspendedSeventhSuffix = getSuspendedSeventhSuffix(quality, extensions);
const baseQuality = (() => {
+ if (collapsedNaturalExtension) {
+ return '';
+ }
+ if (suspendedSeventhSuffix) {
+ return '';
+ }
+
switch (quality) {
case 'maj':
return '';
@@ -553,6 +653,10 @@ export function formatChordSymbolForDisplay(symbol: string): string {
const parentheticalExtensions: string[] = [];
for (const extension of extensions) {
+ if (consumedCollapsedExtensions.has(extension)) {
+ continue;
+ }
+
if (quality === 'dim' && extension === 'b5' && !extensions.includes('7')) {
continue;
}
@@ -565,7 +669,11 @@ export function formatChordSymbolForDisplay(symbol: string): string {
parentheticalExtensions.push(accidentalDisplay(extension));
}
- const inlineText = inlineExtensions.join('');
+ const inlineText = collapsedNaturalExtension
+ ? collapsedNaturalExtension.suffix
+ : suspendedSeventhSuffix
+ ? suspendedSeventhSuffix
+ : inlineExtensions.join('');
const parentheticalText = parentheticalExtensions.length > 0
? `(${parentheticalExtensions.join(', ')})`
: '';