fix: preserve 7sus4 chord canonicalization for global chord MIDI import
This commit is contained in:
@@ -58,6 +58,20 @@ describe('ChordPickerPopup', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'dim7' }).className).toContain('selected');
|
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', () => {
|
it('intercepts tab and delegates popup bar navigation', () => {
|
||||||
const onTabNavigate = vi.fn();
|
const onTabNavigate = vi.fn();
|
||||||
|
|
||||||
|
|||||||
@@ -44,11 +44,17 @@ describe('chordUtil', () => {
|
|||||||
expect(parseChordSymbol('Dm11')?.symbol).toBe('Dm11');
|
expect(parseChordSymbol('Dm11')?.symbol).toBe('Dm11');
|
||||||
expect(parseChordSymbol('G13')?.symbol).toBe('G13');
|
expect(parseChordSymbol('G13')?.symbol).toBe('G13');
|
||||||
expect(parseChordSymbol('Bbmaj9')?.symbol).toBe('Bbmaj9');
|
expect(parseChordSymbol('Bbmaj9')?.symbol).toBe('Bbmaj9');
|
||||||
|
expect(parseChordSymbol('E7sus4')?.symbol).toBe('E7sus4');
|
||||||
expect(buildChordSymbol({
|
expect(buildChordSymbol({
|
||||||
root: 'C',
|
root: 'C',
|
||||||
quality: 'sus4',
|
quality: 'sus4',
|
||||||
extensions: ['9'],
|
extensions: ['9'],
|
||||||
})).toBe('Csus4add9');
|
})).toBe('Csus4add9');
|
||||||
|
expect(buildChordSymbol({
|
||||||
|
root: 'E',
|
||||||
|
quality: 'sus4',
|
||||||
|
extensions: ['7'],
|
||||||
|
})).toBe('E7sus4');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects unsupported symbols deterministically', () => {
|
it('rejects unsupported symbols deterministically', () => {
|
||||||
@@ -84,6 +90,7 @@ describe('chordUtil', () => {
|
|||||||
expect(convertChordSymbolToMidiPitches('Am')).toEqual([45, 57, 60, 64]);
|
expect(convertChordSymbolToMidiPitches('Am')).toEqual([45, 57, 60, 64]);
|
||||||
expect(convertChordSymbolToMidiPitches('Dm')).toEqual([50, 62, 65, 69]);
|
expect(convertChordSymbolToMidiPitches('Dm')).toEqual([50, 62, 65, 69]);
|
||||||
expect(convertChordSymbolToMidiPitches('E7')).toEqual([52, 64, 68, 71, 74]);
|
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]);
|
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', () => {
|
it('builds an import plan for extended chord progressions', () => {
|
||||||
const chordA = new KGChordRegion('chord-1', 'global-chord', 3, 'Am9', 0, 4);
|
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 chordB = new KGChordRegion('chord-2', 'global-chord', 3, 'Dm9', 4, 4);
|
||||||
|
|||||||
+76
-37
@@ -82,6 +82,25 @@ interface CollapsedNaturalExtension {
|
|||||||
consumed: ChordExtension[];
|
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(
|
function getCollapsedNaturalExtension(
|
||||||
quality: ChordQuality,
|
quality: ChordQuality,
|
||||||
extensions: ChordExtension[],
|
extensions: ChordExtension[],
|
||||||
@@ -309,7 +328,15 @@ function parseCustomChordSymbol(symbol: string): ChordDescriptor | null {
|
|||||||
let quality: ChordQuality = 'maj';
|
let quality: ChordQuality = 'maj';
|
||||||
const extensions = new Set<ChordExtension>();
|
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';
|
quality = 'dim';
|
||||||
extensions.add('b5');
|
extensions.add('b5');
|
||||||
extensions.add('7');
|
extensions.add('7');
|
||||||
@@ -473,43 +500,49 @@ export function buildChordSymbol(descriptor: Pick<ChordDescriptor, 'root' | 'qua
|
|||||||
symbol += collapsedNaturalExtension.suffix;
|
symbol += collapsedNaturalExtension.suffix;
|
||||||
collapsedNaturalExtension.consumed.forEach(extension => remainingExtensions.delete(extension));
|
collapsedNaturalExtension.consumed.forEach(extension => remainingExtensions.delete(extension));
|
||||||
} else {
|
} else {
|
||||||
switch (descriptor.quality) {
|
const suspendedSeventhSuffix = getSuspendedSeventhSuffix(descriptor.quality, extensions);
|
||||||
case 'maj':
|
if (suspendedSeventhSuffix) {
|
||||||
break;
|
symbol += suspendedSeventhSuffix;
|
||||||
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');
|
remainingExtensions.delete('7');
|
||||||
} else if (has('6')) {
|
} else {
|
||||||
symbol += '6';
|
switch (descriptor.quality) {
|
||||||
remainingExtensions.delete('6');
|
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 collapsedNaturalExtension = getCollapsedNaturalExtension(quality, extensions);
|
||||||
const consumedCollapsedExtensions = new Set(collapsedNaturalExtension?.consumed ?? []);
|
const consumedCollapsedExtensions = new Set(collapsedNaturalExtension?.consumed ?? []);
|
||||||
|
const suspendedSeventhSuffix = getSuspendedSeventhSuffix(quality, extensions);
|
||||||
const baseQuality = (() => {
|
const baseQuality = (() => {
|
||||||
if (collapsedNaturalExtension) {
|
if (collapsedNaturalExtension) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
if (suspendedSeventhSuffix) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
switch (quality) {
|
switch (quality) {
|
||||||
case 'maj':
|
case 'maj':
|
||||||
@@ -634,6 +671,8 @@ export function formatChordSymbolForDisplay(symbol: string): string {
|
|||||||
|
|
||||||
const inlineText = collapsedNaturalExtension
|
const inlineText = collapsedNaturalExtension
|
||||||
? collapsedNaturalExtension.suffix
|
? collapsedNaturalExtension.suffix
|
||||||
|
: suspendedSeventhSuffix
|
||||||
|
? suspendedSeventhSuffix
|
||||||
: inlineExtensions.join('');
|
: inlineExtensions.join('');
|
||||||
const parentheticalText = parentheticalExtensions.length > 0
|
const parentheticalText = parentheticalExtensions.length > 0
|
||||||
? `(${parentheticalExtensions.join(', ')})`
|
? `(${parentheticalExtensions.join(', ')})`
|
||||||
|
|||||||
Reference in New Issue
Block a user