Files
KGStudio/src/components/KGOnePanel.tsx
T
2026-05-24 23:47:45 -07:00

2643 lines
105 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import { useProjectStore } from '../stores/projectStore';
import { KGCore } from '../core/KGCore';
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 { sliceAudioToWav } from '../util/audioUtil';
import type { KeySignature } from '../core/KGProject';
import { ImportStemsCommand } from '../core/commands';
import type { StemImportEntry } from '../core/commands';
import { showAlert } from '../util/dialogUtil';
import {
LOCAL_SEPARATOR_MODEL_CONFIG,
LOCAL_SEPARATOR_MODEL_FILENAME,
LOCAL_SEPARATOR_DEFAULT_MODEL_URL,
} from '../util/localSeparatorConfig';
import { LocalSeparatorModelCache } from '../util/localSeparatorModelCache';
import { runLocalSeparator } from '../util/localSeparatorRunner';
import { LocalOrtRuntimeManager, detectLocalRuntimeSupport } from '../util/localSeparatorRuntime';
// ─── Types ────────────────────────────────────────────────────────────────────
type Tab = 'clip' | 'fullsong' | 'remix' | 'repaint' | 'separator';
type KGOneMode = 'server' | 'local-separator';
type GenStatus = 'idle' | 'loading-model' | 'generating' | 'polling' | 'downloading' | 'done' | 'error';
const SERVER_SEPARATOR_MODELS = [
{ 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;
const LOCAL_SEPARATOR_MODELS = [
{ label: LOCAL_SEPARATOR_MODEL_CONFIG.displayName, value: LOCAL_SEPARATOR_MODEL_FILENAME },
] as const;
const KGONE_TABS = ['fullsong', 'remix', 'repaint', 'separator'] as const;
const CLIP_NOTES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const FLAT_TO_SHARP: Record<string, string> = {
Bb: 'A#', Eb: 'D#', Ab: 'G#', Db: 'C#', Gb: 'F#', Cb: 'B', Fb: 'E',
};
function parseKeySignature(ks: string): { note: string; scale: 'major' | 'minor' } {
const parts = ks.split(' ');
const rawNote = parts[0] ?? 'C';
const note = FLAT_TO_SHARP[rawNote] ?? rawNote;
const scale = (parts[1] === 'minor' ? 'minor' : 'major') as 'major' | 'minor';
return { note, scale };
}
// Debug helper — logs KGOne requests and responses when DEBUG_MODE.KGONE is on
function kgoneLog(direction: 'REQ' | 'RES', label: string, payload: unknown) {
if (!DEBUG_MODE.KGONE) return;
console.log(`[KGOne ${direction}] ${label}`, payload);
}
function getKGOneBaseUrl(): string {
return (ConfigManager.instance().get('general.kgone.base_url') as string) || 'http://127.0.0.1:8000';
}
function formatTime(sec: number): string {
if (!isFinite(sec)) return '0:00';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
function formatKGOneTabLabel(tab: Tab): string {
if (tab === 'fullsong') return 'Full Song';
if (tab === 'remix') return 'Remix';
if (tab === 'repaint') return 'Repaint';
return 'Separator';
}
function getDefaultKGOneTab(mode: KGOneMode): Tab {
return mode === 'local-separator' ? 'separator' : 'fullsong';
}
function getKGOneMode(): KGOneMode {
const enabled = (ConfigManager.instance().get('general.kgone.enabled') as boolean | undefined) ?? false;
return enabled ? 'server' : 'local-separator';
}
// ─── Shared components ────────────────────────────────────────────────────────
interface ExpanderProps {
label: string;
children: React.ReactNode;
}
const Expander: React.FC<ExpanderProps> = ({ label, children }) => {
const [open, setOpen] = useState(false);
return (
<div>
<button className="kgone-expander-toggle" onClick={() => setOpen(o => !o)}>
<span className={`arrow ${open ? 'open' : ''}`}></span>
{label}
</button>
{open && <div className="kgone-expander-body">{children}</div>}
</div>
);
};
// ─── Audio Player ─────────────────────────────────────────────────────────────
interface AudioPlayerProps {
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, dragData }) => {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const togglePlay = () => {
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) {
audio.play().catch(() => { });
} else {
audio.pause();
}
};
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
const audio = audioRef.current;
if (!audio || duration === 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
audio.currentTime = 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;
return (
<div
className="kgone-audio-player"
draggable={!!dragData}
onDragStart={handleDragStart}
>
<audio
ref={audioRef}
src={src}
onTimeUpdate={() => setCurrentTime(audioRef.current?.currentTime ?? 0)}
onLoadedMetadata={() => setDuration(audioRef.current?.duration ?? 0)}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
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'}>
{isPlaying ? <FaPause /> : <FaPlay />}
</button>
<div className="kgone-player-progress-track" onClick={handleProgressClick} title="Click to seek">
<div className="kgone-player-progress-fill" style={{ width: `${progress}%` }} />
</div>
<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>
);
};
// ─── Clip Tab ─────────────────────────────────────────────────────────────────
interface ClipTabProps {
bpm: number;
keySignature: KeySignature;
}
const ClipTab: React.FC<ClipTabProps> = ({ bpm, keySignature }) => {
const { note: defaultNote, scale: defaultScale } = parseKeySignature(keySignature);
// Form state
const [prompt, setPrompt] = useState('');
const [negativePrompt, setNegativePrompt] = useState('');
const [bars, setBars] = useState<4 | 8>(4);
// Advanced
const [note, setNote] = useState(defaultNote);
const [scale, setScale] = useState<'major' | 'minor'>(defaultScale);
const [clipBpm, setClipBpm] = useState(bpm);
const [steps, setSteps] = useState(75);
const [cfgScale, setCfgScale] = useState(7.0);
const [seed, setSeed] = useState(-1);
const [samplerType, setSamplerType] = useState('dpmpp-2m-sde');
const [sigmaMin, setSigmaMin] = useState(0.03);
const [sigmaMax, setSigmaMax] = useState(500.0);
const [cfgRescale, setCfgRescale] = useState(0.0);
// Generation state
const [genStatus, setGenStatus] = useState<GenStatus>('idle');
const [genHint, setGenHint] = useState('');
const [errorMsg, setErrorMsg] = useState('');
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const taskIdRef = useRef<string>('');
// Revoke blob URL on unmount
useEffect(() => {
return () => {
abortRef.current?.abort();
if (audioUrl) URL.revokeObjectURL(audioUrl);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const isGenerating = genStatus !== 'idle' && genStatus !== 'done' && genStatus !== 'error';
const handleGenerate = useCallback(async () => {
// Abort any in-flight request
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
const { signal } = ctrl;
// Revoke previous audio URL
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
setAudioUrl(null);
}
setErrorMsg('');
try {
// ── 1. Load the clip model ──────────────────────────────────────────────
setGenStatus('loading-model');
setGenHint('Loading model — this can take 60+ seconds, please wait...');
const baseUrl = getKGOneBaseUrl();
const loadPayload = { model: 'clip' };
kgoneLog('REQ', 'POST /v1/models/load', loadPayload);
const loadResp = await fetch(`${baseUrl}/v1/models/load`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(loadPayload),
signal,
// No timeout — model loading can take 60-120 s
});
if (!loadResp.ok) {
const body = await loadResp.text().catch(() => '');
kgoneLog('RES', `POST /v1/models/load → ${loadResp.status}`, body);
throw new Error(`Model load failed (${loadResp.status}): ${body}`);
}
kgoneLog('RES', `POST /v1/models/load → ${loadResp.status}`, await loadResp.clone().json().catch(() => '(unparseable)'));
// ── 2. Submit generation job ────────────────────────────────────────────
setGenStatus('generating');
setGenHint('Submitting generation request...');
const genPayload = {
prompt,
negative_prompt: negativePrompt,
bars,
bpm: clipBpm,
note,
scale,
steps,
cfg_scale: cfgScale,
seed,
sampler_type: samplerType,
sigma_min: sigmaMin,
sigma_max: sigmaMax,
cfg_rescale: cfgRescale,
};
kgoneLog('REQ', 'POST /v1/clip/generate', genPayload);
const genResp = await fetch(`${baseUrl}/v1/clip/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(genPayload),
signal,
});
if (!genResp.ok) {
const body = await genResp.text().catch(() => '');
kgoneLog('RES', `POST /v1/clip/generate → ${genResp.status}`, body);
throw new Error(`Generation request failed (${genResp.status}): ${body}`);
}
const genJson = (await genResp.json()) as { task_id: string };
kgoneLog('RES', `POST /v1/clip/generate → ${genResp.status}`, genJson);
const { task_id } = genJson;
taskIdRef.current = task_id;
// ── 3. Poll for completion ──────────────────────────────────────────────
setGenStatus('polling');
setGenHint('Generating clip...');
while (true) {
if (signal.aborted) return;
await new Promise<void>(r => {
const t = setTimeout(r, 5000);
signal.addEventListener('abort', () => { clearTimeout(t); r(); }, { once: true });
});
if (signal.aborted) return;
kgoneLog('REQ', `GET /v1/clip/result/${task_id}`, null);
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);
if (result.status === 'complete') break;
if (result.status === 'error') {
throw new Error(result.error ?? 'Generation failed on the server');
}
// still running — keep polling
}
// ── 4. Download the WAV ─────────────────────────────────────────────────
setGenStatus('downloading');
setGenHint('Downloading audio...');
kgoneLog('REQ', `GET /v1/clip/audio/${task_id}`, null);
const audioResp = await fetchWithRetry(`${baseUrl}/v1/clip/audio/${task_id}`, { signal });
const blob = await audioResp.blob();
const url = URL.createObjectURL(blob);
kgoneLog('RES', `GET /v1/clip/audio/${task_id}${audioResp.status} (binary WAV)`, url);
setAudioUrl(url);
setGenStatus('done');
setGenHint('');
} catch (err) {
if (signal.aborted) return; // User navigated away / new generation started — silent
console.error('[KGOne] Clip generation error:', err);
setErrorMsg(err instanceof Error ? err.message : String(err));
setGenStatus('error');
setGenHint('');
}
}, [
prompt, negativePrompt, bars, clipBpm, note, scale,
steps, cfgScale, seed, samplerType, sigmaMin, sigmaMax, cfgRescale,
audioUrl,
]);
const btnLabel = () => {
switch (genStatus) {
case 'loading-model': return 'Loading model...';
case 'generating': return 'Generating...';
case 'polling': return 'Processing...';
case 'downloading': return 'Downloading...';
default: return 'Generate Clip';
}
};
return (
<>
<div className="kgone-field">
<label className="kgone-label">Prompt</label>
<textarea
className="kgone-textarea"
value={prompt}
onChange={e => setPrompt(e.target.value)}
placeholder="e.g. Gritty, Acid, Bassline, 303, Synth Lead, FM, Sub, Upper Mids, High Phaser, High Reverb, Pitch Bend, 8 Bars, 140 BPM, E minor"
rows={3}
/>
<div className="kgone-hint">
Describe the clip with comma-separated tags: instrument family, sub-type, timbre, FX, bars, BPM, key.
</div>
</div>
<div className="kgone-field">
<label className="kgone-label">Negative Prompt</label>
<textarea
className="kgone-textarea"
value={negativePrompt}
onChange={e => setNegativePrompt(e.target.value)}
placeholder="e.g. distortion, noise"
rows={2}
/>
</div>
<div className="kgone-field">
<label className="kgone-label">Bars</label>
<select
className="kgone-select"
value={bars}
onChange={e => setBars(Number(e.target.value) as 4 | 8)}
>
<option value={4}>4 Bars</option>
<option value={8}>8 Bars</option>
</select>
</div>
<Expander label="Advanced Settings">
<div className="kgone-row">
<div className="kgone-field">
<label className="kgone-label">Note</label>
<select className="kgone-select" value={note} onChange={e => setNote(e.target.value)}>
{CLIP_NOTES.map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<div className="kgone-field">
<label className="kgone-label">Scale</label>
<select className="kgone-select" value={scale} onChange={e => setScale(e.target.value as 'major' | 'minor')}>
<option value="major">Major</option>
<option value="minor">Minor</option>
</select>
</div>
</div>
<div className="kgone-field">
<label className="kgone-label">BPM</label>
<input type="number" className="kgone-input" value={clipBpm} min={40} max={300}
onChange={e => setClipBpm(Number(e.target.value))} />
</div>
<div className="kgone-row">
<div className="kgone-field">
<label className="kgone-label">Steps</label>
<input type="number" className="kgone-input" value={steps} min={1} max={500}
onChange={e => setSteps(Number(e.target.value))} />
</div>
<div className="kgone-field">
<label className="kgone-label">CFG Scale</label>
<input type="number" className="kgone-input" value={cfgScale} min={0} max={25} step={0.1}
onChange={e => setCfgScale(Number(e.target.value))} />
</div>
</div>
<div className="kgone-field">
<label className="kgone-label">Seed (-1 = random)</label>
<input type="number" className="kgone-input" value={seed}
onChange={e => setSeed(Number(e.target.value))} />
</div>
<div className="kgone-field">
<label className="kgone-label">Sampler Type</label>
<input type="text" className="kgone-input" value={samplerType}
onChange={e => setSamplerType(e.target.value)} />
</div>
<div className="kgone-row">
<div className="kgone-field">
<label className="kgone-label">Sigma Min</label>
<input type="number" className="kgone-input" value={sigmaMin} step={0.01}
onChange={e => setSigmaMin(Number(e.target.value))} />
</div>
<div className="kgone-field">
<label className="kgone-label">Sigma Max</label>
<input type="number" className="kgone-input" value={sigmaMax} step={1}
onChange={e => setSigmaMax(Number(e.target.value))} />
</div>
</div>
<div className="kgone-field">
<label className="kgone-label">CFG Rescale</label>
<input type="number" className="kgone-input" value={cfgRescale} min={0} max={1} step={0.01}
onChange={e => setCfgRescale(Number(e.target.value))} />
</div>
</Expander>
{/* Audio preview player — shown once generation is complete */}
{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 */}
{genStatus === 'error' && errorMsg && (
<div className="kgone-error-msg">{errorMsg}</div>
)}
<button
className="kgone-btn-generate kgone-btn-generate-accent"
disabled={isGenerating || !prompt.trim()}
onClick={handleGenerate}
>
{isGenerating && <FaCircleNotch className="kgone-spinner" />}
{btnLabel()}
</button>
{/* Status hint below button */}
{genHint && <div className="kgone-gen-hint">{genHint}</div>}
<div className="kgone-powered-by">
Powered by <a href="https://huggingface.co/RoyalCities/Foundation-1" target="_blank" rel="noopener noreferrer">Foundation-1</a>
</div>
</>
);
};
// ─── Full Song Tab ────────────────────────────────────────────────────────────
const FullSongTab: React.FC = () => {
// Form state
const [caption, setCaption] = useState('');
const [lyrics, setLyrics] = useState('');
const [instrumental, setInstrumental] = useState(false);
const [inferenceSteps, setInferenceSteps] = useState(8);
const [guidanceScale, setGuidanceScale] = useState(7.0);
const [useRandomSeed, setUseRandomSeed] = useState(true);
const [seed, setSeed] = useState(-1);
const [thinking, setThinking] = useState(true);
// Generation state
const [genStatus, setGenStatus] = useState<GenStatus>('idle');
const [genHint, setGenHint] = useState('');
const [errorMsg, setErrorMsg] = useState('');
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const taskIdRef = useRef<string>('');
useEffect(() => {
return () => {
abortRef.current?.abort();
if (audioUrl) URL.revokeObjectURL(audioUrl);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const isGenerating = genStatus !== 'idle' && genStatus !== 'done' && genStatus !== 'error';
const handleGenerate = useCallback(async () => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
const { signal } = ctrl;
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
setAudioUrl(null);
}
setErrorMsg('');
try {
// ── 1. Load the fullsong model ──────────────────────────────────────────
setGenStatus('loading-model');
setGenHint('Loading model — this can take 60+ seconds, please wait...');
const baseUrl = getKGOneBaseUrl();
const loadPayload = { model: 'fullsong' };
kgoneLog('REQ', 'POST /v1/models/load', loadPayload);
const loadResp = await fetch(`${baseUrl}/v1/models/load`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(loadPayload),
signal,
});
if (!loadResp.ok) {
const body = await loadResp.text().catch(() => '');
kgoneLog('RES', `POST /v1/models/load → ${loadResp.status}`, body);
throw new Error(`Model load failed (${loadResp.status}): ${body}`);
}
kgoneLog('RES', `POST /v1/models/load → ${loadResp.status}`, await loadResp.clone().json().catch(() => '(unparseable)'));
// ── 2. Submit generation job ────────────────────────────────────────────
setGenStatus('generating');
setGenHint('Submitting generation request...');
const genPayload = {
caption,
lyrics: instrumental ? '' : lyrics,
instrumental,
inference_steps: inferenceSteps,
guidance_scale: guidanceScale,
use_random_seed: useRandomSeed,
seed,
thinking,
// Hardcoded — not exposed in UI
// lm_model_path: "acestep-5Hz-lm-0.6B",
batch_size: 1,
audio_format: 'mp3',
};
kgoneLog('REQ', 'POST /v1/fullsong/generate', genPayload);
const genResp = await fetch(`${baseUrl}/v1/fullsong/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(genPayload),
signal,
});
if (!genResp.ok) {
const body = await genResp.text().catch(() => '');
kgoneLog('RES', `POST /v1/fullsong/generate → ${genResp.status}`, body);
throw new Error(`Generation request failed (${genResp.status}): ${body}`);
}
// Response shape: { "data": { "task_id": "...", ... }, "code": 200, ... }
const genJson = (await genResp.json()) as { data: { task_id: string }; code: number };
kgoneLog('RES', `POST /v1/fullsong/generate → ${genResp.status}`, genJson);
const task_id = genJson.data.task_id;
taskIdRef.current = task_id;
// ── 3. Poll for completion ──────────────────────────────────────────────
setGenStatus('polling');
setGenHint('Generating...');
// Response shape: { "data": [{ "status": 0|1, "result": "<JSON_STRING>", ... }], ... }
type ResultItem = { progress: number; stage: string; status: number };
type PollResponse = { data: Array<{ status: number; result: string }>; code: number };
while (true) {
if (signal.aborted) return;
await new Promise<void>(r => {
const t = setTimeout(r, 5000);
signal.addEventListener('abort', () => { clearTimeout(t); r(); }, { once: true });
});
if (signal.aborted) return;
kgoneLog('REQ', `GET /v1/fullsong/result/${task_id}`, null);
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);
const outer = pollJson.data?.[0];
if (!outer) continue;
// Parse the nested JSON string for progress/stage info
try {
const inner = JSON.parse(outer.result) as ResultItem[];
const item = inner[0];
if (item) {
const pct = Math.round((item.progress ?? 0) * 100);
const stage = item.stage ?? '';
setGenHint(stage ? `Generating... ${pct}% — ${stage}` : `Generating... ${pct}%`);
}
} catch {
// result may be empty string while still queued — ignore parse errors
}
if (outer.status === 1) break; // finished
// status === 0 → still running, keep polling
}
// ── 4. Download the MP3 ─────────────────────────────────────────────────
setGenStatus('downloading');
setGenHint('Downloading audio...');
kgoneLog('REQ', `GET /v1/fullsong/audio/${task_id}?index=0`, null);
const audioResp = await fetchWithRetry(`${baseUrl}/v1/fullsong/audio/${task_id}?index=0`, { signal });
const blob = await audioResp.blob();
const url = URL.createObjectURL(blob);
kgoneLog('RES', `GET /v1/fullsong/audio/${task_id}?index=0 → ${audioResp.status} (binary MP3)`, url);
setAudioUrl(url);
setGenStatus('done');
setGenHint('');
} catch (err) {
if (signal.aborted) return;
console.error('[KGOne] Full song generation error:', err);
setErrorMsg(err instanceof Error ? err.message : String(err));
setGenStatus('error');
setGenHint('');
}
}, [caption, lyrics, instrumental, inferenceSteps, guidanceScale, useRandomSeed, seed, thinking, audioUrl]);
const btnLabel = () => {
switch (genStatus) {
case 'loading-model': return 'Loading model...';
case 'generating': return 'Generating...';
case 'polling': return 'Processing...';
case 'downloading': return 'Downloading...';
default: return 'Generate Song';
}
};
return (
<>
<div className="kgone-field">
<label className="kgone-label">Caption</label>
<textarea
className="kgone-textarea"
value={caption}
onChange={e => setCaption(e.target.value)}
placeholder="e.g. Genre: Eurodance, 90s dance-pop, upbeat electronic. Style: Catchy, energetic... Tempo: ~130 BPM. Instrumentation: driving kick drum, eurodance bassline..."
rows={5}
/>
<div className="kgone-hint">
Describe the song style, mood, tempo, instrumentation, and structure in natural language.
</div>
</div>
<div className="kgone-field">
<label className="kgone-label">Lyrics</label>
<textarea
className="kgone-textarea"
value={lyrics}
onChange={e => setLyrics(e.target.value)}
placeholder={"[Verse 1]\nYour lyrics here...\n\n[Chorus]\n..."}
rows={6}
disabled={instrumental}
style={instrumental ? { opacity: 0.4 } : undefined}
/>
<div className="kgone-hint">
Use [Intro], [Verse], [Chorus], [Bridge] tags to mark sections.
</div>
</div>
<div className="kgone-checkbox-row">
<input type="checkbox" id="kgone-instrumental" checked={instrumental}
onChange={e => setInstrumental(e.target.checked)} />
<label htmlFor="kgone-instrumental">Instrumental (no vocals)</label>
</div>
<Expander label="Advanced Settings">
<div className="kgone-row">
<div className="kgone-field">
<label className="kgone-label">Inference Steps</label>
<input type="number" className="kgone-input" value={inferenceSteps} min={1} max={200}
onChange={e => setInferenceSteps(Number(e.target.value))} />
</div>
<div className="kgone-field">
<label className="kgone-label">Guidance Scale</label>
<input type="number" className="kgone-input" value={guidanceScale} min={0} max={20} step={0.1}
onChange={e => setGuidanceScale(Number(e.target.value))} />
</div>
</div>
<div className="kgone-checkbox-row">
<input type="checkbox" id="kgone-use-random-seed" checked={useRandomSeed}
onChange={e => setUseRandomSeed(e.target.checked)} />
<label htmlFor="kgone-use-random-seed">Use Random Seed</label>
</div>
<div className="kgone-field">
<label className="kgone-label">Seed</label>
<input type="number" className="kgone-input" value={seed}
disabled={useRandomSeed} style={useRandomSeed ? { opacity: 0.4 } : undefined}
onChange={e => setSeed(Number(e.target.value))} />
</div>
<div className="kgone-checkbox-row">
<input type="checkbox" id="kgone-thinking" checked={thinking}
onChange={e => setThinking(e.target.checked)} />
<label htmlFor="kgone-thinking">Thinking (CoT metadata generation)</label>
</div>
</Expander>
{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 && (
<div className="kgone-error-msg">{errorMsg}</div>
)}
<button
className="dialog-btn dialog-btn-primary kgone-btn-generate"
disabled={isGenerating || !caption.trim()}
onClick={handleGenerate}
>
{isGenerating && <FaCircleNotch className="kgone-spinner" />}
{btnLabel()}
</button>
{genHint && <div className="kgone-gen-hint">{genHint}</div>}
<div className="kgone-powered-by">
Powered by <a href="https://github.com/ace-step/ACE-Step-1.5" target="_blank" rel="noopener noreferrer">ACE-Step 1.5</a>
</div>
</>
);
};
// ─── Separator Tab ────────────────────────────────────────────────────────────
/** Extract the stem label from a filename like `uuid_(Vocals)_ModelName.mp3` */
function extractStemName(filename: string): string {
const match = filename.match(/\(([^)]+)\)/);
return match ? match[1] : filename;
}
/** Count existing remix tracks derived from `sourceTrackName` (for naming new ones). */
function countRemixTracks(sourceTrackName: string): number {
const tracks = KGCore.instance().getCurrentProject().getTracks();
const escaped = sourceTrackName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`^${escaped} - remix \\(\\d+\\)$`);
return tracks.filter(t => pattern.test(t.getName())).length;
}
/** Count existing repaint tracks derived from `sourceTrackName` (for naming new ones). */
function countRepaintTracks(sourceTrackName: string): number {
const tracks = KGCore.instance().getCurrentProject().getTracks();
const escaped = sourceTrackName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`^${escaped} - repaint \\(\\d+\\)$`);
return tracks.filter(t => pattern.test(t.getName())).length;
}
const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => {
const { selectedRegionIds, projectName, bpm, timeSignature, maxBars, refreshProjectState } = useProjectStore();
const localOnlyMode = mode === 'local-separator';
const availableSeparatorModels = localOnlyMode ? LOCAL_SEPARATOR_MODELS : SERVER_SEPARATOR_MODELS;
const [model, setModel] = useState<typeof SERVER_SEPARATOR_MODELS[number]['value']>(availableSeparatorModels[0].value);
// Generation state
const [genStatus, setGenStatus] = useState<GenStatus>('idle');
const [genHint, setGenHint] = useState('');
const [errorMsg, setErrorMsg] = useState('');
const [stemAudioUrls, setStemAudioUrls] = useState<Array<{ name: string; url: string }>>([]);
const runtimeSupport = useMemo(() => detectLocalRuntimeSupport(), []);
const [localProviderLabel, setLocalProviderLabel] = useState(
runtimeSupport.webgpuExposed ? 'webgpu available' : 'cpu/wasm only',
);
const [isLocalModelCached, setIsLocalModelCached] = useState(false);
const [isCheckingLocalModel, setIsCheckingLocalModel] = useState(false);
const [isDownloadingLocalModel, setIsDownloadingLocalModel] = useState(false);
const [isDeletingLocalModel, setIsDeletingLocalModel] = useState(false);
const [localProgressPercent, setLocalProgressPercent] = useState(0);
const [localProgressText, setLocalProgressText] = useState('');
const [localChunkDurationSeconds, setLocalChunkDurationSeconds] = useState('');
const [localOverlap, setLocalOverlap] = useState(String(LOCAL_SEPARATOR_MODEL_CONFIG.defaults.overlap));
const abortRef = useRef<AbortController | null>(null);
const taskIdRef = useRef<string>('');
const localRuntimeManagerRef = useRef<LocalOrtRuntimeManager | null>(null);
const originalRegionRef = useRef<{
regionName: string;
trackName: string;
startFromBeat: number;
trackIndex: number;
} | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [importError, setImportError] = useState('');
// Revoke all blob URLs on unmount
useEffect(() => {
return () => {
abortRef.current?.abort();
stemAudioUrls.forEach(s => URL.revokeObjectURL(s.url));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Reset separator results whenever the active project changes
useEffect(() => {
stemAudioUrls.forEach(s => URL.revokeObjectURL(s.url));
setStemAudioUrls([]);
setGenStatus('idle');
setGenHint('');
setErrorMsg('');
setIsImporting(false);
setImportError('');
originalRegionRef.current = null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectName]);
useEffect(() => {
setModel(availableSeparatorModels[0].value);
}, [availableSeparatorModels]);
const refreshLocalModelCacheState = useCallback(async () => {
if (!localOnlyMode) return;
setIsCheckingLocalModel(true);
try {
setIsLocalModelCached(await LocalSeparatorModelCache.exists());
} catch (err) {
console.error('[KGOne] Local model cache check failed:', err);
setErrorMsg(err instanceof Error ? err.message : String(err));
} finally {
setIsCheckingLocalModel(false);
}
}, [localOnlyMode]);
useEffect(() => {
if (!localOnlyMode) return;
localRuntimeManagerRef.current = new LocalOrtRuntimeManager({
onProviderChange: provider => setLocalProviderLabel(provider),
});
void refreshLocalModelCacheState();
}, [localOnlyMode, refreshLocalModelCacheState]);
const selectedAudioRegion = useMemo(() => {
if (!selectedRegionIds.length) return null;
const project = KGCore.instance().getCurrentProject();
for (const track of project.getTracks()) {
for (const region of track.getRegions()) {
if (
selectedRegionIds.includes(region.getId()) &&
region.getCurrentType() === 'KGAudioRegion'
) {
return { region: region as KGAudioRegion, trackName: track.getName(), trackIndex: track.getTrackIndex() };
}
}
}
return null;
}, [selectedRegionIds]);
const getConfiguredLocalSeparatorModelUrl = useCallback(() => {
const configured = ConfigManager.instance().get('general.uvr5_web_runtime.mdx_net_model_url');
return typeof configured === 'string' && configured.trim()
? configured
: LOCAL_SEPARATOR_DEFAULT_MODEL_URL;
}, []);
const isGenerating = genStatus !== 'idle' && genStatus !== 'done' && genStatus !== 'error';
const handleDownloadLocalModel = useCallback(async () => {
setIsDownloadingLocalModel(true);
setErrorMsg('');
setLocalProgressPercent(0);
setLocalProgressText('Downloading local separator model...');
try {
await LocalSeparatorModelCache.download(
getConfiguredLocalSeparatorModelUrl(),
LOCAL_SEPARATOR_MODEL_FILENAME,
progress => {
const receivedMb = (progress.receivedBytes / (1024 * 1024)).toFixed(1);
const totalMb = progress.totalBytes ? (progress.totalBytes / (1024 * 1024)).toFixed(1) : null;
setLocalProgressPercent(progress.totalBytes ? progress.percent : 0);
setLocalProgressText(
totalMb
? `Downloading local separator model... ${receivedMb} / ${totalMb} MB`
: `Downloading local separator model... ${receivedMb} MB`,
);
},
);
setLocalProgressPercent(100);
setLocalProgressText('Local separator model is ready.');
await refreshLocalModelCacheState();
} catch (err) {
setLocalProgressPercent(0);
setLocalProgressText('');
setErrorMsg(err instanceof Error ? err.message : String(err));
} finally {
setIsDownloadingLocalModel(false);
}
}, [getConfiguredLocalSeparatorModelUrl, refreshLocalModelCacheState]);
const handleDeleteLocalModel = useCallback(async () => {
setIsDeletingLocalModel(true);
setErrorMsg('');
try {
await LocalSeparatorModelCache.delete();
localRuntimeManagerRef.current?.reset();
setLocalProviderLabel(runtimeSupport.webgpuExposed ? 'webgpu available' : 'cpu/wasm only');
setLocalProgressPercent(0);
setLocalProgressText('');
await refreshLocalModelCacheState();
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : String(err));
} finally {
setIsDeletingLocalModel(false);
}
}, [refreshLocalModelCacheState, runtimeSupport.webgpuExposed]);
const handleSeparateServer = useCallback(async () => {
if (!selectedAudioRegion) return;
// Capture snapshot before anything changes — selection may shift during generation
originalRegionRef.current = {
regionName: selectedAudioRegion.region.getName(),
trackName: selectedAudioRegion.trackName,
startFromBeat: selectedAudioRegion.region.getStartFromBeat(),
trackIndex: selectedAudioRegion.trackIndex,
};
setImportError('');
// Abort any in-flight request
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
const { signal } = ctrl;
// Revoke previous stem URLs
stemAudioUrls.forEach(s => URL.revokeObjectURL(s.url));
setStemAudioUrls([]);
setErrorMsg('');
try {
// ── 1. Load the separator model ────────────────────────────────────────
setGenStatus('loading-model');
setGenHint('Loading model — this can take 60+ seconds, please wait...');
const baseUrl = getKGOneBaseUrl();
const loadPayload = { model: 'separator' };
kgoneLog('REQ', 'POST /v1/models/load', loadPayload);
const loadResp = await fetch(`${baseUrl}/v1/models/load`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(loadPayload),
signal,
});
if (!loadResp.ok) {
const body = await loadResp.text().catch(() => '');
kgoneLog('RES', `POST /v1/models/load → ${loadResp.status}`, body);
throw new Error(`Model load failed (${loadResp.status}): ${body}`);
}
kgoneLog('RES', `POST /v1/models/load → ${loadResp.status}`, await loadResp.clone().json().catch(() => '(unparseable)'));
if (signal.aborted) return;
// ── 2. Load audio file from OPFS and build multipart form ──────────────
setGenStatus('generating');
setGenHint('Reading audio file...');
const audioFileId = selectedAudioRegion.region.getAudioFileId();
const audioFileName = selectedAudioRegion.region.getAudioFileName();
const clipStart = selectedAudioRegion.region.getClipStartOffsetSeconds();
const fullDuration = selectedAudioRegion.region.getAudioDurationSeconds();
const regionLengthSec = selectedAudioRegion.region.getLength() * (60 / bpm);
const effectiveDuration = Math.min(regionLengthSec, fullDuration - clipStart);
const rawBuffer = await KGAudioFileStorage.loadAudioFile(projectName, audioFileId);
const needsSlice = clipStart > 0.01 || effectiveDuration < fullDuration - 0.01;
let uploadBuffer: ArrayBuffer;
let uploadFileName: string;
let uploadMimeType: string;
if (needsSlice) {
setGenHint('Trimming audio to region range...');
uploadBuffer = await sliceAudioToWav(rawBuffer, clipStart, effectiveDuration);
uploadFileName = audioFileName.replace(/\.[^.]+$/, '.wav');
uploadMimeType = 'audio/wav';
} else {
uploadBuffer = rawBuffer;
uploadFileName = audioFileName;
uploadMimeType = 'audio/mpeg';
}
const audioFile = new File([uploadBuffer], uploadFileName, { type: uploadMimeType });
const formData = new FormData();
formData.append('file', audioFile, uploadFileName);
formData.append('model_filename', model);
if (signal.aborted) return;
setGenHint('Submitting separation request...');
kgoneLog('REQ', 'POST /v1/separator/separate', { file: uploadFileName, model_filename: model });
const sepResp = await fetch(`${baseUrl}/v1/separator/separate`, {
method: 'POST',
body: formData,
signal,
});
if (!sepResp.ok) {
const body = await sepResp.text().catch(() => '');
kgoneLog('RES', `POST /v1/separator/separate → ${sepResp.status}`, body);
throw new Error(`Separation request failed (${sepResp.status}): ${body}`);
}
const sepJson = (await sepResp.json()) as { task_id: string };
kgoneLog('RES', `POST /v1/separator/separate → ${sepResp.status}`, sepJson);
const { task_id } = sepJson;
taskIdRef.current = task_id;
// ── 3. Poll for completion ─────────────────────────────────────────────
setGenStatus('polling');
setGenHint('Separating stems...');
type SepPollResponse = {
task_id: string;
status: 'running' | 'complete' | 'error';
files?: string[];
error?: string;
};
let files: string[] = [];
while (true) {
if (signal.aborted) return;
await new Promise<void>(r => {
const t = setTimeout(r, 5000);
signal.addEventListener('abort', () => { clearTimeout(t); r(); }, { once: true });
});
if (signal.aborted) return;
kgoneLog('REQ', `GET /v1/separator/result/${task_id}`, null);
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);
if (pollJson.status === 'complete') {
files = pollJson.files ?? [];
break;
}
if (pollJson.status === 'error') {
throw new Error(pollJson.error ?? 'Separation failed on the server');
}
// status === 'running' — keep polling
}
// ── 4. Download all stem files in parallel ─────────────────────────────
setGenStatus('downloading');
setGenHint('Downloading stems...');
const stemResults = await Promise.all(
files.map(async (filename) => {
kgoneLog('REQ', `GET /v1/separator/download/${filename}`, null);
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);
return { name: extractStemName(filename), url };
})
);
setStemAudioUrls(stemResults);
setGenStatus('done');
setGenHint('');
} catch (err) {
if (signal.aborted) return;
console.error('[KGOne] Separator error:', err);
setErrorMsg(err instanceof Error ? err.message : String(err));
setGenStatus('error');
setGenHint('');
}
}, [selectedAudioRegion, projectName, model, stemAudioUrls, bpm]);
const handleSeparateLocal = useCallback(async () => {
if (!selectedAudioRegion || !isLocalModelCached) return;
originalRegionRef.current = {
regionName: selectedAudioRegion.region.getName(),
trackName: selectedAudioRegion.trackName,
startFromBeat: selectedAudioRegion.region.getStartFromBeat(),
trackIndex: selectedAudioRegion.trackIndex,
};
setImportError('');
stemAudioUrls.forEach(s => URL.revokeObjectURL(s.url));
setStemAudioUrls([]);
setErrorMsg('');
setGenStatus('loading-model');
setLocalProgressPercent(0);
setLocalProgressText('Preparing ONNX Runtime session...');
setLocalProviderLabel(runtimeSupport.webgpuExposed ? 'webgpu available' : 'cpu/wasm only');
try {
const modelBuffer = await LocalSeparatorModelCache.getArrayBuffer();
const runtimeManager = localRuntimeManagerRef.current ?? new LocalOrtRuntimeManager({
onProviderChange: provider => setLocalProviderLabel(provider),
});
localRuntimeManagerRef.current = runtimeManager;
const runtime = await runtimeManager.ensureRuntime(LOCAL_SEPARATOR_MODEL_CONFIG, new Uint8Array(modelBuffer));
setGenStatus('generating');
setLocalProgressPercent(3);
setLocalProgressText('Reading audio file...');
const audioFileId = selectedAudioRegion.region.getAudioFileId();
const clipStart = selectedAudioRegion.region.getClipStartOffsetSeconds();
const fullDuration = selectedAudioRegion.region.getAudioDurationSeconds();
const regionLengthSec = selectedAudioRegion.region.getLength() * (60 / bpm);
const effectiveDuration = Math.min(regionLengthSec, fullDuration - clipStart);
const rawBuffer = await KGAudioFileStorage.loadAudioFile(projectName, audioFileId);
const needsSlice = clipStart > 0.01 || effectiveDuration < fullDuration - 0.01;
const inputBuffer = needsSlice
? await sliceAudioToWav(rawBuffer, clipStart, effectiveDuration)
: rawBuffer;
setGenStatus('polling');
setLocalProgressPercent(5);
setLocalProgressText('Running browser separation...');
const chunkDuration = localChunkDurationSeconds.trim()
? Number.parseFloat(localChunkDurationSeconds)
: null;
const overlapValue = Number.parseFloat(localOverlap);
const result = await runLocalSeparator({
session: runtime.session,
runtimeProvider: runtime.provider,
modelConfig: LOCAL_SEPARATOR_MODEL_CONFIG,
audioBuffer: inputBuffer,
chunkDurationSeconds: Number.isFinite(chunkDuration) && (chunkDuration ?? 0) > 0 ? chunkDuration : null,
overlap: Number.isFinite(overlapValue) ? overlapValue : LOCAL_SEPARATOR_MODEL_CONFIG.defaults.overlap,
onProviderChange: provider => setLocalProviderLabel(provider),
onProgress: progress => {
setLocalProgressPercent(progress.percent);
const chunkSuffix = progress.totalChunks ? ` (${progress.processedChunks}/${progress.totalChunks} chunks)` : '';
setLocalProgressText(`${progress.passLabel}${chunkSuffix}`);
},
});
taskIdRef.current = `local_${Date.now()}`;
const nextStemAudioUrls = result.stems.map(stem => ({
name: stem.name,
url: URL.createObjectURL(stem.blob),
}));
setStemAudioUrls(nextStemAudioUrls);
setGenStatus('done');
setLocalProgressPercent(100);
setLocalProgressText('Separation complete.');
kgoneLog('RES', 'local separator summary', result.debugSummary);
} catch (err) {
console.error('[KGOne] Local separator error:', err);
setGenStatus('error');
setErrorMsg(err instanceof Error ? err.message : String(err));
}
}, [
selectedAudioRegion,
isLocalModelCached,
stemAudioUrls,
runtimeSupport.webgpuExposed,
bpm,
projectName,
localChunkDurationSeconds,
localOverlap,
]);
const handleSeparate = useCallback(async () => {
if (localOnlyMode) {
await handleSeparateLocal();
return;
}
await handleSeparateServer();
}, [handleSeparateLocal, handleSeparateServer, localOnlyMode]);
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}.${localOnlyMode ? 'wav' : 'mp3'}`;
const fileId = `kgone_stem_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
const audioFile = new File([blob], fileName, { type: localOnlyMode ? 'audio/wav' : '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.trackName} - ${stem.name}`,
regionName: `${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, localOnlyMode]);
const btnLabel = () => {
if (localOnlyMode) {
switch (genStatus) {
case 'loading-model': return 'Preparing local model...';
case 'generating': return 'Preparing audio...';
case 'polling': return 'Separating locally...';
case 'downloading': return 'Finalizing...';
default: return 'Separate Stems';
}
}
switch (genStatus) {
case 'loading-model': return 'Loading model...';
case 'generating': return 'Preparing upload...';
case 'polling': return 'Separating stems...';
case 'downloading': return 'Downloading...';
default: return 'Separate Stems';
}
};
return (
<>
{localOnlyMode && (
<div className="kgone-local-mode-card">
<div className="kgone-local-mode-title">Local Separator Mode</div>
<div className="kgone-local-mode-text">
Only Vocal and Instrument (Medium Accuracy) is available while not integrated with K.G.One Music Studio server.
Processing in local may take long time depending on your hardware. When fallback to CPU happens, the webpage may
temporarily hang with little or no UI response until processing advances.{' '}
<a href="https://github.com/KGAudioLab/K.G.One" target="_blank" rel="noopener noreferrer">Learn more about K.G.One Music Studio server integration.</a>
</div>
<div className="kgone-runtime-row">
<div className="kgone-provider-chip">Provider: {localProviderLabel}</div>
<div className="kgone-provider-chip">Model: {isLocalModelCached ? 'downloaded' : 'not downloaded'}</div>
</div>
{(localProgressText || isCheckingLocalModel) && (
<div className="kgone-progress-block">
<div
className="kgone-progress-track"
role="progressbar"
aria-valuenow={Math.round(localProgressPercent)}
aria-valuemin={0}
aria-valuemax={100}
>
<div className="kgone-progress-fill" style={{ width: `${Math.max(0, Math.min(100, localProgressPercent))}%` }} />
</div>
<div className="kgone-gen-hint">
{isCheckingLocalModel ? 'Checking local model cache...' : localProgressText}
</div>
</div>
)}
<div className="kgone-row">
{!isLocalModelCached ? (
<button
className="kgone-btn-secondary"
type="button"
disabled={isCheckingLocalModel || isDownloadingLocalModel || isDeletingLocalModel || isGenerating}
onClick={() => void handleDownloadLocalModel()}
>
{isDownloadingLocalModel ? 'Downloading Model...' : 'Download Model'}
</button>
) : (
<>
<button
className="kgone-btn-secondary"
type="button"
disabled={isCheckingLocalModel || isDownloadingLocalModel || isDeletingLocalModel || isGenerating}
onClick={() => void handleDownloadLocalModel()}
>
{isDownloadingLocalModel ? 'Redownloading...' : 'Redownload Model'}
</button>
<button
className="kgone-btn-secondary kgone-btn-danger"
type="button"
disabled={isCheckingLocalModel || isDownloadingLocalModel || isDeletingLocalModel || isGenerating}
onClick={() => void handleDeleteLocalModel()}
>
{isDeletingLocalModel ? 'Deleting...' : 'Delete Cached Model'}
</button>
</>
)}
</div>
</div>
)}
{selectedAudioRegion ? (
<>
<div className="kgone-region-info">
<div className="kgone-region-info-label">Selected Region</div>
<div className="kgone-region-info-value">{selectedAudioRegion.region.getName()}</div>
<div className="kgone-region-info-label" style={{ marginTop: 4 }}>Track</div>
<div className="kgone-region-info-value">{selectedAudioRegion.trackName}</div>
</div>
<div className="kgone-field">
<label className="kgone-label">Separation Model</label>
<select className="kgone-select" value={model} onChange={e => setModel(e.target.value as typeof SERVER_SEPARATOR_MODELS[number]['value'])}>
{availableSeparatorModels.map(m => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</div>
{localOnlyMode && (
<Expander label="Advanced Settings">
<div className="kgone-field">
<label className="kgone-label">Optional audio chunk duration (seconds)</label>
<input
className="kgone-input"
aria-label="Optional audio chunk duration (seconds)"
type="number"
min={1}
step={1}
value={localChunkDurationSeconds}
onChange={e => setLocalChunkDurationSeconds(e.target.value)}
placeholder="Leave blank to process the full region"
/>
</div>
<div className="kgone-field">
<label className="kgone-label">MDX overlap</label>
<input
className="kgone-input"
aria-label="MDX overlap"
type="number"
min={0.001}
max={0.999}
step={0.01}
value={localOverlap}
onChange={e => setLocalOverlap(e.target.value)}
/>
</div>
</Expander>
)}
{/* Stem audio players — shown once separation is complete */}
{stemAudioUrls.length > 0 && (
<div className="kgone-stems">
{stemAudioUrls.map(stem => (
<div key={stem.name} className="kgone-stem-player">
<div className="kgone-label" style={{ marginBottom: 4 }}>{stem.name}</div>
<AudioPlayer
src={stem.url}
dragData={taskIdRef.current ? {
audioFileName: `KGOne_Stem_${stem.name}_${taskIdRef.current}.${localOnlyMode ? 'wav' : 'mp3'}`,
} : undefined}
/>
</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>
)}
{/* Bulk import button — shown after successful separation */}
{genStatus === 'done' && stemAudioUrls.length > 0 && (
<>
<button
className="dialog-btn dialog-btn-primary 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>
)}
<button
className="dialog-btn dialog-btn-primary kgone-btn-generate"
disabled={isGenerating || (localOnlyMode && !isLocalModelCached)}
onClick={handleSeparate}
>
{isGenerating && <FaCircleNotch className="kgone-spinner" />}
{btnLabel()}
</button>
{/* Status hint below button */}
{(localOnlyMode ? localProgressText : genHint) && (
<div className="kgone-gen-hint">{localOnlyMode ? localProgressText : genHint}</div>
)}
</>
) : (
<div className="kgone-separator-hint">
{localOnlyMode && !isLocalModelCached
? 'Download the local separator model, then select an audio region on the timeline to extract stems from it.'
: 'Select an audio region on the timeline to extract stems from it. Only audio regions are supported — MIDI regions cannot be separated.'}
</div>
)}
{!localOnlyMode && (
<div className="kgone-powered-by">
Powered by <a href="https://github.com/nomadkaraoke/python-audio-separator" target="_blank" rel="noopener noreferrer">UVR5 CLI</a>
</div>
)}
</>
);
};
// ─── Remix Tab ────────────────────────────────────────────────────────────────
const RemixTab: React.FC = () => {
const { selectedRegionIds, projectName, bpm, maxBars, refreshProjectState } = useProjectStore();
// Form state (mirrors FullSongTab)
const [caption, setCaption] = useState('');
const [lyrics, setLyrics] = useState('');
const [instrumental, setInstrumental] = useState(false);
const [inferenceSteps, setInferenceSteps] = useState(8);
const [guidanceScale, setGuidanceScale] = useState(7.0);
const [useRandomSeed, setUseRandomSeed] = useState(true);
const [seed, setSeed] = useState(-1);
const [thinking, setThinking] = useState(true);
// Remix-specific params
const [audioCoverStrength, setAudioCoverStrength] = useState(0.5);
const [coverNoiseStrength, setCoverNoiseStrength] = useState(0.2);
// Generation state
const [genStatus, setGenStatus] = useState<GenStatus>('idle');
const [genHint, setGenHint] = useState('');
const [errorMsg, setErrorMsg] = useState('');
const [audioUrl, setAudioUrl] = useState<string | null>(null);
// Import state
const [isImporting, setIsImporting] = useState(false);
const [importError, setImportError] = useState('');
const abortRef = useRef<AbortController | null>(null);
const taskIdRef = useRef<string>('');
const originalRegionRef = useRef<{
regionName: string;
trackName: string;
startFromBeat: number;
trackIndex: number;
} | null>(null);
// Revoke blob URL on unmount
useEffect(() => {
return () => {
abortRef.current?.abort();
if (audioUrl) URL.revokeObjectURL(audioUrl);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const selectedAudioRegion = useMemo(() => {
if (!selectedRegionIds.length) return null;
const project = KGCore.instance().getCurrentProject();
for (const track of project.getTracks()) {
for (const region of track.getRegions()) {
if (
selectedRegionIds.includes(region.getId()) &&
region.getCurrentType() === 'KGAudioRegion'
) {
return { region: region as KGAudioRegion, trackName: track.getName(), trackIndex: track.getTrackIndex() };
}
}
}
return null;
}, [selectedRegionIds]);
const isGenerating = genStatus !== 'idle' && genStatus !== 'done' && genStatus !== 'error';
const handleRemix = useCallback(async () => {
if (!selectedAudioRegion) return;
// Capture snapshot before anything changes — selection may shift during generation
originalRegionRef.current = {
regionName: selectedAudioRegion.region.getName(),
trackName: selectedAudioRegion.trackName,
startFromBeat: selectedAudioRegion.region.getStartFromBeat(),
trackIndex: selectedAudioRegion.trackIndex,
};
setImportError('');
// Abort any in-flight request
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
const { signal } = ctrl;
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
setAudioUrl(null);
}
setErrorMsg('');
try {
const baseUrl = getKGOneBaseUrl();
// ── 1. Load the fullsong model ──────────────────────────────────────────
setGenStatus('loading-model');
setGenHint('Loading model — this can take 60+ seconds, please wait...');
const loadPayload = { model: 'fullsong' };
kgoneLog('REQ', 'POST /v1/models/load', loadPayload);
const loadResp = await fetch(`${baseUrl}/v1/models/load`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(loadPayload),
signal,
});
if (!loadResp.ok) {
const body = await loadResp.text().catch(() => '');
kgoneLog('RES', `POST /v1/models/load → ${loadResp.status}`, body);
throw new Error(`Model load failed (${loadResp.status}): ${body}`);
}
kgoneLog('RES', `POST /v1/models/load → ${loadResp.status}`, await loadResp.clone().json().catch(() => '(unparseable)'));
if (signal.aborted) return;
// ── 2. Load audio from OPFS and build multipart form ───────────────────
setGenStatus('generating');
setGenHint('Reading audio file...');
const audioFileId = selectedAudioRegion.region.getAudioFileId();
const audioFileName = selectedAudioRegion.region.getAudioFileName();
const clipStart = selectedAudioRegion.region.getClipStartOffsetSeconds();
const fullDuration = selectedAudioRegion.region.getAudioDurationSeconds();
const regionLengthSec = selectedAudioRegion.region.getLength() * (60 / bpm);
const effectiveDuration = Math.min(regionLengthSec, fullDuration - clipStart);
const rawBuffer = await KGAudioFileStorage.loadAudioFile(projectName, audioFileId);
const needsSlice = clipStart > 0.01 || effectiveDuration < fullDuration - 0.01;
let uploadBuffer: ArrayBuffer;
let uploadFileName: string;
let uploadMimeType: string;
if (needsSlice) {
setGenHint('Trimming audio to region range...');
uploadBuffer = await sliceAudioToWav(rawBuffer, clipStart, effectiveDuration);
uploadFileName = audioFileName.replace(/\.[^.]+$/, '.wav');
uploadMimeType = 'audio/wav';
} else {
uploadBuffer = rawBuffer;
uploadFileName = audioFileName;
uploadMimeType = 'audio/mpeg';
}
const audioFile = new File([uploadBuffer], uploadFileName, { type: uploadMimeType });
const formData = new FormData();
formData.append('audio_file', audioFile, uploadFileName);
formData.append('caption', caption);
formData.append('lyrics', instrumental ? '' : lyrics);
formData.append('instrumental', String(instrumental));
formData.append('inference_steps', String(inferenceSteps));
formData.append('guidance_scale', String(guidanceScale));
formData.append('use_random_seed', String(useRandomSeed));
formData.append('seed', String(seed));
formData.append('thinking', String(thinking));
formData.append('batch_size', '1');
formData.append('audio_format', 'mp3');
formData.append('audio_cover_strength', String(audioCoverStrength));
formData.append('cover_noise_strength', String(coverNoiseStrength));
if (signal.aborted) return;
setGenHint('Submitting remix request...');
kgoneLog('REQ', 'POST /v1/fullsong/remix', {
file: uploadFileName, caption, instrumental, inference_steps: inferenceSteps,
guidance_scale: guidanceScale, use_random_seed: useRandomSeed, seed, thinking,
audio_cover_strength: audioCoverStrength, cover_noise_strength: coverNoiseStrength,
});
const remixResp = await fetch(`${baseUrl}/v1/fullsong/remix`, {
method: 'POST',
body: formData,
signal,
});
if (!remixResp.ok) {
const body = await remixResp.text().catch(() => '');
kgoneLog('RES', `POST /v1/fullsong/remix → ${remixResp.status}`, body);
throw new Error(`Remix request failed (${remixResp.status}): ${body}`);
}
// Response shape: { "data": { "task_id": "...", ... }, "code": 200, ... }
const remixJson = (await remixResp.json()) as { data: { task_id: string }; code: number };
kgoneLog('RES', `POST /v1/fullsong/remix → ${remixResp.status}`, remixJson);
const task_id = remixJson.data.task_id;
taskIdRef.current = task_id;
// ── 3. Poll for completion (same as FullSongTab) ───────────────────────
setGenStatus('polling');
setGenHint('Generating remix...');
type ResultItem = { progress: number; stage: string; status: number };
type PollResponse = { data: Array<{ status: number; result: string }>; code: number };
while (true) {
if (signal.aborted) return;
await new Promise<void>(r => {
const t = setTimeout(r, 5000);
signal.addEventListener('abort', () => { clearTimeout(t); r(); }, { once: true });
});
if (signal.aborted) return;
kgoneLog('REQ', `GET /v1/fullsong/result/${task_id}`, null);
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);
const outer = pollJson.data?.[0];
if (!outer) continue;
try {
const inner = JSON.parse(outer.result) as ResultItem[];
const item = inner[0];
if (item) {
const pct = Math.round((item.progress ?? 0) * 100);
const stage = item.stage ?? '';
setGenHint(stage ? `Generating remix... ${pct}% — ${stage}` : `Generating remix... ${pct}%`);
}
} catch {
// result may be empty string while still queued — ignore parse errors
}
if (outer.status === 1) break;
}
// ── 4. Download the MP3 ─────────────────────────────────────────────────
setGenStatus('downloading');
setGenHint('Downloading audio...');
kgoneLog('REQ', `GET /v1/fullsong/audio/${task_id}?index=0`, null);
const audioResp = await fetchWithRetry(`${baseUrl}/v1/fullsong/audio/${task_id}?index=0`, { signal });
const blob = await audioResp.blob();
const url = URL.createObjectURL(blob);
kgoneLog('RES', `GET /v1/fullsong/audio/${task_id}?index=0 → ${audioResp.status} (binary MP3)`, url);
setAudioUrl(url);
setGenStatus('done');
setGenHint('');
} catch (err) {
if (signal.aborted) return;
console.error('[KGOne] Remix error:', err);
setErrorMsg(err instanceof Error ? err.message : String(err));
setGenStatus('error');
setGenHint('');
}
}, [selectedAudioRegion, projectName, bpm, caption, lyrics, instrumental, inferenceSteps, guidanceScale, useRandomSeed, seed, thinking, audioCoverStrength, coverNoiseStrength, audioUrl]);
const handleImportAligned = useCallback(async () => {
const snap = originalRegionRef.current;
if (!snap || !audioUrl) return;
setIsImporting(true);
setImportError('');
try {
const audioContext = Tone.getContext().rawContext as AudioContext;
const blob = await fetch(audioUrl).then(r => r.blob());
const fileName = `KGOne_Remix_${taskIdRef.current || Date.now()}.mp3`;
const fileId = `kgone_remix_${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);
const project = KGCore.instance().getCurrentProject();
const count = countRemixTracks(snap.trackName) + 1;
const stemEntry: StemImportEntry = {
trackName: `${snap.trackName} - remix (${count})`,
regionName: `${snap.regionName} - remix (${count})`,
audioFileId: fileId,
audioFileName: fileName,
audioDurationSeconds: toneBuffer.duration,
toneBuffer,
};
const cmd = new ImportStemsCommand(
project.getTracks().length,
snap.trackIndex,
snap.startFromBeat,
[stemEntry],
maxBars,
);
KGCore.instance().executeCommand(cmd);
refreshProjectState();
} catch (err) {
setImportError(err instanceof Error ? err.message : String(err));
} finally {
setIsImporting(false);
}
}, [audioUrl, projectName, maxBars, refreshProjectState]);
const btnLabel = () => {
switch (genStatus) {
case 'loading-model': return 'Loading model...';
case 'generating': return 'Preparing upload...';
case 'polling': return 'Processing remix...';
case 'downloading': return 'Downloading...';
default: return 'Generate Remix';
}
};
return (
<>
{selectedAudioRegion ? (
<>
<div className="kgone-region-info">
<div className="kgone-region-info-label">Selected Region</div>
<div className="kgone-region-info-value">{selectedAudioRegion.region.getName()}</div>
<div className="kgone-region-info-label" style={{ marginTop: 4 }}>Track</div>
<div className="kgone-region-info-value">{selectedAudioRegion.trackName}</div>
</div>
<div className="kgone-field">
<label className="kgone-label">Caption</label>
<textarea
className="kgone-textarea"
value={caption}
onChange={e => setCaption(e.target.value)}
placeholder="e.g. Genre: Jazz, Style: Smooth and mellow, Instrumentation: piano, upright bass, brushed drums..."
rows={5}
/>
<div className="kgone-hint">
Describe the target style, mood, and instrumentation for the remix.
</div>
</div>
<div className="kgone-field">
<label className="kgone-label">Lyrics</label>
<textarea
className="kgone-textarea"
value={lyrics}
onChange={e => setLyrics(e.target.value)}
placeholder={"[Verse 1]\nYour lyrics here...\n\n[Chorus]\n..."}
rows={6}
disabled={instrumental}
style={instrumental ? { opacity: 0.4 } : undefined}
/>
<div className="kgone-hint">
Leave empty to keep the original lyrics, or provide new ones. Use [Verse], [Chorus], [Bridge] tags.
</div>
</div>
<div className="kgone-checkbox-row">
<input type="checkbox" id="kgone-remix-instrumental" checked={instrumental}
onChange={e => setInstrumental(e.target.checked)} />
<label htmlFor="kgone-remix-instrumental">Instrumental (no vocals)</label>
</div>
<Expander label="Advanced Settings">
<div className="kgone-row">
<div className="kgone-field">
<label className="kgone-label">Inference Steps</label>
<input type="number" className="kgone-input" value={inferenceSteps} min={1} max={200}
onChange={e => setInferenceSteps(Number(e.target.value))} />
</div>
<div className="kgone-field">
<label className="kgone-label">Guidance Scale</label>
<input type="number" className="kgone-input" value={guidanceScale} min={0} max={20} step={0.1}
onChange={e => setGuidanceScale(Number(e.target.value))} />
</div>
</div>
<div className="kgone-checkbox-row">
<input type="checkbox" id="kgone-remix-use-random-seed" checked={useRandomSeed}
onChange={e => setUseRandomSeed(e.target.checked)} />
<label htmlFor="kgone-remix-use-random-seed">Use Random Seed</label>
</div>
<div className="kgone-field">
<label className="kgone-label">Seed</label>
<input type="number" className="kgone-input" value={seed}
disabled={useRandomSeed} style={useRandomSeed ? { opacity: 0.4 } : undefined}
onChange={e => setSeed(Number(e.target.value))} />
</div>
<div className="kgone-row">
<div className="kgone-field">
<label className="kgone-label">Cover Strength</label>
<input type="number" className="kgone-input" value={audioCoverStrength} min={0} max={1} step={0.05}
onChange={e => setAudioCoverStrength(Number(e.target.value))} />
<div className="kgone-hint">0 = creative, 1 = faithful to source structure</div>
</div>
<div className="kgone-field">
<label className="kgone-label">Noise Strength</label>
<input type="number" className="kgone-input" value={coverNoiseStrength} min={0} max={1} step={0.05}
onChange={e => setCoverNoiseStrength(Number(e.target.value))} />
<div className="kgone-hint">0 = pure style transfer, 0.10.25 recommended</div>
</div>
</div>
<div className="kgone-checkbox-row">
<input type="checkbox" id="kgone-remix-thinking" checked={thinking}
onChange={e => setThinking(e.target.checked)} />
<label htmlFor="kgone-remix-thinking">Thinking (CoT metadata generation)</label>
</div>
</Expander>
{audioUrl && (
<AudioPlayer
src={audioUrl}
dragData={taskIdRef.current ? {
audioFileName: `KGOne_Remix_${taskIdRef.current}.mp3`,
} : undefined}
/>
)}
{genStatus === 'done' && (
<div className="kgone-hint">
Drag the player above to an <strong>audio track</strong> to import the remix.
</div>
)}
{genStatus === 'done' && audioUrl && (
<>
<button
className="kgone-btn-generate kgone-btn-generate-accent"
disabled={isImporting}
onClick={handleImportAligned}
style={{ marginTop: 0 }}
>
{isImporting && <FaCircleNotch className="kgone-spinner" />}
{isImporting ? 'Importing...' : 'Import Aligned to Source'}
</button>
{importError && <div className="kgone-error-msg">{importError}</div>}
</>
)}
{genStatus === 'error' && errorMsg && (
<div className="kgone-error-msg">{errorMsg}</div>
)}
<button
className="dialog-btn dialog-btn-primary kgone-btn-generate"
disabled={isGenerating || !caption.trim()}
onClick={handleRemix}
>
{isGenerating && <FaCircleNotch className="kgone-spinner" />}
{btnLabel()}
</button>
{genHint && <div className="kgone-gen-hint">{genHint}</div>}
</>
) : (
<div className="kgone-separator-hint">
Select an audio region on the timeline to remix it.
Only audio regions are supported MIDI regions cannot be remixed.
</div>
)}
<div className="kgone-powered-by">
Powered by <a href="https://github.com/ace-step/ACE-Step-1.5" target="_blank" rel="noopener noreferrer">ACE-Step 1.5</a>
</div>
</>
);
};
// ─── Repaint Tab ──────────────────────────────────────────────────────────────
const RepaintTab: React.FC = () => {
const { selectedRegionIds, projectName, bpm, timeSignature, maxBars,
isLooping, loopingRange, refreshProjectState } = useProjectStore();
// Form state (mirrors FullSongTab)
const [caption, setCaption] = useState('');
const [lyrics, setLyrics] = useState('');
const [instrumental, setInstrumental] = useState(false);
const [inferenceSteps, setInferenceSteps] = useState(8);
const [guidanceScale, setGuidanceScale] = useState(7.0);
const [useRandomSeed, setUseRandomSeed] = useState(true);
const [seed, setSeed] = useState(-1);
const [thinking, setThinking] = useState(true);
// Repaint-specific param
const [repaintStrength, setRepaintStrength] = useState(0.5);
// Generation state
const [genStatus, setGenStatus] = useState<GenStatus>('idle');
const [genHint, setGenHint] = useState('');
const [errorMsg, setErrorMsg] = useState('');
const [audioUrl, setAudioUrl] = useState<string | null>(null);
// Import state
const [isImporting, setIsImporting] = useState(false);
const [importError, setImportError] = useState('');
const abortRef = useRef<AbortController | null>(null);
const taskIdRef = useRef<string>('');
const originalRegionRef = useRef<{
regionName: string;
trackName: string;
startFromBeat: number;
trackIndex: number;
} | null>(null);
// Revoke blob URL on unmount
useEffect(() => {
return () => {
abortRef.current?.abort();
if (audioUrl) URL.revokeObjectURL(audioUrl);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const selectedAudioRegion = useMemo(() => {
if (!selectedRegionIds.length) return null;
const project = KGCore.instance().getCurrentProject();
for (const track of project.getTracks()) {
for (const region of track.getRegions()) {
if (
selectedRegionIds.includes(region.getId()) &&
region.getCurrentType() === 'KGAudioRegion'
) {
return { region: region as KGAudioRegion, trackName: track.getName(), trackIndex: track.getTrackIndex() };
}
}
}
return null;
}, [selectedRegionIds]);
const isGenerating = genStatus !== 'idle' && genStatus !== 'done' && genStatus !== 'error';
// Computed repaint range in seconds (read-only display, derived from loop + region)
const computedRepaintRange = useMemo(() => {
if (!selectedAudioRegion || !isLooping) return null;
const beatsPerBar = timeSignature.numerator;
const secondsPerBeat = 60 / bpm;
const regionStartBeat = selectedAudioRegion.region.getStartFromBeat();
const clipStart = selectedAudioRegion.region.getClipStartOffsetSeconds();
const fullDuration = selectedAudioRegion.region.getAudioDurationSeconds();
const regionLengthSec = selectedAudioRegion.region.getLength() * secondsPerBeat;
const effectiveDuration = Math.min(regionLengthSec, fullDuration - clipStart);
const needsSlice = clipStart > 0.01 || effectiveDuration < fullDuration - 0.01;
const uploadedDuration = needsSlice ? effectiveDuration : fullDuration;
const loopStartBeat = loopingRange[0] * beatsPerBar;
const loopEndBeat = (loopingRange[1] + 1) * beatsPerBar; // endBar is inclusive
// Both sliced and non-sliced cases: uploaded audio time 0 ≈ regionStartBeat in the timeline
const startRaw = (loopStartBeat - regionStartBeat) * secondsPerBeat;
const endRaw = (loopEndBeat - regionStartBeat) * secondsPerBeat;
const startClamped = Math.max(0, startRaw);
const endClamped = Math.min(uploadedDuration, endRaw);
return { startClamped, endClamped, uploadedDuration, hasIntersection: endClamped > startClamped };
}, [selectedAudioRegion, isLooping, loopingRange, bpm, timeSignature]);
const handleRepaint = useCallback(async () => {
if (!selectedAudioRegion) return;
// ── Validate loop state ────────────────────────────────────────────────────
if (!isLooping) {
await showAlert('Please enable loop mode on the toolbar and set a loop range to define the repaint window.');
return;
}
// Re-compute range inline (fresh values independent of the display memo)
const beatsPerBar = timeSignature.numerator;
const secondsPerBeat = 60 / bpm;
const regionStartBeat = selectedAudioRegion.region.getStartFromBeat();
const clipStart = selectedAudioRegion.region.getClipStartOffsetSeconds();
const fullDuration = selectedAudioRegion.region.getAudioDurationSeconds();
const regionLengthSec = selectedAudioRegion.region.getLength() * secondsPerBeat;
const effectiveDuration = Math.min(regionLengthSec, fullDuration - clipStart);
const needsSlice = clipStart > 0.01 || effectiveDuration < fullDuration - 0.01;
const uploadedDuration = needsSlice ? effectiveDuration : fullDuration;
const loopStartBeat = loopingRange[0] * beatsPerBar;
const loopEndBeat = (loopingRange[1] + 1) * beatsPerBar;
const startClamped = Math.max(0, (loopStartBeat - regionStartBeat) * secondsPerBeat);
const endClamped = Math.min(uploadedDuration, (loopEndBeat - regionStartBeat) * secondsPerBeat);
if (endClamped <= startClamped) {
await showAlert('The loop range does not overlap with the selected audio region. Please adjust the loop range to overlap with the region.');
return;
}
const apiRepaintStart = startClamped;
const apiRepaintEnd = endClamped >= uploadedDuration - 0.1 ? -1 : endClamped;
// Capture snapshot before anything changes — selection may shift during generation
originalRegionRef.current = {
regionName: selectedAudioRegion.region.getName(),
trackName: selectedAudioRegion.trackName,
startFromBeat: selectedAudioRegion.region.getStartFromBeat(),
trackIndex: selectedAudioRegion.trackIndex,
};
setImportError('');
// Abort any in-flight request
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
const { signal } = ctrl;
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
setAudioUrl(null);
}
setErrorMsg('');
try {
const baseUrl = getKGOneBaseUrl();
// ── 1. Load the fullsong model ──────────────────────────────────────────
setGenStatus('loading-model');
setGenHint('Loading model — this can take 60+ seconds, please wait...');
const loadPayload = { model: 'fullsong' };
kgoneLog('REQ', 'POST /v1/models/load', loadPayload);
const loadResp = await fetch(`${baseUrl}/v1/models/load`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(loadPayload),
signal,
});
if (!loadResp.ok) {
const body = await loadResp.text().catch(() => '');
kgoneLog('RES', `POST /v1/models/load → ${loadResp.status}`, body);
throw new Error(`Model load failed (${loadResp.status}): ${body}`);
}
kgoneLog('RES', `POST /v1/models/load → ${loadResp.status}`, await loadResp.clone().json().catch(() => '(unparseable)'));
if (signal.aborted) return;
// ── 2. Load audio from OPFS and build multipart form ───────────────────
setGenStatus('generating');
setGenHint('Reading audio file...');
const audioFileId = selectedAudioRegion.region.getAudioFileId();
const audioFileName = selectedAudioRegion.region.getAudioFileName();
const rawBuffer = await KGAudioFileStorage.loadAudioFile(projectName, audioFileId);
let uploadBuffer: ArrayBuffer;
let uploadFileName: string;
let uploadMimeType: string;
if (needsSlice) {
setGenHint('Trimming audio to region range...');
uploadBuffer = await sliceAudioToWav(rawBuffer, clipStart, effectiveDuration);
uploadFileName = audioFileName.replace(/\.[^.]+$/, '.wav');
uploadMimeType = 'audio/wav';
} else {
uploadBuffer = rawBuffer;
uploadFileName = audioFileName;
uploadMimeType = 'audio/mpeg';
}
const audioFile = new File([uploadBuffer], uploadFileName, { type: uploadMimeType });
const formData = new FormData();
formData.append('audio_file', audioFile, uploadFileName);
formData.append('caption', caption);
formData.append('lyrics', instrumental ? '' : lyrics);
formData.append('instrumental', String(instrumental));
formData.append('inference_steps', String(inferenceSteps));
formData.append('guidance_scale', String(guidanceScale));
formData.append('use_random_seed', String(useRandomSeed));
formData.append('seed', String(seed));
formData.append('thinking', String(thinking));
formData.append('batch_size', '1');
formData.append('audio_format', 'mp3');
formData.append('repaint_strength', String(repaintStrength));
formData.append('repainting_start', String(apiRepaintStart));
formData.append('repainting_end', String(apiRepaintEnd));
if (signal.aborted) return;
setGenHint('Submitting repaint request...');
kgoneLog('REQ', 'POST /v1/fullsong/repaint', {
file: uploadFileName, caption, instrumental, inference_steps: inferenceSteps,
guidance_scale: guidanceScale, use_random_seed: useRandomSeed, seed, thinking,
repaint_strength: repaintStrength, repainting_start: apiRepaintStart, repainting_end: apiRepaintEnd,
});
const repaintResp = await fetch(`${baseUrl}/v1/fullsong/repaint`, {
method: 'POST',
body: formData,
signal,
});
if (!repaintResp.ok) {
const body = await repaintResp.text().catch(() => '');
kgoneLog('RES', `POST /v1/fullsong/repaint → ${repaintResp.status}`, body);
throw new Error(`Repaint request failed (${repaintResp.status}): ${body}`);
}
// Response shape: { "data": { "task_id": "...", ... }, "code": 200, ... }
const repaintJson = (await repaintResp.json()) as { data: { task_id: string }; code: number };
kgoneLog('RES', `POST /v1/fullsong/repaint → ${repaintResp.status}`, repaintJson);
const task_id = repaintJson.data.task_id;
taskIdRef.current = task_id;
// ── 3. Poll for completion (same as FullSongTab) ───────────────────────
setGenStatus('polling');
setGenHint('Generating repaint...');
type ResultItem = { progress: number; stage: string; status: number };
type PollResponse = { data: Array<{ status: number; result: string }>; code: number };
while (true) {
if (signal.aborted) return;
await new Promise<void>(r => {
const t = setTimeout(r, 5000);
signal.addEventListener('abort', () => { clearTimeout(t); r(); }, { once: true });
});
if (signal.aborted) return;
kgoneLog('REQ', `GET /v1/fullsong/result/${task_id}`, null);
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);
const outer = pollJson.data?.[0];
if (!outer) continue;
try {
const inner = JSON.parse(outer.result) as ResultItem[];
const item = inner[0];
if (item) {
const pct = Math.round((item.progress ?? 0) * 100);
const stage = item.stage ?? '';
setGenHint(stage ? `Generating repaint... ${pct}% — ${stage}` : `Generating repaint... ${pct}%`);
}
} catch {
// result may be empty string while still queued — ignore parse errors
}
if (outer.status === 1) break;
}
// ── 4. Download the MP3 ─────────────────────────────────────────────────
setGenStatus('downloading');
setGenHint('Downloading audio...');
kgoneLog('REQ', `GET /v1/fullsong/audio/${task_id}?index=0`, null);
const audioResp = await fetchWithRetry(`${baseUrl}/v1/fullsong/audio/${task_id}?index=0`, { signal });
const blob = await audioResp.blob();
const url = URL.createObjectURL(blob);
kgoneLog('RES', `GET /v1/fullsong/audio/${task_id}?index=0 → ${audioResp.status} (binary MP3)`, url);
setAudioUrl(url);
setGenStatus('done');
setGenHint('');
} catch (err) {
if (signal.aborted) return;
console.error('[KGOne] Repaint error:', err);
setErrorMsg(err instanceof Error ? err.message : String(err));
setGenStatus('error');
setGenHint('');
}
}, [selectedAudioRegion, projectName, bpm, timeSignature, isLooping, loopingRange, caption, lyrics, instrumental, inferenceSteps, guidanceScale, useRandomSeed, seed, thinking, repaintStrength, audioUrl]);
const handleImportAligned = useCallback(async () => {
const snap = originalRegionRef.current;
if (!snap || !audioUrl) return;
setIsImporting(true);
setImportError('');
try {
const audioContext = Tone.getContext().rawContext as AudioContext;
const blob = await fetch(audioUrl).then(r => r.blob());
const fileName = `KGOne_Repaint_${taskIdRef.current || Date.now()}.mp3`;
const fileId = `kgone_repaint_${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);
const project = KGCore.instance().getCurrentProject();
const count = countRepaintTracks(snap.trackName) + 1;
const stemEntry: StemImportEntry = {
trackName: `${snap.trackName} - repaint (${count})`,
regionName: `${snap.regionName} - repaint (${count})`,
audioFileId: fileId,
audioFileName: fileName,
audioDurationSeconds: toneBuffer.duration,
toneBuffer,
};
const cmd = new ImportStemsCommand(
project.getTracks().length,
snap.trackIndex,
snap.startFromBeat,
[stemEntry],
maxBars,
);
KGCore.instance().executeCommand(cmd);
refreshProjectState();
} catch (err) {
setImportError(err instanceof Error ? err.message : String(err));
} finally {
setIsImporting(false);
}
}, [audioUrl, projectName, maxBars, refreshProjectState]);
const btnLabel = () => {
switch (genStatus) {
case 'loading-model': return 'Loading model...';
case 'generating': return 'Preparing upload...';
case 'polling': return 'Processing repaint...';
case 'downloading': return 'Downloading...';
default: return 'Generate Repaint';
}
};
return (
<>
{selectedAudioRegion ? (
<>
<div className="kgone-region-info">
<div className="kgone-region-info-label">Selected Region</div>
<div className="kgone-region-info-value">{selectedAudioRegion.region.getName()}</div>
<div className="kgone-region-info-label" style={{ marginTop: 4 }}>Track</div>
<div className="kgone-region-info-value">{selectedAudioRegion.trackName}</div>
</div>
<div className="kgone-region-info">
<div className="kgone-region-info-label">Repaint Range</div>
{!isLooping ? (
<div className="kgone-region-info-value" style={{ opacity: 0.6 }}>
Loop mode is off. Enable the loop button on the toolbar and set a loop range to define the repaint window.
</div>
) : computedRepaintRange ? (
<>
<div className="kgone-region-info-label" style={{ marginTop: 4 }}>Start</div>
<div className="kgone-region-info-value">{computedRepaintRange.startClamped.toFixed(2)} s</div>
<div className="kgone-region-info-label" style={{ marginTop: 4 }}>End</div>
<div className="kgone-region-info-value">
{computedRepaintRange.endClamped >= computedRepaintRange.uploadedDuration - 0.1
? 'until end of audio'
: `${computedRepaintRange.endClamped.toFixed(2)} s`}
</div>
</>
) : null}
</div>
<div className="kgone-field">
<label className="kgone-label">Caption</label>
<textarea
className="kgone-textarea"
value={caption}
onChange={e => setCaption(e.target.value)}
placeholder="e.g. Genre: Jazz, Style: Smooth and mellow, Instrumentation: piano, upright bass, brushed drums..."
rows={5}
/>
<div className="kgone-hint">
Describe the target style and instrumentation for the repainted section.
</div>
</div>
<div className="kgone-field">
<label className="kgone-label">Lyrics</label>
<textarea
className="kgone-textarea"
value={lyrics}
onChange={e => setLyrics(e.target.value)}
placeholder={"[Verse 1]\nYour lyrics here...\n\n[Chorus]\n..."}
rows={6}
disabled={instrumental}
style={instrumental ? { opacity: 0.4 } : undefined}
/>
<div className="kgone-hint">
Leave empty to keep the original lyrics, or provide new ones for the repainted section.
</div>
</div>
<div className="kgone-checkbox-row">
<input type="checkbox" id="kgone-repaint-instrumental" checked={instrumental}
onChange={e => setInstrumental(e.target.checked)} />
<label htmlFor="kgone-repaint-instrumental">Instrumental (no vocals)</label>
</div>
<Expander label="Advanced Settings">
<div className="kgone-row">
<div className="kgone-field">
<label className="kgone-label">Inference Steps</label>
<input type="number" className="kgone-input" value={inferenceSteps} min={1} max={200}
onChange={e => setInferenceSteps(Number(e.target.value))} />
</div>
<div className="kgone-field">
<label className="kgone-label">Guidance Scale</label>
<input type="number" className="kgone-input" value={guidanceScale} min={0} max={20} step={0.1}
onChange={e => setGuidanceScale(Number(e.target.value))} />
</div>
</div>
<div className="kgone-checkbox-row">
<input type="checkbox" id="kgone-repaint-use-random-seed" checked={useRandomSeed}
onChange={e => setUseRandomSeed(e.target.checked)} />
<label htmlFor="kgone-repaint-use-random-seed">Use Random Seed</label>
</div>
<div className="kgone-field">
<label className="kgone-label">Seed</label>
<input type="number" className="kgone-input" value={seed}
disabled={useRandomSeed} style={useRandomSeed ? { opacity: 0.4 } : undefined}
onChange={e => setSeed(Number(e.target.value))} />
</div>
<div className="kgone-field">
<label className="kgone-label">Repaint Strength</label>
<input type="number" className="kgone-input" value={repaintStrength} min={0} max={1} step={0.05}
onChange={e => setRepaintStrength(Number(e.target.value))} />
<div className="kgone-hint">0 = preserve original, 1 = full regeneration</div>
</div>
<div className="kgone-checkbox-row">
<input type="checkbox" id="kgone-repaint-thinking" checked={thinking}
onChange={e => setThinking(e.target.checked)} />
<label htmlFor="kgone-repaint-thinking">Thinking (CoT metadata generation)</label>
</div>
</Expander>
{audioUrl && (
<AudioPlayer
src={audioUrl}
dragData={taskIdRef.current ? {
audioFileName: `KGOne_Repaint_${taskIdRef.current}.mp3`,
} : undefined}
/>
)}
{genStatus === 'done' && (
<div className="kgone-hint">
Drag the player above to an <strong>audio track</strong> to import the repaint.
</div>
)}
{genStatus === 'done' && audioUrl && (
<>
<button
className="kgone-btn-generate kgone-btn-generate-accent"
disabled={isImporting}
onClick={handleImportAligned}
style={{ marginTop: 0 }}
>
{isImporting && <FaCircleNotch className="kgone-spinner" />}
{isImporting ? 'Importing...' : 'Import Aligned to Source'}
</button>
{importError && <div className="kgone-error-msg">{importError}</div>}
</>
)}
{genStatus === 'error' && errorMsg && (
<div className="kgone-error-msg">{errorMsg}</div>
)}
<button
className="dialog-btn dialog-btn-primary kgone-btn-generate"
disabled={isGenerating || !caption.trim()}
onClick={handleRepaint}
>
{isGenerating && <FaCircleNotch className="kgone-spinner" />}
{btnLabel()}
</button>
{genHint && <div className="kgone-gen-hint">{genHint}</div>}
</>
) : (
<div className="kgone-separator-hint">
Select an audio region on the timeline to repaint it.
Only audio regions are supported MIDI regions cannot be repainted.
</div>
)}
<div className="kgone-powered-by">
Powered by <a href="https://github.com/ace-step/ACE-Step-1.5" target="_blank" rel="noopener noreferrer">ACE-Step 1.5</a>
</div>
</>
);
};
// ─── Main Panel ───────────────────────────────────────────────────────────────
interface KGOnePanelProps {
isVisible: boolean;
}
const KGOnePanel: React.FC<KGOnePanelProps> = ({ isVisible }) => {
const mode = getKGOneMode();
const [activeTab, setActiveTab] = useState<Tab>(getDefaultKGOneTab(mode));
const { bpm, keySignature } = useProjectStore();
const disabledTabs = mode === 'local-separator'
? new Set<Tab>(['fullsong', 'remix', 'repaint'])
: new Set<Tab>();
useEffect(() => {
setActiveTab(current => {
if (disabledTabs.has(current)) {
return 'separator';
}
return getDefaultKGOneTab(mode) === 'separator' ? 'separator' : current;
});
}, [mode]);
return (
<div className={`kgone-panel${isVisible ? '' : ' is-hidden'}`}>
<div className="kgone-panel-header">
<h3>{mode === 'local-separator' ? 'Music Generator' : 'K.G.One Music Generator'}</h3>
</div>
<div className="kgone-tabs">
{/* Clip tab temporarily disabled, will enable in the future */}
{KGONE_TABS.map(tab => {
const isDisabled = disabledTabs.has(tab);
const button = (
<button
key={tab}
className={`kgone-tab${activeTab === tab ? ' active' : ''}${isDisabled ? ' is-disabled' : ''}`}
onClick={() => {
if (isDisabled) return;
setActiveTab(tab);
}}
disabled={isDisabled}
style={isDisabled ? { width: '100%' } : undefined}
>
{formatKGOneTabLabel(tab)}
</button>
);
return isDisabled ? (
<span
key={tab}
title="Requires K.G.One Music Studio server integration"
style={{ flex: 1 }}
>
{button}
</span>
) : button;
})}
</div>
<div className="kgone-panel-body">
{/* Clip tab temporarily disabled, will enable in the future */}
{activeTab === 'clip' && <ClipTab bpm={bpm} keySignature={keySignature} />}
{activeTab === 'fullsong' && <FullSongTab />}
{activeTab === 'remix' && <RemixTab />}
{activeTab === 'repaint' && <RepaintTab />}
{activeTab === 'separator' && <SeparatorTab mode={mode} />}
</div>
</div>
);
};
export default KGOnePanel;