fix: preserve 7sus4 chord canonicalization for global chord MIDI import

This commit is contained in:
Xiaohan-Tian
2026-06-10 18:52:35 -07:00
parent 6bfce38c79
commit 3ba8f81c87
3 changed files with 116 additions and 37 deletions
+14
View File
@@ -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(<ChordPickerPopup value="C" onChange={onChange} />);
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();
+26
View File
@@ -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);
+40 -1
View File
@@ -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');
@@ -472,6 +499,11 @@ export function buildChordSymbol(descriptor: Pick<ChordDescriptor, 'root' | 'qua
if (collapsedNaturalExtension) {
symbol += collapsedNaturalExtension.suffix;
collapsedNaturalExtension.consumed.forEach(extension => remainingExtensions.delete(extension));
} else {
const suspendedSeventhSuffix = getSuspendedSeventhSuffix(descriptor.quality, extensions);
if (suspendedSeventhSuffix) {
symbol += suspendedSeventhSuffix;
remainingExtensions.delete('7');
} else {
switch (descriptor.quality) {
case 'maj':
@@ -513,6 +545,7 @@ export function buildChordSymbol(descriptor: Pick<ChordDescriptor, 'root' | 'qua
}
}
}
}
for (const extension of REMAINING_EXTENSION_ORDER) {
if (!remainingExtensions.has(extension)) {
@@ -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(', ')})`