diff --git a/src/components/ChordPickerPopup.test.tsx b/src/components/ChordPickerPopup.test.tsx
index aa7c97a..e21add8 100644
--- a/src/components/ChordPickerPopup.test.tsx
+++ b/src/components/ChordPickerPopup.test.tsx
@@ -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();
+
+ 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();
diff --git a/src/util/chordUtil.test.ts b/src/util/chordUtil.test.ts
index 0832f29..403ba8e 100644
--- a/src/util/chordUtil.test.ts
+++ b/src/util/chordUtil.test.ts
@@ -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);
diff --git a/src/util/chordUtil.ts b/src/util/chordUtil.ts
index 8e36a4d..6923adb 100644
--- a/src/util/chordUtil.ts
+++ b/src/util/chordUtil.ts
@@ -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();
- 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 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(', ')})`