fix: support extended chord symbols in chord-to-MIDI import
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { Chord, Note } from 'tonal';
|
import { Note } from 'tonal';
|
||||||
import { KGChordRegion } from '../core/region/KGChordRegion';
|
import { KGChordRegion } from '../core/region/KGChordRegion';
|
||||||
import { getChordMidiPitches, parseChordSymbol } from './chordUtil';
|
import { getChordMidiPitches, parseChordSymbol } from './chordUtil';
|
||||||
|
|
||||||
@@ -59,11 +59,6 @@ export function convertChordSymbolToMidiPitches(symbol: string): number[] | null
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tonalChord = Chord.get(descriptor.symbol);
|
|
||||||
if (tonalChord.empty) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootMidi = getBaseRootMidi(descriptor.symbol);
|
const rootMidi = getBaseRootMidi(descriptor.symbol);
|
||||||
if (rootMidi === null) {
|
if (rootMidi === null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ describe('chordUtil', () => {
|
|||||||
})).toBe('A#7');
|
})).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', () => {
|
it('rejects unsupported symbols deterministically', () => {
|
||||||
expect(parseChordSymbol('C/E')).toBeNull();
|
expect(parseChordSymbol('C/E')).toBeNull();
|
||||||
expect(parseChordSymbol('not-a-chord')).toBeNull();
|
expect(parseChordSymbol('not-a-chord')).toBeNull();
|
||||||
@@ -52,6 +64,10 @@ describe('chordUtil', () => {
|
|||||||
|
|
||||||
it('formats the preview using standard chord display conventions', () => {
|
it('formats the preview using standard chord display conventions', () => {
|
||||||
expect(formatChordSymbolForDisplay('Bm7b5')).toBe('Bm7(♭5)');
|
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('Bbmaj7#11')).toBe('B♭maj7(♯11)');
|
||||||
expect(formatChordSymbolForDisplay('G#dim7')).toBe('G♯dim7');
|
expect(formatChordSymbolForDisplay('G#dim7')).toBe('G♯dim7');
|
||||||
});
|
});
|
||||||
@@ -71,6 +87,13 @@ describe('chordUtil', () => {
|
|||||||
expect(convertChordSymbolToMidiPitches('Bm7b5')).toEqual([47, 59, 62, 65, 69]);
|
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', () => {
|
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 chordA = new KGChordRegion('chord-1', 'global-chord', 3, 'C', 8, 4);
|
||||||
const chordB = new KGChordRegion('chord-2', 'global-chord', 3, 'F', 12, 2);
|
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', () => {
|
it('returns structured failure metadata for unsupported symbols', () => {
|
||||||
const badChord = new KGChordRegion('chord-1', 'global-chord', 3, 'not-a-chord', 0, 4);
|
const badChord = new KGChordRegion('chord-1', 'global-chord', 3, 'not-a-chord', 0, 4);
|
||||||
const result = buildChordRegionImportPlan([badChord]);
|
const result = buildChordRegionImportPlan([badChord]);
|
||||||
|
|||||||
+70
-1
@@ -77,6 +77,57 @@ function hasExtension(descriptor: Pick<ChordDescriptor, 'extensions'>, extension
|
|||||||
return descriptor.extensions.includes(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<ChordDescriptor, 'quality' | 'extensions'>): string[] {
|
function getDescriptorIntervals(descriptor: Pick<ChordDescriptor, 'quality' | 'extensions'>): string[] {
|
||||||
const intervals = ['1P'];
|
const intervals = ['1P'];
|
||||||
|
|
||||||
@@ -416,6 +467,11 @@ export function buildChordSymbol(descriptor: Pick<ChordDescriptor, 'root' | 'qua
|
|||||||
symbol += 'maj7#5';
|
symbol += 'maj7#5';
|
||||||
remainingExtensions.delete('maj7');
|
remainingExtensions.delete('maj7');
|
||||||
remainingExtensions.delete('#5');
|
remainingExtensions.delete('#5');
|
||||||
|
} else {
|
||||||
|
const collapsedNaturalExtension = getCollapsedNaturalExtension(descriptor.quality, extensions);
|
||||||
|
if (collapsedNaturalExtension) {
|
||||||
|
symbol += collapsedNaturalExtension.suffix;
|
||||||
|
collapsedNaturalExtension.consumed.forEach(extension => remainingExtensions.delete(extension));
|
||||||
} else {
|
} else {
|
||||||
switch (descriptor.quality) {
|
switch (descriptor.quality) {
|
||||||
case 'maj':
|
case 'maj':
|
||||||
@@ -456,6 +512,7 @@ export function buildChordSymbol(descriptor: Pick<ChordDescriptor, 'root' | 'qua
|
|||||||
remainingExtensions.delete('6');
|
remainingExtensions.delete('6');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const extension of REMAINING_EXTENSION_ORDER) {
|
for (const extension of REMAINING_EXTENSION_ORDER) {
|
||||||
if (!remainingExtensions.has(extension)) {
|
if (!remainingExtensions.has(extension)) {
|
||||||
@@ -530,7 +587,13 @@ export function formatChordSymbolForDisplay(symbol: string): string {
|
|||||||
if (quality === 'dim' && extensions.includes('dim7')) {
|
if (quality === 'dim' && extensions.includes('dim7')) {
|
||||||
return `${accidentalDisplay(root)}dim7`;
|
return `${accidentalDisplay(root)}dim7`;
|
||||||
}
|
}
|
||||||
|
const collapsedNaturalExtension = getCollapsedNaturalExtension(quality, extensions);
|
||||||
|
const consumedCollapsedExtensions = new Set(collapsedNaturalExtension?.consumed ?? []);
|
||||||
const baseQuality = (() => {
|
const baseQuality = (() => {
|
||||||
|
if (collapsedNaturalExtension) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
switch (quality) {
|
switch (quality) {
|
||||||
case 'maj':
|
case 'maj':
|
||||||
return '';
|
return '';
|
||||||
@@ -553,6 +616,10 @@ export function formatChordSymbolForDisplay(symbol: string): string {
|
|||||||
const parentheticalExtensions: string[] = [];
|
const parentheticalExtensions: string[] = [];
|
||||||
|
|
||||||
for (const extension of extensions) {
|
for (const extension of extensions) {
|
||||||
|
if (consumedCollapsedExtensions.has(extension)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (quality === 'dim' && extension === 'b5' && !extensions.includes('7')) {
|
if (quality === 'dim' && extension === 'b5' && !extensions.includes('7')) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -565,7 +632,9 @@ export function formatChordSymbolForDisplay(symbol: string): string {
|
|||||||
parentheticalExtensions.push(accidentalDisplay(extension));
|
parentheticalExtensions.push(accidentalDisplay(extension));
|
||||||
}
|
}
|
||||||
|
|
||||||
const inlineText = inlineExtensions.join('');
|
const inlineText = collapsedNaturalExtension
|
||||||
|
? collapsedNaturalExtension.suffix
|
||||||
|
: inlineExtensions.join('');
|
||||||
const parentheticalText = parentheticalExtensions.length > 0
|
const parentheticalText = parentheticalExtensions.length > 0
|
||||||
? `(${parentheticalExtensions.join(', ')})`
|
? `(${parentheticalExtensions.join(', ')})`
|
||||||
: '';
|
: '';
|
||||||
|
|||||||
Reference in New Issue
Block a user