fix: long track name displaying issue; wrong separator model name mapping; imported generated full song not adjusting max bars; implemented retry mechanism for K.G.One server calls.
This commit is contained in:
+127
-53
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import * as Tone from 'tone';
|
||||||
import './KGOnePanel.css';
|
import './KGOnePanel.css';
|
||||||
import { FaPlay, FaPause, FaDownload } from 'react-icons/fa';
|
import { FaPlay, FaPause, FaDownload } from 'react-icons/fa';
|
||||||
import { FaCircleNotch, FaGripVertical } from 'react-icons/fa6';
|
import { FaCircleNotch, FaGripVertical } from 'react-icons/fa6';
|
||||||
@@ -8,7 +9,10 @@ import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
|||||||
import { KGAudioFileStorage } from '../core/io/KGAudioFileStorage';
|
import { KGAudioFileStorage } from '../core/io/KGAudioFileStorage';
|
||||||
import { ConfigManager } from '../core/config/ConfigManager';
|
import { ConfigManager } from '../core/config/ConfigManager';
|
||||||
import { DEBUG_MODE } from '../constants/uiConstants';
|
import { DEBUG_MODE } from '../constants/uiConstants';
|
||||||
|
import { fetchWithRetry } from '../util/retryUtil';
|
||||||
import type { KeySignature } from '../core/KGProject';
|
import type { KeySignature } from '../core/KGProject';
|
||||||
|
import { ImportStemsCommand } from '../core/commands';
|
||||||
|
import type { StemImportEntry } from '../core/commands';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -17,8 +21,8 @@ type Tab = 'clip' | 'fullsong' | 'separator';
|
|||||||
type GenStatus = 'idle' | 'loading-model' | 'generating' | 'polling' | 'downloading' | 'done' | 'error';
|
type GenStatus = 'idle' | 'loading-model' | 'generating' | 'polling' | 'downloading' | 'done' | 'error';
|
||||||
|
|
||||||
const SEPARATOR_MODELS = [
|
const SEPARATOR_MODELS = [
|
||||||
{ label: 'Vocal and Instrument (High Accuracy)', value: 'UVR-MDX-NET-Inst_HQ_3.onnx' },
|
{ label: 'Vocal and Instrument (Medium Accuracy)', value: 'UVR-MDX-NET-Inst_HQ_3.onnx' },
|
||||||
{ label: 'Vocal and Instrument (Medium Accuracy)', value: 'MDX23C-8KFFT-InstVoc_HQ.ckpt' },
|
{ label: 'Vocal and Instrument (High Accuracy)', value: 'MDX23C-8KFFT-InstVoc_HQ.ckpt' },
|
||||||
{ label: 'Vocal, Drums, Bass, Guitar, Piano, and Others', value: 'htdemucs_6s.yaml' },
|
{ label: 'Vocal, Drums, Bass, Guitar, Piano, and Others', value: 'htdemucs_6s.yaml' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -94,7 +98,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, dragData }) => {
|
|||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
if (audio.paused) {
|
if (audio.paused) {
|
||||||
audio.play().catch(() => {});
|
audio.play().catch(() => { });
|
||||||
} else {
|
} else {
|
||||||
audio.pause();
|
audio.pause();
|
||||||
}
|
}
|
||||||
@@ -306,12 +310,7 @@ const ClipTab: React.FC<ClipTabProps> = ({ bpm, keySignature }) => {
|
|||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
|
|
||||||
kgoneLog('REQ', `GET /v1/clip/result/${task_id}`, null);
|
kgoneLog('REQ', `GET /v1/clip/result/${task_id}`, null);
|
||||||
const resultResp = await fetch(`${baseUrl}/v1/clip/result/${task_id}`, { signal });
|
const resultResp = await fetchWithRetry(`${baseUrl}/v1/clip/result/${task_id}`, { signal });
|
||||||
if (!resultResp.ok) {
|
|
||||||
const body = await resultResp.text().catch(() => '');
|
|
||||||
kgoneLog('RES', `GET /v1/clip/result/${task_id} → ${resultResp.status}`, body);
|
|
||||||
throw new Error(`Poll failed (${resultResp.status}): ${body}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = (await resultResp.json()) as { task_id: string; status: string; error?: string };
|
const result = (await resultResp.json()) as { task_id: string; status: string; error?: string };
|
||||||
kgoneLog('RES', `GET /v1/clip/result/${task_id} → ${resultResp.status}`, result);
|
kgoneLog('RES', `GET /v1/clip/result/${task_id} → ${resultResp.status}`, result);
|
||||||
@@ -328,11 +327,7 @@ const ClipTab: React.FC<ClipTabProps> = ({ bpm, keySignature }) => {
|
|||||||
setGenHint('Downloading audio...');
|
setGenHint('Downloading audio...');
|
||||||
|
|
||||||
kgoneLog('REQ', `GET /v1/clip/audio/${task_id}`, null);
|
kgoneLog('REQ', `GET /v1/clip/audio/${task_id}`, null);
|
||||||
const audioResp = await fetch(`${baseUrl}/v1/clip/audio/${task_id}`, { signal });
|
const audioResp = await fetchWithRetry(`${baseUrl}/v1/clip/audio/${task_id}`, { signal });
|
||||||
if (!audioResp.ok) {
|
|
||||||
kgoneLog('RES', `GET /v1/clip/audio/${task_id} → ${audioResp.status}`, '(error)');
|
|
||||||
throw new Error(`Audio download failed (${audioResp.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await audioResp.blob();
|
const blob = await audioResp.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -357,10 +352,10 @@ const ClipTab: React.FC<ClipTabProps> = ({ bpm, keySignature }) => {
|
|||||||
const btnLabel = () => {
|
const btnLabel = () => {
|
||||||
switch (genStatus) {
|
switch (genStatus) {
|
||||||
case 'loading-model': return 'Loading model...';
|
case 'loading-model': return 'Loading model...';
|
||||||
case 'generating': return 'Generating...';
|
case 'generating': return 'Generating...';
|
||||||
case 'polling': return 'Processing...';
|
case 'polling': return 'Processing...';
|
||||||
case 'downloading': return 'Downloading...';
|
case 'downloading': return 'Downloading...';
|
||||||
default: return 'Generate Clip';
|
default: return 'Generate Clip';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -636,12 +631,7 @@ const FullSongTab: React.FC = () => {
|
|||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
|
|
||||||
kgoneLog('REQ', `GET /v1/fullsong/result/${task_id}`, null);
|
kgoneLog('REQ', `GET /v1/fullsong/result/${task_id}`, null);
|
||||||
const resultResp = await fetch(`${baseUrl}/v1/fullsong/result/${task_id}`, { signal });
|
const resultResp = await fetchWithRetry(`${baseUrl}/v1/fullsong/result/${task_id}`, { signal });
|
||||||
if (!resultResp.ok) {
|
|
||||||
const body = await resultResp.text().catch(() => '');
|
|
||||||
kgoneLog('RES', `GET /v1/fullsong/result/${task_id} → ${resultResp.status}`, body);
|
|
||||||
throw new Error(`Poll failed (${resultResp.status}): ${body}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pollJson = (await resultResp.json()) as PollResponse;
|
const pollJson = (await resultResp.json()) as PollResponse;
|
||||||
kgoneLog('RES', `GET /v1/fullsong/result/${task_id} → ${resultResp.status}`, pollJson);
|
kgoneLog('RES', `GET /v1/fullsong/result/${task_id} → ${resultResp.status}`, pollJson);
|
||||||
@@ -671,11 +661,7 @@ const FullSongTab: React.FC = () => {
|
|||||||
setGenHint('Downloading audio...');
|
setGenHint('Downloading audio...');
|
||||||
|
|
||||||
kgoneLog('REQ', `GET /v1/fullsong/audio/${task_id}?index=0`, null);
|
kgoneLog('REQ', `GET /v1/fullsong/audio/${task_id}?index=0`, null);
|
||||||
const audioResp = await fetch(`${baseUrl}/v1/fullsong/audio/${task_id}?index=0`, { signal });
|
const audioResp = await fetchWithRetry(`${baseUrl}/v1/fullsong/audio/${task_id}?index=0`, { signal });
|
||||||
if (!audioResp.ok) {
|
|
||||||
kgoneLog('RES', `GET /v1/fullsong/audio/${task_id}?index=0 → ${audioResp.status}`, '(error)');
|
|
||||||
throw new Error(`Audio download failed (${audioResp.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await audioResp.blob();
|
const blob = await audioResp.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -696,10 +682,10 @@ const FullSongTab: React.FC = () => {
|
|||||||
const btnLabel = () => {
|
const btnLabel = () => {
|
||||||
switch (genStatus) {
|
switch (genStatus) {
|
||||||
case 'loading-model': return 'Loading model...';
|
case 'loading-model': return 'Loading model...';
|
||||||
case 'generating': return 'Generating...';
|
case 'generating': return 'Generating...';
|
||||||
case 'polling': return 'Processing...';
|
case 'polling': return 'Processing...';
|
||||||
case 'downloading': return 'Downloading...';
|
case 'downloading': return 'Downloading...';
|
||||||
default: return 'Generate Song';
|
default: return 'Generate Song';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -818,8 +804,8 @@ function extractStemName(filename: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SeparatorTab: React.FC = () => {
|
const SeparatorTab: React.FC = () => {
|
||||||
const { selectedRegionIds, projectName } = useProjectStore();
|
const { selectedRegionIds, projectName, bpm, timeSignature, maxBars, refreshProjectState } = useProjectStore();
|
||||||
const [model, setModel] = useState(SEPARATOR_MODELS[0].value);
|
const [model, setModel] = useState<typeof SEPARATOR_MODELS[number]['value']>(SEPARATOR_MODELS[0].value);
|
||||||
|
|
||||||
// Generation state
|
// Generation state
|
||||||
const [genStatus, setGenStatus] = useState<GenStatus>('idle');
|
const [genStatus, setGenStatus] = useState<GenStatus>('idle');
|
||||||
@@ -829,6 +815,14 @@ const SeparatorTab: React.FC = () => {
|
|||||||
|
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const taskIdRef = useRef<string>('');
|
const taskIdRef = useRef<string>('');
|
||||||
|
const originalRegionRef = useRef<{
|
||||||
|
regionName: string;
|
||||||
|
startFromBeat: number;
|
||||||
|
trackIndex: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
const [importError, setImportError] = useState('');
|
||||||
|
|
||||||
// Revoke all blob URLs on unmount
|
// Revoke all blob URLs on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -848,7 +842,7 @@ const SeparatorTab: React.FC = () => {
|
|||||||
selectedRegionIds.includes(region.getId()) &&
|
selectedRegionIds.includes(region.getId()) &&
|
||||||
region.getCurrentType() === 'KGAudioRegion'
|
region.getCurrentType() === 'KGAudioRegion'
|
||||||
) {
|
) {
|
||||||
return { region: region as KGAudioRegion, trackName: track.getName() };
|
return { region: region as KGAudioRegion, trackName: track.getName(), trackIndex: track.getTrackIndex() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -860,6 +854,14 @@ const SeparatorTab: React.FC = () => {
|
|||||||
const handleSeparate = useCallback(async () => {
|
const handleSeparate = useCallback(async () => {
|
||||||
if (!selectedAudioRegion) return;
|
if (!selectedAudioRegion) return;
|
||||||
|
|
||||||
|
// Capture snapshot before anything changes — selection may shift during generation
|
||||||
|
originalRegionRef.current = {
|
||||||
|
regionName: selectedAudioRegion.region.getName(),
|
||||||
|
startFromBeat: selectedAudioRegion.region.getStartFromBeat(),
|
||||||
|
trackIndex: selectedAudioRegion.trackIndex,
|
||||||
|
};
|
||||||
|
setImportError('');
|
||||||
|
|
||||||
// Abort any in-flight request
|
// Abort any in-flight request
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
@@ -956,12 +958,7 @@ const SeparatorTab: React.FC = () => {
|
|||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
|
|
||||||
kgoneLog('REQ', `GET /v1/separator/result/${task_id}`, null);
|
kgoneLog('REQ', `GET /v1/separator/result/${task_id}`, null);
|
||||||
const resultResp = await fetch(`${baseUrl}/v1/separator/result/${task_id}`, { signal });
|
const resultResp = await fetchWithRetry(`${baseUrl}/v1/separator/result/${task_id}`, { signal });
|
||||||
if (!resultResp.ok) {
|
|
||||||
const body = await resultResp.text().catch(() => '');
|
|
||||||
kgoneLog('RES', `GET /v1/separator/result/${task_id} → ${resultResp.status}`, body);
|
|
||||||
throw new Error(`Poll failed (${resultResp.status}): ${body}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pollJson = (await resultResp.json()) as SepPollResponse;
|
const pollJson = (await resultResp.json()) as SepPollResponse;
|
||||||
kgoneLog('RES', `GET /v1/separator/result/${task_id} → ${resultResp.status}`, pollJson);
|
kgoneLog('RES', `GET /v1/separator/result/${task_id} → ${resultResp.status}`, pollJson);
|
||||||
@@ -983,11 +980,10 @@ const SeparatorTab: React.FC = () => {
|
|||||||
const stemResults = await Promise.all(
|
const stemResults = await Promise.all(
|
||||||
files.map(async (filename) => {
|
files.map(async (filename) => {
|
||||||
kgoneLog('REQ', `GET /v1/separator/download/${filename}`, null);
|
kgoneLog('REQ', `GET /v1/separator/download/${filename}`, null);
|
||||||
const dlResp = await fetch(`${baseUrl}/v1/separator/download/${encodeURIComponent(filename)}`, { signal });
|
const dlResp = await fetchWithRetry(
|
||||||
if (!dlResp.ok) {
|
`${baseUrl}/v1/separator/download/${encodeURIComponent(filename)}`,
|
||||||
kgoneLog('RES', `GET /v1/separator/download/${filename} → ${dlResp.status}`, '(error)');
|
{ signal }
|
||||||
throw new Error(`Stem download failed (${dlResp.status}): ${filename}`);
|
);
|
||||||
}
|
|
||||||
const blob = await dlResp.blob();
|
const blob = await dlResp.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
kgoneLog('RES', `GET /v1/separator/download/${filename} → ${dlResp.status} (binary MP3)`, url);
|
kgoneLog('RES', `GET /v1/separator/download/${filename} → ${dlResp.status} (binary MP3)`, url);
|
||||||
@@ -1007,13 +1003,74 @@ const SeparatorTab: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [selectedAudioRegion, projectName, model, stemAudioUrls]);
|
}, [selectedAudioRegion, projectName, model, stemAudioUrls]);
|
||||||
|
|
||||||
|
const handleImportAll = useCallback(async () => {
|
||||||
|
const snap = originalRegionRef.current;
|
||||||
|
if (!snap || stemAudioUrls.length === 0) return;
|
||||||
|
|
||||||
|
setIsImporting(true);
|
||||||
|
setImportError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const audioContext = Tone.getContext().rawContext as AudioContext;
|
||||||
|
|
||||||
|
// Decode and store every stem before touching the core model
|
||||||
|
const stems: StemImportEntry[] = await Promise.all(
|
||||||
|
stemAudioUrls.map(async (stem) => {
|
||||||
|
const blob = await fetch(stem.url).then(r => r.blob());
|
||||||
|
const fileName = `KGOne_Stem_${stem.name}_${taskIdRef.current}.mp3`;
|
||||||
|
const fileId = `kgone_stem_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
const audioFile = new File([blob], fileName, { type: 'audio/mpeg' });
|
||||||
|
|
||||||
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
const toneBuffer = new Tone.ToneAudioBuffer();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
audioContext.decodeAudioData(
|
||||||
|
arrayBuffer.slice(0),
|
||||||
|
decoded => { toneBuffer.set(decoded); resolve(); },
|
||||||
|
reject,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await KGAudioFileStorage.storeAudioFile(projectName, fileId, audioFile);
|
||||||
|
|
||||||
|
return {
|
||||||
|
trackName: `${snap.regionName} (${stem.name})`,
|
||||||
|
audioFileId: fileId,
|
||||||
|
audioFileName: fileName,
|
||||||
|
audioDurationSeconds: toneBuffer.duration,
|
||||||
|
toneBuffer,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute composite command (single undo step)
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const cmd = new ImportStemsCommand(
|
||||||
|
project.getTracks().length,
|
||||||
|
snap.trackIndex,
|
||||||
|
snap.startFromBeat,
|
||||||
|
stems,
|
||||||
|
maxBars,
|
||||||
|
);
|
||||||
|
KGCore.instance().executeCommand(cmd);
|
||||||
|
|
||||||
|
// Sync store — triggers MainContent's useEffect to rebuild tracks + regions
|
||||||
|
refreshProjectState();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
setImportError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false);
|
||||||
|
}
|
||||||
|
}, [stemAudioUrls, projectName, maxBars, refreshProjectState]);
|
||||||
|
|
||||||
const btnLabel = () => {
|
const btnLabel = () => {
|
||||||
switch (genStatus) {
|
switch (genStatus) {
|
||||||
case 'loading-model': return 'Loading model...';
|
case 'loading-model': return 'Loading model...';
|
||||||
case 'generating': return 'Preparing upload...';
|
case 'generating': return 'Preparing upload...';
|
||||||
case 'polling': return 'Separating stems...';
|
case 'polling': return 'Separating stems...';
|
||||||
case 'downloading': return 'Downloading...';
|
case 'downloading': return 'Downloading...';
|
||||||
default: return 'Separate Stems';
|
default: return 'Separate Stems';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1030,7 +1087,8 @@ const SeparatorTab: React.FC = () => {
|
|||||||
|
|
||||||
<div className="kgone-field">
|
<div className="kgone-field">
|
||||||
<label className="kgone-label">Separation Model</label>
|
<label className="kgone-label">Separation Model</label>
|
||||||
<select className="kgone-select" value={model} onChange={e => setModel(e.target.value)}>
|
<select className="kgone-select" value={model} onChange={e => setModel(e.target.value as typeof SEPARATOR_MODELS[number]['value'])}>
|
||||||
|
|
||||||
{SEPARATOR_MODELS.map(m => (
|
{SEPARATOR_MODELS.map(m => (
|
||||||
<option key={m.value} value={m.value}>{m.label}</option>
|
<option key={m.value} value={m.value}>{m.label}</option>
|
||||||
))}
|
))}
|
||||||
@@ -1062,7 +1120,23 @@ const SeparatorTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Bulk import button — shown after successful separation */}
|
||||||
|
{genStatus === 'done' && stemAudioUrls.length > 0 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="kgone-btn-generate"
|
||||||
|
disabled={isImporting}
|
||||||
|
onClick={handleImportAll}
|
||||||
|
style={{ marginTop: 0 }}
|
||||||
|
>
|
||||||
|
{isImporting && <FaCircleNotch className="kgone-spinner" />}
|
||||||
|
{isImporting ? 'Importing...' : 'Import All Stems to Timeline'}
|
||||||
|
</button>
|
||||||
|
{importError && <div className="kgone-error-msg">{importError}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Separation error message */}
|
||||||
{genStatus === 'error' && errorMsg && (
|
{genStatus === 'error' && errorMsg && (
|
||||||
<div className="kgone-error-msg">{errorMsg}</div>
|
<div className="kgone-error-msg">{errorMsg}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -246,6 +246,14 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
if (!track) return;
|
if (!track) return;
|
||||||
updateTrack(track);
|
updateTrack(track);
|
||||||
setSelectedTrack(track.getId().toString());
|
setSelectedTrack(track.getId().toString());
|
||||||
|
|
||||||
|
// Sync maxBars from core model — ImportAudioCommand may have expanded it
|
||||||
|
const coreMaxBars = KGCore.instance().getCurrentProject().getMaxBars();
|
||||||
|
if (coreMaxBars > maxBars) {
|
||||||
|
useProjectStore.setState({ maxBars: coreMaxBars });
|
||||||
|
document.documentElement.style.setProperty('--max-number-of-bars', coreMaxBars.toString());
|
||||||
|
}
|
||||||
|
|
||||||
setRegions(prev => {
|
setRegions(prev => {
|
||||||
const updated = [...prev, regionUI];
|
const updated = [...prev, regionUI];
|
||||||
selectRegion(regionUI.id, updated);
|
selectRegion(regionUI.id, updated);
|
||||||
|
|||||||
@@ -97,6 +97,9 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-name:hover {
|
.track-name:hover {
|
||||||
|
|||||||
@@ -292,6 +292,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
<div
|
<div
|
||||||
className="track-name"
|
className="track-name"
|
||||||
onClick={handleTrackNameClick}
|
onClick={handleTrackNameClick}
|
||||||
|
title={track.getName()}
|
||||||
>
|
>
|
||||||
{track.getName()}
|
{track.getName()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,3 +108,9 @@ export const CONFIG_UPGRADER_CONSTANTS = {
|
|||||||
export const URL_CONSTANTS = {
|
export const URL_CONSTANTS = {
|
||||||
DEFAULT_OPENAI_BASE_URL: 'https://api.openai.com/v1',
|
DEFAULT_OPENAI_BASE_URL: 'https://api.openai.com/v1',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const KGONE_CONSTANTS = {
|
||||||
|
MAX_RETRY_ATTEMPTS: 3, // max retries for polling & download calls
|
||||||
|
RETRY_INITIAL_DELAY_MS: 1000, // first wait before retry (ms)
|
||||||
|
RETRY_BACKOFF_MULTIPLIER: 2, // doubles each attempt: 1 s → 2 s → 4 s
|
||||||
|
};
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ 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';
|
export { ImportMidiClipCommand } from './region/ImportMidiClipCommand';
|
||||||
|
export { ImportStemsCommand } from './region/ImportStemsCommand';
|
||||||
|
export type { StemImportEntry } from './region/ImportStemsCommand';
|
||||||
|
|
||||||
// Note commands
|
// Note commands
|
||||||
export { CreateNoteCommand } from './note/CreateNoteCommand';
|
export { CreateNoteCommand } from './note/CreateNoteCommand';
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import * as Tone from 'tone';
|
||||||
|
import { KGCommand } from '../KGCommand';
|
||||||
|
import { KGCore } from '../../KGCore';
|
||||||
|
import { KGAudioTrack } from '../../track/KGAudioTrack';
|
||||||
|
import { KGAudioRegion } from '../../region/KGAudioRegion';
|
||||||
|
import { KGAudioInterface } from '../../audio-interface/KGAudioInterface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-stem data passed to ImportStemsCommand.
|
||||||
|
* All async work (OPFS storage, audio decode) must be completed before
|
||||||
|
* constructing the command — execute() is synchronous.
|
||||||
|
*/
|
||||||
|
export interface StemImportEntry {
|
||||||
|
trackName: string;
|
||||||
|
audioFileId: string;
|
||||||
|
audioFileName: string;
|
||||||
|
audioDurationSeconds: number;
|
||||||
|
toneBuffer: Tone.ToneAudioBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composite command that atomically:
|
||||||
|
* 1. Creates one KGAudioTrack per stem (appended to end)
|
||||||
|
* 2. Reorders each new track to sit immediately below the original source track
|
||||||
|
* 3. Creates one KGAudioRegion per stem, all starting at the same beat
|
||||||
|
* 4. Expands maxBars if needed
|
||||||
|
*
|
||||||
|
* This is a single undo/redo step.
|
||||||
|
*/
|
||||||
|
export class ImportStemsCommand extends KGCommand {
|
||||||
|
private readonly originalTrackCount: number;
|
||||||
|
private readonly originalTrackIndex: number;
|
||||||
|
private readonly insertBeat: number;
|
||||||
|
private readonly stems: StemImportEntry[];
|
||||||
|
private readonly originalMaxBars: number;
|
||||||
|
|
||||||
|
private createdTrackIds: number[] = [];
|
||||||
|
private createdRegionIds: string[] = [];
|
||||||
|
private finalMaxBars: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
originalTrackCount: number,
|
||||||
|
originalTrackIndex: number,
|
||||||
|
insertBeat: number,
|
||||||
|
stems: StemImportEntry[],
|
||||||
|
originalMaxBars: number,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.originalTrackCount = originalTrackCount;
|
||||||
|
this.originalTrackIndex = originalTrackIndex;
|
||||||
|
this.insertBeat = insertBeat;
|
||||||
|
this.stems = stems;
|
||||||
|
this.originalMaxBars = originalMaxBars;
|
||||||
|
this.finalMaxBars = originalMaxBars;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(): void {
|
||||||
|
const core = KGCore.instance();
|
||||||
|
const project = core.getCurrentProject();
|
||||||
|
const audioInterface = KGAudioInterface.instance();
|
||||||
|
|
||||||
|
this.createdTrackIds = [];
|
||||||
|
this.createdRegionIds = [];
|
||||||
|
|
||||||
|
// ── 1. Compute starting track ID ──────────────────────────────────────
|
||||||
|
const existingTracks = project.getTracks();
|
||||||
|
let nextId = existingTracks.length > 0
|
||||||
|
? Math.max(...existingTracks.map(t => t.getId())) + 1
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
// ── 2. Append one audio track per stem ────────────────────────────────
|
||||||
|
for (const stem of this.stems) {
|
||||||
|
const trackId = nextId++;
|
||||||
|
this.createdTrackIds.push(trackId);
|
||||||
|
|
||||||
|
const track = new KGAudioTrack(stem.trackName, trackId);
|
||||||
|
track.setTrackIndex(project.getTracks().length);
|
||||||
|
project.setTracks([...project.getTracks(), track]);
|
||||||
|
|
||||||
|
// Bus creation is async; chain buffer load so it runs once the bus is ready
|
||||||
|
audioInterface.createTrackAudioPlayerBus(trackId.toString()).then(() => {
|
||||||
|
audioInterface.loadAudioBufferForTrack(trackId.toString(), stem.audioFileId, stem.toneBuffer);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error(`[ImportStemsCommand] Failed to create bus for track ${trackId}:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Reorder each stem track to sit just below the original ─────────
|
||||||
|
// For stem i: sourceIndex = originalTrackCount + i, destIndex = originalTrackIndex + 1 + i
|
||||||
|
// Each reorder shifts the remaining appended stems left by 1 in the tail, but their
|
||||||
|
// absolute indices stay at originalTrackCount + i because each move keeps them packed.
|
||||||
|
for (let i = 0; i < this.stems.length; i++) {
|
||||||
|
const srcIdx = this.originalTrackCount + i;
|
||||||
|
const destIdx = this.originalTrackIndex + 1 + i;
|
||||||
|
|
||||||
|
if (srcIdx === destIdx) continue; // no-op when original is the last track
|
||||||
|
|
||||||
|
const tracks = project.getTracks();
|
||||||
|
const updated = [...tracks];
|
||||||
|
const [moved] = updated.splice(srcIdx, 1);
|
||||||
|
updated.splice(destIdx, 0, moved);
|
||||||
|
updated.forEach((t, idx) => t.setTrackIndex(idx));
|
||||||
|
project.setTracks(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Create one audio region per stem ───────────────────────────────
|
||||||
|
const bpm = project.getBpm();
|
||||||
|
const beatsPerBar = project.getTimeSignature().numerator;
|
||||||
|
let currentMaxBars = this.originalMaxBars;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.stems.length; i++) {
|
||||||
|
const stem = this.stems[i];
|
||||||
|
const trackId = this.createdTrackIds[i];
|
||||||
|
|
||||||
|
// Look up the track by ID so we use the post-reorder trackIndex
|
||||||
|
const track = project.getTracks().find(t => t.getId() === trackId);
|
||||||
|
if (!track) continue;
|
||||||
|
|
||||||
|
const durationInBeats = stem.audioDurationSeconds * (bpm / 60);
|
||||||
|
const endBeat = this.insertBeat + durationInBeats;
|
||||||
|
const requiredBars = Math.ceil(endBeat / beatsPerBar);
|
||||||
|
const newMaxBars = Math.max(currentMaxBars, requiredBars);
|
||||||
|
|
||||||
|
const regionId = `audio_region_${Date.now()}_${Math.random().toString(36).substring(2, 8)}_${i}`;
|
||||||
|
this.createdRegionIds.push(regionId);
|
||||||
|
|
||||||
|
const region = new KGAudioRegion(
|
||||||
|
regionId,
|
||||||
|
trackId.toString(),
|
||||||
|
track.getTrackIndex(),
|
||||||
|
stem.audioFileName,
|
||||||
|
this.insertBeat,
|
||||||
|
durationInBeats,
|
||||||
|
stem.audioFileId,
|
||||||
|
stem.audioFileName,
|
||||||
|
stem.audioDurationSeconds,
|
||||||
|
);
|
||||||
|
track.addRegion(region);
|
||||||
|
currentMaxBars = newMaxBars;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Expand project maxBars if needed ───────────────────────────────
|
||||||
|
this.finalMaxBars = currentMaxBars;
|
||||||
|
if (this.finalMaxBars > this.originalMaxBars) {
|
||||||
|
project.setMaxBars(this.finalMaxBars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
const core = KGCore.instance();
|
||||||
|
const project = core.getCurrentProject();
|
||||||
|
const audioInterface = KGAudioInterface.instance();
|
||||||
|
|
||||||
|
// Remove all created tracks and their audio buses
|
||||||
|
const remaining = project.getTracks().filter(t => !this.createdTrackIds.includes(t.getId()));
|
||||||
|
remaining.forEach((t, idx) => t.setTrackIndex(idx));
|
||||||
|
project.setTracks(remaining);
|
||||||
|
|
||||||
|
for (const trackId of this.createdTrackIds) {
|
||||||
|
audioInterface.removeTrackAudioPlayerBus(trackId.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revert maxBars if it was expanded
|
||||||
|
if (this.finalMaxBars > this.originalMaxBars) {
|
||||||
|
project.setMaxBars(this.originalMaxBars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
const n = this.stems.length;
|
||||||
|
return `Import ${n} stem${n !== 1 ? 's' : ''} to timeline`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { KGONE_CONSTANTS } from '../constants/coreConstants';
|
||||||
|
|
||||||
|
export interface RetryConfig {
|
||||||
|
maxAttempts?: number;
|
||||||
|
initialDelayMs?: number;
|
||||||
|
backoffMultiplier?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps fetch() with exponential-backoff retry on non-200 responses or
|
||||||
|
* network errors. AbortErrors propagate immediately — no retry.
|
||||||
|
*
|
||||||
|
* Use only for idempotent calls (polling, downloads).
|
||||||
|
* Do NOT use for load-model or job-submission calls.
|
||||||
|
*/
|
||||||
|
export async function fetchWithRetry(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit,
|
||||||
|
config: RetryConfig = {}
|
||||||
|
): Promise<Response> {
|
||||||
|
const {
|
||||||
|
maxAttempts = KGONE_CONSTANTS.MAX_RETRY_ATTEMPTS,
|
||||||
|
initialDelayMs = KGONE_CONSTANTS.RETRY_INITIAL_DELAY_MS,
|
||||||
|
backoffMultiplier = KGONE_CONSTANTS.RETRY_BACKOFF_MULTIPLIER,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const signal = options.signal as AbortSignal | undefined;
|
||||||
|
let delay = initialDelayMs;
|
||||||
|
let lastError: Error = new Error('Unknown error');
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, options);
|
||||||
|
if (resp.ok) return resp;
|
||||||
|
|
||||||
|
const body = await resp.text().catch(() => '');
|
||||||
|
lastError = new Error(`HTTP ${resp.status}${body ? `: ${body}` : ''}`);
|
||||||
|
} catch (err) {
|
||||||
|
// Propagate abort immediately — user cancelled, do not retry
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') throw err;
|
||||||
|
lastError = err instanceof Error ? err : new Error(String(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < maxAttempts - 1) {
|
||||||
|
// Abort-aware sleep before the next attempt
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
const t = setTimeout(resolve, delay);
|
||||||
|
signal?.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });
|
||||||
|
});
|
||||||
|
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
||||||
|
delay *= backoffMultiplier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user