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:
Xiaohan-Tian
2026-04-16 17:44:44 -07:00
parent 7b82572d5c
commit 3010cf4e2d
8 changed files with 377 additions and 54 deletions
+114 -40
View File
@@ -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>
)}
+8
View File
@@ -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);
+3
View File
@@ -97,6 +97,9 @@
font-size: 12px;
cursor: pointer;
position: relative;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-name:hover {
+1
View File
@@ -292,6 +292,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
<div
className="track-name"
onClick={handleTrackNameClick}
title={track.getName()}
>
{track.getName()}
</div>
+6
View File
@@ -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
};
+2
View File
@@ -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`;
}
}
+56
View File
@@ -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;
}