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:
+114
-40
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
||||
import * as Tone from 'tone';
|
||||
import './KGOnePanel.css';
|
||||
import { FaPlay, FaPause, FaDownload } from 'react-icons/fa';
|
||||
import { FaCircleNotch, FaGripVertical } from 'react-icons/fa6';
|
||||
@@ -8,7 +9,10 @@ import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||
import { KGAudioFileStorage } from '../core/io/KGAudioFileStorage';
|
||||
import { ConfigManager } from '../core/config/ConfigManager';
|
||||
import { DEBUG_MODE } from '../constants/uiConstants';
|
||||
import { fetchWithRetry } from '../util/retryUtil';
|
||||
import type { KeySignature } from '../core/KGProject';
|
||||
import { ImportStemsCommand } from '../core/commands';
|
||||
import type { StemImportEntry } from '../core/commands';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -17,8 +21,8 @@ type Tab = 'clip' | 'fullsong' | 'separator';
|
||||
type GenStatus = 'idle' | 'loading-model' | 'generating' | 'polling' | 'downloading' | 'done' | 'error';
|
||||
|
||||
const SEPARATOR_MODELS = [
|
||||
{ label: 'Vocal and Instrument (High 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 (Medium Accuracy)', value: 'UVR-MDX-NET-Inst_HQ_3.onnx' },
|
||||
{ label: 'Vocal and Instrument (High Accuracy)', value: 'MDX23C-8KFFT-InstVoc_HQ.ckpt' },
|
||||
{ label: 'Vocal, Drums, Bass, Guitar, Piano, and Others', value: 'htdemucs_6s.yaml' },
|
||||
] as const;
|
||||
|
||||
@@ -306,12 +310,7 @@ const ClipTab: React.FC<ClipTabProps> = ({ bpm, keySignature }) => {
|
||||
if (signal.aborted) return;
|
||||
|
||||
kgoneLog('REQ', `GET /v1/clip/result/${task_id}`, null);
|
||||
const resultResp = await fetch(`${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 resultResp = await fetchWithRetry(`${baseUrl}/v1/clip/result/${task_id}`, { signal });
|
||||
|
||||
const result = (await resultResp.json()) as { task_id: string; status: string; error?: string };
|
||||
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...');
|
||||
|
||||
kgoneLog('REQ', `GET /v1/clip/audio/${task_id}`, null);
|
||||
const audioResp = await fetch(`${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 audioResp = await fetchWithRetry(`${baseUrl}/v1/clip/audio/${task_id}`, { signal });
|
||||
|
||||
const blob = await audioResp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -636,12 +631,7 @@ const FullSongTab: React.FC = () => {
|
||||
if (signal.aborted) return;
|
||||
|
||||
kgoneLog('REQ', `GET /v1/fullsong/result/${task_id}`, null);
|
||||
const resultResp = await fetch(`${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 resultResp = await fetchWithRetry(`${baseUrl}/v1/fullsong/result/${task_id}`, { signal });
|
||||
|
||||
const pollJson = (await resultResp.json()) as PollResponse;
|
||||
kgoneLog('RES', `GET /v1/fullsong/result/${task_id} → ${resultResp.status}`, pollJson);
|
||||
@@ -671,11 +661,7 @@ const FullSongTab: React.FC = () => {
|
||||
setGenHint('Downloading audio...');
|
||||
|
||||
kgoneLog('REQ', `GET /v1/fullsong/audio/${task_id}?index=0`, null);
|
||||
const audioResp = await fetch(`${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 audioResp = await fetchWithRetry(`${baseUrl}/v1/fullsong/audio/${task_id}?index=0`, { signal });
|
||||
|
||||
const blob = await audioResp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -818,8 +804,8 @@ function extractStemName(filename: string): string {
|
||||
}
|
||||
|
||||
const SeparatorTab: React.FC = () => {
|
||||
const { selectedRegionIds, projectName } = useProjectStore();
|
||||
const [model, setModel] = useState(SEPARATOR_MODELS[0].value);
|
||||
const { selectedRegionIds, projectName, bpm, timeSignature, maxBars, refreshProjectState } = useProjectStore();
|
||||
const [model, setModel] = useState<typeof SEPARATOR_MODELS[number]['value']>(SEPARATOR_MODELS[0].value);
|
||||
|
||||
// Generation state
|
||||
const [genStatus, setGenStatus] = useState<GenStatus>('idle');
|
||||
@@ -829,6 +815,14 @@ const SeparatorTab: React.FC = () => {
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -848,7 +842,7 @@ const SeparatorTab: React.FC = () => {
|
||||
selectedRegionIds.includes(region.getId()) &&
|
||||
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 () => {
|
||||
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
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
@@ -956,12 +958,7 @@ const SeparatorTab: React.FC = () => {
|
||||
if (signal.aborted) return;
|
||||
|
||||
kgoneLog('REQ', `GET /v1/separator/result/${task_id}`, null);
|
||||
const resultResp = await fetch(`${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 resultResp = await fetchWithRetry(`${baseUrl}/v1/separator/result/${task_id}`, { signal });
|
||||
|
||||
const pollJson = (await resultResp.json()) as SepPollResponse;
|
||||
kgoneLog('RES', `GET /v1/separator/result/${task_id} → ${resultResp.status}`, pollJson);
|
||||
@@ -983,11 +980,10 @@ const SeparatorTab: React.FC = () => {
|
||||
const stemResults = await Promise.all(
|
||||
files.map(async (filename) => {
|
||||
kgoneLog('REQ', `GET /v1/separator/download/${filename}`, null);
|
||||
const dlResp = await fetch(`${baseUrl}/v1/separator/download/${encodeURIComponent(filename)}`, { signal });
|
||||
if (!dlResp.ok) {
|
||||
kgoneLog('RES', `GET /v1/separator/download/${filename} → ${dlResp.status}`, '(error)');
|
||||
throw new Error(`Stem download failed (${dlResp.status}): ${filename}`);
|
||||
}
|
||||
const dlResp = await fetchWithRetry(
|
||||
`${baseUrl}/v1/separator/download/${encodeURIComponent(filename)}`,
|
||||
{ signal }
|
||||
);
|
||||
const blob = await dlResp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
kgoneLog('RES', `GET /v1/separator/download/${filename} → ${dlResp.status} (binary MP3)`, url);
|
||||
@@ -1007,6 +1003,67 @@ const SeparatorTab: React.FC = () => {
|
||||
}
|
||||
}, [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 = () => {
|
||||
switch (genStatus) {
|
||||
case 'loading-model': return 'Loading model...';
|
||||
@@ -1030,7 +1087,8 @@ const SeparatorTab: React.FC = () => {
|
||||
|
||||
<div className="kgone-field">
|
||||
<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 => (
|
||||
<option key={m.value} value={m.value}>{m.label}</option>
|
||||
))}
|
||||
@@ -1062,7 +1120,23 @@ const SeparatorTab: React.FC = () => {
|
||||
</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 && (
|
||||
<div className="kgone-error-msg">{errorMsg}</div>
|
||||
)}
|
||||
|
||||
@@ -246,6 +246,14 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
if (!track) return;
|
||||
updateTrack(track);
|
||||
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 => {
|
||||
const updated = [...prev, regionUI];
|
||||
selectRegion(regionUI.id, updated);
|
||||
|
||||
@@ -97,6 +97,9 @@
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-name:hover {
|
||||
|
||||
@@ -292,6 +292,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
<div
|
||||
className="track-name"
|
||||
onClick={handleTrackNameClick}
|
||||
title={track.getName()}
|
||||
>
|
||||
{track.getName()}
|
||||
</div>
|
||||
|
||||
@@ -108,3 +108,9 @@ export const CONFIG_UPGRADER_CONSTANTS = {
|
||||
export const URL_CONSTANTS = {
|
||||
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 { ImportAudioCommand } from './region/ImportAudioCommand';
|
||||
export { ImportMidiClipCommand } from './region/ImportMidiClipCommand';
|
||||
export { ImportStemsCommand } from './region/ImportStemsCommand';
|
||||
export type { StemImportEntry } from './region/ImportStemsCommand';
|
||||
|
||||
// Note commands
|
||||
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