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/util/chordUtil.test.ts b/src/util/chordUtil.test.ts index 0832f29..403ba8e 100644 --- a/src/util/chordUtil.test.ts +++ b/src/util/chordUtil.test.ts @@ -44,11 +44,17 @@ describe('chordUtil', () => { 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', () => { @@ -84,6 +90,7 @@ 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]); }); @@ -156,6 +163,25 @@ 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); diff --git a/src/util/chordUtil.ts b/src/util/chordUtil.ts index 8e36a4d..6923adb 100644 --- a/src/util/chordUtil.ts +++ b/src/util/chordUtil.ts @@ -82,6 +82,25 @@ interface CollapsedNaturalExtension { 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[], @@ -309,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'); @@ -473,43 +500,49 @@ export function buildChordSymbol(descriptor: Pick remainingExtensions.delete(extension)); } 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'; + const suspendedSeventhSuffix = getSuspendedSeventhSuffix(descriptor.quality, extensions); + if (suspendedSeventhSuffix) { + symbol += suspendedSeventhSuffix; remainingExtensions.delete('7'); - } else if (has('6')) { - symbol += '6'; - remainingExtensions.delete('6'); + } 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'); + } } } } @@ -589,10 +622,14 @@ export function formatChordSymbolForDisplay(symbol: string): string { } 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': @@ -634,6 +671,8 @@ export function formatChordSymbolForDisplay(symbol: string): string { const inlineText = collapsedNaturalExtension ? collapsedNaturalExtension.suffix + : suspendedSeventhSuffix + ? suspendedSeventhSuffix : inlineExtensions.join(''); const parentheticalText = parentheticalExtensions.length > 0 ? `(${parentheticalExtensions.join(', ')})`