From cd4d5847ebae3b08ecac62e490791ce41d846478 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Thu, 28 May 2026 17:25:08 -0700 Subject: [PATCH] feat: allow user to drag-n-drop audio clip into audio tracks; added m4a support --- src/components/track/TrackGridItem.test.tsx | 92 ++++- src/components/track/TrackGridItem.tsx | 30 +- src/components/track/TrackGridPanel.test.tsx | 401 ++++++++++++++++++- src/components/track/TrackGridPanel.tsx | 258 ++++++------ src/components/track/TrackInfoItem.test.tsx | 75 ++++ src/components/track/TrackInfoItem.tsx | 3 +- src/stores/projectStore.ts | 33 +- src/util/audioImportUtil.ts | 18 + 8 files changed, 762 insertions(+), 148 deletions(-) create mode 100644 src/components/track/TrackInfoItem.test.tsx create mode 100644 src/util/audioImportUtil.ts diff --git a/src/components/track/TrackGridItem.test.tsx b/src/components/track/TrackGridItem.test.tsx index 288d7aa..f3a86f5 100644 --- a/src/components/track/TrackGridItem.test.tsx +++ b/src/components/track/TrackGridItem.test.tsx @@ -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; + + 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( + + ); + + 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( + + ); + + 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'); + }); }); diff --git a/src/components/track/TrackGridItem.tsx b/src/components/track/TrackGridItem.tsx index aa03061..a4c04e6 100644 --- a/src/components/track/TrackGridItem.tsx +++ b/src/components/track/TrackGridItem.tsx @@ -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, trackIndex: number) => void; + onAudioFileDrop?: (e: React.DragEvent, trackIndex: number) => void; onChordRegionDrop?: (e: React.DragEvent, trackIndex: number) => void; previewRegionStyles?: Record; setPreviewRegionStyles?: React.Dispatch>>; @@ -86,6 +88,7 @@ const TrackGridItem: React.FC = ({ onOpenHybrid, allTracks, onKGOneClipDrop, + onAudioFileDrop, onChordRegionDrop, previewRegionStyles, setPreviewRegionStyles, @@ -740,24 +743,39 @@ const TrackGridItem: React.FC = ({ }} 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); } }} > diff --git a/src/components/track/TrackGridPanel.test.tsx b/src/components/track/TrackGridPanel.test.tsx index 0663d1d..91c07c7 100644 --- a/src/components/track/TrackGridPanel.test.tsx +++ b/src/components/track/TrackGridPanel.test.tsx @@ -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 | null = null; +const storeAudioFileMock = vi.fn<(projectName: string, fileId: string, file: File) => Promise>(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) => { + 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; + + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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'); + }); }); diff --git a/src/components/track/TrackGridPanel.tsx b/src/components/track/TrackGridPanel.tsx index 3d0e6ff..9595e9d 100644 --- a/src/components/track/TrackGridPanel.tsx +++ b/src/components/track/TrackGridPanel.tsx @@ -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): RegionClickOptions => ({ shiftKey: event.shiftKey, @@ -97,6 +102,92 @@ const TrackGridPanel: React.FC = ({ : [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((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) => { if (e.button !== 0) return; if (KGMainContentState.instance().getActiveTool() !== 'pointer') return; @@ -332,71 +423,53 @@ const TrackGridPanel: React.FC = ({ 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((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, 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 = ({ 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 = ({ // ── 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((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 = ({ onOpenHybrid={onOpenHybrid} allTracks={tracks} onKGOneClipDrop={handleExternalDrop} + onAudioFileDrop={handleLocalAudioFileDrop} onChordRegionDrop={handleChordRegionDrop} previewRegionStyles={previewRegionStyles} setPreviewRegionStyles={setPreviewRegionStyles} @@ -1059,7 +1073,7 @@ const TrackGridPanel: React.FC = ({ 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" /> diff --git a/src/components/track/TrackInfoItem.test.tsx b/src/components/track/TrackInfoItem.test.tsx new file mode 100644 index 0000000..d962264 --- /dev/null +++ b/src/components/track/TrackInfoItem.test.tsx @@ -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 | 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) => { + 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( + + ); + + expect(fileImportModalProps?.acceptedTypes).toEqual(['.wav', '.mp3', '.ogg', '.flac', '.aac', '.m4a']); + }); +}); diff --git a/src/components/track/TrackInfoItem.tsx b/src/components/track/TrackInfoItem.tsx index a4425e6..e9da9a1 100644 --- a/src/components/track/TrackInfoItem.tsx +++ b/src/components/track/TrackInfoItem.tsx @@ -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 = ({ 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" /> diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 4c6b129..a0d8179 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -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,19 +588,23 @@ export const useProjectStore = create((set, get) => { // Decode the audio file to get duration const arrayBuffer = await file.arrayBuffer(); const toneBuffer = new Tone.ToneAudioBuffer(); - await new Promise((resolve, reject) => { - toneBuffer.onload = () => resolve(); - // Set buffer from array buffer - const audioContext = Tone.getContext().rawContext as AudioContext; - audioContext.decodeAudioData( - arrayBuffer.slice(0), // slice to avoid detached buffer - (decoded) => { - toneBuffer.set(decoded); - resolve(); - }, - (err) => reject(err) - ); - }); + try { + await new Promise((resolve, reject) => { + toneBuffer.onload = () => resolve(); + // Set buffer from array buffer + const audioContext = Tone.getContext().rawContext as AudioContext; + audioContext.decodeAudioData( + arrayBuffer.slice(0), // slice to avoid detached buffer + (decoded) => { + toneBuffer.set(decoded); + resolve(); + }, + (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((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}".`); } }, diff --git a/src/util/audioImportUtil.ts b/src/util/audioImportUtil.ts new file mode 100644 index 0000000..7b0e75d --- /dev/null +++ b/src/util/audioImportUtil.ts @@ -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.`; +}