Merge pull request #53 from KGAudioLab/feat/2026-06-01-enhance-ai-agent

Feat/2026 06 01 enhance ai agent
This commit is contained in:
Xiaohan-Tian
2026-06-12 17:24:06 -07:00
committed by GitHub
13 changed files with 454 additions and 63 deletions
+14
View File
@@ -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();
+2 -2
View File
@@ -26,7 +26,7 @@
background-color: #2d2d2d; background-color: #2d2d2d;
border-bottom: 1px solid #3a3a3a; border-bottom: 1px solid #3a3a3a;
border-right: 1px solid #3a3a3a; border-right: 1px solid #3a3a3a;
z-index: 1002; z-index: 1005;
/* Higher than other elements to ensure it's always visible */ /* Higher than other elements to ensure it's always visible */
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -107,7 +107,7 @@
display: flex; display: flex;
border-bottom: 1px solid #3a3a3a; border-bottom: 1px solid #3a3a3a;
background-color: #2d2d2d; background-color: #2d2d2d;
z-index: 20; z-index: 1004;
margin-left: var(--track-info-panel-width); margin-left: var(--track-info-panel-width);
/* Offset for info-container */ /* Offset for info-container */
width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width)); width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width));
@@ -0,0 +1,121 @@
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import PianoGridHeader from './PianoGridHeader';
const setPlayheadPosition = vi.fn();
const requestMainContentScroll = vi.fn();
const getPlayheadPosition = vi.fn(() => 0);
const storeState = {
setPlayheadPosition,
requestMainContentScroll,
};
vi.mock('../../stores/projectStore', () => ({
useProjectStore: () => storeState,
}));
vi.mock('../../core/KGCore', () => ({
KGCore: {
instance: () => ({
getPlayheadPosition,
}),
},
}));
function renderHeader({
hasPianoKeys = true,
scrollLeft = 0,
paddingLeft = hasPianoKeys ? '60px' : '0px',
}: {
hasPianoKeys?: boolean;
scrollLeft?: number;
paddingLeft?: string;
} = {}) {
const view = render(
<div className="piano-roll-note-scroll">
<PianoGridHeader maxBars={8} hasPianoKeys={hasPianoKeys} />
</div>
);
const scrollContainer = view.container.querySelector('.piano-roll-note-scroll') as HTMLDivElement;
const header = view.container.querySelector('.piano-grid-header') as HTMLDivElement;
Object.defineProperty(scrollContainer, 'scrollLeft', {
configurable: true,
value: scrollLeft,
writable: true,
});
header.style.paddingLeft = paddingLeft;
Object.defineProperty(header, 'getBoundingClientRect', {
configurable: true,
value: () => ({
left: 100,
top: 0,
right: 600,
bottom: 20,
width: 500,
height: 20,
x: 100,
y: 0,
toJSON: () => ({}),
}),
});
return { ...view, header };
}
describe('PianoGridHeader', () => {
beforeEach(() => {
document.documentElement.style.setProperty('--region-grid-beat-width', '40px');
setPlayheadPosition.mockClear();
requestMainContentScroll.mockClear();
getPlayheadPosition.mockClear();
getPlayheadPosition.mockReturnValue(0);
});
it('seeks to the expected beat without horizontal scroll', () => {
const { header } = renderHeader();
fireEvent.click(header, { clientX: 280 });
expect(setPlayheadPosition).toHaveBeenCalledTimes(1);
expect(setPlayheadPosition).toHaveBeenCalledWith(3);
expect(requestMainContentScroll).toHaveBeenCalledTimes(1);
expect(requestMainContentScroll).toHaveBeenCalledWith(3);
});
it('includes the note scroll offset when seeking after horizontal scroll', () => {
const { header } = renderHeader({ scrollLeft: 160 });
fireEvent.click(header, { clientX: 280 });
expect(setPlayheadPosition).toHaveBeenCalledTimes(1);
expect(setPlayheadPosition).toHaveBeenCalledWith(7);
expect(requestMainContentScroll).toHaveBeenCalledTimes(1);
expect(requestMainContentScroll).toHaveBeenCalledWith(7);
});
it('does not subtract a gutter when piano keys are hidden', () => {
const { header } = renderHeader({ hasPianoKeys: false });
fireEvent.click(header, { clientX: 180 });
expect(setPlayheadPosition).toHaveBeenCalledTimes(1);
expect(setPlayheadPosition).toHaveBeenCalledWith(2);
expect(requestMainContentScroll).toHaveBeenCalledWith(2);
});
it('uses the same corrected math on mousedown for drag-to-seek', () => {
const { header } = renderHeader({ scrollLeft: 80 });
fireEvent.mouseDown(header, { button: 0, clientX: 280 });
expect(setPlayheadPosition).toHaveBeenCalledTimes(1);
expect(setPlayheadPosition).toHaveBeenCalledWith(5);
expect(requestMainContentScroll).not.toHaveBeenCalled();
});
});
+7 -11
View File
@@ -27,19 +27,15 @@ const PianoGridHeader: React.FC<PianoGridHeaderProps> = ({
const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => { const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => {
if (!headerElementRef.current) return null; if (!headerElementRef.current) return null;
const rect = headerElementRef.current.getBoundingClientRect(); const headerElement = headerElementRef.current;
const rect = headerElement.getBoundingClientRect();
const relativeX = clientX - rect.left; const relativeX = clientX - rect.left;
const scrollContainer = headerElement.closest('.piano-roll-note-scroll') as HTMLElement | null;
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
const leftGutter = parseFloat(getComputedStyle(headerElement).paddingLeft) || 0;
const adjustedX = relativeX + scrollLeft - leftGutter;
// Account for the piano keys width offset // Ignore clicks inside the visual left gutter reserved for piano keys.
const pianoKeysWidth = hasPianoKeys
? (parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
) || 60)
: 0;
const adjustedX = relativeX - pianoKeysWidth;
// If the click is in the piano keys area (left side), ignore it
if (adjustedX < 0) { if (adjustedX < 0) {
return null; return null;
} }
+57 -2
View File
@@ -1,8 +1,10 @@
import React from 'react'; import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import TrackInfoItem from './TrackInfoItem'; import TrackInfoItem from './TrackInfoItem';
import { KGAudioTrack } from '../../core/track/KGAudioTrack'; import { KGAudioTrack } from '../../core/track/KGAudioTrack';
import { showConfirm } from '../../util/dialogUtil';
import { translate } from '../../i18n/translate';
const storeState = { const storeState = {
selectedTrackId: null as string | null, selectedTrackId: null as string | null,
@@ -24,8 +26,31 @@ vi.mock('../../stores/projectStore', () => ({
), ),
})); }));
vi.mock('../../i18n/useI18n', async () => {
const { translate } = await import('../../i18n/translate');
return {
useI18n: () => ({
t: (key: string, params?: Record<string, string | number>) => translate(key, params, 'zh_cn'),
}),
};
});
vi.mock('../common/KGDropdown', () => ({ vi.mock('../common/KGDropdown', () => ({
default: () => null, default: ({ options, isOpen, onChange }: { options: Array<string | { label: string; value: string }>; isOpen?: boolean; onChange: (value: string) => void }) => (
isOpen ? (
<div>
{options.map((option) => {
const value = typeof option === 'string' ? option : option.value;
const label = typeof option === 'string' ? option : option.label;
return (
<button key={value} type="button" onClick={() => onChange(value)}>
{label}
</button>
);
})}
</div>
) : null
),
})); }));
vi.mock('../common/FileImportModal', () => ({ vi.mock('../common/FileImportModal', () => ({
@@ -49,6 +74,7 @@ describe('TrackInfoItem audio import', () => {
storeState.toggleInstrumentSelectionForTrack.mockReset(); storeState.toggleInstrumentSelectionForTrack.mockReset();
storeState.importAudioToTrack.mockReset(); storeState.importAudioToTrack.mockReset();
storeState.setTrackAutomationView.mockReset(); storeState.setTrackAutomationView.mockReset();
vi.mocked(showConfirm).mockReset();
}); });
it('advertises m4a support in the track audio import modal', () => { it('advertises m4a support in the track audio import modal', () => {
@@ -72,4 +98,33 @@ describe('TrackInfoItem audio import', () => {
expect(fileImportModalProps?.acceptedTypes).toEqual(['.wav', '.mp3', '.ogg', '.flac', '.aac', '.m4a']); expect(fileImportModalProps?.acceptedTypes).toEqual(['.wav', '.mp3', '.ogg', '.flac', '.aac', '.m4a']);
}); });
it('uses the localized delete-track confirmation message', () => {
const audioTrack = new KGAudioTrack('钢琴', 1);
audioTrack.setTrackIndex(0);
storeState.tracks = [audioTrack];
vi.mocked(showConfirm).mockResolvedValue(false);
render(
<TrackInfoItem
track={audioTrack}
index={0}
isDragging={false}
isDragOver={false}
onTrackNameEdit={vi.fn()}
onDragStart={vi.fn()}
onDragOver={vi.fn()}
onDrop={vi.fn()}
onDragEnd={vi.fn()}
/>
);
fireEvent.click(screen.getByRole('button', { name: '更多操作' }));
fireEvent.click(screen.getByRole('button', { name: '删除轨道' }));
expect(showConfirm).toHaveBeenCalledWith(
translate('track.controls.settings.deleteTrackConfirm', { name: '钢琴' }, 'zh_cn')
);
});
}); });
+4 -2
View File
@@ -360,7 +360,9 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
// Handle settings action // Handle settings action
const handleSettingsAction = async (action: string) => { const handleSettingsAction = async (action: string) => {
if (action === 'Delete Track') { if (action === 'Delete Track') {
const confirmed = await showConfirm(`Are you sure you want to delete track "${track.getName()}"?`); const confirmed = await showConfirm(
t('track.controls.settings.deleteTrackConfirm', { name: track.getName() })
);
if (confirmed) { if (confirmed) {
try { try {
if (DEBUG_MODE.TRACK_INFO) { if (DEBUG_MODE.TRACK_INFO) {
@@ -379,7 +381,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
setShowSettingsDropdown(false); setShowSettingsDropdown(false);
} catch (error) { } catch (error) {
console.error('Failed to delete track:', error); console.error('Failed to delete track:', error);
await showAlert('Failed to delete track. Please try again.'); await showAlert(t('track.controls.settings.deleteTrackError'));
} }
} }
} }
+2
View File
@@ -539,6 +539,8 @@ export const enUsMessages: TranslationMessages = {
'track.controls.automation.pan': 'Pan', 'track.controls.automation.pan': 'Pan',
'track.controls.moreActions': 'More actions', 'track.controls.moreActions': 'More actions',
'track.controls.settings.deleteTrack': 'Delete Track', 'track.controls.settings.deleteTrack': 'Delete Track',
'track.controls.settings.deleteTrackConfirm': 'Are you sure you want to delete track "{name}"?',
'track.controls.settings.deleteTrackError': 'Failed to delete track. Please try again.',
'toolbar.keySignatureChooser': 'Choose key signature, current {value}', 'toolbar.keySignatureChooser': 'Choose key signature, current {value}',
'toolbar.export.label': 'Export', 'toolbar.export.label': 'Export',
'toolbar.importProject.title': 'Import Project', 'toolbar.importProject.title': 'Import Project',
+2
View File
@@ -423,6 +423,8 @@ export const frFrMessages: TranslationMessages = {
'track.controls.automation.pan': 'Panoramique', 'track.controls.automation.pan': 'Panoramique',
'track.controls.moreActions': 'Autres actions', 'track.controls.moreActions': 'Autres actions',
'track.controls.settings.deleteTrack': 'Supprimer la piste', 'track.controls.settings.deleteTrack': 'Supprimer la piste',
'track.controls.settings.deleteTrackConfirm': 'Voulez-vous vraiment supprimer la piste « {name} » ?',
'track.controls.settings.deleteTrackError': 'Impossible de supprimer la piste. Veuillez réessayer.',
'toolbar.keySignatureChooser': 'Choisir l\'armure, actuelle : {value}', 'toolbar.keySignatureChooser': 'Choisir l\'armure, actuelle : {value}',
'toolbar.export.label': 'Exporter', 'toolbar.export.label': 'Exporter',
'toolbar.importProject.title': 'Importer un projet', 'toolbar.importProject.title': 'Importer un projet',
+2
View File
@@ -537,6 +537,8 @@ export const zhCnMessages: TranslationMessages = {
'track.controls.automation.pan': '声像', 'track.controls.automation.pan': '声像',
'track.controls.moreActions': '更多操作', 'track.controls.moreActions': '更多操作',
'track.controls.settings.deleteTrack': '删除轨道', 'track.controls.settings.deleteTrack': '删除轨道',
'track.controls.settings.deleteTrackConfirm': '确定要删除轨道“{name}”吗?',
'track.controls.settings.deleteTrackError': '删除轨道失败。请重试。',
'toolbar.keySignatureChooser': '选择调号,当前为 {value}', 'toolbar.keySignatureChooser': '选择调号,当前为 {value}',
'toolbar.export.label': '导出', 'toolbar.export.label': '导出',
'toolbar.importProject.title': '导入项目', 'toolbar.importProject.title': '导入项目',
+2
View File
@@ -537,6 +537,8 @@ export const zhHkMessages: TranslationMessages = {
'track.controls.automation.pan': '聲像', 'track.controls.automation.pan': '聲像',
'track.controls.moreActions': '更多操作', 'track.controls.moreActions': '更多操作',
'track.controls.settings.deleteTrack': '刪除音軌', 'track.controls.settings.deleteTrack': '刪除音軌',
'track.controls.settings.deleteTrackConfirm': '確定要刪除音軌「{name}」嗎?',
'track.controls.settings.deleteTrackError': '刪除音軌失敗。請再試一次。',
'toolbar.keySignatureChooser': '選擇調號,當前為 {value}', 'toolbar.keySignatureChooser': '選擇調號,當前為 {value}',
'toolbar.export.label': '匯出', 'toolbar.export.label': '匯出',
'toolbar.importProject.title': '匯入專案', 'toolbar.importProject.title': '匯入專案',
+1 -6
View File
@@ -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;
+92
View File
@@ -39,6 +39,24 @@ 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(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', () => { 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 +70,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');
}); });
@@ -68,9 +90,17 @@ 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]);
}); });
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 +163,68 @@ 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);
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]);
+146 -38
View File
@@ -77,6 +77,76 @@ function hasExtension(descriptor: Pick<ChordDescriptor, 'extensions'>, extension
return descriptor.extensions.includes(extension); return descriptor.extensions.includes(extension);
} }
interface CollapsedNaturalExtension {
suffix: string;
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[],
): 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'];
@@ -258,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');
@@ -417,43 +495,55 @@ export function buildChordSymbol(descriptor: Pick<ChordDescriptor, 'root' | 'qua
remainingExtensions.delete('maj7'); remainingExtensions.delete('maj7');
remainingExtensions.delete('#5'); remainingExtensions.delete('#5');
} else { } else {
switch (descriptor.quality) { const collapsedNaturalExtension = getCollapsedNaturalExtension(descriptor.quality, extensions);
case 'maj': if (collapsedNaturalExtension) {
break; symbol += collapsedNaturalExtension.suffix;
case 'min': collapsedNaturalExtension.consumed.forEach(extension => remainingExtensions.delete(extension));
symbol += 'm'; } else {
break; const suspendedSeventhSuffix = getSuspendedSeventhSuffix(descriptor.quality, extensions);
case 'sus2': if (suspendedSeventhSuffix) {
symbol += 'sus2'; symbol += suspendedSeventhSuffix;
break; remainingExtensions.delete('7');
case 'sus4': } else {
symbol += 'sus4'; switch (descriptor.quality) {
break; case 'maj':
case 'power': break;
symbol += '5'; case 'min':
break; symbol += 'm';
case 'aug': break;
symbol += 'aug'; case 'sus2':
remainingExtensions.delete('#5'); symbol += 'sus2';
break; break;
case 'dim': case 'sus4':
symbol += 'dim'; symbol += 'sus4';
remainingExtensions.delete('b5'); break;
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')) { if (has('dim7')) {
symbol += 'dim7'; symbol += 'dim7';
remainingExtensions.delete('dim7'); remainingExtensions.delete('dim7');
} else if (has('maj7')) { } else if (has('maj7')) {
symbol += 'maj7'; symbol += 'maj7';
remainingExtensions.delete('maj7'); remainingExtensions.delete('maj7');
} else if (has('7')) { } else if (has('7')) {
symbol += '7'; symbol += '7';
remainingExtensions.delete('7'); remainingExtensions.delete('7');
} else if (has('6')) { } else if (has('6')) {
symbol += '6'; symbol += '6';
remainingExtensions.delete('6'); remainingExtensions.delete('6');
}
}
} }
} }
@@ -530,7 +620,17 @@ 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 suspendedSeventhSuffix = getSuspendedSeventhSuffix(quality, extensions);
const baseQuality = (() => { const baseQuality = (() => {
if (collapsedNaturalExtension) {
return '';
}
if (suspendedSeventhSuffix) {
return '';
}
switch (quality) { switch (quality) {
case 'maj': case 'maj':
return ''; return '';
@@ -553,6 +653,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 +669,11 @@ export function formatChordSymbolForDisplay(symbol: string): string {
parentheticalExtensions.push(accidentalDisplay(extension)); parentheticalExtensions.push(accidentalDisplay(extension));
} }
const inlineText = inlineExtensions.join(''); const inlineText = collapsedNaturalExtension
? collapsedNaturalExtension.suffix
: suspendedSeventhSuffix
? suspendedSeventhSuffix
: inlineExtensions.join('');
const parentheticalText = parentheticalExtensions.length > 0 const parentheticalText = parentheticalExtensions.length > 0
? `(${parentheticalExtensions.join(', ')})` ? `(${parentheticalExtensions.join(', ')})`
: ''; : '';