feat: allow user to drag-n-drop audio clip into audio tracks; added m4a support

This commit is contained in:
Xiaohan-Tian
2026-05-28 17:25:08 -07:00
parent c8af2ddcca
commit cd4d5847eb
8 changed files with 762 additions and 148 deletions
+91 -1
View File
@@ -1,5 +1,5 @@
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 TrackGridItem from './TrackGridItem';
import { KGAudioTrack } from '../../core/track/KGAudioTrack';
@@ -178,6 +178,26 @@ describe('TrackGridItem preview behavior', () => {
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', () => {
const track = new KGAudioTrack('Audio Track', 1);
track.setTrackIndex(0);
@@ -440,4 +460,74 @@ describe('TrackGridItem preview behavior', () => {
expect(getRegionItem('region-b').previewContentStyle).toBeUndefined();
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');
});
});
+24 -6
View File
@@ -11,6 +11,7 @@ import { KGMainContentState } from '../../core/state/KGMainContentState';
import { useProjectStore } from '../../stores/projectStore';
import { isModifierKeyPressed } from '../../util/osUtil';
import { CHORD_REGION_IMPORT_MIME_TYPE } from '../../util/chordRegionImportUtil';
import { TrackType } from '../../core/track/KGTrack';
interface RegionResizePreviewBaseline {
regionId: string;
@@ -54,6 +55,7 @@ interface TrackGridItemProps {
onOpenHybrid?: (regionId: string) => void;
allTracks?: KGTrack[]; // Added to access all tracks for drag operations
onKGOneClipDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void;
onAudioFileDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void;
onChordRegionDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void;
previewRegionStyles?: Record<string, React.CSSProperties>;
setPreviewRegionStyles?: React.Dispatch<React.SetStateAction<Record<string, React.CSSProperties>>>;
@@ -86,6 +88,7 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
onOpenHybrid,
allTracks,
onKGOneClipDrop,
onAudioFileDrop,
onChordRegionDrop,
previewRegionStyles,
setPreviewRegionStyles,
@@ -740,24 +743,39 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
}}
ref={trackElementRef}
onDragOver={(e) => {
if (
Array.from(e.dataTransfer.types).includes('application/kgone-clip')
|| Array.from(e.dataTransfer.types).includes(CHORD_REGION_IMPORT_MIME_TYPE)
) {
const dataTypes = Array.from(e.dataTransfer.types);
const hasLocalFiles = (e.dataTransfer.files?.length ?? 0) > 0 || dataTypes.includes('Files');
if (dataTypes.includes('application/kgone-clip') || dataTypes.includes(CHORD_REGION_IMPORT_MIME_TYPE)) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
return;
}
if (hasLocalFiles) {
e.preventDefault();
e.dataTransfer.dropEffect = track.getType() === TrackType.Wave ? 'copy' : 'none';
}
}}
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();
onKGOneClipDrop?.(e, index);
return;
}
if (Array.from(e.dataTransfer.types).includes(CHORD_REGION_IMPORT_MIME_TYPE)) {
if (dataTypes.includes(CHORD_REGION_IMPORT_MIME_TYPE)) {
e.preventDefault();
onChordRegionDrop?.(e, index);
return;
}
if (hasLocalFiles) {
e.preventDefault();
onAudioFileDrop?.(e, index);
}
}}
>
+397 -4
View File
@@ -1,16 +1,21 @@
import React from 'react';
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 { createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data';
import { KGAudioTrack } from '../../core/track/KGAudioTrack';
import { CHORD_REGION_IMPORT_MIME_TYPE } from '../../util/chordRegionImportUtil';
import { KGChordRegion } from '../../core/region/KGChordRegion';
import { KGMidiNote } from '../../core/midi/KGMidiNote';
import { KGMainContentState } from '../../core/state/KGMainContentState';
const executeCommandMock = vi.fn();
const getCreatedRegionMock = 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 = [
new KGChordRegion('chord-1', 'global-chord', 3, 'C', 0, 4),
new KGChordRegion('chord-2', 'global-chord', 3, 'F', 4, 4),
@@ -36,7 +41,10 @@ vi.mock('../../stores/projectStore', () => ({
vi.mock('../common', () => ({
Playhead: () => null,
FileImportModal: () => null,
FileImportModal: (props: Record<string, unknown>) => {
fileImportModalProps = props;
return null;
},
}));
vi.mock('../../core/KGCore', () => ({
@@ -51,11 +59,46 @@ vi.mock('../../core/KGCore', () => ({
getGlobalTracks: () => [{
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', () => ({
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 });
vi.spyOn(gridContainer, 'getBoundingClientRect').mockReturnValue({
x: 0,
@@ -151,8 +229,18 @@ describe('TrackGridPanel lasso selection', () => {
height: 240,
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(() => {
@@ -160,8 +248,16 @@ describe('TrackGridPanel lasso selection', () => {
executeCommandMock.mockReset();
getCreatedRegionMock.mockReset();
showAlertMock.mockReset();
fileImportModalProps = null;
storeAudioFileMock.mockReset();
loadAudioBufferForTrackMock.mockReset();
decodeAudioDataMock.mockReset();
decodeAudioDataMock.mockImplementation((_arrayBuffer, onSuccess) => {
onSuccess({ duration: 2 });
});
globalChordRegions[0].setSymbol('C');
currentTracks = [];
KGMainContentState.instance().setSnapping(true);
});
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.');
});
});
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');
});
});
+136 -122
View File
@@ -26,6 +26,11 @@ import {
CHORD_REGION_IMPORT_REGION_NAME,
type ChordRegionImportPayload,
} from '../../util/chordRegionImportUtil';
import {
AUDIO_IMPORT_ACCEPTED_TYPES,
getAudioImportDecodeFailureMessage,
isAcceptedAudioImportFile,
} from '../../util/audioImportUtil';
const getRegionClickOptions = (event: Pick<MouseEvent | React.MouseEvent, 'shiftKey' | 'metaKey' | 'ctrlKey'>): RegionClickOptions => ({
shiftKey: event.shiftKey,
@@ -97,6 +102,92 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
: [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>) => {
if (e.button !== 0) return;
if (KGMainContentState.instance().getActiveTool() !== 'pointer') return;
@@ -332,71 +423,53 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
const track = tracks[trackIndex];
if (!track) return;
const beatsPerBar = timeSignature.numerator;
const fileId = KGAudioFileStorage.generateAudioFileId(file.name);
try {
const arrayBuffer = await file.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, 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);
}
const { audioDurationSeconds } = await importAudioFileToTrackAtBar(file, track, trackIndex, barNumber);
if (DEBUG_MODE.TRACK_GRID_PANEL) {
console.log(`[TrackGrid] Imported audio "${file.name}" to track ${trackIndex}, bar ${barNumber}, ${audioDurationSeconds.toFixed(2)}s`);
}
} catch (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;
}
// Calculate drop bar position
if (!gridContainerRef.current) 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 barNumber = getBarNumberFromGridClientX(e.clientX);
if (barNumber === null) return;
const track = tracks[trackIndex];
if (!track) return;
@@ -871,61 +938,7 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
// ── Audio track: save blob to OPFS and create a KGAudioRegion ──────
const blob = await fetch(dropData.audioUrl).then(r => r.blob());
const audioFile = new File([blob], dropData.audioFileName, { type: 'audio/wav' });
const fileId = `kgone_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
// 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);
}
const { audioDurationSeconds } = await importAudioFileToTrackAtBar(audioFile, track, trackIndex, barNumber);
if (DEBUG_MODE.TRACK_GRID_PANEL) {
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}
allTracks={tracks}
onKGOneClipDrop={handleExternalDrop}
onAudioFileDrop={handleLocalAudioFileDrop}
onChordRegionDrop={handleChordRegionDrop}
previewRegionStyles={previewRegionStyles}
setPreviewRegionStyles={setPreviewRegionStyles}
@@ -1059,7 +1073,7 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
isVisible={showAudioImportModal}
onClose={() => setShowAudioImportModal(false)}
onFileImport={handleAudioFileImport}
acceptedTypes={['.wav', '.mp3', '.ogg', '.flac', '.aac']}
acceptedTypes={[...AUDIO_IMPORT_ACCEPTED_TYPES]}
title="Import Audio"
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']);
});
});
+2 -1
View File
@@ -14,6 +14,7 @@ import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
import { showAlert, showConfirm } from '../../util/dialogUtil';
import type { TrackAutomationType } from '../../core/track/KGTrackAutomationPoint';
import { AUDIO_IMPORT_ACCEPTED_TYPES } from '../../util/audioImportUtil';
const UNITY_POS = 750;
const SLIDER_MAX = 1000;
@@ -554,7 +555,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
isVisible={showAudioImportModal}
onClose={() => setShowAudioImportModal(false)}
onFileImport={handleAudioFileImport}
acceptedTypes={['.wav', '.mp3', '.ogg', '.flac', '.aac']}
acceptedTypes={[...AUDIO_IMPORT_ACCEPTED_TYPES]}
title="Import Audio"
description="Drag and drop your audio file here"
/>
+6 -1
View File
@@ -27,6 +27,7 @@ import { CreateMidiEventsCommand, type NoteCreationData, type PitchBendCreationD
import { MIDI_PITCH_BEND_CENTER } from '../util/midiUtil';
import { KGTrackAutomationPoint, type TrackAutomationType } from '../core/track/KGTrackAutomationPoint';
import type { AudioRecordingPeak } from '../core/audio-interface/KGAudioRecorder';
import { getAudioImportDecodeFailureMessage } from '../util/audioImportUtil';
import { beatToSeconds } from '../util/globalTrackUtil';
/**
@@ -587,6 +588,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
// Decode the audio file to get duration
const arrayBuffer = await file.arrayBuffer();
const toneBuffer = new Tone.ToneAudioBuffer();
try {
await new Promise<void>((resolve, reject) => {
toneBuffer.onload = () => resolve();
// Set buffer from array buffer
@@ -600,6 +602,9 @@ export const useProjectStore = create<ProjectState>((set, get) => {
(err) => reject(err)
);
});
} catch {
throw new Error(getAudioImportDecodeFailureMessage(file.name));
}
const audioDurationSeconds = toneBuffer.duration;
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}`);
} catch (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}".`);
}
},
+18
View File
@@ -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.`;
}