From 6bfce38c79d7d7e32ba3906c5237e5f0c0c26696 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:46:33 -0700 Subject: [PATCH] fix: support extended chord symbols in chord-to-MIDI import --- src/util/chordRegionImportUtil.ts | 7 +- src/util/chordUtil.test.ts | 66 ++++++++++++++ src/util/chordUtil.ts | 143 ++++++++++++++++++++++-------- 3 files changed, 173 insertions(+), 43 deletions(-) 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..0832f29 100644 --- a/src/util/chordUtil.test.ts +++ b/src/util/chordUtil.test.ts @@ -39,6 +39,18 @@ 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(buildChordSymbol({ + root: 'C', + quality: 'sus4', + extensions: ['9'], + })).toBe('Csus4add9'); + }); + it('rejects unsupported symbols deterministically', () => { expect(parseChordSymbol('C/E')).toBeNull(); expect(parseChordSymbol('not-a-chord')).toBeNull(); @@ -52,6 +64,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'); }); @@ -71,6 +87,13 @@ describe('chordUtil', () => { 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 +156,49 @@ describe('chordUtil', () => { ]); }); + 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..8e36a4d 100644 --- a/src/util/chordUtil.ts +++ b/src/util/chordUtil.ts @@ -77,6 +77,57 @@ function hasExtension(descriptor: Pick, extension return descriptor.extensions.includes(extension); } +interface CollapsedNaturalExtension { + suffix: string; + consumed: ChordExtension[]; +} + +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']; @@ -417,43 +468,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'; - 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 +587,13 @@ 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 baseQuality = (() => { + if (collapsedNaturalExtension) { + return ''; + } + switch (quality) { case 'maj': return ''; @@ -553,6 +616,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 +632,9 @@ export function formatChordSymbolForDisplay(symbol: string): string { parentheticalExtensions.push(accidentalDisplay(extension)); } - const inlineText = inlineExtensions.join(''); + const inlineText = collapsedNaturalExtension + ? collapsedNaturalExtension.suffix + : inlineExtensions.join(''); const parentheticalText = parentheticalExtensions.length > 0 ? `(${parentheticalExtensions.join(', ')})` : '';