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(', ')})` : '';