fix: preserve 7sus4 chord canonicalization for global chord MIDI import
This commit is contained in:
@@ -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);
|
||||
|
||||
+76
-37
@@ -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<ChordExtension>();
|
||||
|
||||
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<ChordDescriptor, 'root' | 'qua
|
||||
symbol += collapsedNaturalExtension.suffix;
|
||||
collapsedNaturalExtension.consumed.forEach(extension => 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(', ')})`
|
||||
|
||||
Reference in New Issue
Block a user