feat: allow user to drag-n-drop audio clip into audio tracks; added m4a support
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { act, render } from '@testing-library/react';
|
import { act, createEvent, fireEvent, render } from '@testing-library/react';
|
||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import TrackGridItem from './TrackGridItem';
|
import TrackGridItem from './TrackGridItem';
|
||||||
import { KGAudioTrack } from '../../core/track/KGAudioTrack';
|
import { KGAudioTrack } from '../../core/track/KGAudioTrack';
|
||||||
@@ -178,6 +178,26 @@ describe('TrackGridItem preview behavior', () => {
|
|||||||
return baseProps;
|
return baseProps;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createFileList = (files: File[]) => {
|
||||||
|
const fileList = {
|
||||||
|
length: files.length,
|
||||||
|
item: (index: number) => files[index] ?? null,
|
||||||
|
[Symbol.iterator]: function* iterator() {
|
||||||
|
yield* files;
|
||||||
|
},
|
||||||
|
} as FileList & Iterable<File>;
|
||||||
|
|
||||||
|
files.forEach((file, index) => {
|
||||||
|
Object.defineProperty(fileList, index, {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
value: file,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return fileList;
|
||||||
|
};
|
||||||
|
|
||||||
it('renders a non-interactive preview region on the recording audio track', () => {
|
it('renders a non-interactive preview region on the recording audio track', () => {
|
||||||
const track = new KGAudioTrack('Audio Track', 1);
|
const track = new KGAudioTrack('Audio Track', 1);
|
||||||
track.setTrackIndex(0);
|
track.setTrackIndex(0);
|
||||||
@@ -440,4 +460,74 @@ describe('TrackGridItem preview behavior', () => {
|
|||||||
expect(getRegionItem('region-b').previewContentStyle).toBeUndefined();
|
expect(getRegionItem('region-b').previewContentStyle).toBeUndefined();
|
||||||
expect(onRegionDragEnd).toHaveBeenCalledWith('region-a', 2, 0);
|
expect(onRegionDragEnd).toHaveBeenCalledWith('region-a', 2, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('advertises local file drag acceptance on audio rows', () => {
|
||||||
|
const audioTrack = new KGAudioTrack('Audio Track', 1);
|
||||||
|
audioTrack.setTrackIndex(0);
|
||||||
|
const onAudioFileDrop = vi.fn();
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<TrackGridItem
|
||||||
|
track={audioTrack}
|
||||||
|
index={0}
|
||||||
|
isDragging={false}
|
||||||
|
isDragOver={false}
|
||||||
|
regions={[]}
|
||||||
|
maxBars={8}
|
||||||
|
selectedRegionId={null}
|
||||||
|
gridContainerRef={createGridContainerRef()}
|
||||||
|
onDoubleClick={vi.fn()}
|
||||||
|
onAudioFileDrop={onAudioFileDrop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const grid = view.container.querySelector('[data-test-id="track-grid-1"]') as HTMLDivElement;
|
||||||
|
const dragEvent = createEvent.dragOver(grid);
|
||||||
|
Object.defineProperty(dragEvent, 'dataTransfer', {
|
||||||
|
value: {
|
||||||
|
files: createFileList([new File(['a'], 'drop.wav', { type: 'audio/wav' })]),
|
||||||
|
types: ['Files'],
|
||||||
|
dropEffect: 'move',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent(grid, dragEvent);
|
||||||
|
|
||||||
|
expect(dragEvent.defaultPrevented).toBe(true);
|
||||||
|
expect((dragEvent as unknown as { dataTransfer: { dropEffect: string } }).dataTransfer.dropEffect).toBe('copy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not advertise local file drag acceptance on MIDI rows', () => {
|
||||||
|
const midiTrack = createMockMidiTrack({ id: 1, regions: [] });
|
||||||
|
midiTrack.setTrackIndex(0);
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<TrackGridItem
|
||||||
|
track={midiTrack}
|
||||||
|
index={0}
|
||||||
|
isDragging={false}
|
||||||
|
isDragOver={false}
|
||||||
|
regions={[]}
|
||||||
|
maxBars={8}
|
||||||
|
selectedRegionId={null}
|
||||||
|
gridContainerRef={createGridContainerRef()}
|
||||||
|
onDoubleClick={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const grid = view.container.querySelector('[data-test-id="track-grid-1"]') as HTMLDivElement;
|
||||||
|
const dragEvent = createEvent.dragOver(grid);
|
||||||
|
Object.defineProperty(dragEvent, 'dataTransfer', {
|
||||||
|
value: {
|
||||||
|
files: createFileList([new File(['a'], 'drop.wav', { type: 'audio/wav' })]),
|
||||||
|
types: ['Files'],
|
||||||
|
dropEffect: 'move',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent(grid, dragEvent);
|
||||||
|
|
||||||
|
expect(dragEvent.defaultPrevented).toBe(true);
|
||||||
|
expect((dragEvent as unknown as { dataTransfer: { dropEffect: string } }).dataTransfer.dropEffect).toBe('none');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { KGMainContentState } from '../../core/state/KGMainContentState';
|
|||||||
import { useProjectStore } from '../../stores/projectStore';
|
import { useProjectStore } from '../../stores/projectStore';
|
||||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||||
import { CHORD_REGION_IMPORT_MIME_TYPE } from '../../util/chordRegionImportUtil';
|
import { CHORD_REGION_IMPORT_MIME_TYPE } from '../../util/chordRegionImportUtil';
|
||||||
|
import { TrackType } from '../../core/track/KGTrack';
|
||||||
|
|
||||||
interface RegionResizePreviewBaseline {
|
interface RegionResizePreviewBaseline {
|
||||||
regionId: string;
|
regionId: string;
|
||||||
@@ -54,6 +55,7 @@ interface TrackGridItemProps {
|
|||||||
onOpenHybrid?: (regionId: string) => void;
|
onOpenHybrid?: (regionId: string) => void;
|
||||||
allTracks?: KGTrack[]; // Added to access all tracks for drag operations
|
allTracks?: KGTrack[]; // Added to access all tracks for drag operations
|
||||||
onKGOneClipDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void;
|
onKGOneClipDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void;
|
||||||
|
onAudioFileDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void;
|
||||||
onChordRegionDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void;
|
onChordRegionDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void;
|
||||||
previewRegionStyles?: Record<string, React.CSSProperties>;
|
previewRegionStyles?: Record<string, React.CSSProperties>;
|
||||||
setPreviewRegionStyles?: React.Dispatch<React.SetStateAction<Record<string, React.CSSProperties>>>;
|
setPreviewRegionStyles?: React.Dispatch<React.SetStateAction<Record<string, React.CSSProperties>>>;
|
||||||
@@ -86,6 +88,7 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
onOpenHybrid,
|
onOpenHybrid,
|
||||||
allTracks,
|
allTracks,
|
||||||
onKGOneClipDrop,
|
onKGOneClipDrop,
|
||||||
|
onAudioFileDrop,
|
||||||
onChordRegionDrop,
|
onChordRegionDrop,
|
||||||
previewRegionStyles,
|
previewRegionStyles,
|
||||||
setPreviewRegionStyles,
|
setPreviewRegionStyles,
|
||||||
@@ -740,24 +743,39 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
}}
|
}}
|
||||||
ref={trackElementRef}
|
ref={trackElementRef}
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
if (
|
const dataTypes = Array.from(e.dataTransfer.types);
|
||||||
Array.from(e.dataTransfer.types).includes('application/kgone-clip')
|
const hasLocalFiles = (e.dataTransfer.files?.length ?? 0) > 0 || dataTypes.includes('Files');
|
||||||
|| Array.from(e.dataTransfer.types).includes(CHORD_REGION_IMPORT_MIME_TYPE)
|
|
||||||
) {
|
if (dataTypes.includes('application/kgone-clip') || dataTypes.includes(CHORD_REGION_IMPORT_MIME_TYPE)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'copy';
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasLocalFiles) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = track.getType() === TrackType.Wave ? 'copy' : 'none';
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
if (Array.from(e.dataTransfer.types).includes('application/kgone-clip')) {
|
const dataTypes = Array.from(e.dataTransfer.types);
|
||||||
|
const hasLocalFiles = (e.dataTransfer.files?.length ?? 0) > 0 || dataTypes.includes('Files');
|
||||||
|
|
||||||
|
if (dataTypes.includes('application/kgone-clip')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onKGOneClipDrop?.(e, index);
|
onKGOneClipDrop?.(e, index);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.from(e.dataTransfer.types).includes(CHORD_REGION_IMPORT_MIME_TYPE)) {
|
if (dataTypes.includes(CHORD_REGION_IMPORT_MIME_TYPE)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onChordRegionDrop?.(e, index);
|
onChordRegionDrop?.(e, index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasLocalFiles) {
|
||||||
|
e.preventDefault();
|
||||||
|
onAudioFileDrop?.(e, index);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { fireEvent, render } from '@testing-library/react';
|
import { createEvent, fireEvent, render } from '@testing-library/react';
|
||||||
import TrackGridPanel from './TrackGridPanel';
|
import TrackGridPanel from './TrackGridPanel';
|
||||||
import { createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data';
|
import { createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data';
|
||||||
import { KGAudioTrack } from '../../core/track/KGAudioTrack';
|
import { KGAudioTrack } from '../../core/track/KGAudioTrack';
|
||||||
import { CHORD_REGION_IMPORT_MIME_TYPE } from '../../util/chordRegionImportUtil';
|
import { CHORD_REGION_IMPORT_MIME_TYPE } from '../../util/chordRegionImportUtil';
|
||||||
import { KGChordRegion } from '../../core/region/KGChordRegion';
|
import { KGChordRegion } from '../../core/region/KGChordRegion';
|
||||||
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
||||||
|
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
||||||
|
|
||||||
const executeCommandMock = vi.fn();
|
const executeCommandMock = vi.fn();
|
||||||
const getCreatedRegionMock = vi.fn();
|
const getCreatedRegionMock = vi.fn();
|
||||||
const showAlertMock = vi.fn();
|
const showAlertMock = vi.fn();
|
||||||
|
let fileImportModalProps: Record<string, unknown> | null = null;
|
||||||
|
const storeAudioFileMock = vi.fn<(projectName: string, fileId: string, file: File) => Promise<void>>(async () => undefined);
|
||||||
|
const loadAudioBufferForTrackMock = vi.fn<(trackId: string, fileId: string, toneBuffer: unknown) => void>();
|
||||||
|
const decodeAudioDataMock = vi.fn<(arrayBuffer: ArrayBuffer, onSuccess: (decoded: { duration?: number }) => void, onError: (error: unknown) => void) => void>();
|
||||||
const globalChordRegions = [
|
const globalChordRegions = [
|
||||||
new KGChordRegion('chord-1', 'global-chord', 3, 'C', 0, 4),
|
new KGChordRegion('chord-1', 'global-chord', 3, 'C', 0, 4),
|
||||||
new KGChordRegion('chord-2', 'global-chord', 3, 'F', 4, 4),
|
new KGChordRegion('chord-2', 'global-chord', 3, 'F', 4, 4),
|
||||||
@@ -36,7 +41,10 @@ vi.mock('../../stores/projectStore', () => ({
|
|||||||
|
|
||||||
vi.mock('../common', () => ({
|
vi.mock('../common', () => ({
|
||||||
Playhead: () => null,
|
Playhead: () => null,
|
||||||
FileImportModal: () => null,
|
FileImportModal: (props: Record<string, unknown>) => {
|
||||||
|
fileImportModalProps = props;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../core/KGCore', () => ({
|
vi.mock('../../core/KGCore', () => ({
|
||||||
@@ -51,11 +59,46 @@ vi.mock('../../core/KGCore', () => ({
|
|||||||
getGlobalTracks: () => [{
|
getGlobalTracks: () => [{
|
||||||
getRegions: () => globalChordRegions,
|
getRegions: () => globalChordRegions,
|
||||||
}],
|
}],
|
||||||
|
getBpm: () => 120,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../core/io/KGAudioFileStorage', () => ({
|
||||||
|
KGAudioFileStorage: {
|
||||||
|
generateAudioFileId: vi.fn((fileName: string) => `audio-file-id-${fileName}`),
|
||||||
|
storeAudioFile: (projectName: string, fileId: string, file: File) => storeAudioFileMock(projectName, fileId, file),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../core/audio-interface/KGAudioInterface', () => ({
|
||||||
|
KGAudioInterface: {
|
||||||
|
instance: () => ({
|
||||||
|
loadAudioBufferForTrack: (trackId: string, fileId: string, toneBuffer: unknown) => loadAudioBufferForTrackMock(trackId, fileId, toneBuffer),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('tone', () => ({
|
||||||
|
ToneAudioBuffer: class {
|
||||||
|
duration = 0;
|
||||||
|
|
||||||
|
set(decoded: { duration?: number }) {
|
||||||
|
this.duration = decoded.duration ?? 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getContext: () => ({
|
||||||
|
rawContext: {
|
||||||
|
decodeAudioData: (
|
||||||
|
arrayBuffer: ArrayBuffer,
|
||||||
|
onSuccess: (decoded: { duration?: number }) => void,
|
||||||
|
onError: (error: unknown) => void
|
||||||
|
) => decodeAudioDataMock(arrayBuffer, onSuccess, onError),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../util/dialogUtil', () => ({
|
vi.mock('../../util/dialogUtil', () => ({
|
||||||
showAlert: (...args: unknown[]) => showAlertMock(...args),
|
showAlert: (...args: unknown[]) => showAlertMock(...args),
|
||||||
}));
|
}));
|
||||||
@@ -138,7 +181,42 @@ describe('TrackGridPanel lasso selection', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const gridContainer = view.container.querySelector('.grid-container') as HTMLDivElement;
|
configureGridContainer(view.container);
|
||||||
|
|
||||||
|
return { ...view, onRegionLassoSelection, onRegionCreated };
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockDroppedFileList = (files: File[]) => {
|
||||||
|
const fileList = {
|
||||||
|
length: files.length,
|
||||||
|
item: (index: number) => files[index] ?? null,
|
||||||
|
[Symbol.iterator]: function* iterator() {
|
||||||
|
yield* files;
|
||||||
|
},
|
||||||
|
} as FileList & Iterable<File>;
|
||||||
|
|
||||||
|
files.forEach((file, index) => {
|
||||||
|
Object.defineProperty(fileList, index, {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
value: file,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return fileList;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAudioFile = (name: string) => {
|
||||||
|
const file = new File([new Uint8Array([1, 2, 3])], name, { type: name.endsWith('.m4a') ? 'audio/mp4' : 'audio/wav' });
|
||||||
|
Object.defineProperty(file, 'arrayBuffer', {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
|
||||||
|
});
|
||||||
|
return file;
|
||||||
|
};
|
||||||
|
|
||||||
|
const configureGridContainer = (container: HTMLElement) => {
|
||||||
|
const gridContainer = container.querySelector('.grid-container') as HTMLDivElement;
|
||||||
Object.defineProperty(gridContainer, 'clientWidth', { configurable: true, value: 320 });
|
Object.defineProperty(gridContainer, 'clientWidth', { configurable: true, value: 320 });
|
||||||
vi.spyOn(gridContainer, 'getBoundingClientRect').mockReturnValue({
|
vi.spyOn(gridContainer, 'getBoundingClientRect').mockReturnValue({
|
||||||
x: 0,
|
x: 0,
|
||||||
@@ -151,8 +229,18 @@ describe('TrackGridPanel lasso selection', () => {
|
|||||||
height: 240,
|
height: 240,
|
||||||
toJSON: () => ({}),
|
toJSON: () => ({}),
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return { ...view, onRegionLassoSelection, onRegionCreated };
|
const dispatchFileDrop = (target: HTMLElement, files: File[], clientX: number) => {
|
||||||
|
const dropEvent = createEvent.drop(target);
|
||||||
|
Object.defineProperty(dropEvent, 'clientX', { configurable: true, value: clientX });
|
||||||
|
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||||
|
value: {
|
||||||
|
files: mockDroppedFileList(files),
|
||||||
|
types: ['Files'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fireEvent(target, dropEvent);
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -160,8 +248,16 @@ describe('TrackGridPanel lasso selection', () => {
|
|||||||
executeCommandMock.mockReset();
|
executeCommandMock.mockReset();
|
||||||
getCreatedRegionMock.mockReset();
|
getCreatedRegionMock.mockReset();
|
||||||
showAlertMock.mockReset();
|
showAlertMock.mockReset();
|
||||||
|
fileImportModalProps = null;
|
||||||
|
storeAudioFileMock.mockReset();
|
||||||
|
loadAudioBufferForTrackMock.mockReset();
|
||||||
|
decodeAudioDataMock.mockReset();
|
||||||
|
decodeAudioDataMock.mockImplementation((_arrayBuffer, onSuccess) => {
|
||||||
|
onSuccess({ duration: 2 });
|
||||||
|
});
|
||||||
globalChordRegions[0].setSymbol('C');
|
globalChordRegions[0].setSymbol('C');
|
||||||
currentTracks = [];
|
currentTracks = [];
|
||||||
|
KGMainContentState.instance().setSnapping(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('selects intersecting regions across multiple track rows', () => {
|
it('selects intersecting regions across multiple track rows', () => {
|
||||||
@@ -343,4 +439,301 @@ describe('TrackGridPanel lasso selection', () => {
|
|||||||
expect(showAlertMock).toHaveBeenCalledWith('Unable to import chord "not-a-chord". Please update the chord symbol and try again.');
|
expect(showAlertMock).toHaveBeenCalledWith('Unable to import chord "not-a-chord". Please update the chord symbol and try again.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('imports a dropped audio file into an audio track and notifies completion', async () => {
|
||||||
|
const audioTrack = new KGAudioTrack('Audio Track', 2);
|
||||||
|
audioTrack.setTrackIndex(0);
|
||||||
|
currentTracks = [audioTrack];
|
||||||
|
const onExternalDropComplete = vi.fn();
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<TrackGridPanel
|
||||||
|
tracks={[audioTrack]}
|
||||||
|
regions={[]}
|
||||||
|
maxBars={8}
|
||||||
|
timeSignature={{ numerator: 4, denominator: 4 }}
|
||||||
|
draggedTrackIndex={null}
|
||||||
|
dragOverTrackIndex={null}
|
||||||
|
selectedRegionId={null}
|
||||||
|
projectName="Test"
|
||||||
|
onRegionCreated={vi.fn()}
|
||||||
|
onExternalDropComplete={onExternalDropComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
configureGridContainer(view.container);
|
||||||
|
|
||||||
|
const targetGrid = view.container.querySelector('[data-test-id="track-grid-2"]') as HTMLDivElement;
|
||||||
|
const audioFile = createAudioFile('dropped.wav');
|
||||||
|
|
||||||
|
dispatchFileDrop(targetGrid, [audioFile], 80);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(onExternalDropComplete).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(storeAudioFileMock).toHaveBeenCalledWith('Test', 'audio-file-id-dropped.wav', audioFile);
|
||||||
|
expect(loadAudioBufferForTrackMock).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const command = executeCommandMock.mock.calls.at(-1)?.[0];
|
||||||
|
const createdRegion = command.getCreatedRegion();
|
||||||
|
expect(createdRegion?.getName()).toBe('dropped.wav');
|
||||||
|
expect(createdRegion?.getStartFromBeat()).toBe(8);
|
||||||
|
expect(createdRegion?.getLength()).toBeCloseTo(4);
|
||||||
|
expect(onExternalDropComplete).toHaveBeenCalledWith(0, expect.objectContaining({
|
||||||
|
name: 'dropped.wav',
|
||||||
|
barNumber: 3,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advertises m4a support in the timeline audio import modal', () => {
|
||||||
|
const audioTrack = new KGAudioTrack('Audio Track', 2);
|
||||||
|
audioTrack.setTrackIndex(0);
|
||||||
|
currentTracks = [audioTrack];
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<TrackGridPanel
|
||||||
|
tracks={[audioTrack]}
|
||||||
|
regions={[]}
|
||||||
|
maxBars={8}
|
||||||
|
timeSignature={{ numerator: 4, denominator: 4 }}
|
||||||
|
draggedTrackIndex={null}
|
||||||
|
dragOverTrackIndex={null}
|
||||||
|
selectedRegionId={null}
|
||||||
|
projectName="Test"
|
||||||
|
onRegionCreated={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
configureGridContainer(view.container);
|
||||||
|
|
||||||
|
expect(fileImportModalProps?.acceptedTypes).toEqual(['.wav', '.mp3', '.ogg', '.flac', '.aac', '.m4a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts dropped m4a files on audio tracks', async () => {
|
||||||
|
const audioTrack = new KGAudioTrack('Audio Track', 2);
|
||||||
|
audioTrack.setTrackIndex(0);
|
||||||
|
currentTracks = [audioTrack];
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<TrackGridPanel
|
||||||
|
tracks={[audioTrack]}
|
||||||
|
regions={[]}
|
||||||
|
maxBars={8}
|
||||||
|
timeSignature={{ numerator: 4, denominator: 4 }}
|
||||||
|
draggedTrackIndex={null}
|
||||||
|
dragOverTrackIndex={null}
|
||||||
|
selectedRegionId={null}
|
||||||
|
projectName="Test"
|
||||||
|
onRegionCreated={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
configureGridContainer(view.container);
|
||||||
|
|
||||||
|
const targetGrid = view.container.querySelector('[data-test-id="track-grid-2"]') as HTMLDivElement;
|
||||||
|
dispatchFileDrop(targetGrid, [createAudioFile('clip.m4a')], 80);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(executeCommandMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const command = executeCommandMock.mock.calls.at(-1)?.[0];
|
||||||
|
expect(command.getCreatedRegion()?.getName()).toBe('clip.m4a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a polite dialog when dropping an audio file onto a MIDI track', async () => {
|
||||||
|
const midiTrack = createMockMidiTrack({ id: 1, regions: [] });
|
||||||
|
midiTrack.setTrackIndex(0);
|
||||||
|
currentTracks = [midiTrack];
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<TrackGridPanel
|
||||||
|
tracks={[midiTrack]}
|
||||||
|
regions={[]}
|
||||||
|
maxBars={8}
|
||||||
|
timeSignature={{ numerator: 4, denominator: 4 }}
|
||||||
|
draggedTrackIndex={null}
|
||||||
|
dragOverTrackIndex={null}
|
||||||
|
selectedRegionId={null}
|
||||||
|
projectName="Test"
|
||||||
|
onRegionCreated={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
configureGridContainer(view.container);
|
||||||
|
|
||||||
|
const targetGrid = view.container.querySelector('[data-test-id="track-grid-1"]') as HTMLDivElement;
|
||||||
|
dispatchFileDrop(targetGrid, [createAudioFile('dropped.wav')], 80);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(showAlertMock).toHaveBeenCalledWith('Audio files can only be imported into audio tracks. Please drop them onto an audio track.');
|
||||||
|
});
|
||||||
|
expect(executeCommandMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a validation dialog when dropping an unsupported local file', async () => {
|
||||||
|
const audioTrack = new KGAudioTrack('Audio Track', 2);
|
||||||
|
audioTrack.setTrackIndex(0);
|
||||||
|
currentTracks = [audioTrack];
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<TrackGridPanel
|
||||||
|
tracks={[audioTrack]}
|
||||||
|
regions={[]}
|
||||||
|
maxBars={8}
|
||||||
|
timeSignature={{ numerator: 4, denominator: 4 }}
|
||||||
|
draggedTrackIndex={null}
|
||||||
|
dragOverTrackIndex={null}
|
||||||
|
selectedRegionId={null}
|
||||||
|
projectName="Test"
|
||||||
|
onRegionCreated={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
configureGridContainer(view.container);
|
||||||
|
|
||||||
|
const targetGrid = view.container.querySelector('[data-test-id="track-grid-2"]') as HTMLDivElement;
|
||||||
|
dispatchFileDrop(targetGrid, [new File(['{}'], 'notes.txt', { type: 'text/plain' })], 80);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(showAlertMock).toHaveBeenCalledWith('Invalid file type. Please select a file with one of these extensions: .wav, .mp3, .ogg, .flac, .aac, .m4a');
|
||||||
|
});
|
||||||
|
expect(executeCommandMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a clear decode failure dialog for m4a imports when the browser rejects the codec', async () => {
|
||||||
|
const audioTrack = new KGAudioTrack('Audio Track', 2);
|
||||||
|
audioTrack.setTrackIndex(0);
|
||||||
|
currentTracks = [audioTrack];
|
||||||
|
decodeAudioDataMock.mockImplementationOnce((_arrayBuffer, _onSuccess, onError) => {
|
||||||
|
onError(new Error('decode failed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<TrackGridPanel
|
||||||
|
tracks={[audioTrack]}
|
||||||
|
regions={[]}
|
||||||
|
maxBars={8}
|
||||||
|
timeSignature={{ numerator: 4, denominator: 4 }}
|
||||||
|
draggedTrackIndex={null}
|
||||||
|
dragOverTrackIndex={null}
|
||||||
|
selectedRegionId={null}
|
||||||
|
projectName="Test"
|
||||||
|
onRegionCreated={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
configureGridContainer(view.container);
|
||||||
|
|
||||||
|
const targetGrid = view.container.querySelector('[data-test-id="track-grid-2"]') as HTMLDivElement;
|
||||||
|
dispatchFileDrop(targetGrid, [createAudioFile('unsupported.m4a')], 80);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(showAlertMock).toHaveBeenCalledWith('Unable to import "unsupported.m4a". This browser could not decode the file\'s audio codec. M4A import depends on browser support.');
|
||||||
|
});
|
||||||
|
expect(storeAudioFileMock).not.toHaveBeenCalled();
|
||||||
|
expect(executeCommandMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the same snapped bar placement for dropped audio files as click import', async () => {
|
||||||
|
const audioTrack = new KGAudioTrack('Audio Track', 2);
|
||||||
|
audioTrack.setTrackIndex(0);
|
||||||
|
currentTracks = [audioTrack];
|
||||||
|
KGMainContentState.instance().setSnapping(true);
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<TrackGridPanel
|
||||||
|
tracks={[audioTrack]}
|
||||||
|
regions={[]}
|
||||||
|
maxBars={8}
|
||||||
|
timeSignature={{ numerator: 4, denominator: 4 }}
|
||||||
|
draggedTrackIndex={null}
|
||||||
|
dragOverTrackIndex={null}
|
||||||
|
selectedRegionId={null}
|
||||||
|
projectName="Test"
|
||||||
|
onRegionCreated={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
configureGridContainer(view.container);
|
||||||
|
|
||||||
|
const targetGrid = view.container.querySelector('[data-test-id="track-grid-2"]') as HTMLDivElement;
|
||||||
|
dispatchFileDrop(targetGrid, [createAudioFile('snapped.wav')], 100);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(executeCommandMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const command = executeCommandMock.mock.calls.at(-1)?.[0];
|
||||||
|
expect(command.getCreatedRegion()?.getStartFromBeat()).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves fractional bar placement when snapping is disabled for dropped audio files', async () => {
|
||||||
|
const audioTrack = new KGAudioTrack('Audio Track', 2);
|
||||||
|
audioTrack.setTrackIndex(0);
|
||||||
|
currentTracks = [audioTrack];
|
||||||
|
KGMainContentState.instance().setSnapping(false);
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<TrackGridPanel
|
||||||
|
tracks={[audioTrack]}
|
||||||
|
regions={[]}
|
||||||
|
maxBars={8}
|
||||||
|
timeSignature={{ numerator: 4, denominator: 4 }}
|
||||||
|
draggedTrackIndex={null}
|
||||||
|
dragOverTrackIndex={null}
|
||||||
|
selectedRegionId={null}
|
||||||
|
projectName="Test"
|
||||||
|
onRegionCreated={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
configureGridContainer(view.container);
|
||||||
|
|
||||||
|
const targetGrid = view.container.querySelector('[data-test-id="track-grid-2"]') as HTMLDivElement;
|
||||||
|
dispatchFileDrop(targetGrid, [createAudioFile('unsnapped.wav')], 100);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(executeCommandMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const command = executeCommandMock.mock.calls.at(-1)?.[0];
|
||||||
|
expect(command.getCreatedRegion()?.getStartFromBeat()).toBeCloseTo(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advertises local file drops only on audio rows during drag over', () => {
|
||||||
|
const audioTrack = new KGAudioTrack('Audio Track', 2);
|
||||||
|
audioTrack.setTrackIndex(0);
|
||||||
|
const midiTrack = createMockMidiTrack({ id: 1, regions: [] });
|
||||||
|
midiTrack.setTrackIndex(1);
|
||||||
|
currentTracks = [audioTrack, midiTrack];
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<TrackGridPanel
|
||||||
|
tracks={[audioTrack, midiTrack]}
|
||||||
|
regions={[]}
|
||||||
|
maxBars={8}
|
||||||
|
timeSignature={{ numerator: 4, denominator: 4 }}
|
||||||
|
draggedTrackIndex={null}
|
||||||
|
dragOverTrackIndex={null}
|
||||||
|
selectedRegionId={null}
|
||||||
|
projectName="Test"
|
||||||
|
onRegionCreated={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
configureGridContainer(view.container);
|
||||||
|
|
||||||
|
const audioGrid = view.container.querySelector('[data-test-id="track-grid-2"]') as HTMLDivElement;
|
||||||
|
const midiGrid = view.container.querySelector('[data-test-id="track-grid-1"]') as HTMLDivElement;
|
||||||
|
|
||||||
|
const audioEvent = createEvent.dragOver(audioGrid);
|
||||||
|
Object.defineProperty(audioEvent, 'dataTransfer', {
|
||||||
|
value: { files: mockDroppedFileList([createAudioFile('drag.wav')]), types: ['Files'], dropEffect: 'move' },
|
||||||
|
});
|
||||||
|
fireEvent(audioGrid, audioEvent);
|
||||||
|
|
||||||
|
const midiEvent = createEvent.dragOver(midiGrid);
|
||||||
|
Object.defineProperty(midiEvent, 'dataTransfer', {
|
||||||
|
value: { files: mockDroppedFileList([createAudioFile('drag.wav')]), types: ['Files'], dropEffect: 'move' },
|
||||||
|
});
|
||||||
|
fireEvent(midiGrid, midiEvent);
|
||||||
|
|
||||||
|
expect(audioEvent.defaultPrevented).toBe(true);
|
||||||
|
expect((audioEvent as unknown as { dataTransfer: { dropEffect: string } }).dataTransfer.dropEffect).toBe('copy');
|
||||||
|
expect(midiEvent.defaultPrevented).toBe(true);
|
||||||
|
expect((midiEvent as unknown as { dataTransfer: { dropEffect: string } }).dataTransfer.dropEffect).toBe('none');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ import {
|
|||||||
CHORD_REGION_IMPORT_REGION_NAME,
|
CHORD_REGION_IMPORT_REGION_NAME,
|
||||||
type ChordRegionImportPayload,
|
type ChordRegionImportPayload,
|
||||||
} from '../../util/chordRegionImportUtil';
|
} from '../../util/chordRegionImportUtil';
|
||||||
|
import {
|
||||||
|
AUDIO_IMPORT_ACCEPTED_TYPES,
|
||||||
|
getAudioImportDecodeFailureMessage,
|
||||||
|
isAcceptedAudioImportFile,
|
||||||
|
} from '../../util/audioImportUtil';
|
||||||
|
|
||||||
const getRegionClickOptions = (event: Pick<MouseEvent | React.MouseEvent, 'shiftKey' | 'metaKey' | 'ctrlKey'>): RegionClickOptions => ({
|
const getRegionClickOptions = (event: Pick<MouseEvent | React.MouseEvent, 'shiftKey' | 'metaKey' | 'ctrlKey'>): RegionClickOptions => ({
|
||||||
shiftKey: event.shiftKey,
|
shiftKey: event.shiftKey,
|
||||||
@@ -97,6 +102,92 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
: [primaryRegionId]
|
: [primaryRegionId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getBarNumberFromGridClientX = (clientX: number) => {
|
||||||
|
if (!gridContainerRef.current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridRect = gridContainerRef.current.getBoundingClientRect();
|
||||||
|
const relativeX = clientX - gridRect.left;
|
||||||
|
const barWidth = gridContainerRef.current.clientWidth / maxBars;
|
||||||
|
const rawBar = relativeX / barWidth + 1;
|
||||||
|
const snap = KGMainContentState.instance().isSnappingEnabled();
|
||||||
|
|
||||||
|
return Math.max(1, snap ? Math.round(rawBar) : rawBar);
|
||||||
|
};
|
||||||
|
|
||||||
|
const importAudioFileToTrackAtBar = async (
|
||||||
|
file: File,
|
||||||
|
track: KGTrack,
|
||||||
|
trackIndex: number,
|
||||||
|
barNumber: number
|
||||||
|
) => {
|
||||||
|
const beatsPerBar = timeSignature.numerator;
|
||||||
|
const fileId = KGAudioFileStorage.generateAudioFileId(file.name);
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const toneBuffer = new Tone.ToneAudioBuffer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const audioContext = Tone.getContext().rawContext as AudioContext;
|
||||||
|
audioContext.decodeAudioData(
|
||||||
|
arrayBuffer.slice(0),
|
||||||
|
(decoded) => { toneBuffer.set(decoded); resolve(); },
|
||||||
|
(err) => reject(err)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new Error(getAudioImportDecodeFailureMessage(file.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioDurationSeconds = toneBuffer.duration;
|
||||||
|
await KGAudioFileStorage.storeAudioFile(projectName, fileId, file);
|
||||||
|
KGAudioInterface.instance().loadAudioBufferForTrack(
|
||||||
|
track.getId().toString(),
|
||||||
|
fileId,
|
||||||
|
toneBuffer
|
||||||
|
);
|
||||||
|
|
||||||
|
const bpm = KGCore.instance().getCurrentProject().getBpm();
|
||||||
|
const durationInBeats = audioDurationSeconds * (bpm / 60);
|
||||||
|
const insertBeat = (barNumber - 1) * beatsPerBar;
|
||||||
|
const lengthInBars = Math.max(1, Math.ceil(durationInBeats / beatsPerBar));
|
||||||
|
const prevMaxBars = maxBars;
|
||||||
|
const newMaxBars = Math.max(maxBars, barNumber + lengthInBars - 1);
|
||||||
|
|
||||||
|
const cmd = new ImportAudioCommand(
|
||||||
|
track.getId() as unknown as number,
|
||||||
|
trackIndex,
|
||||||
|
fileId,
|
||||||
|
file.name,
|
||||||
|
audioDurationSeconds,
|
||||||
|
insertBeat,
|
||||||
|
durationInBeats,
|
||||||
|
prevMaxBars,
|
||||||
|
newMaxBars
|
||||||
|
);
|
||||||
|
KGCore.instance().executeCommand(cmd);
|
||||||
|
|
||||||
|
const created = cmd.getCreatedRegion();
|
||||||
|
if (created && onExternalDropComplete) {
|
||||||
|
const displayLengthInBars = Math.max(
|
||||||
|
1,
|
||||||
|
getAudioRegionDisplayLengthBeats(KGCore.instance().getCurrentProject(), created) / beatsPerBar
|
||||||
|
);
|
||||||
|
const regionUI: RegionUI = {
|
||||||
|
id: created.getId(),
|
||||||
|
trackId: track.getId().toString(),
|
||||||
|
trackIndex,
|
||||||
|
barNumber: (created.getStartFromBeat() / beatsPerBar) + 1,
|
||||||
|
length: displayLengthInBars,
|
||||||
|
name: created.getName(),
|
||||||
|
};
|
||||||
|
onExternalDropComplete(trackIndex, regionUI);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { audioDurationSeconds };
|
||||||
|
};
|
||||||
|
|
||||||
const startLassoSelection = (e: React.MouseEvent<HTMLDivElement>) => {
|
const startLassoSelection = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
if (KGMainContentState.instance().getActiveTool() !== 'pointer') return;
|
if (KGMainContentState.instance().getActiveTool() !== 'pointer') return;
|
||||||
@@ -332,71 +423,53 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
const track = tracks[trackIndex];
|
const track = tracks[trackIndex];
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
|
|
||||||
const beatsPerBar = timeSignature.numerator;
|
|
||||||
const fileId = KGAudioFileStorage.generateAudioFileId(file.name);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const { audioDurationSeconds } = await importAudioFileToTrackAtBar(file, track, trackIndex, barNumber);
|
||||||
const toneBuffer = new Tone.ToneAudioBuffer();
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const audioContext = Tone.getContext().rawContext as AudioContext;
|
|
||||||
audioContext.decodeAudioData(
|
|
||||||
arrayBuffer.slice(0),
|
|
||||||
(decoded) => { toneBuffer.set(decoded); resolve(); },
|
|
||||||
(err) => reject(err)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const audioDurationSeconds = toneBuffer.duration;
|
|
||||||
await KGAudioFileStorage.storeAudioFile(projectName, fileId, file);
|
|
||||||
KGAudioInterface.instance().loadAudioBufferForTrack(
|
|
||||||
track.getId().toString(),
|
|
||||||
fileId,
|
|
||||||
toneBuffer
|
|
||||||
);
|
|
||||||
|
|
||||||
const bpm = KGCore.instance().getCurrentProject().getBpm();
|
|
||||||
const durationInBeats = audioDurationSeconds * (bpm / 60);
|
|
||||||
const insertBeat = (barNumber - 1) * beatsPerBar;
|
|
||||||
const lengthInBars = Math.max(1, Math.ceil(durationInBeats / beatsPerBar));
|
|
||||||
const prevMaxBars = maxBars;
|
|
||||||
const newMaxBars = Math.max(maxBars, barNumber + lengthInBars - 1);
|
|
||||||
|
|
||||||
const cmd = new ImportAudioCommand(
|
|
||||||
track.getId() as unknown as number,
|
|
||||||
trackIndex,
|
|
||||||
fileId,
|
|
||||||
file.name,
|
|
||||||
audioDurationSeconds,
|
|
||||||
insertBeat,
|
|
||||||
durationInBeats,
|
|
||||||
prevMaxBars,
|
|
||||||
newMaxBars
|
|
||||||
);
|
|
||||||
KGCore.instance().executeCommand(cmd);
|
|
||||||
|
|
||||||
const created = cmd.getCreatedRegion();
|
|
||||||
if (created && onExternalDropComplete) {
|
|
||||||
const displayLengthInBars = Math.max(
|
|
||||||
1,
|
|
||||||
getAudioRegionDisplayLengthBeats(KGCore.instance().getCurrentProject(), created) / beatsPerBar
|
|
||||||
);
|
|
||||||
const regionUI: RegionUI = {
|
|
||||||
id: created.getId(),
|
|
||||||
trackId: track.getId().toString(),
|
|
||||||
trackIndex,
|
|
||||||
barNumber,
|
|
||||||
length: displayLengthInBars,
|
|
||||||
name: created.getName(),
|
|
||||||
};
|
|
||||||
onExternalDropComplete(trackIndex, regionUI);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
||||||
console.log(`[TrackGrid] Imported audio "${file.name}" to track ${trackIndex}, bar ${barNumber}, ${audioDurationSeconds.toFixed(2)}s`);
|
console.log(`[TrackGrid] Imported audio "${file.name}" to track ${trackIndex}, bar ${barNumber}, ${audioDurationSeconds.toFixed(2)}s`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[TrackGrid] Audio import from click failed:', err);
|
console.error('[TrackGrid] Audio import from click failed:', err);
|
||||||
|
await showAlert(err instanceof Error ? err.message : `Failed to import "${file.name}".`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLocalAudioFileDrop = async (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => {
|
||||||
|
const file = e.dataTransfer.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = tracks[trackIndex];
|
||||||
|
if (!track) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.getType() !== TrackType.Wave) {
|
||||||
|
await showAlert('Audio files can only be imported into audio tracks. Please drop them onto an audio track.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAcceptedAudioImportFile(file)) {
|
||||||
|
await showAlert(`Invalid file type. Please select a file with one of these extensions: ${AUDIO_IMPORT_ACCEPTED_TYPES.join(', ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const barNumber = getBarNumberFromGridClientX(e.clientX);
|
||||||
|
if (barNumber === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { audioDurationSeconds } = await importAudioFileToTrackAtBar(file, track, trackIndex, barNumber);
|
||||||
|
|
||||||
|
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
||||||
|
console.log(`[TrackGrid] Imported dropped audio "${file.name}" to track ${trackIndex}, bar ${barNumber}, ${audioDurationSeconds.toFixed(2)}s`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[TrackGrid] Audio import from file drop failed:', err);
|
||||||
|
await showAlert(err instanceof Error ? err.message : `Failed to import "${file.name}".`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -805,14 +878,8 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate drop bar position
|
const barNumber = getBarNumberFromGridClientX(e.clientX);
|
||||||
if (!gridContainerRef.current) return;
|
if (barNumber === null) return;
|
||||||
const gridRect = gridContainerRef.current.getBoundingClientRect();
|
|
||||||
const relativeX = e.clientX - gridRect.left;
|
|
||||||
const barWidth = gridContainerRef.current.clientWidth / maxBars;
|
|
||||||
const rawBar = relativeX / barWidth + 1;
|
|
||||||
const snap = KGMainContentState.instance().isSnappingEnabled();
|
|
||||||
const barNumber = Math.max(1, snap ? Math.round(rawBar) : Math.floor(rawBar));
|
|
||||||
|
|
||||||
const track = tracks[trackIndex];
|
const track = tracks[trackIndex];
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
@@ -871,61 +938,7 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
// ── Audio track: save blob to OPFS and create a KGAudioRegion ──────
|
// ── Audio track: save blob to OPFS and create a KGAudioRegion ──────
|
||||||
const blob = await fetch(dropData.audioUrl).then(r => r.blob());
|
const blob = await fetch(dropData.audioUrl).then(r => r.blob());
|
||||||
const audioFile = new File([blob], dropData.audioFileName, { type: 'audio/wav' });
|
const audioFile = new File([blob], dropData.audioFileName, { type: 'audio/wav' });
|
||||||
const fileId = `kgone_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
const { audioDurationSeconds } = await importAudioFileToTrackAtBar(audioFile, track, trackIndex, barNumber);
|
||||||
|
|
||||||
// Decode audio to get accurate duration and load into player bus
|
|
||||||
const arrayBuffer = await audioFile.arrayBuffer();
|
|
||||||
const toneBuffer = new Tone.ToneAudioBuffer();
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const audioContext = Tone.getContext().rawContext as AudioContext;
|
|
||||||
audioContext.decodeAudioData(
|
|
||||||
arrayBuffer.slice(0),
|
|
||||||
(decoded) => { toneBuffer.set(decoded); resolve(); },
|
|
||||||
(err) => reject(err)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const audioDurationSeconds = toneBuffer.duration;
|
|
||||||
|
|
||||||
await KGAudioFileStorage.storeAudioFile(projectName, fileId, audioFile);
|
|
||||||
KGAudioInterface.instance().loadAudioBufferForTrack(
|
|
||||||
track.getId().toString(),
|
|
||||||
fileId,
|
|
||||||
toneBuffer
|
|
||||||
);
|
|
||||||
|
|
||||||
const bpm = KGCore.instance().getCurrentProject().getBpm();
|
|
||||||
const durationInBeats = audioDurationSeconds * (bpm / 60);
|
|
||||||
const insertBeat = (barNumber - 1) * beatsPerBar;
|
|
||||||
const lengthInBars = Math.max(1, Math.ceil(durationInBeats / beatsPerBar));
|
|
||||||
const prevMaxBars = maxBars;
|
|
||||||
const newMaxBars = Math.max(maxBars, barNumber + lengthInBars - 1);
|
|
||||||
|
|
||||||
const cmd = new ImportAudioCommand(
|
|
||||||
track.getId() as unknown as number,
|
|
||||||
trackIndex,
|
|
||||||
fileId,
|
|
||||||
dropData.audioFileName,
|
|
||||||
audioDurationSeconds,
|
|
||||||
insertBeat,
|
|
||||||
durationInBeats,
|
|
||||||
prevMaxBars,
|
|
||||||
newMaxBars
|
|
||||||
);
|
|
||||||
KGCore.instance().executeCommand(cmd);
|
|
||||||
|
|
||||||
const created = cmd.getCreatedRegion();
|
|
||||||
if (created && onExternalDropComplete) {
|
|
||||||
const regionUI: RegionUI = {
|
|
||||||
id: created.getId(),
|
|
||||||
trackId: track.getId().toString(),
|
|
||||||
trackIndex,
|
|
||||||
barNumber,
|
|
||||||
length: lengthInBars,
|
|
||||||
name: created.getName(),
|
|
||||||
};
|
|
||||||
onExternalDropComplete(trackIndex, regionUI);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
||||||
console.log(`[KGOne] Imported audio clip to track ${trackIndex}, bar ${barNumber}, ${audioDurationSeconds.toFixed(2)}s`);
|
console.log(`[KGOne] Imported audio clip to track ${trackIndex}, bar ${barNumber}, ${audioDurationSeconds.toFixed(2)}s`);
|
||||||
@@ -1047,6 +1060,7 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
onOpenHybrid={onOpenHybrid}
|
onOpenHybrid={onOpenHybrid}
|
||||||
allTracks={tracks}
|
allTracks={tracks}
|
||||||
onKGOneClipDrop={handleExternalDrop}
|
onKGOneClipDrop={handleExternalDrop}
|
||||||
|
onAudioFileDrop={handleLocalAudioFileDrop}
|
||||||
onChordRegionDrop={handleChordRegionDrop}
|
onChordRegionDrop={handleChordRegionDrop}
|
||||||
previewRegionStyles={previewRegionStyles}
|
previewRegionStyles={previewRegionStyles}
|
||||||
setPreviewRegionStyles={setPreviewRegionStyles}
|
setPreviewRegionStyles={setPreviewRegionStyles}
|
||||||
@@ -1059,7 +1073,7 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
isVisible={showAudioImportModal}
|
isVisible={showAudioImportModal}
|
||||||
onClose={() => setShowAudioImportModal(false)}
|
onClose={() => setShowAudioImportModal(false)}
|
||||||
onFileImport={handleAudioFileImport}
|
onFileImport={handleAudioFileImport}
|
||||||
acceptedTypes={['.wav', '.mp3', '.ogg', '.flac', '.aac']}
|
acceptedTypes={[...AUDIO_IMPORT_ACCEPTED_TYPES]}
|
||||||
title="Import Audio"
|
title="Import Audio"
|
||||||
description="Drag and drop your audio file here"
|
description="Drag and drop your audio file here"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import TrackInfoItem from './TrackInfoItem';
|
||||||
|
import { KGAudioTrack } from '../../core/track/KGAudioTrack';
|
||||||
|
|
||||||
|
const storeState = {
|
||||||
|
selectedTrackId: null as string | null,
|
||||||
|
setSelectedTrack: vi.fn(),
|
||||||
|
removeTrack: vi.fn(),
|
||||||
|
toggleInstrumentSelectionForTrack: vi.fn(),
|
||||||
|
importAudioToTrack: vi.fn(),
|
||||||
|
tracks: [] as KGAudioTrack[],
|
||||||
|
activeTrackAutomationTrackId: null as string | null,
|
||||||
|
activeTrackAutomationType: null as string | null,
|
||||||
|
setTrackAutomationView: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let fileImportModalProps: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
vi.mock('../../stores/projectStore', () => ({
|
||||||
|
useProjectStore: (selector?: (state: typeof storeState) => unknown) => (
|
||||||
|
selector ? selector(storeState) : storeState
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../common/KGDropdown', () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../common/FileImportModal', () => ({
|
||||||
|
default: (props: Record<string, unknown>) => {
|
||||||
|
fileImportModalProps = props;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../util/dialogUtil', () => ({
|
||||||
|
showAlert: vi.fn(),
|
||||||
|
showConfirm: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('TrackInfoItem audio import', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fileImportModalProps = null;
|
||||||
|
storeState.selectedTrackId = null;
|
||||||
|
storeState.setSelectedTrack.mockReset();
|
||||||
|
storeState.removeTrack.mockReset();
|
||||||
|
storeState.toggleInstrumentSelectionForTrack.mockReset();
|
||||||
|
storeState.importAudioToTrack.mockReset();
|
||||||
|
storeState.setTrackAutomationView.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advertises m4a support in the track audio import modal', () => {
|
||||||
|
const audioTrack = new KGAudioTrack('Audio Track', 1);
|
||||||
|
audioTrack.setTrackIndex(0);
|
||||||
|
storeState.tracks = [audioTrack];
|
||||||
|
|
||||||
|
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()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fileImportModalProps?.acceptedTypes).toEqual(['.wav', '.mp3', '.ogg', '.flac', '.aac', '.m4a']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
|||||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||||
import { showAlert, showConfirm } from '../../util/dialogUtil';
|
import { showAlert, showConfirm } from '../../util/dialogUtil';
|
||||||
import type { TrackAutomationType } from '../../core/track/KGTrackAutomationPoint';
|
import type { TrackAutomationType } from '../../core/track/KGTrackAutomationPoint';
|
||||||
|
import { AUDIO_IMPORT_ACCEPTED_TYPES } from '../../util/audioImportUtil';
|
||||||
|
|
||||||
const UNITY_POS = 750;
|
const UNITY_POS = 750;
|
||||||
const SLIDER_MAX = 1000;
|
const SLIDER_MAX = 1000;
|
||||||
@@ -554,7 +555,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
isVisible={showAudioImportModal}
|
isVisible={showAudioImportModal}
|
||||||
onClose={() => setShowAudioImportModal(false)}
|
onClose={() => setShowAudioImportModal(false)}
|
||||||
onFileImport={handleAudioFileImport}
|
onFileImport={handleAudioFileImport}
|
||||||
acceptedTypes={['.wav', '.mp3', '.ogg', '.flac', '.aac']}
|
acceptedTypes={[...AUDIO_IMPORT_ACCEPTED_TYPES]}
|
||||||
title="Import Audio"
|
title="Import Audio"
|
||||||
description="Drag and drop your audio file here"
|
description="Drag and drop your audio file here"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { CreateMidiEventsCommand, type NoteCreationData, type PitchBendCreationD
|
|||||||
import { MIDI_PITCH_BEND_CENTER } from '../util/midiUtil';
|
import { MIDI_PITCH_BEND_CENTER } from '../util/midiUtil';
|
||||||
import { KGTrackAutomationPoint, type TrackAutomationType } from '../core/track/KGTrackAutomationPoint';
|
import { KGTrackAutomationPoint, type TrackAutomationType } from '../core/track/KGTrackAutomationPoint';
|
||||||
import type { AudioRecordingPeak } from '../core/audio-interface/KGAudioRecorder';
|
import type { AudioRecordingPeak } from '../core/audio-interface/KGAudioRecorder';
|
||||||
|
import { getAudioImportDecodeFailureMessage } from '../util/audioImportUtil';
|
||||||
import { beatToSeconds } from '../util/globalTrackUtil';
|
import { beatToSeconds } from '../util/globalTrackUtil';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -587,6 +588,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
// Decode the audio file to get duration
|
// Decode the audio file to get duration
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const toneBuffer = new Tone.ToneAudioBuffer();
|
const toneBuffer = new Tone.ToneAudioBuffer();
|
||||||
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
toneBuffer.onload = () => resolve();
|
toneBuffer.onload = () => resolve();
|
||||||
// Set buffer from array buffer
|
// Set buffer from array buffer
|
||||||
@@ -600,6 +602,9 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
(err) => reject(err)
|
(err) => reject(err)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
} catch {
|
||||||
|
throw new Error(getAudioImportDecodeFailureMessage(file.name));
|
||||||
|
}
|
||||||
|
|
||||||
const audioDurationSeconds = toneBuffer.duration;
|
const audioDurationSeconds = toneBuffer.duration;
|
||||||
const { bpm, timeSignature, playheadPosition, maxBars } = get();
|
const { bpm, timeSignature, playheadPosition, maxBars } = get();
|
||||||
@@ -657,7 +662,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
console.log(`Imported audio "${file.name}" to track ${trackId}`);
|
console.log(`Imported audio "${file.name}" to track ${trackId}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error importing audio:', error);
|
console.error('Error importing audio:', error);
|
||||||
get().setStatus(`Failed to import audio: ${error}`);
|
get().setStatus(error instanceof Error ? error.message : `Failed to import "${file.name}".`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export const AUDIO_IMPORT_ACCEPTED_TYPES = ['.wav', '.mp3', '.ogg', '.flac', '.aac', '.m4a'] as const;
|
||||||
|
|
||||||
|
export function getAudioImportExtension(fileOrName: File | string): string {
|
||||||
|
const fileName = typeof fileOrName === 'string' ? fileOrName : fileOrName.name;
|
||||||
|
return `.${fileName.split('.').pop()?.toLowerCase() ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAcceptedAudioImportFile(file: File): boolean {
|
||||||
|
return AUDIO_IMPORT_ACCEPTED_TYPES.includes(getAudioImportExtension(file) as typeof AUDIO_IMPORT_ACCEPTED_TYPES[number]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAudioImportDecodeFailureMessage(fileName: string): string {
|
||||||
|
if (getAudioImportExtension(fileName) === '.m4a') {
|
||||||
|
return `Unable to import "${fileName}". This browser could not decode the file's audio codec. M4A import depends on browser support.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Unable to import "${fileName}". This browser could not decode the selected audio file.`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user