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:
@@ -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,7 +26,7 @@
|
||||
background-color: #2d2d2d;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
border-right: 1px solid #3a3a3a;
|
||||
z-index: 1002;
|
||||
z-index: 1005;
|
||||
/* Higher than other elements to ensure it's always visible */
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -107,7 +107,7 @@
|
||||
display: flex;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
background-color: #2d2d2d;
|
||||
z-index: 20;
|
||||
z-index: 1004;
|
||||
margin-left: var(--track-info-panel-width);
|
||||
/* Offset for info-container */
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -27,19 +27,15 @@ const PianoGridHeader: React.FC<PianoGridHeaderProps> = ({
|
||||
const calculatePlayheadFromMouse = useCallback((clientX: number): number | 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 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
|
||||
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
|
||||
// Ignore clicks inside the visual left gutter reserved for piano keys.
|
||||
if (adjustedX < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
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 { KGAudioTrack } from '../../core/track/KGAudioTrack';
|
||||
import { showConfirm } from '../../util/dialogUtil';
|
||||
import { translate } from '../../i18n/translate';
|
||||
|
||||
const storeState = {
|
||||
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', () => ({
|
||||
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', () => ({
|
||||
@@ -49,6 +74,7 @@ describe('TrackInfoItem audio import', () => {
|
||||
storeState.toggleInstrumentSelectionForTrack.mockReset();
|
||||
storeState.importAudioToTrack.mockReset();
|
||||
storeState.setTrackAutomationView.mockReset();
|
||||
vi.mocked(showConfirm).mockReset();
|
||||
});
|
||||
|
||||
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']);
|
||||
});
|
||||
|
||||
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')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -360,7 +360,9 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
// Handle settings action
|
||||
const handleSettingsAction = async (action: string) => {
|
||||
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) {
|
||||
try {
|
||||
if (DEBUG_MODE.TRACK_INFO) {
|
||||
@@ -379,7 +381,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
setShowSettingsDropdown(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete track:', error);
|
||||
await showAlert('Failed to delete track. Please try again.');
|
||||
await showAlert(t('track.controls.settings.deleteTrackError'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,6 +539,8 @@ export const enUsMessages: TranslationMessages = {
|
||||
'track.controls.automation.pan': 'Pan',
|
||||
'track.controls.moreActions': 'More actions',
|
||||
'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.export.label': 'Export',
|
||||
'toolbar.importProject.title': 'Import Project',
|
||||
|
||||
@@ -423,6 +423,8 @@ export const frFrMessages: TranslationMessages = {
|
||||
'track.controls.automation.pan': 'Panoramique',
|
||||
'track.controls.moreActions': 'Autres actions',
|
||||
'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.export.label': 'Exporter',
|
||||
'toolbar.importProject.title': 'Importer un projet',
|
||||
|
||||
@@ -537,6 +537,8 @@ export const zhCnMessages: TranslationMessages = {
|
||||
'track.controls.automation.pan': '声像',
|
||||
'track.controls.moreActions': '更多操作',
|
||||
'track.controls.settings.deleteTrack': '删除轨道',
|
||||
'track.controls.settings.deleteTrackConfirm': '确定要删除轨道“{name}”吗?',
|
||||
'track.controls.settings.deleteTrackError': '删除轨道失败。请重试。',
|
||||
'toolbar.keySignatureChooser': '选择调号,当前为 {value}',
|
||||
'toolbar.export.label': '导出',
|
||||
'toolbar.importProject.title': '导入项目',
|
||||
|
||||
@@ -537,6 +537,8 @@ export const zhHkMessages: TranslationMessages = {
|
||||
'track.controls.automation.pan': '聲像',
|
||||
'track.controls.moreActions': '更多操作',
|
||||
'track.controls.settings.deleteTrack': '刪除音軌',
|
||||
'track.controls.settings.deleteTrackConfirm': '確定要刪除音軌「{name}」嗎?',
|
||||
'track.controls.settings.deleteTrackError': '刪除音軌失敗。請再試一次。',
|
||||
'toolbar.keySignatureChooser': '選擇調號,當前為 {value}',
|
||||
'toolbar.export.label': '匯出',
|
||||
'toolbar.importProject.title': '匯入專案',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Chord, Note } from 'tonal';
|
||||
import { Note } from 'tonal';
|
||||
import { KGChordRegion } from '../core/region/KGChordRegion';
|
||||
import { getChordMidiPitches, parseChordSymbol } from './chordUtil';
|
||||
|
||||
@@ -59,11 +59,6 @@ export function convertChordSymbolToMidiPitches(symbol: string): number[] | null
|
||||
return null;
|
||||
}
|
||||
|
||||
const tonalChord = Chord.get(descriptor.symbol);
|
||||
if (tonalChord.empty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootMidi = getBaseRootMidi(descriptor.symbol);
|
||||
if (rootMidi === null) {
|
||||
return null;
|
||||
|
||||
@@ -39,6 +39,24 @@ describe('chordUtil', () => {
|
||||
})).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', () => {
|
||||
expect(parseChordSymbol('C/E')).toBeNull();
|
||||
expect(parseChordSymbol('not-a-chord')).toBeNull();
|
||||
@@ -52,6 +70,10 @@ describe('chordUtil', () => {
|
||||
|
||||
it('formats the preview using standard chord display conventions', () => {
|
||||
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('G#dim7')).toBe('G♯dim7');
|
||||
});
|
||||
@@ -68,9 +90,17 @@ 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]);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const chordA = new KGChordRegion('chord-1', 'global-chord', 3, 'C', 8, 4);
|
||||
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', () => {
|
||||
const badChord = new KGChordRegion('chord-1', 'global-chord', 3, 'not-a-chord', 0, 4);
|
||||
const result = buildChordRegionImportPlan([badChord]);
|
||||
|
||||
+146
-38
@@ -77,6 +77,76 @@ function hasExtension(descriptor: Pick<ChordDescriptor, 'extensions'>, 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[] {
|
||||
const intervals = ['1P'];
|
||||
|
||||
@@ -258,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');
|
||||
@@ -417,43 +495,55 @@ export function buildChordSymbol(descriptor: Pick<ChordDescriptor, 'root' | 'qua
|
||||
remainingExtensions.delete('maj7');
|
||||
remainingExtensions.delete('#5');
|
||||
} 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;
|
||||
}
|
||||
const collapsedNaturalExtension = getCollapsedNaturalExtension(descriptor.quality, extensions);
|
||||
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':
|
||||
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');
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,7 +620,17 @@ export function formatChordSymbolForDisplay(symbol: string): string {
|
||||
if (quality === 'dim' && extensions.includes('dim7')) {
|
||||
return `${accidentalDisplay(root)}dim7`;
|
||||
}
|
||||
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':
|
||||
return '';
|
||||
@@ -553,6 +653,10 @@ export function formatChordSymbolForDisplay(symbol: string): string {
|
||||
const parentheticalExtensions: string[] = [];
|
||||
|
||||
for (const extension of extensions) {
|
||||
if (consumedCollapsedExtensions.has(extension)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quality === 'dim' && extension === 'b5' && !extensions.includes('7')) {
|
||||
continue;
|
||||
}
|
||||
@@ -565,7 +669,11 @@ export function formatChordSymbolForDisplay(symbol: string): string {
|
||||
parentheticalExtensions.push(accidentalDisplay(extension));
|
||||
}
|
||||
|
||||
const inlineText = inlineExtensions.join('');
|
||||
const inlineText = collapsedNaturalExtension
|
||||
? collapsedNaturalExtension.suffix
|
||||
: suspendedSeventhSuffix
|
||||
? suspendedSeventhSuffix
|
||||
: inlineExtensions.join('');
|
||||
const parentheticalText = parentheticalExtensions.length > 0
|
||||
? `(${parentheticalExtensions.join(', ')})`
|
||||
: '';
|
||||
|
||||
Reference in New Issue
Block a user