2643 lines
105 KiB
TypeScript
2643 lines
105 KiB
TypeScript
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.1–0.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;
|