feat: implemented drag-n-drop to import KGOne generated audio/MIDI file feature

This commit is contained in:
Xiaohan-Tian
2026-04-15 22:40:47 -07:00
parent cadd51c3a4
commit 7b82572d5c
8 changed files with 464 additions and 13 deletions
+35
View File
@@ -124,6 +124,11 @@
margin-top: 2px; margin-top: 2px;
} }
.kgone-hint strong {
color: #999;
font-weight: 600;
}
/* Two-column row (e.g. note + scale) */ /* Two-column row (e.g. note + scale) */
.kgone-row { .kgone-row {
display: flex; display: flex;
@@ -284,6 +289,36 @@
flex-shrink: 0; flex-shrink: 0;
} }
.kgone-player-drag-handle {
color: #555;
cursor: grab;
font-size: 12px;
padding: 2px 4px;
flex-shrink: 0;
display: flex;
align-items: center;
}
.kgone-player-drag-handle:hover {
color: #888;
}
.kgone-player-download-btn {
background: transparent;
border: none;
color: #888;
cursor: pointer;
font-size: 12px;
padding: 2px 4px;
flex-shrink: 0;
display: flex;
align-items: center;
}
.kgone-player-download-btn:hover {
color: #7a9cff;
}
/* ── Generation state feedback ─────────────────────────────────────────────── */ /* ── Generation state feedback ─────────────────────────────────────────────── */
.kgone-gen-hint { .kgone-gen-hint {
color: #888; color: #888;
+97 -7
View File
@@ -1,7 +1,7 @@
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react'; import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import './KGOnePanel.css'; import './KGOnePanel.css';
import { FaPlay, FaPause } from 'react-icons/fa'; import { FaPlay, FaPause, FaDownload } from 'react-icons/fa';
import { FaCircleNotch } from 'react-icons/fa6'; import { FaCircleNotch, FaGripVertical } from 'react-icons/fa6';
import { useProjectStore } from '../stores/projectStore'; import { useProjectStore } from '../stores/projectStore';
import { KGCore } from '../core/KGCore'; import { KGCore } from '../core/KGCore';
import { KGAudioRegion } from '../core/region/KGAudioRegion'; import { KGAudioRegion } from '../core/region/KGAudioRegion';
@@ -77,9 +77,14 @@ const Expander: React.FC<ExpanderProps> = ({ label, children }) => {
interface AudioPlayerProps { interface AudioPlayerProps {
src: string; src: string;
/** When provided, makes the player draggable (shows grip handle) and adds a download button */
dragData?: {
midiUrl?: string; // full URL to /v1/clip/midi/{taskId}; absent = MIDI import not supported
audioFileName: string; // filename used for download and OPFS storage
};
} }
const AudioPlayer: React.FC<AudioPlayerProps> = ({ src }) => { const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, dragData }) => {
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
@@ -104,10 +109,34 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src }) => {
setCurrentTime(ratio * duration); setCurrentTime(ratio * duration);
}; };
const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
if (!dragData) return;
e.dataTransfer.setData('application/kgone-clip', JSON.stringify({
midiUrl: dragData.midiUrl,
audioUrl: src,
audioDurationSeconds: duration,
audioFileName: dragData.audioFileName,
}));
e.dataTransfer.effectAllowed = 'copy';
};
const handleDownload = () => {
const a = document.createElement('a');
a.href = src;
a.download = dragData?.audioFileName ?? 'kgone_clip.wav';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const progress = duration > 0 ? (currentTime / duration) * 100 : 0; const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
return ( return (
<div className="kgone-audio-player"> <div
className="kgone-audio-player"
draggable={!!dragData}
onDragStart={handleDragStart}
>
<audio <audio
ref={audioRef} ref={audioRef}
src={src} src={src}
@@ -117,6 +146,11 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src }) => {
onPause={() => setIsPlaying(false)} onPause={() => setIsPlaying(false)}
onEnded={() => { setIsPlaying(false); setCurrentTime(0); }} onEnded={() => { setIsPlaying(false); setCurrentTime(0); }}
/> />
{dragData && (
<span className="kgone-player-drag-handle" title="Drag to a track to import">
<FaGripVertical />
</span>
)}
<button className="kgone-player-play-btn" onClick={togglePlay} title={isPlaying ? 'Pause' : 'Play'}> <button className="kgone-player-play-btn" onClick={togglePlay} title={isPlaying ? 'Pause' : 'Play'}>
{isPlaying ? <FaPause /> : <FaPlay />} {isPlaying ? <FaPause /> : <FaPlay />}
</button> </button>
@@ -124,6 +158,11 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src }) => {
<div className="kgone-player-progress-fill" style={{ width: `${progress}%` }} /> <div className="kgone-player-progress-fill" style={{ width: `${progress}%` }} />
</div> </div>
<span className="kgone-player-time">{formatTime(currentTime)} / {formatTime(duration)}</span> <span className="kgone-player-time">{formatTime(currentTime)} / {formatTime(duration)}</span>
{dragData && (
<button className="kgone-player-download-btn" onClick={handleDownload} title="Download audio">
<FaDownload />
</button>
)}
</div> </div>
); );
}; };
@@ -161,6 +200,7 @@ const ClipTab: React.FC<ClipTabProps> = ({ bpm, keySignature }) => {
const [audioUrl, setAudioUrl] = useState<string | null>(null); const [audioUrl, setAudioUrl] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const taskIdRef = useRef<string>('');
// Revoke blob URL on unmount // Revoke blob URL on unmount
useEffect(() => { useEffect(() => {
@@ -248,6 +288,7 @@ const ClipTab: React.FC<ClipTabProps> = ({ bpm, keySignature }) => {
const genJson = (await genResp.json()) as { task_id: string }; const genJson = (await genResp.json()) as { task_id: string };
kgoneLog('RES', `POST /v1/clip/generate → ${genResp.status}`, genJson); kgoneLog('RES', `POST /v1/clip/generate → ${genResp.status}`, genJson);
const { task_id } = genJson; const { task_id } = genJson;
taskIdRef.current = task_id;
// ── 3. Poll for completion ────────────────────────────────────────────── // ── 3. Poll for completion ──────────────────────────────────────────────
setGenStatus('polling'); setGenStatus('polling');
@@ -431,7 +472,25 @@ const ClipTab: React.FC<ClipTabProps> = ({ bpm, keySignature }) => {
</Expander> </Expander>
{/* Audio preview player — shown once generation is complete */} {/* Audio preview player — shown once generation is complete */}
{audioUrl && <AudioPlayer src={audioUrl} />} {audioUrl && (
<AudioPlayer
src={audioUrl}
dragData={taskIdRef.current ? {
midiUrl: `${getKGOneBaseUrl()}/v1/clip/midi/${taskIdRef.current}`,
audioFileName: `KGOne_Clip_${taskIdRef.current}.wav`,
} : undefined}
/>
)}
{/* Drag-to-track hint — shown after a successful generation */}
{genStatus === 'done' && (
<div className="kgone-hint">
Drag the player above to a track to import the clip.
Drop onto an <strong>audio track</strong> to import as a WAV region (recommended),
or onto a <strong>MIDI track</strong> to import as a MIDI region.
Note: MIDI is transcribed from the audio and may not be perfectly accurate.
</div>
)}
{/* Error message */} {/* Error message */}
{genStatus === 'error' && errorMsg && ( {genStatus === 'error' && errorMsg && (
@@ -473,6 +532,7 @@ const FullSongTab: React.FC = () => {
const [audioUrl, setAudioUrl] = useState<string | null>(null); const [audioUrl, setAudioUrl] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const taskIdRef = useRef<string>('');
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -554,6 +614,7 @@ const FullSongTab: React.FC = () => {
const genJson = (await genResp.json()) as { data: { task_id: string }; code: number }; const genJson = (await genResp.json()) as { data: { task_id: string }; code: number };
kgoneLog('RES', `POST /v1/fullsong/generate → ${genResp.status}`, genJson); kgoneLog('RES', `POST /v1/fullsong/generate → ${genResp.status}`, genJson);
const task_id = genJson.data.task_id; const task_id = genJson.data.task_id;
taskIdRef.current = task_id;
// ── 3. Poll for completion ────────────────────────────────────────────── // ── 3. Poll for completion ──────────────────────────────────────────────
setGenStatus('polling'); setGenStatus('polling');
@@ -714,7 +775,21 @@ const FullSongTab: React.FC = () => {
</div> </div>
</Expander> </Expander>
{audioUrl && <AudioPlayer src={audioUrl} />} {audioUrl && (
<AudioPlayer
src={audioUrl}
dragData={taskIdRef.current ? {
audioFileName: `KGOne_FullSong_${taskIdRef.current}.mp3`,
} : undefined}
/>
)}
{genStatus === 'done' && (
<div className="kgone-hint">
Drag the player above to an <strong>audio track</strong> to import the song.
Dropping onto a MIDI track is not supported for full song generation.
</div>
)}
{genStatus === 'error' && errorMsg && ( {genStatus === 'error' && errorMsg && (
<div className="kgone-error-msg">{errorMsg}</div> <div className="kgone-error-msg">{errorMsg}</div>
@@ -753,6 +828,7 @@ const SeparatorTab: React.FC = () => {
const [stemAudioUrls, setStemAudioUrls] = useState<Array<{ name: string; url: string }>>([]); const [stemAudioUrls, setStemAudioUrls] = useState<Array<{ name: string; url: string }>>([]);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const taskIdRef = useRef<string>('');
// Revoke all blob URLs on unmount // Revoke all blob URLs on unmount
useEffect(() => { useEffect(() => {
@@ -853,6 +929,7 @@ const SeparatorTab: React.FC = () => {
const sepJson = (await sepResp.json()) as { task_id: string }; const sepJson = (await sepResp.json()) as { task_id: string };
kgoneLog('RES', `POST /v1/separator/separate → ${sepResp.status}`, sepJson); kgoneLog('RES', `POST /v1/separator/separate → ${sepResp.status}`, sepJson);
const { task_id } = sepJson; const { task_id } = sepJson;
taskIdRef.current = task_id;
// ── 3. Poll for completion ───────────────────────────────────────────── // ── 3. Poll for completion ─────────────────────────────────────────────
setGenStatus('polling'); setGenStatus('polling');
@@ -966,12 +1043,25 @@ const SeparatorTab: React.FC = () => {
{stemAudioUrls.map(stem => ( {stemAudioUrls.map(stem => (
<div key={stem.name} className="kgone-stem-player"> <div key={stem.name} className="kgone-stem-player">
<div className="kgone-label" style={{ marginBottom: 4 }}>{stem.name}</div> <div className="kgone-label" style={{ marginBottom: 4 }}>{stem.name}</div>
<AudioPlayer src={stem.url} /> <AudioPlayer
src={stem.url}
dragData={taskIdRef.current ? {
audioFileName: `KGOne_Stem_${stem.name}_${taskIdRef.current}.mp3`,
} : undefined}
/>
</div> </div>
))} ))}
</div> </div>
)} )}
{/* Drag-to-track hint — shown after successful separation */}
{genStatus === 'done' && (
<div className="kgone-hint">
Drag each stem player above to an <strong>audio track</strong> to import it.
Dropping onto a MIDI track is not supported for stem separation.
</div>
)}
{/* Error message */} {/* Error message */}
{genStatus === 'error' && errorMsg && ( {genStatus === 'error' && errorMsg && (
<div className="kgone-error-msg">{errorMsg}</div> <div className="kgone-error-msg">{errorMsg}</div>
+17 -1
View File
@@ -38,7 +38,8 @@ const MainContent: React.FC<MainContentProps> = ({
setShowPianoRoll, setShowPianoRoll,
setActiveRegionId, setActiveRegionId,
addTrack, addTrack,
addAudioTrack addAudioTrack,
projectName,
} = useProjectStore(); } = useProjectStore();
// State to store regions // State to store regions
@@ -239,6 +240,19 @@ const MainContent: React.FC<MainContentProps> = ({
}); });
}; };
// Handle regions dropped from K.G.One panel (external drag-and-drop)
const handleExternalDropComplete = (trackIndex: number, regionUI: RegionUI) => {
const track = tracks[trackIndex];
if (!track) return;
updateTrack(track);
setSelectedTrack(track.getId().toString());
setRegions(prev => {
const updated = [...prev, regionUI];
selectRegion(regionUI.id, updated);
return updated;
});
};
// Handle region updates (resize, move, etc.) // Handle region updates (resize, move, etc.)
const handleRegionUpdated = ( const handleRegionUpdated = (
regionId: string, regionId: string,
@@ -742,10 +756,12 @@ const MainContent: React.FC<MainContentProps> = ({
draggedTrackIndex={draggedTrackIndex} draggedTrackIndex={draggedTrackIndex}
dragOverTrackIndex={dragOverTrackIndex} dragOverTrackIndex={dragOverTrackIndex}
selectedRegionId={selectedRegionId} selectedRegionId={selectedRegionId}
projectName={projectName}
onRegionCreated={handleRegionCreated} onRegionCreated={handleRegionCreated}
onRegionUpdated={handleRegionUpdated} onRegionUpdated={handleRegionUpdated}
onRegionClick={handleRegionClick} onRegionClick={handleRegionClick}
onOpenPianoRoll={handleOpenPianoRoll} onOpenPianoRoll={handleOpenPianoRoll}
onExternalDropComplete={handleExternalDropComplete}
/> />
</div> </div>
</div> </div>
+16 -2
View File
@@ -27,6 +27,7 @@ interface TrackGridItemProps {
onRegionClick?: (regionId: string) => void; onRegionClick?: (regionId: string) => void;
onOpenPianoRoll?: (regionId: string) => void; onOpenPianoRoll?: (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;
} }
const TrackGridItem: React.FC<TrackGridItemProps> = ({ const TrackGridItem: React.FC<TrackGridItemProps> = ({
@@ -46,7 +47,8 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
onRegionDragEnd, onRegionDragEnd,
onRegionClick, onRegionClick,
onOpenPianoRoll, onOpenPianoRoll,
allTracks allTracks,
onKGOneClipDrop,
}) => { }) => {
const [containerWidth, setContainerWidth] = useState(0); const [containerWidth, setContainerWidth] = useState(0);
const [resizingRegion, setResizingRegion] = useState<string | null>(null); const [resizingRegion, setResizingRegion] = useState<string | null>(null);
@@ -503,12 +505,24 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
const trackRegions = regions.filter(region => region.trackIndex === index); const trackRegions = regions.filter(region => region.trackIndex === index);
return ( return (
<div <div
className={`track-grid ${isDragOver ? 'drag-over' : ''} ${isDragging ? 'dragging' : ''} ${isModifierPressed ? 'pencil-cursor' : ''}`} className={`track-grid ${isDragOver ? 'drag-over' : ''} ${isDragging ? 'dragging' : ''} ${isModifierPressed ? 'pencil-cursor' : ''}`}
data-test-id={`track-grid-${track.getId()}`} data-test-id={`track-grid-${track.getId()}`}
onDoubleClick={(e) => onDoubleClick(e, index)} onDoubleClick={(e) => onDoubleClick(e, index)}
onClick={(e) => onClick && onClick(e, index)} onClick={(e) => onClick && onClick(e, index)}
ref={trackElementRef} 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 */} {/* Render regions for this track */}
{trackRegions.map(region => { {trackRegions.map(region => {
+151 -3
View File
@@ -7,11 +7,14 @@ import type { RegionUI } from '../interfaces';
import { DEBUG_MODE, REGION_CONSTANTS } from '../../constants'; import { DEBUG_MODE, REGION_CONSTANTS } from '../../constants';
import { KGMainContentState } from '../../core/state/KGMainContentState'; import { KGMainContentState } from '../../core/state/KGMainContentState';
import { isModifierKeyPressed } from '../../util/osUtil'; 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 { KGCore } from '../../core/KGCore';
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface'; import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import { KGAudioRegion } from '../../core/region/KGAudioRegion'; import { KGAudioRegion } from '../../core/region/KGAudioRegion';
import { generateNewRegionName } from '../../util/miscUtil'; import { generateNewRegionName } from '../../util/miscUtil';
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
import { parseMidiFirstTrackNotes } from '../../util/midiUtil';
import * as Tone from 'tone';
interface TrackGridPanelProps { interface TrackGridPanelProps {
tracks: KGTrack[]; tracks: KGTrack[];
@@ -21,10 +24,12 @@ interface TrackGridPanelProps {
draggedTrackIndex: number | null; draggedTrackIndex: number | null;
dragOverTrackIndex: number | null; dragOverTrackIndex: number | null;
selectedRegionId: string | null; selectedRegionId: string | null;
projectName: string;
onRegionCreated: (trackIndex: number, region: RegionUI, midiRegion: KGMidiRegion) => void; onRegionCreated: (trackIndex: number, region: RegionUI, midiRegion: KGMidiRegion) => void;
onRegionUpdated?: (regionId: string, updates: Partial<RegionUI>, expectedModelUpdates?: { startBeat: number, length: number }) => void; onRegionUpdated?: (regionId: string, updates: Partial<RegionUI>, expectedModelUpdates?: { startBeat: number, length: number }) => void;
onRegionClick?: (regionId: string) => void; onRegionClick?: (regionId: string) => void;
onOpenPianoRoll?: (regionId: string) => void; onOpenPianoRoll?: (regionId: string) => void;
onExternalDropComplete?: (trackIndex: number, regionUI: RegionUI) => void;
} }
const TrackGridPanel: React.FC<TrackGridPanelProps> = ({ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
@@ -35,10 +40,12 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
draggedTrackIndex, draggedTrackIndex,
dragOverTrackIndex, dragOverTrackIndex,
selectedRegionId, selectedRegionId,
projectName,
onRegionCreated, onRegionCreated,
onRegionUpdated, onRegionUpdated,
onRegionClick, onRegionClick,
onOpenPianoRoll onOpenPianoRoll,
onExternalDropComplete,
}) => { }) => {
const gridContainerRef = useRef<HTMLDivElement>(null); 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 ( return (
<div className="grid-container" ref={gridContainerRef}> <div className="grid-container" ref={gridContainerRef}>
{/* Playhead */} {/* Playhead */}
<Playhead context="main-grid" /> <Playhead context="main-grid" />
{/* Track grids */} {/* Track grids */}
{tracks.map((track, index) => ( {tracks.map((track, index) => (
<TrackGridItem <TrackGridItem
@@ -422,6 +569,7 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
onRegionClick={handleRegionClick} onRegionClick={handleRegionClick}
onOpenPianoRoll={onOpenPianoRoll} onOpenPianoRoll={onOpenPianoRoll}
allTracks={tracks} allTracks={tracks}
onKGOneClipDrop={handleExternalDrop}
/> />
))} ))}
</div> </div>
+1
View File
@@ -21,6 +21,7 @@ export { MoveRegionCommand } from './region/MoveRegionCommand';
export { PasteRegionsCommand } from './region/PasteRegionsCommand'; export { PasteRegionsCommand } from './region/PasteRegionsCommand';
export { UpdateRegionCommand, type RegionUpdateProperties } from './region/UpdateRegionCommand'; export { UpdateRegionCommand, type RegionUpdateProperties } from './region/UpdateRegionCommand';
export { ImportAudioCommand } from './region/ImportAudioCommand'; export { ImportAudioCommand } from './region/ImportAudioCommand';
export { ImportMidiClipCommand } from './region/ImportMidiClipCommand';
// Note commands // Note commands
export { CreateNoteCommand } from './note/CreateNoteCommand'; export { CreateNoteCommand } from './note/CreateNoteCommand';
@@ -0,0 +1,113 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { KGMidiNote } from '../../midi/KGMidiNote';
import { generateUniqueId } from '../../../util/miscUtil';
import type { RawMidiNote } from '../../../util/midiUtil';
/**
* Command to insert a MIDI clip (from K.G.One generation) into a MIDI track.
* All note data is stored so that redo recreates the full region with notes.
*/
export class ImportMidiClipCommand extends KGCommand {
private trackId: string;
private trackIndex: number;
private regionId: string;
private regionName: string;
private startBeat: number;
private lengthInBeats: number;
private rawNotes: RawMidiNote[];
private createdRegion: KGMidiRegion | null = null;
constructor(
trackId: string,
trackIndex: number,
startBeat: number,
lengthInBeats: number,
rawNotes: RawMidiNote[],
regionName?: string,
regionId?: string
) {
super();
this.trackId = trackId;
this.trackIndex = trackIndex;
this.startBeat = startBeat;
this.lengthInBeats = lengthInBeats;
this.rawNotes = rawNotes;
this.regionId = regionId || generateUniqueId('KGMidiRegion');
this.regionName = regionName || 'KGOne Clip';
}
execute(): void {
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const track = currentProject.getTracks().find(t => t.getId().toString() === this.trackId);
if (!track) {
throw new Error(`Track ${this.trackId} not found`);
}
// Create the region
this.createdRegion = new KGMidiRegion(
this.regionId,
this.trackId,
this.trackIndex,
this.regionName,
this.startBeat,
this.lengthInBeats
);
// Populate notes
for (const raw of this.rawNotes) {
const note = new KGMidiNote(
generateUniqueId('KGMidiNote'),
raw.startBeat,
raw.endBeat,
raw.pitch,
raw.velocity
);
this.createdRegion.addNote(note);
}
track.addRegion(this.createdRegion);
console.log(`Imported MIDI clip "${this.regionName}" (${this.rawNotes.length} notes) at beat ${this.startBeat}`);
}
undo(): void {
const core = KGCore.instance();
const currentProject = core.getCurrentProject();
const track = currentProject.getTracks().find(t => t.getId().toString() === this.trackId);
if (track) {
track.removeRegion(this.regionId);
}
console.log(`Undid MIDI clip import "${this.regionName}"`);
}
getDescription(): string {
return `Import MIDI clip "${this.regionName}"`;
}
getCreatedRegion(): KGMidiRegion | null {
return this.createdRegion;
}
/**
* Factory from bar-based coordinates (common UI pattern).
*/
static fromBarCoordinates(
trackId: string,
trackIndex: number,
barNumber: number,
lengthInBars: number,
beatsPerBar: number,
rawNotes: RawMidiNote[],
regionName?: string,
regionId?: string
): ImportMidiClipCommand {
const startBeat = (barNumber - 1) * beatsPerBar;
const lengthInBeats = lengthInBars * beatsPerBar;
return new ImportMidiClipCommand(
trackId, trackIndex, startBeat, lengthInBeats,
rawNotes, regionName, regionId
);
}
}
+34
View File
@@ -465,6 +465,40 @@ function getKeySignatureBytes(keySignature: KeySignature): Uint8Array {
return new Uint8Array([0, 0]); return new Uint8Array([0, 0]);
} }
// ─── K.G.One clip MIDI import helpers ────────────────────────────────────────
export interface RawMidiNote {
startBeat: number;
endBeat: number;
pitch: number;
velocity: number;
}
/**
* Parses a MIDI binary and returns all notes from the first track that has
* notes, with beat offsets normalised so the earliest note starts at beat 0.
* Used by the K.G.One Clip drag-to-MIDI-track feature.
*/
export function parseMidiFirstTrackNotes(data: Uint8Array): {
notes: RawMidiNote[];
totalBeats: number;
} {
const midiFile = parseMidiFile(data);
const firstTrack = midiFile.tracks.find(t => t.notes.length > 0);
if (!firstTrack || firstTrack.notes.length === 0) {
return { notes: [], totalBeats: 0 };
}
const minBeat = Math.min(...firstTrack.notes.map(n => n.startBeat));
const notes: RawMidiNote[] = firstTrack.notes.map(n => ({
startBeat: n.startBeat - minBeat,
endBeat: n.endBeat - minBeat,
pitch: n.pitch,
velocity: n.velocity,
}));
const totalBeats = Math.max(...notes.map(n => n.endBeat));
return { notes, totalBeats };
}
/** /**
* Converts a MIDI binary file to a KGSP project * Converts a MIDI binary file to a KGSP project
* @param midiData - The MIDI file data as Uint8Array * @param midiData - The MIDI file data as Uint8Array