feat: added local separator implementation
This commit is contained in:
@@ -62,6 +62,15 @@
|
||||
border-bottom-color: #5a9fd4;
|
||||
}
|
||||
|
||||
.kgone-tab.is-disabled {
|
||||
color: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.kgone-tab.is-disabled:hover {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Scrollable body */
|
||||
.kgone-panel-body {
|
||||
flex: 1;
|
||||
@@ -203,6 +212,64 @@
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.kgone-local-mode-card {
|
||||
background-color: #252525;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kgone-local-mode-title {
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.kgone-local-mode-text {
|
||||
color: #999;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.kgone-runtime-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.kgone-provider-chip {
|
||||
background-color: #313131;
|
||||
border: 1px solid #444;
|
||||
border-radius: 999px;
|
||||
color: #bbb;
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.kgone-progress-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.kgone-progress-track {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background-color: #1d1d1d;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kgone-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #5a9fd4 0%, #7cc2f1 100%);
|
||||
transition: width 0.15s linear;
|
||||
}
|
||||
|
||||
.kgone-region-info {
|
||||
background-color: #252525;
|
||||
border: 1px solid #3a3a3a;
|
||||
@@ -252,6 +319,32 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.kgone-btn-secondary {
|
||||
background-color: #353535;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
color: #ddd;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.kgone-btn-secondary:hover:not(:disabled) {
|
||||
background-color: #404040;
|
||||
}
|
||||
|
||||
.kgone-btn-secondary:disabled {
|
||||
color: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.kgone-btn-danger {
|
||||
border-color: #5a2a2a;
|
||||
color: #f0b0b0;
|
||||
}
|
||||
|
||||
/* ── Audio player ──────────────────────────────────────────────────────────── */
|
||||
.kgone-audio-player {
|
||||
display: flex;
|
||||
@@ -404,4 +497,4 @@
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import KGOnePanel from './KGOnePanel';
|
||||
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
||||
|
||||
let kgoneEnabled = false;
|
||||
let selectedRegionIds: string[] = [];
|
||||
let localModelCached = false;
|
||||
let localSeparationResult: Array<{ name: string; blob: Blob }> = [];
|
||||
|
||||
const mockRefreshProjectState = vi.fn();
|
||||
const mockExecuteCommand = vi.fn();
|
||||
|
||||
vi.mock('../stores/projectStore', () => ({
|
||||
useProjectStore: () => ({
|
||||
selectedRegionIds,
|
||||
projectName: 'Test Project',
|
||||
bpm: 120,
|
||||
keySignature: 'C major',
|
||||
timeSignature: { numerator: 4, denominator: 4 },
|
||||
maxBars: 32,
|
||||
refreshProjectState: mockRefreshProjectState,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../core/config/ConfigManager', () => ({
|
||||
ConfigManager: {
|
||||
instance: () => ({
|
||||
get: (key: string) => {
|
||||
if (key === 'general.kgone.enabled') return kgoneEnabled;
|
||||
if (key === 'general.kgone.base_url') return 'http://127.0.0.1:8000';
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const audioRegion = new KGAudioRegion(
|
||||
'audio-region-1',
|
||||
'track-1',
|
||||
0,
|
||||
'Verse Stem',
|
||||
0,
|
||||
4,
|
||||
'audio-file-1',
|
||||
'verse.wav',
|
||||
2,
|
||||
0,
|
||||
);
|
||||
const audioTrack = new KGAudioTrack('Audio Track', 1);
|
||||
audioTrack.setTrackIndex(0);
|
||||
audioTrack.setRegions([audioRegion]);
|
||||
|
||||
vi.mock('../core/KGCore', () => ({
|
||||
KGCore: {
|
||||
instance: () => ({
|
||||
getCurrentProject: () => ({
|
||||
getTracks: () => [audioTrack],
|
||||
getName: () => 'Test Project',
|
||||
}),
|
||||
executeCommand: mockExecuteCommand,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../core/io/KGAudioFileStorage', () => ({
|
||||
KGAudioFileStorage: {
|
||||
loadAudioFile: vi.fn(async () => new ArrayBuffer(8)),
|
||||
storeAudioFile: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../util/audioUtil', () => ({
|
||||
sliceAudioToWav: vi.fn(async (_buffer: ArrayBuffer) => _buffer),
|
||||
}));
|
||||
|
||||
vi.mock('../util/localSeparatorModelCache', () => ({
|
||||
LocalSeparatorModelCache: {
|
||||
exists: vi.fn(async () => localModelCached),
|
||||
download: vi.fn(async () => {
|
||||
localModelCached = true;
|
||||
}),
|
||||
delete: vi.fn(async () => {
|
||||
localModelCached = false;
|
||||
}),
|
||||
getArrayBuffer: vi.fn(async () => new ArrayBuffer(16)),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../util/localSeparatorRuntime', () => ({
|
||||
detectLocalRuntimeSupport: () => ({ webgpuExposed: false }),
|
||||
LocalOrtRuntimeManager: class {
|
||||
constructor(private readonly options?: { onProviderChange?: (provider: string) => void }) {}
|
||||
|
||||
reset() {}
|
||||
|
||||
async ensureRuntime() {
|
||||
this.options?.onProviderChange?.('cpu/wasm');
|
||||
return { provider: 'wasm', session: {} };
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../util/localSeparatorRunner', () => ({
|
||||
runLocalSeparator: vi.fn(async ({ onProgress, onProviderChange }) => {
|
||||
onProviderChange?.('cpu/wasm');
|
||||
onProgress({ stage: 'main', passLabel: 'Main pass', percent: 100, processedChunks: 1, totalChunks: 1 });
|
||||
return {
|
||||
stems: localSeparationResult,
|
||||
providerLabel: 'CPU/wasm',
|
||||
debugSummary: {},
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('KGOnePanel local separator mode', () => {
|
||||
beforeEach(() => {
|
||||
kgoneEnabled = false;
|
||||
selectedRegionIds = [];
|
||||
localModelCached = false;
|
||||
localSeparationResult = [
|
||||
{ name: 'Instrumental', blob: new Blob(['instrumental'], { type: 'audio/wav' }) },
|
||||
{ name: 'Vocals', blob: new Blob(['vocals'], { type: 'audio/wav' }) },
|
||||
];
|
||||
mockRefreshProjectState.mockReset();
|
||||
mockExecuteCommand.mockReset();
|
||||
});
|
||||
|
||||
it('defaults to separator and shows other tabs as disabled in local mode', async () => {
|
||||
render(<KGOnePanel isVisible={true} />);
|
||||
|
||||
expect(await screen.findByText('Local Separator Mode')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Full Song' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Remix' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Repaint' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Separator' })).not.toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Download Model' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the single local separator model and advanced settings when the model is cached', async () => {
|
||||
localModelCached = true;
|
||||
selectedRegionIds = ['audio-region-1'];
|
||||
|
||||
render(<KGOnePanel isVisible={true} />);
|
||||
|
||||
await screen.findByText('Selected Region');
|
||||
const options = await screen.findAllByRole('option');
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Vocal and Instrument (Medium Accuracy)');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Advanced Settings/i }));
|
||||
expect(screen.getByLabelText('Optional audio chunk duration (seconds)')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('MDX overlap')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prompts for an audio region when the model is cached but nothing is selected', async () => {
|
||||
localModelCached = true;
|
||||
|
||||
render(<KGOnePanel isVisible={true} />);
|
||||
|
||||
expect(await screen.findByText(/Select an audio region on the timeline/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders local separation outputs after processing completes', async () => {
|
||||
localModelCached = true;
|
||||
selectedRegionIds = ['audio-region-1'];
|
||||
|
||||
render(<KGOnePanel isVisible={true} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Separate Stems' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Instrumental')).toBeInTheDocument();
|
||||
expect(screen.getByText('Vocals')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Import All Stems to Timeline' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
+368
-28
@@ -15,18 +15,31 @@ 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_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 SEPARATOR_MODELS = [
|
||||
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'];
|
||||
|
||||
@@ -59,6 +72,22 @@ function formatTime(sec: number): string {
|
||||
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';
|
||||
}
|
||||
|
||||
export function getDefaultKGOneTab(mode: KGOneMode): Tab {
|
||||
return mode === 'local-separator' ? 'separator' : 'fullsong';
|
||||
}
|
||||
|
||||
export function getKGOneMode(): KGOneMode {
|
||||
const enabled = (ConfigManager.instance().get('general.kgone.enabled') as boolean | undefined) ?? false;
|
||||
return enabled ? 'server' : 'local-separator';
|
||||
}
|
||||
|
||||
// ─── Shared components ────────────────────────────────────────────────────────
|
||||
|
||||
interface ExpanderProps {
|
||||
@@ -830,18 +859,33 @@ function countRepaintTracks(sourceTrackName: string): number {
|
||||
return tracks.filter(t => pattern.test(t.getName())).length;
|
||||
}
|
||||
|
||||
const SeparatorTab: React.FC = () => {
|
||||
const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => {
|
||||
const { selectedRegionIds, projectName, bpm, timeSignature, maxBars, refreshProjectState } = useProjectStore();
|
||||
const [model, setModel] = useState<typeof SEPARATOR_MODELS[number]['value']>(SEPARATOR_MODELS[0].value);
|
||||
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;
|
||||
@@ -861,6 +905,31 @@ const SeparatorTab: React.FC = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
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();
|
||||
@@ -879,7 +948,57 @@ const SeparatorTab: React.FC = () => {
|
||||
|
||||
const isGenerating = genStatus !== 'idle' && genStatus !== 'done' && genStatus !== 'error';
|
||||
|
||||
const handleSeparate = useCallback(async () => {
|
||||
const handleDownloadLocalModel = useCallback(async () => {
|
||||
setIsDownloadingLocalModel(true);
|
||||
setErrorMsg('');
|
||||
setLocalProgressPercent(0);
|
||||
setLocalProgressText('Downloading local separator model...');
|
||||
|
||||
try {
|
||||
await LocalSeparatorModelCache.download(
|
||||
LOCAL_SEPARATOR_MODEL_URL,
|
||||
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);
|
||||
}
|
||||
}, [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
|
||||
@@ -1052,7 +1171,107 @@ const SeparatorTab: React.FC = () => {
|
||||
setGenStatus('error');
|
||||
setGenHint('');
|
||||
}
|
||||
}, [selectedAudioRegion, projectName, model, stemAudioUrls]);
|
||||
}, [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;
|
||||
@@ -1068,9 +1287,9 @@ const SeparatorTab: React.FC = () => {
|
||||
const stems: StemImportEntry[] = await Promise.all(
|
||||
stemAudioUrls.map(async (stem) => {
|
||||
const blob = await fetch(stem.url).then(r => r.blob());
|
||||
const fileName = `KGOne_Stem_${stem.name}_${taskIdRef.current}.mp3`;
|
||||
const 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: 'audio/mpeg' });
|
||||
const audioFile = new File([blob], fileName, { type: localOnlyMode ? 'audio/wav' : 'audio/mpeg' });
|
||||
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const toneBuffer = new Tone.ToneAudioBuffer();
|
||||
@@ -1114,9 +1333,19 @@ const SeparatorTab: React.FC = () => {
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [stemAudioUrls, projectName, maxBars, refreshProjectState]);
|
||||
}, [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...';
|
||||
@@ -1128,6 +1357,68 @@ const SeparatorTab: React.FC = () => {
|
||||
|
||||
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.
|
||||
</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">
|
||||
@@ -1139,14 +1430,44 @@ const SeparatorTab: React.FC = () => {
|
||||
|
||||
<div className="kgone-field">
|
||||
<label className="kgone-label">Separation Model</label>
|
||||
<select className="kgone-select" value={model} onChange={e => setModel(e.target.value as typeof SEPARATOR_MODELS[number]['value'])}>
|
||||
|
||||
{SEPARATOR_MODELS.map(m => (
|
||||
<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">
|
||||
@@ -1156,7 +1477,7 @@ const SeparatorTab: React.FC = () => {
|
||||
<AudioPlayer
|
||||
src={stem.url}
|
||||
dragData={taskIdRef.current ? {
|
||||
audioFileName: `KGOne_Stem_${stem.name}_${taskIdRef.current}.mp3`,
|
||||
audioFileName: `KGOne_Stem_${stem.name}_${taskIdRef.current}.${localOnlyMode ? 'wav' : 'mp3'}`,
|
||||
} : undefined}
|
||||
/>
|
||||
</div>
|
||||
@@ -1195,7 +1516,7 @@ const SeparatorTab: React.FC = () => {
|
||||
|
||||
<button
|
||||
className="kgone-btn-generate"
|
||||
disabled={isGenerating}
|
||||
disabled={isGenerating || (localOnlyMode && !isLocalModelCached)}
|
||||
onClick={handleSeparate}
|
||||
>
|
||||
{isGenerating && <FaCircleNotch className="kgone-spinner" />}
|
||||
@@ -1203,18 +1524,23 @@ const SeparatorTab: React.FC = () => {
|
||||
</button>
|
||||
|
||||
{/* Status hint below button */}
|
||||
{genHint && <div className="kgone-gen-hint">{genHint}</div>}
|
||||
{(localOnlyMode ? localProgressText : genHint) && (
|
||||
<div className="kgone-gen-hint">{localOnlyMode ? localProgressText : genHint}</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="kgone-separator-hint">
|
||||
Select an audio region on the timeline to extract stems from it.
|
||||
Only audio regions are supported — MIDI regions cannot be separated.
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{!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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -2228,8 +2554,21 @@ interface KGOnePanelProps {
|
||||
}
|
||||
|
||||
const KGOnePanel: React.FC<KGOnePanelProps> = ({ isVisible }) => {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('fullsong');
|
||||
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'}`}>
|
||||
@@ -2239,16 +2578,17 @@ const KGOnePanel: React.FC<KGOnePanelProps> = ({ isVisible }) => {
|
||||
|
||||
<div className="kgone-tabs">
|
||||
{/* Clip tab temporarily disabled, will enable in the future */}
|
||||
{(['fullsong', 'remix', 'repaint', 'separator'] as const).map(tab => (
|
||||
{KGONE_TABS.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`kgone-tab${activeTab === tab ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`kgone-tab${activeTab === tab ? ' active' : ''}${disabledTabs.has(tab) ? ' is-disabled' : ''}`}
|
||||
onClick={() => {
|
||||
if (disabledTabs.has(tab)) return;
|
||||
setActiveTab(tab);
|
||||
}}
|
||||
disabled={disabledTabs.has(tab)}
|
||||
>
|
||||
{tab === 'fullsong' ? 'Full Song'
|
||||
: tab === 'remix' ? 'Remix'
|
||||
: tab === 'repaint' ? 'Repaint'
|
||||
: 'Separator'}
|
||||
{formatKGOneTabLabel(tab)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -2259,7 +2599,7 @@ const KGOnePanel: React.FC<KGOnePanelProps> = ({ isVisible }) => {
|
||||
{activeTab === 'fullsong' && <FullSongTab />}
|
||||
{activeTab === 'remix' && <RemixTab />}
|
||||
{activeTab === 'repaint' && <RepaintTab />}
|
||||
{activeTab === 'separator' && <SeparatorTab />}
|
||||
{activeTab === 'separator' && <SeparatorTab mode={mode} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -961,8 +961,6 @@ const Toolbar: React.FC = () => {
|
||||
};
|
||||
|
||||
// K.G.One panel toggle
|
||||
const isKGOneEnabled = ConfigManager.instance().get('general.kgone.enabled') as boolean ?? false;
|
||||
|
||||
const handleKGOneClick = () => {
|
||||
if (DEBUG_MODE.TOOLBAR) {
|
||||
console.log("K.G.One button clicked");
|
||||
@@ -1241,11 +1239,9 @@ const Toolbar: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
title={isKGOneEnabled ? 'K.G.One Music Generator' : 'K.G.One integration is disabled — enable it in Settings'}
|
||||
title="K.G.One Music Generator"
|
||||
onClick={handleKGOneClick}
|
||||
disabled={!isKGOneEnabled}
|
||||
className={showKGOnePanel ? 'active' : ''}
|
||||
style={!isKGOneEnabled ? { opacity: 0.4, cursor: 'not-allowed' } : undefined}
|
||||
>
|
||||
<FaWandMagicSparkles />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user