feat: implemented drag-n-drop to import KGOne generated audio/MIDI file feature
This commit is contained in:
@@ -27,6 +27,7 @@ interface TrackGridItemProps {
|
||||
onRegionClick?: (regionId: string) => void;
|
||||
onOpenPianoRoll?: (regionId: string) => void;
|
||||
allTracks?: KGTrack[]; // Added to access all tracks for drag operations
|
||||
onKGOneClipDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void;
|
||||
}
|
||||
|
||||
const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
||||
@@ -46,7 +47,8 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
||||
onRegionDragEnd,
|
||||
onRegionClick,
|
||||
onOpenPianoRoll,
|
||||
allTracks
|
||||
allTracks,
|
||||
onKGOneClipDrop,
|
||||
}) => {
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [resizingRegion, setResizingRegion] = useState<string | null>(null);
|
||||
@@ -503,12 +505,24 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
||||
const trackRegions = regions.filter(region => region.trackIndex === index);
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className={`track-grid ${isDragOver ? 'drag-over' : ''} ${isDragging ? 'dragging' : ''} ${isModifierPressed ? 'pencil-cursor' : ''}`}
|
||||
data-test-id={`track-grid-${track.getId()}`}
|
||||
onDoubleClick={(e) => onDoubleClick(e, index)}
|
||||
onClick={(e) => onClick && onClick(e, index)}
|
||||
ref={trackElementRef}
|
||||
onDragOver={(e) => {
|
||||
if (Array.from(e.dataTransfer.types).includes('application/kgone-clip')) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (Array.from(e.dataTransfer.types).includes('application/kgone-clip')) {
|
||||
e.preventDefault();
|
||||
onKGOneClipDrop?.(e, index);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Render regions for this track */}
|
||||
{trackRegions.map(region => {
|
||||
|
||||
@@ -7,11 +7,14 @@ import type { RegionUI } from '../interfaces';
|
||||
import { DEBUG_MODE, REGION_CONSTANTS } from '../../constants';
|
||||
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||
import { CreateRegionCommand, ResizeRegionCommand, MoveRegionCommand } from '../../core/commands';
|
||||
import { CreateRegionCommand, ResizeRegionCommand, MoveRegionCommand, ImportAudioCommand, ImportMidiClipCommand } from '../../core/commands';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
||||
import { generateNewRegionName } from '../../util/miscUtil';
|
||||
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
|
||||
import { parseMidiFirstTrackNotes } from '../../util/midiUtil';
|
||||
import * as Tone from 'tone';
|
||||
|
||||
interface TrackGridPanelProps {
|
||||
tracks: KGTrack[];
|
||||
@@ -21,10 +24,12 @@ interface TrackGridPanelProps {
|
||||
draggedTrackIndex: number | null;
|
||||
dragOverTrackIndex: number | null;
|
||||
selectedRegionId: string | null;
|
||||
projectName: string;
|
||||
onRegionCreated: (trackIndex: number, region: RegionUI, midiRegion: KGMidiRegion) => void;
|
||||
onRegionUpdated?: (regionId: string, updates: Partial<RegionUI>, expectedModelUpdates?: { startBeat: number, length: number }) => void;
|
||||
onRegionClick?: (regionId: string) => void;
|
||||
onOpenPianoRoll?: (regionId: string) => void;
|
||||
onExternalDropComplete?: (trackIndex: number, regionUI: RegionUI) => void;
|
||||
}
|
||||
|
||||
const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
@@ -35,10 +40,12 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
draggedTrackIndex,
|
||||
dragOverTrackIndex,
|
||||
selectedRegionId,
|
||||
projectName,
|
||||
onRegionCreated,
|
||||
onRegionUpdated,
|
||||
onRegionClick,
|
||||
onOpenPianoRoll
|
||||
onOpenPianoRoll,
|
||||
onExternalDropComplete,
|
||||
}) => {
|
||||
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -396,11 +403,151 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Handle external K.G.One clip drop onto a track row
|
||||
const handleExternalDrop = async (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => {
|
||||
const raw = e.dataTransfer.getData('application/kgone-clip');
|
||||
if (!raw) return;
|
||||
|
||||
let dropData: { midiUrl?: string; audioUrl: string; audioDurationSeconds: number; audioFileName: string };
|
||||
try {
|
||||
dropData = JSON.parse(raw);
|
||||
} catch {
|
||||
console.error('[KGOne] Invalid drop data');
|
||||
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 track = tracks[trackIndex];
|
||||
if (!track) return;
|
||||
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
|
||||
try {
|
||||
if (track.getType() === TrackType.MIDI) {
|
||||
if (!dropData.midiUrl) {
|
||||
window.alert(
|
||||
'This audio clip can only be imported into an audio track.\n' +
|
||||
'Please drag it onto an audio track instead.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
// ── MIDI track: fetch MIDI file and create a KGMidiRegion ──────────
|
||||
const midiResp = await fetch(dropData.midiUrl);
|
||||
if (!midiResp.ok) throw new Error(`MIDI fetch failed (${midiResp.status})`);
|
||||
const buf = await midiResp.arrayBuffer();
|
||||
const { notes, totalBeats } = parseMidiFirstTrackNotes(new Uint8Array(buf));
|
||||
const lengthInBars = Math.max(1, Math.ceil(totalBeats / beatsPerBar));
|
||||
|
||||
const cmd = ImportMidiClipCommand.fromBarCoordinates(
|
||||
track.getId().toString(),
|
||||
trackIndex,
|
||||
barNumber,
|
||||
lengthInBars,
|
||||
beatsPerBar,
|
||||
notes,
|
||||
'KGOne Clip'
|
||||
);
|
||||
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) {
|
||||
console.log(`[KGOne] Imported MIDI clip to track ${trackIndex}, bar ${barNumber}, ${notes.length} notes`);
|
||||
}
|
||||
|
||||
} else if (track.getType() === TrackType.Wave) {
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
if (DEBUG_MODE.TRACK_GRID_PANEL) {
|
||||
console.log(`[KGOne] Imported audio clip to track ${trackIndex}, bar ${barNumber}, ${audioDurationSeconds.toFixed(2)}s`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[KGOne] Drop import failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid-container" ref={gridContainerRef}>
|
||||
{/* Playhead */}
|
||||
<Playhead context="main-grid" />
|
||||
|
||||
|
||||
{/* Track grids */}
|
||||
{tracks.map((track, index) => (
|
||||
<TrackGridItem
|
||||
@@ -422,6 +569,7 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
||||
onRegionClick={handleRegionClick}
|
||||
onOpenPianoRoll={onOpenPianoRoll}
|
||||
allTracks={tracks}
|
||||
onKGOneClipDrop={handleExternalDrop}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user