From 2140e944f234725b7b7f68e7fe665bcf0627adf8 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Wed, 27 May 2026 20:17:17 -0700 Subject: [PATCH] feat: added local separator model `demucs_4s` support --- LICENSE | 102 +++++++++ README.md | 2 +- package-lock.json | 13 ++ package.json | 1 + public/config.json | 3 +- src/components/KGOnePanel.test.tsx | 67 ++++-- src/components/KGOnePanel.tsx | 92 +++++--- .../sections/GeneralSettings.test.tsx | 15 ++ .../settings/sections/GeneralSettings.tsx | 90 +++++++- src/core/config/ConfigManager.ts | 4 +- src/core/io/LocalSeparatorModelCache.test.ts | 68 +++--- src/types/demucs-web.d.ts | 35 +++ src/util/local-separator/config.ts | 101 ++++++--- src/util/local-separator/cpuDsp.ts | 4 +- src/util/local-separator/gpuDsp.ts | 6 +- src/util/local-separator/modelCache.ts | 29 ++- src/util/local-separator/runner.ts | 209 +++++++++++++++++- src/util/local-separator/types.ts | 31 ++- 18 files changed, 723 insertions(+), 149 deletions(-) create mode 100644 src/types/demucs-web.d.ts diff --git a/LICENSE b/LICENSE index f60cee5..e03c9b6 100644 --- a/LICENSE +++ b/LICENSE @@ -328,4 +328,106 @@ SOFTWARE. --- +### 11. htdemucs_4s + +This project uses the htdemucs_4s model from the Demucs project by Facebook Research, +used for music source separation (stem extraction). + +Original project: https://github.com/facebookresearch/demucs +Model weights (original, PyTorch): https://dl.fbaipublicfiles.com/demucs/hybrid_transformer/955717e8-8726e21a.th +ONNX conversion (original): https://huggingface.co/timcsy/demucs-web-onnx/resolve/main/htdemucs_embedded.onnx +ONNX model downloaded by this project (unmodified copy of the above): https://huggingface.co/notabilia/uvr5-models/resolve/main/htdemucs_embedded.onnx + +``` +MIT License + +Copyright (c) Facebook, Inc. and its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this model and associated documentation files, to deal in the model without +restriction, including without limitation the rights to use, copy, modify, +merge, publish, distribute, sublicense, and/or sell copies of the model, and +to permit persons to whom the model is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the model. + +THE MODEL IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE MODEL OR THE USE OR OTHER DEALINGS IN THE +MODEL. +``` + +--- + +### 12. onnxruntime-web + +onnxruntime-web is a JavaScript library for running ONNX models in the browser, +used for on-device neural network inference. + +Original project: https://github.com/microsoft/onnxruntime + +``` +MIT License + +Copyright (c) Microsoft Corporation. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +--- + +### 13. demucs-web + +demucs-web is a browser-based wrapper for running Demucs stem separation models +in the browser via ONNX Runtime Web. + +Original project: https://github.com/timcsy/demucs-web + +``` +MIT License + +Copyright (c) 2024 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +--- + Apache License Version 2.0, January 2004: http://www.apache.org/licenses/LICENSE-2.0 \ No newline at end of file diff --git a/README.md b/README.md index bce81c8..d1a82a6 100644 --- a/README.md +++ b/README.md @@ -391,4 +391,4 @@ Licensed under the Apache License, Version 2.0, with additional terms (see `LICE - No patent applications using this software or assets - Attribution required when used in public/commercial products (“Powered by K.G.Studio”) -Third‑party notices (FluidR3_GM SoundFont, midi‑js‑soundfonts, VexFlow, prompt structure notes, Gemma 4 E4B, UVR-MDX-NET-Inst_HQ_3, MediaPipe, Meyda, web-audio-beat-detector, and tonal) are included in `LICENSE`. +Third‑party notices (FluidR3_GM SoundFont, midi‑js‑soundfonts, VexFlow, prompt structure notes, Gemma 4 E4B, UVR-MDX-NET-Inst_HQ_3, MediaPipe, Meyda, web-audio-beat-detector, tonal, htdemucs_4s, onnxruntime-web, and demucs-web) are included in `LICENSE`. diff --git a/package-lock.json b/package-lock.json index 713c808..d93a3d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@breezystack/lamejs": "^1.2.7", "class-transformer": "^0.5.1", + "demucs-web": "^1.0.2", "fft.js": "^4.0.4", "idb": "^8.0.3", "jszip": "^3.10.1", @@ -4440,6 +4441,18 @@ "dev": true, "license": "MIT" }, + "node_modules/demucs-web": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/demucs-web/-/demucs-web-1.0.2.tgz", + "integrity": "sha512-G/HWbbBDvfGzaxDZQhqTp4+6i8nlCIRGEd6E1+EZJJONx5Es3bBc+jHqhfyblTlsKo/7cAdylA8HfetBpbQ98w==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "onnxruntime-web": ">=1.17.0" + } + }, "node_modules/deprecation": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", diff --git a/package.json b/package.json index 7078c56..ef90b2a 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dependencies": { "@breezystack/lamejs": "^1.2.7", "class-transformer": "^0.5.1", + "demucs-web": "^1.0.2", "fft.js": "^4.0.4", "idb": "^8.0.3", "jszip": "^3.10.1", diff --git a/public/config.json b/public/config.json index 3d55123..5dd4168 100644 --- a/public/config.json +++ b/public/config.json @@ -31,7 +31,8 @@ "model_url": "https://huggingface.co/notabilia/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.task" }, "uvr5_web_runtime": { - "mdx_net_model_url": "https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx" + "mdx_net_model_url": "https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx", + "htdemucs_4s_model_url": "https://huggingface.co/notabilia/uvr5-models/resolve/main/htdemucs_embedded.onnx" }, "soundfont": { "base_url": "https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/" diff --git a/src/components/KGOnePanel.test.tsx b/src/components/KGOnePanel.test.tsx index 23f04c7..74a86d7 100644 --- a/src/components/KGOnePanel.test.tsx +++ b/src/components/KGOnePanel.test.tsx @@ -4,6 +4,7 @@ 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'; +import { LOCAL_SEPARATOR_MODEL_IDS } from '../util/local-separator/config'; const { mockLocalSeparatorDownload } = vi.hoisted(() => ({ mockLocalSeparatorDownload: vi.fn(async (_url?: string, _filename?: string, _onProgress?: unknown) => undefined), @@ -11,7 +12,7 @@ const { mockLocalSeparatorDownload } = vi.hoisted(() => ({ let kgoneEnabled = false; let selectedRegionIds: string[] = []; -let localModelCached = false; +let localModelCached: Record = {}; let localSeparationResult: Array<{ name: string; blob: Blob }> = []; const mockRefreshProjectState = vi.fn(); @@ -36,6 +37,7 @@ vi.mock('../core/config/ConfigManager', () => ({ if (key === 'general.kgone.enabled') return kgoneEnabled; if (key === 'general.kgone.base_url') return 'http://127.0.0.1:8000'; if (key === 'general.uvr5_web_runtime.mdx_net_model_url') return 'https://example.com/custom-uvr5.onnx'; + if (key === 'general.uvr5_web_runtime.htdemucs_4s_model_url') return 'https://example.com/custom-htdemucs.onnx'; return undefined; }, }), @@ -83,13 +85,13 @@ vi.mock('../util/audioUtil', () => ({ vi.mock('../util/local-separator/modelCache', () => ({ LocalSeparatorModelCache: { - exists: vi.fn(async () => localModelCached), - download: vi.fn(async (url: string, filename: string, onProgress: (progress: unknown) => void) => { - localModelCached = true; - return mockLocalSeparatorDownload(url, filename, onProgress); + exists: vi.fn(async (modelConfig: { id: string }) => localModelCached[modelConfig.id] ?? false), + download: vi.fn(async (modelConfig: { id: string; filename: string }, url: string, onProgress: (progress: unknown) => void) => { + localModelCached[modelConfig.id] = true; + return mockLocalSeparatorDownload(modelConfig.filename, url, onProgress); }), - delete: vi.fn(async () => { - localModelCached = false; + delete: vi.fn(async (modelConfig: { id: string }) => { + localModelCached[modelConfig.id] = false; }), getArrayBuffer: vi.fn(async () => new ArrayBuffer(16)), }, @@ -125,7 +127,7 @@ describe('KGOnePanel local separator mode', () => { beforeEach(() => { kgoneEnabled = false; selectedRegionIds = []; - localModelCached = false; + localModelCached = {}; localSeparationResult = [ { name: 'Instrumental', blob: new Blob(['instrumental'], { type: 'audio/wav' }) }, { name: 'Vocals', blob: new Blob(['vocals'], { type: 'audio/wav' }) }, @@ -143,41 +145,42 @@ describe('KGOnePanel local separator mode', () => { 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(); + expect(screen.getByRole('button', { name: 'Download Selected Model' })).toBeInTheDocument(); }); it('shows the single local separator model and advanced settings when the model is cached', async () => { - localModelCached = true; + localModelCached[LOCAL_SEPARATOR_MODEL_IDS.mdxMedium] = true; selectedRegionIds = ['audio-region-1']; render(); await screen.findByText('Selected Region'); const options = await screen.findAllByRole('option'); - expect(options).toHaveLength(1); + expect(options).toHaveLength(2); expect(options[0]).toHaveTextContent('Vocal and Instrument (Medium Accuracy)'); + expect(options[1]).toHaveTextContent('Vocal, Drums, Bass, and Others'); fireEvent.click(screen.getByRole('button', { name: /Advanced Settings/i })); expect(screen.getByLabelText('Optional audio chunk duration (seconds)')).toBeInTheDocument(); - expect(screen.getByLabelText('MDX overlap')).toBeInTheDocument(); + expect(screen.getByLabelText('Model overlap')).toBeInTheDocument(); }); it('uses the configured UVR5 model URL when downloading the local model', async () => { render(); - fireEvent.click(await screen.findByRole('button', { name: 'Download Model' })); + fireEvent.click(await screen.findByRole('button', { name: 'Download Selected Model' })); await waitFor(() => { expect(mockLocalSeparatorDownload).toHaveBeenCalledWith( - 'https://example.com/custom-uvr5.onnx', 'UVR-MDX-NET-Inst_HQ_3.onnx', + 'https://example.com/custom-uvr5.onnx', expect.any(Function), ); }); }); it('prompts for an audio region when the model is cached but nothing is selected', async () => { - localModelCached = true; + localModelCached[LOCAL_SEPARATOR_MODEL_IDS.mdxMedium] = true; render(); @@ -185,7 +188,7 @@ describe('KGOnePanel local separator mode', () => { }); it('renders local separation outputs after processing completes', async () => { - localModelCached = true; + localModelCached[LOCAL_SEPARATOR_MODEL_IDS.mdxMedium] = true; selectedRegionIds = ['audio-region-1']; render(); @@ -198,4 +201,36 @@ describe('KGOnePanel local separator mode', () => { expect(screen.getByRole('button', { name: 'Import All Stems to Timeline' })).toBeInTheDocument(); }); }); + + it('uses Demucs defaults and renders four local stem players', async () => { + localModelCached[LOCAL_SEPARATOR_MODEL_IDS.htdemucs4s] = true; + localSeparationResult = [ + { name: 'Vocals', blob: new Blob(['vocals'], { type: 'audio/wav' }) }, + { name: 'Drums', blob: new Blob(['drums'], { type: 'audio/wav' }) }, + { name: 'Bass', blob: new Blob(['bass'], { type: 'audio/wav' }) }, + { name: 'Others', blob: new Blob(['others'], { type: 'audio/wav' }) }, + ]; + selectedRegionIds = ['audio-region-1']; + + render(); + + await screen.findByText('Selected Region'); + fireEvent.change(screen.getByRole('combobox'), { target: { value: LOCAL_SEPARATOR_MODEL_IDS.htdemucs4s } }); + fireEvent.click(screen.getByRole('button', { name: /Advanced Settings/i })); + + await waitFor(() => { + expect((screen.getByLabelText('Optional audio chunk duration (seconds)') as HTMLInputElement).value).toBe('8'); + expect((screen.getByLabelText('Model overlap') as HTMLInputElement).value).toBe('0.25'); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Separate Stems' })); + + await waitFor(() => { + expect(screen.getByText('Vocals')).toBeInTheDocument(); + expect(screen.getByText('Drums')).toBeInTheDocument(); + expect(screen.getByText('Bass')).toBeInTheDocument(); + expect(screen.getByText('Others')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Import All Stems to Timeline' })).toBeInTheDocument(); + }); + }); }); diff --git a/src/components/KGOnePanel.tsx b/src/components/KGOnePanel.tsx index cf9386f..7c46099 100644 --- a/src/components/KGOnePanel.tsx +++ b/src/components/KGOnePanel.tsx @@ -16,13 +16,15 @@ 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, + getLocalSeparatorModelConfig, + LOCAL_SEPARATOR_MODELS, + LOCAL_SEPARATOR_MODEL_CONFIGS, + LOCAL_SEPARATOR_MODEL_IDS, } from '../util/local-separator/config'; import { LocalSeparatorModelCache } from '../util/local-separator/modelCache'; import { runLocalSeparator } from '../util/local-separator/runner'; import { LocalOrtRuntimeManager, detectLocalRuntimeSupport } from '../util/local-separator/runtime'; +import type { LocalSeparatorModelConfig, LocalSeparatorModelId } from '../util/local-separator/types'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -36,9 +38,10 @@ const SERVER_SEPARATOR_MODELS = [ { 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 LOCAL_SEPARATOR_MODEL_OPTIONS = LOCAL_SEPARATOR_MODELS.map(modelConfig => ({ + label: modelConfig.displayName, + value: modelConfig.id, +})) as ReadonlyArray<{ label: string; value: LocalSeparatorModelId }>; const KGONE_TABS = ['fullsong', 'remix', 'repaint', 'separator'] as const; const CLIP_NOTES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; @@ -862,8 +865,11 @@ function countRepaintTracks(sourceTrackName: string): number { 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(availableSeparatorModels[0].value); + const availableSeparatorModels = localOnlyMode ? LOCAL_SEPARATOR_MODEL_OPTIONS : SERVER_SEPARATOR_MODELS; + const [model, setModel] = useState(availableSeparatorModels[0].value); + const currentLocalModelConfig = useMemo(() => { + return getLocalSeparatorModelConfig(model); + }, [model]); // Generation state const [genStatus, setGenStatus] = useState('idle'); @@ -881,7 +887,7 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { 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 [localOverlap, setLocalOverlap] = useState(String(currentLocalModelConfig.defaults.overlap)); const abortRef = useRef(null); const taskIdRef = useRef(''); @@ -922,18 +928,28 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { setModel(availableSeparatorModels[0].value); }, [availableSeparatorModels]); + useEffect(() => { + if (!localOnlyMode) return; + setLocalChunkDurationSeconds( + currentLocalModelConfig.defaultChunkDurationSeconds == null + ? '' + : String(currentLocalModelConfig.defaultChunkDurationSeconds), + ); + setLocalOverlap(String(currentLocalModelConfig.defaults.overlap)); + }, [currentLocalModelConfig, localOnlyMode]); + const refreshLocalModelCacheState = useCallback(async () => { if (!localOnlyMode) return; setIsCheckingLocalModel(true); try { - setIsLocalModelCached(await LocalSeparatorModelCache.exists()); + setIsLocalModelCached(await LocalSeparatorModelCache.exists(currentLocalModelConfig)); } catch (err) { console.error('[KGOne] Local model cache check failed:', err); setErrorMsg(err instanceof Error ? err.message : String(err)); } finally { setIsCheckingLocalModel(false); } - }, [localOnlyMode]); + }, [currentLocalModelConfig, localOnlyMode]); useEffect(() => { if (!localOnlyMode) return; @@ -960,11 +976,11 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { }, [selectedRegionIds]); const getConfiguredLocalSeparatorModelUrl = useCallback(() => { - const configured = ConfigManager.instance().get('general.uvr5_web_runtime.mdx_net_model_url'); + const configured = ConfigManager.instance().get(currentLocalModelConfig.download.configKey); return typeof configured === 'string' && configured.trim() ? configured - : LOCAL_SEPARATOR_DEFAULT_MODEL_URL; - }, []); + : currentLocalModelConfig.download.defaultUrl; + }, [currentLocalModelConfig]); const isGenerating = genStatus !== 'idle' && genStatus !== 'done' && genStatus !== 'error'; @@ -972,25 +988,25 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { setIsDownloadingLocalModel(true); setErrorMsg(''); setLocalProgressPercent(0); - setLocalProgressText('Downloading local separator model...'); + setLocalProgressText(`Downloading ${currentLocalModelConfig.displayName}...`); try { await LocalSeparatorModelCache.download( + currentLocalModelConfig, 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`, + ? `Downloading ${currentLocalModelConfig.displayName}... ${receivedMb} / ${totalMb} MB` + : `Downloading ${currentLocalModelConfig.displayName}... ${receivedMb} MB`, ); }, ); setLocalProgressPercent(100); - setLocalProgressText('Local separator model is ready.'); + setLocalProgressText(`${currentLocalModelConfig.displayName} is ready.`); await refreshLocalModelCacheState(); } catch (err) { setLocalProgressPercent(0); @@ -999,13 +1015,13 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { } finally { setIsDownloadingLocalModel(false); } - }, [getConfiguredLocalSeparatorModelUrl, refreshLocalModelCacheState]); + }, [currentLocalModelConfig, getConfiguredLocalSeparatorModelUrl, refreshLocalModelCacheState]); const handleDeleteLocalModel = useCallback(async () => { setIsDeletingLocalModel(true); setErrorMsg(''); try { - await LocalSeparatorModelCache.delete(); + await LocalSeparatorModelCache.delete(currentLocalModelConfig); localRuntimeManagerRef.current?.reset(); setLocalProviderLabel(runtimeSupport.webgpuExposed ? 'webgpu available' : 'cpu/wasm only'); setLocalProgressPercent(0); @@ -1016,7 +1032,7 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { } finally { setIsDeletingLocalModel(false); } - }, [refreshLocalModelCacheState, runtimeSupport.webgpuExposed]); + }, [currentLocalModelConfig, refreshLocalModelCacheState, runtimeSupport.webgpuExposed]); const handleSeparateServer = useCallback(async () => { if (!selectedAudioRegion) return; @@ -1212,12 +1228,12 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { setLocalProviderLabel(runtimeSupport.webgpuExposed ? 'webgpu available' : 'cpu/wasm only'); try { - const modelBuffer = await LocalSeparatorModelCache.getArrayBuffer(); + const modelBuffer = await LocalSeparatorModelCache.getArrayBuffer(currentLocalModelConfig); 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)); + const runtime = await runtimeManager.ensureRuntime(currentLocalModelConfig, new Uint8Array(modelBuffer)); setGenStatus('generating'); setLocalProgressPercent(3); @@ -1247,10 +1263,10 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { const result = await runLocalSeparator({ session: runtime.session, runtimeProvider: runtime.provider, - modelConfig: LOCAL_SEPARATOR_MODEL_CONFIG, + modelConfig: currentLocalModelConfig, audioBuffer: inputBuffer, chunkDurationSeconds: Number.isFinite(chunkDuration) && (chunkDuration ?? 0) > 0 ? chunkDuration : null, - overlap: Number.isFinite(overlapValue) ? overlapValue : LOCAL_SEPARATOR_MODEL_CONFIG.defaults.overlap, + overlap: Number.isFinite(overlapValue) ? overlapValue : currentLocalModelConfig.defaults.overlap, onProviderChange: provider => setLocalProviderLabel(provider), onProgress: progress => { setLocalProgressPercent(progress.percent); @@ -1283,6 +1299,7 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { projectName, localChunkDurationSeconds, localOverlap, + currentLocalModelConfig, ]); const handleSeparate = useCallback(async () => { @@ -1381,14 +1398,19 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => {
Local Separator Mode
- 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.{' '} + If K.G.One Music Studio is unavailable, Local Separator Mode provides a built-in alternative for extracting stems + directly in your browser. Two local models are available: Vocal and Instrument (Medium Accuracy), and Vocal, + Drums, Bass, and Others. Download status below reflects the currently selected model. Vocal and Instrument + (Medium Accuracy) usually takes longer to process than Vocal, Drums, Bass, and Others, and total processing time + will still depend on your hardware. If processing falls back to CPU, the page may become temporarily less + responsive while separation is running.{' '} Learn more about K.G.One Music Studio server integration.
Provider: {localProviderLabel}
-
Model: {isLocalModelCached ? 'downloaded' : 'not downloaded'}
+
+ Model: {currentLocalModelConfig.displayName} ({isLocalModelCached ? 'downloaded' : 'not downloaded'}) +
{(localProgressText || isCheckingLocalModel) && (
@@ -1414,7 +1436,7 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { disabled={isCheckingLocalModel || isDownloadingLocalModel || isDeletingLocalModel || isGenerating} onClick={() => void handleDownloadLocalModel()} > - {isDownloadingLocalModel ? 'Downloading Model...' : 'Download Model'} + {isDownloadingLocalModel ? 'Downloading Model...' : 'Download Selected Model'} ) : ( <> @@ -1451,7 +1473,7 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => {
- setModel(e.target.value)}> {availableSeparatorModels.map(m => ( ))} @@ -1474,10 +1496,10 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { />
- + = ({ mode }) => { ) : (
{localOnlyMode && !isLocalModelCached - ? 'Download the local separator model, then select an audio region on the timeline to extract stems from it.' + ? `Download ${currentLocalModelConfig.displayName}, 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.'}
)} diff --git a/src/components/settings/sections/GeneralSettings.test.tsx b/src/components/settings/sections/GeneralSettings.test.tsx index 6f29bc0..8fb60be 100644 --- a/src/components/settings/sections/GeneralSettings.test.tsx +++ b/src/components/settings/sections/GeneralSettings.test.tsx @@ -29,6 +29,7 @@ const configState = new Map([ ['general.local_browser.context_length', 65536], ['general.local_browser.model_url', 'https://huggingface.co/notabilia/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.task'], ['general.uvr5_web_runtime.mdx_net_model_url', 'https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx'], + ['general.uvr5_web_runtime.htdemucs_4s_model_url', 'https://huggingface.co/notabilia/uvr5-models/resolve/main/htdemucs_embedded.onnx'], ['general.soundfont.base_url', 'https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/'], ['general.kgone.enabled', false], ['general.kgone.base_url', 'http://127.0.0.1:8000'], @@ -140,6 +141,7 @@ describe('GeneralSettings', () => { expect(await screen.findByDisplayValue('https://huggingface.co/notabilia/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.task')).toBeTruthy(); expect(screen.getByDisplayValue('https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx')).toBeTruthy(); + expect(screen.getByDisplayValue('https://huggingface.co/notabilia/uvr5-models/resolve/main/htdemucs_embedded.onnx')).toBeTruthy(); const inputs = screen.getAllByRole('textbox'); const gemmaUrlInput = inputs.find(input => @@ -148,22 +150,30 @@ describe('GeneralSettings', () => { const uvr5UrlInput = inputs.find(input => (input as HTMLInputElement).value.includes('UVR-MDX-NET-Inst_HQ_3.onnx'), ) as HTMLInputElement | undefined; + const htdemucsUrlInput = inputs.find(input => + (input as HTMLInputElement).value.includes('htdemucs_embedded.onnx'), + ) as HTMLInputElement | undefined; expect(gemmaUrlInput).toBeTruthy(); expect(uvr5UrlInput).toBeTruthy(); + expect(htdemucsUrlInput).toBeTruthy(); fireEvent.change(gemmaUrlInput!, { target: { value: 'https://example.com/gemma.task' } }); fireEvent.change(uvr5UrlInput!, { target: { value: 'https://example.com/uvr5.onnx' } }); + fireEvent.change(htdemucsUrlInput!, { target: { value: 'https://example.com/htdemucs.onnx' } }); await waitFor(() => { expect(configManagerMock.set).toHaveBeenCalledWith('general.local_browser.model_url', 'https://example.com/gemma.task'); expect(configManagerMock.set).toHaveBeenCalledWith('general.uvr5_web_runtime.mdx_net_model_url', 'https://example.com/uvr5.onnx'); + expect(configManagerMock.set).toHaveBeenCalledWith('general.uvr5_web_runtime.htdemucs_4s_model_url', 'https://example.com/htdemucs.onnx'); }); }); it('restores default download URLs and deletes the UVR5 model cache', async () => { localSeparatorModelCacheMock.exists .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) .mockResolvedValueOnce(false); render(); @@ -173,6 +183,7 @@ describe('GeneralSettings', () => { const restoreLinks = screen.getAllByText('Restore default'); fireEvent.click(restoreLinks[0]); fireEvent.click(restoreLinks[1]); + fireEvent.click(restoreLinks[2]); const uvr5DeleteButton = screen.getAllByRole('button', { name: 'Delete Cached Model' })[1]; expect(uvr5DeleteButton).not.toBeDisabled(); fireEvent.click(uvr5DeleteButton); @@ -186,6 +197,10 @@ describe('GeneralSettings', () => { 'general.uvr5_web_runtime.mdx_net_model_url', 'https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx', ); + expect(configManagerMock.set).toHaveBeenCalledWith( + 'general.uvr5_web_runtime.htdemucs_4s_model_url', + 'https://huggingface.co/notabilia/uvr5-models/resolve/main/htdemucs_embedded.onnx', + ); expect(localSeparatorModelCacheMock.delete).toHaveBeenCalled(); }); diff --git a/src/components/settings/sections/GeneralSettings.tsx b/src/components/settings/sections/GeneralSettings.tsx index 4fee891..6c90b45 100644 --- a/src/components/settings/sections/GeneralSettings.tsx +++ b/src/components/settings/sections/GeneralSettings.tsx @@ -12,7 +12,10 @@ import { normalizeLocalLLMContextLength, type LocalLLMContextLength, } from '../../../util/localLLMConfig'; -import { LOCAL_SEPARATOR_DEFAULT_MODEL_URL } from '../../../util/local-separator/config'; +import { + LOCAL_SEPARATOR_MODEL_CONFIGS, + LOCAL_SEPARATOR_MODEL_IDS, +} from '../../../util/local-separator/config'; const GeneralSettings: React.FC = () => { const [llmProvider, setLlmProvider] = useState(LOCAL_LLM_PROVIDER_KEY); @@ -39,9 +42,12 @@ const GeneralSettings: React.FC = () => { const [localModelState, setLocalModelState] = useState(LocalLLMModelManager.getState()); const [localModelUrl, setLocalModelUrl] = useState(''); const [uvr5ModelUrl, setUvr5ModelUrl] = useState(''); + const [htdemucsModelUrl, setHtdemucsModelUrl] = useState(''); const [isUvr5ModelCached, setIsUvr5ModelCached] = useState(false); const [isCheckingUvr5ModelCache, setIsCheckingUvr5ModelCache] = useState(false); const [isDeletingUvr5Model, setIsDeletingUvr5Model] = useState(false); + const [isHtdemucsModelCached, setIsHtdemucsModelCached] = useState(false); + const [isDeletingHtdemucsModel, setIsDeletingHtdemucsModel] = useState(false); const configManager = ConfigManager.instance(); @@ -62,10 +68,16 @@ const GeneralSettings: React.FC = () => { const refreshUvr5ModelCacheState = useCallback(async () => { setIsCheckingUvr5ModelCache(true); try { - setIsUvr5ModelCached(await LocalSeparatorModelCache.exists()); + const [mdxCached, demucsCached] = await Promise.all([ + LocalSeparatorModelCache.exists(LOCAL_SEPARATOR_MODEL_CONFIGS[LOCAL_SEPARATOR_MODEL_IDS.mdxMedium]), + LocalSeparatorModelCache.exists(LOCAL_SEPARATOR_MODEL_CONFIGS[LOCAL_SEPARATOR_MODEL_IDS.htdemucs4s]), + ]); + setIsUvr5ModelCached(mdxCached); + setIsHtdemucsModelCached(demucsCached); } catch (error) { console.error('Failed to check UVR5 cached model state:', error); setIsUvr5ModelCached(false); + setIsHtdemucsModelCached(false); } finally { setIsCheckingUvr5ModelCache(false); } @@ -95,7 +107,14 @@ const GeneralSettings: React.FC = () => { setCompatibleModel((configManager.get('general.openai_compatible.model') as string) || ''); setLocalContextLength(normalizeLocalLLMContextLength(configManager.get('general.local_browser.context_length'))); setLocalModelUrl((configManager.get('general.local_browser.model_url') as string) || LOCAL_LLM_DEFAULT_MODEL_URL); - setUvr5ModelUrl((configManager.get('general.uvr5_web_runtime.mdx_net_model_url') as string) || LOCAL_SEPARATOR_DEFAULT_MODEL_URL); + setUvr5ModelUrl( + (configManager.get('general.uvr5_web_runtime.mdx_net_model_url') as string) + || LOCAL_SEPARATOR_MODEL_CONFIGS[LOCAL_SEPARATOR_MODEL_IDS.mdxMedium].download.defaultUrl, + ); + setHtdemucsModelUrl( + (configManager.get('general.uvr5_web_runtime.htdemucs_4s_model_url') as string) + || LOCAL_SEPARATOR_MODEL_CONFIGS[LOCAL_SEPARATOR_MODEL_IDS.htdemucs4s].download.defaultUrl, + ); setSoundfontBaseUrl((configManager.get('general.soundfont.base_url') as string) || ''); setKgoneEnabled((configManager.get('general.kgone.enabled') as boolean) ?? false); setKgoneBaseUrl((configManager.get('general.kgone.base_url') as string) || ''); @@ -245,6 +264,11 @@ const GeneralSettings: React.FC = () => { debouncedSave('general.uvr5_web_runtime.mdx_net_model_url', value); }; + const handleHtdemucsModelUrlChange = (value: string) => { + setHtdemucsModelUrl(value); + debouncedSave('general.uvr5_web_runtime.htdemucs_4s_model_url', value); + }; + const handleDeleteLocalModel = async () => { try { await LocalLLMModelManager.deleteCachedModel(); @@ -256,7 +280,7 @@ const GeneralSettings: React.FC = () => { const handleDeleteUvr5Model = async () => { setIsDeletingUvr5Model(true); try { - await LocalSeparatorModelCache.delete(); + await LocalSeparatorModelCache.delete(LOCAL_SEPARATOR_MODEL_CONFIGS[LOCAL_SEPARATOR_MODEL_IDS.mdxMedium]); setIsUvr5ModelCached(false); } catch (error) { console.error('Failed to delete UVR5 cached model:', error); @@ -266,6 +290,19 @@ const GeneralSettings: React.FC = () => { } }; + const handleDeleteHtdemucsModel = async () => { + setIsDeletingHtdemucsModel(true); + try { + await LocalSeparatorModelCache.delete(LOCAL_SEPARATOR_MODEL_CONFIGS[LOCAL_SEPARATOR_MODEL_IDS.htdemucs4s]); + setIsHtdemucsModelCached(false); + } catch (error) { + console.error('Failed to delete HTDemucs cached model:', error); + } finally { + setIsDeletingHtdemucsModel(false); + await refreshUvr5ModelCacheState(); + } + }; + const handleLocalContextLengthChange = async (value: string) => { const parsed = Number(value); const normalized = normalizeLocalLLMContextLength(parsed); @@ -448,7 +485,7 @@ const GeneralSettings: React.FC = () => { handleUvr5ModelUrlChange(e.target.value)} /> @@ -458,7 +495,9 @@ const GeneralSettings: React.FC = () => { href="#" onClick={(e) => { e.preventDefault(); - handleUvr5ModelUrlChange(LOCAL_SEPARATOR_DEFAULT_MODEL_URL); + handleUvr5ModelUrlChange( + LOCAL_SEPARATOR_MODEL_CONFIGS[LOCAL_SEPARATOR_MODEL_IDS.mdxMedium].download.defaultUrl, + ); }} style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }} > @@ -477,6 +516,45 @@ const GeneralSettings: React.FC = () => { {isDeletingUvr5Model ? 'Deleting...' : 'Delete Cached Model'}
+ +
+ + handleHtdemucsModelUrlChange(e.target.value)} + /> + +
+ +
+ +
diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index 9b08356..10dd957 100644 --- a/src/core/config/ConfigManager.ts +++ b/src/core/config/ConfigManager.ts @@ -14,6 +14,7 @@ interface AppConfig { }; uvr5_web_runtime: { mdx_net_model_url: string; + htdemucs_4s_model_url: string; }; openai: { api_key: string; @@ -224,7 +225,8 @@ export class ConfigManager { model_url: 'https://huggingface.co/notabilia/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.task' }, uvr5_web_runtime: { - mdx_net_model_url: 'https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx' + mdx_net_model_url: 'https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx', + htdemucs_4s_model_url: 'https://huggingface.co/notabilia/uvr5-models/resolve/main/htdemucs_embedded.onnx', }, soundfont: { base_url: 'https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/' diff --git a/src/core/io/LocalSeparatorModelCache.test.ts b/src/core/io/LocalSeparatorModelCache.test.ts index 8429f50..6b66620 100644 --- a/src/core/io/LocalSeparatorModelCache.test.ts +++ b/src/core/io/LocalSeparatorModelCache.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { LocalSeparatorModelCache } from '../../util/local-separator/modelCache'; import { - LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES, - LOCAL_SEPARATOR_MODEL_FILENAME, + LOCAL_SEPARATOR_MODEL_CONFIGS, + LOCAL_SEPARATOR_MODEL_IDS, } from '../../util/local-separator/config'; class MockWritableFileStream { @@ -113,7 +113,9 @@ vi.stubGlobal('navigator', { }); describe('LocalSeparatorModelCache', () => { - const makeModelBytes = (fill: number): Uint8Array => new Uint8Array(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES).fill(fill); + const mdxConfig = LOCAL_SEPARATOR_MODEL_CONFIGS[LOCAL_SEPARATOR_MODEL_IDS.mdxMedium]; + const demucsConfig = LOCAL_SEPARATOR_MODEL_CONFIGS[LOCAL_SEPARATOR_MODEL_IDS.htdemucs4s]; + const makeModelBytes = (size: number, fill: number): Uint8Array => new Uint8Array(size).fill(fill); beforeEach(() => { mockRoot.clear(); @@ -121,64 +123,64 @@ describe('LocalSeparatorModelCache', () => { }); it('downloads and stores a model in OPFS cache', async () => { - const bytes = makeModelBytes(1); + const bytes = makeModelBytes(mdxConfig.download.expectedSizeBytes, 1); vi.stubGlobal('fetch', vi.fn(async () => new Response(bytes, { status: 200, - headers: { 'Content-Length': String(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES) }, + headers: { 'Content-Length': String(mdxConfig.download.expectedSizeBytes) }, }))); - await LocalSeparatorModelCache.download('https://example.com/model.onnx'); + await LocalSeparatorModelCache.download(mdxConfig, 'https://example.com/model.onnx'); - expect(await LocalSeparatorModelCache.exists()).toBe(true); - const buffer = await LocalSeparatorModelCache.getArrayBuffer(); - expect(buffer.byteLength).toBe(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES); + expect(await LocalSeparatorModelCache.exists(mdxConfig)).toBe(true); + const buffer = await LocalSeparatorModelCache.getArrayBuffer(mdxConfig); + expect(buffer.byteLength).toBe(mdxConfig.download.expectedSizeBytes); expect(new Uint8Array(buffer)[0]).toBe(1); }); it('replaces a broken cached file on redownload', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response(makeModelBytes(1), { + vi.stubGlobal('fetch', vi.fn(async () => new Response(makeModelBytes(mdxConfig.download.expectedSizeBytes, 1), { status: 200, - headers: { 'Content-Length': String(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES) }, + headers: { 'Content-Length': String(mdxConfig.download.expectedSizeBytes) }, }))); - await LocalSeparatorModelCache.download('https://example.com/model.onnx'); + await LocalSeparatorModelCache.download(mdxConfig, 'https://example.com/model.onnx'); - vi.stubGlobal('fetch', vi.fn(async () => new Response(makeModelBytes(9), { + vi.stubGlobal('fetch', vi.fn(async () => new Response(makeModelBytes(mdxConfig.download.expectedSizeBytes, 9), { status: 200, - headers: { 'Content-Length': String(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES) }, + headers: { 'Content-Length': String(mdxConfig.download.expectedSizeBytes) }, }))); - await LocalSeparatorModelCache.download('https://example.com/model.onnx'); + await LocalSeparatorModelCache.download(mdxConfig, 'https://example.com/model.onnx'); - const buffer = await LocalSeparatorModelCache.getArrayBuffer(); - expect(buffer.byteLength).toBe(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES); + const buffer = await LocalSeparatorModelCache.getArrayBuffer(mdxConfig); + expect(buffer.byteLength).toBe(mdxConfig.download.expectedSizeBytes); expect(new Uint8Array(buffer)[0]).toBe(9); }); it('deletes the cached model file', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response(makeModelBytes(2), { + vi.stubGlobal('fetch', vi.fn(async () => new Response(makeModelBytes(mdxConfig.download.expectedSizeBytes, 2), { status: 200, - headers: { 'Content-Length': String(LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES) }, + headers: { 'Content-Length': String(mdxConfig.download.expectedSizeBytes) }, }))); - await LocalSeparatorModelCache.download('https://example.com/model.onnx'); + await LocalSeparatorModelCache.download(mdxConfig, 'https://example.com/model.onnx'); - await LocalSeparatorModelCache.delete(); + await LocalSeparatorModelCache.delete(mdxConfig); - expect(await LocalSeparatorModelCache.exists()).toBe(false); + expect(await LocalSeparatorModelCache.exists(mdxConfig)).toBe(false); }); it('rejects and deletes a cached file when the size is wrong', async () => { const dir = await navigator.storage.getDirectory(); const modelsDir = await dir.getDirectoryHandle('models', { create: true }); - const fileHandle = await modelsDir.getFileHandle(LOCAL_SEPARATOR_MODEL_FILENAME, { create: true }); + const fileHandle = await modelsDir.getFileHandle(mdxConfig.filename, { create: true }); const fileWritable = await fileHandle.createWritable(); await fileWritable.write(new Uint8Array([1, 2, 3])); await fileWritable.close(); - const sizeHandle = await modelsDir.getFileHandle(`${LOCAL_SEPARATOR_MODEL_FILENAME}.size`, { create: true }); + const sizeHandle = await modelsDir.getFileHandle(`${mdxConfig.filename}.size`, { create: true }); const sizeWritable = await sizeHandle.createWritable(); await sizeWritable.write(String(3)); await sizeWritable.close(); - expect(await LocalSeparatorModelCache.exists()).toBe(false); + expect(await LocalSeparatorModelCache.exists(mdxConfig)).toBe(false); }); it('fails a download when the final size does not match the expected model size', async () => { @@ -187,7 +189,19 @@ describe('LocalSeparatorModelCache', () => { headers: { 'Content-Length': '3' }, }))); - await expect(LocalSeparatorModelCache.download('https://example.com/model.onnx')).rejects.toThrow(/size mismatch/i); - expect(await LocalSeparatorModelCache.exists()).toBe(false); + await expect(LocalSeparatorModelCache.download(mdxConfig, 'https://example.com/model.onnx')).rejects.toThrow(/size mismatch/i); + expect(await LocalSeparatorModelCache.exists(mdxConfig)).toBe(false); + }); + + it('tracks cached files independently per model', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(makeModelBytes(demucsConfig.download.expectedSizeBytes, 7), { + status: 200, + headers: { 'Content-Length': String(demucsConfig.download.expectedSizeBytes) }, + }))); + + await LocalSeparatorModelCache.download(demucsConfig, 'https://example.com/htdemucs.onnx'); + + expect(await LocalSeparatorModelCache.exists(demucsConfig)).toBe(true); + expect(await LocalSeparatorModelCache.exists(mdxConfig)).toBe(false); }); }); diff --git a/src/types/demucs-web.d.ts b/src/types/demucs-web.d.ts new file mode 100644 index 0000000..1f567ab --- /dev/null +++ b/src/types/demucs-web.d.ts @@ -0,0 +1,35 @@ +declare module 'demucs-web' { + export interface DemucsTrackOutput { + left: Float32Array; + right: Float32Array; + } + + export interface DemucsSeparationResult { + drums: DemucsTrackOutput; + bass: DemucsTrackOutput; + other: DemucsTrackOutput; + vocals: DemucsTrackOutput; + } + + export interface DemucsProgressInfo { + progress: number; + currentSegment: number; + totalSegments: number; + } + + export interface DemucsProcessorOptions { + ort: typeof import('onnxruntime-web/webgpu'); + modelPath?: string; + sessionOptions?: import('onnxruntime-web/webgpu').InferenceSession.SessionOptions; + onProgress?: (info: DemucsProgressInfo) => void; + onLog?: (phase: string, message: string) => void; + onDownloadProgress?: (loaded: number, total: number) => void; + } + + export class DemucsProcessor { + constructor(options?: DemucsProcessorOptions); + session: import('onnxruntime-web/webgpu').InferenceSession | null; + loadModel(pathOrBuffer?: string | ArrayBuffer): Promise; + separate(left: Float32Array, right: Float32Array): Promise; + } +} diff --git a/src/util/local-separator/config.ts b/src/util/local-separator/config.ts index 79c8d31..457ec55 100644 --- a/src/util/local-separator/config.ts +++ b/src/util/local-separator/config.ts @@ -1,32 +1,81 @@ -import type { LocalSeparatorModelConfig } from './types'; +import type { LocalSeparatorModelConfig, LocalSeparatorModelId } from './types'; -export const LOCAL_SEPARATOR_DEFAULT_MODEL_URL = - 'https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx'; +export const LOCAL_SEPARATOR_MODEL_IDS = { + mdxMedium: 'UVR-MDX-NET-Inst_HQ_3.onnx', + htdemucs4s: 'htdemucs_4s.onnx', +} as const satisfies Record; -export const LOCAL_SEPARATOR_MODEL_FILENAME = 'UVR-MDX-NET-Inst_HQ_3.onnx'; -export const LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES = 66759214; +export const LOCAL_SEPARATOR_DEFAULT_MODEL_ID = LOCAL_SEPARATOR_MODEL_IDS.mdxMedium; -export const LOCAL_SEPARATOR_MODEL_CONFIG: LocalSeparatorModelConfig = { - filename: LOCAL_SEPARATOR_MODEL_FILENAME, - displayName: 'Vocal and Instrument (Medium Accuracy)', - status: 'ready', - defaults: { - sampleRate: 44100, - hopLength: 1024, - segmentSize: 256, - overlap: 0.25, - batchSize: 1, - enableDenoise: false, - invertUsingSpec: false, - normalizationThreshold: 0.9, - amplificationThreshold: 0, - matchMixOverlap: 0.02, +export const LOCAL_SEPARATOR_MODEL_CONFIGS: Record = { + [LOCAL_SEPARATOR_MODEL_IDS.mdxMedium]: { + id: LOCAL_SEPARATOR_MODEL_IDS.mdxMedium, + filename: 'UVR-MDX-NET-Inst_HQ_3.onnx', + kind: 'mdx', + displayName: 'Vocal and Instrument (Medium Accuracy)', + status: 'ready', + outputStemNames: ['Instrumental', 'Vocals'], + defaultChunkDurationSeconds: null, + defaults: { + sampleRate: 44100, + hopLength: 1024, + segmentSize: 256, + overlap: 0.25, + batchSize: 1, + enableDenoise: false, + invertUsingSpec: false, + normalizationThreshold: 0.9, + amplificationThreshold: 0, + matchMixOverlap: 0.02, + }, + metadata: { + compensate: 1.021, + mdx_dim_f_set: 3072, + mdx_dim_t_set: 8, + mdx_n_fft_scale_set: 7680, + primary_stem: 'Instrumental', + }, + download: { + configKey: 'general.uvr5_web_runtime.mdx_net_model_url', + defaultUrl: 'https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx', + expectedSizeBytes: 66759214, + }, }, - metadata: { - compensate: 1.021, - mdx_dim_f_set: 3072, - mdx_dim_t_set: 8, - mdx_n_fft_scale_set: 7680, - primary_stem: 'Instrumental', + [LOCAL_SEPARATOR_MODEL_IDS.htdemucs4s]: { + id: LOCAL_SEPARATOR_MODEL_IDS.htdemucs4s, + filename: 'htdemucs_4s.onnx', + kind: 'demucs', + displayName: 'Vocal, Drums, Bass, and Others', + status: 'ready', + outputStemNames: ['Vocals', 'Drums', 'Bass', 'Others'], + defaultChunkDurationSeconds: 8, + defaults: { + sampleRate: 44100, + hopLength: 1024, + segmentSize: 256, + overlap: 0.25, + batchSize: 1, + enableDenoise: false, + invertUsingSpec: false, + normalizationThreshold: 0.9, + amplificationThreshold: 0, + matchMixOverlap: 0.02, + }, + metadata: null, + download: { + configKey: 'general.uvr5_web_runtime.htdemucs_4s_model_url', + defaultUrl: 'https://huggingface.co/notabilia/uvr5-models/resolve/main/htdemucs_embedded.onnx', + expectedSizeBytes: 180534758, + }, }, }; + +export const LOCAL_SEPARATOR_MODELS = Object.values(LOCAL_SEPARATOR_MODEL_CONFIGS); + +export function getLocalSeparatorModelConfig(modelId: string): LocalSeparatorModelConfig { + const config = LOCAL_SEPARATOR_MODEL_CONFIGS[modelId as LocalSeparatorModelId]; + if (!config) { + return LOCAL_SEPARATOR_MODEL_CONFIGS[LOCAL_SEPARATOR_DEFAULT_MODEL_ID]; + } + return config; +} diff --git a/src/util/local-separator/cpuDsp.ts b/src/util/local-separator/cpuDsp.ts index b69d20e..2f59938 100644 --- a/src/util/local-separator/cpuDsp.ts +++ b/src/util/local-separator/cpuDsp.ts @@ -1,4 +1,4 @@ -import type { LocalSeparatorModelConfig, StereoChannels } from './types'; +import type { LocalSeparatorMdxModelConfig, StereoChannels } from './types'; import { FFT, createWindowCache, getHannPeriodic, index4d, reflectPad } from './shared'; interface SpectrogramPayload { @@ -18,7 +18,7 @@ export class LocalSeparatorCpuDsp { private readonly numFreqBins: number; private readonly inverseFft: FFT; - constructor(config: LocalSeparatorModelConfig) { + constructor(config: LocalSeparatorMdxModelConfig) { this.nFft = config.metadata.mdx_n_fft_scale_set; this.hopLength = config.defaults.hopLength; this.dimF = config.metadata.mdx_dim_f_set; diff --git a/src/util/local-separator/gpuDsp.ts b/src/util/local-separator/gpuDsp.ts index 56d6909..1f636c3 100644 --- a/src/util/local-separator/gpuDsp.ts +++ b/src/util/local-separator/gpuDsp.ts @@ -1,4 +1,4 @@ -import type { LocalSeparatorModelConfig } from './types'; +import type { LocalSeparatorMdxModelConfig } from './types'; import { LocalSeparatorCpuDsp } from './cpuDsp'; import { reflectPad } from './shared'; @@ -140,7 +140,7 @@ export class LocalSeparatorGpuDsp { private paramBuffer: GPUBufferLike | null = null; private readonly pipeline: GPUComputePipelineLike; - public static async create(config: LocalSeparatorModelConfig): Promise { + public static async create(config: LocalSeparatorMdxModelConfig): Promise { if (!('gpu' in navigator)) { throw new Error('WebGPU is not available for GPU DSP.'); } @@ -164,7 +164,7 @@ export class LocalSeparatorGpuDsp { return new LocalSeparatorGpuDsp(config, device); } - private constructor(config: LocalSeparatorModelConfig, device: GPUDeviceLike) { + private constructor(config: LocalSeparatorMdxModelConfig, device: GPUDeviceLike) { this.device = device; this.cpuDsp = new LocalSeparatorCpuDsp(config); this.nFft = config.metadata.mdx_n_fft_scale_set; diff --git a/src/util/local-separator/modelCache.ts b/src/util/local-separator/modelCache.ts index 96dc940..de9d6b3 100644 --- a/src/util/local-separator/modelCache.ts +++ b/src/util/local-separator/modelCache.ts @@ -1,7 +1,4 @@ -import { - LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES, - LOCAL_SEPARATOR_MODEL_FILENAME, -} from './config'; +import type { LocalSeparatorModelConfig } from './types'; import { OpfsModelCache, type ModelDownloadProgress } from '../opfsModelCache'; const cache = new OpfsModelCache({ directoryName: 'models' }); @@ -9,34 +6,34 @@ const cache = new OpfsModelCache({ directoryName: 'models' }); export { type ModelDownloadProgress }; export class LocalSeparatorModelCache { - public static async exists(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise { - return cache.exists(filename, { - expectedSizeBytes: LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES, + public static async exists(modelConfig: LocalSeparatorModelConfig): Promise { + return cache.exists(modelConfig.filename, { + expectedSizeBytes: modelConfig.download.expectedSizeBytes, }); } - public static async getFile(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise { - return cache.getFile(filename); + public static async getFile(modelConfig: LocalSeparatorModelConfig): Promise { + return cache.getFile(modelConfig.filename); } - public static async getArrayBuffer(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise { - return cache.getArrayBuffer(filename); + public static async getArrayBuffer(modelConfig: LocalSeparatorModelConfig): Promise { + return cache.getArrayBuffer(modelConfig.filename); } - public static async delete(filename: string = LOCAL_SEPARATOR_MODEL_FILENAME): Promise { - await cache.delete(filename); + public static async delete(modelConfig: LocalSeparatorModelConfig): Promise { + await cache.delete(modelConfig.filename); } public static async download( + modelConfig: LocalSeparatorModelConfig, sourceUrl: string, - filename: string = LOCAL_SEPARATOR_MODEL_FILENAME, onProgress?: (progress: ModelDownloadProgress) => void, ): Promise { await cache.download( sourceUrl, - filename, + modelConfig.filename, { - expectedSizeBytes: LOCAL_SEPARATOR_MODEL_EXPECTED_SIZE_BYTES, + expectedSizeBytes: modelConfig.download.expectedSizeBytes, }, onProgress, ); diff --git a/src/util/local-separator/runner.ts b/src/util/local-separator/runner.ts index 916b5ec..df2b913 100644 --- a/src/util/local-separator/runner.ts +++ b/src/util/local-separator/runner.ts @@ -1,10 +1,13 @@ import * as ort from 'onnxruntime-web/webgpu'; +import { DemucsProcessor } from 'demucs-web'; import { LocalSeparatorCpuDsp } from './cpuDsp'; import { LocalSeparatorGpuDsp } from './gpuDsp'; import { LocalSeparatorTimingCollector } from './timing'; import type { LocalRuntimeProvider, + LocalSeparatorDemucsModelConfig, LocalSeparatorModelConfig, + LocalSeparatorMdxModelConfig, LocalSeparatorProgress, StereoChannels, } from './types'; @@ -76,8 +79,8 @@ function unpackBatchOutput(outputData: Float32Array, batchInfo: { dims: number[] class BrowserMdxSeparator { private readonly session: ort.InferenceSession; private readonly runtimeProvider: LocalRuntimeProvider; - private readonly defaults: LocalSeparatorModelConfig['defaults']; - private readonly metadata: LocalSeparatorModelConfig['metadata']; + private readonly defaults: LocalSeparatorMdxModelConfig['defaults']; + private readonly metadata: LocalSeparatorMdxModelConfig['metadata']; public onProgress: (progress: LocalSeparatorProgress) => void; private overlap: number; private runtimeBatchSize: number; @@ -97,7 +100,7 @@ class BrowserMdxSeparator { public static async create( session: ort.InferenceSession, runtimeProvider: LocalRuntimeProvider, - config: LocalSeparatorModelConfig, + config: LocalSeparatorMdxModelConfig, options: BrowserMdxSeparatorOptions = {}, ): Promise { const timing = options.timing ?? new LocalSeparatorTimingCollector('mdx-separation'); @@ -139,7 +142,7 @@ class BrowserMdxSeparator { private constructor( session: ort.InferenceSession, runtimeProvider: LocalRuntimeProvider, - config: LocalSeparatorModelConfig, + config: LocalSeparatorMdxModelConfig, options: BrowserMdxSeparatorOptions & { dsp: LocalSeparatorCpuDsp | LocalSeparatorGpuDsp; dspMode: 'cpu' | 'gpu-hybrid'; @@ -470,10 +473,24 @@ function concatChannelPairs(chunks: StereoChannels[]): StereoChannels { return [concatFloat32(chunks.map(chunk => chunk[0])), concatFloat32(chunks.map(chunk => chunk[1]))]; } -export async function runLocalSeparator(options: { +function getProviderLabel(runtimeProvider: LocalRuntimeProvider): string { + return runtimeProvider === 'webgpu' ? 'GPU/WebGPU' : 'CPU/wasm'; +} + +function getChunkDurationSeconds( + requestedChunkDurationSeconds: number | null, + modelConfig: LocalSeparatorModelConfig, +): number | null { + if (requestedChunkDurationSeconds != null) { + return requestedChunkDurationSeconds; + } + return modelConfig.defaultChunkDurationSeconds; +} + +async function runMdxLocalSeparator(options: { session: ort.InferenceSession; runtimeProvider: LocalRuntimeProvider; - modelConfig: LocalSeparatorModelConfig; + modelConfig: LocalSeparatorMdxModelConfig; audioBuffer: ArrayBuffer; chunkDurationSeconds: number | null; overlap: number; @@ -486,7 +503,7 @@ export async function runLocalSeparator(options: { }> { const timing = new LocalSeparatorTimingCollector('local-separation'); const decoded = await timing.measureAsync('decode', () => decodeAudioToStereo(options.audioBuffer)); - log(`Running browser MDX separation on ${options.runtimeProvider === 'webgpu' ? 'GPU/WebGPU' : 'CPU/wasm'}...`); + log(`Running browser MDX separation on ${getProviderLabel(options.runtimeProvider)}...`); const separator = await BrowserMdxSeparator.create( options.session, @@ -502,11 +519,11 @@ export async function runLocalSeparator(options: { ); try { - const outputs = await separateWithOptionalChunking( + const outputs = await separateMdxWithOptionalChunking( separator, decoded, timing, - options.chunkDurationSeconds, + getChunkDurationSeconds(options.chunkDurationSeconds, options.modelConfig), options.modelConfig, options.onProgress, ); @@ -518,7 +535,7 @@ export async function runLocalSeparator(options: { { name: outputs.primaryStem, blob: primaryBlob }, { name: outputs.secondaryStem, blob: secondaryBlob }, ], - providerLabel: options.runtimeProvider === 'webgpu' ? 'GPU/WebGPU' : 'CPU/wasm', + providerLabel: getProviderLabel(options.runtimeProvider), debugSummary: separator.getDebugSummary({ model: options.modelConfig.filename }), }; } finally { @@ -527,12 +544,12 @@ export async function runLocalSeparator(options: { } } -async function separateWithOptionalChunking( +async function separateMdxWithOptionalChunking( separator: BrowserMdxSeparator, decoded: StereoChannels, timing: LocalSeparatorTimingCollector, chunkDurationSeconds: number | null, - modelConfig: LocalSeparatorModelConfig, + modelConfig: LocalSeparatorMdxModelConfig, onProgress: (progress: LocalSeparatorProgress) => void, ): Promise<{ stems: Record; @@ -594,3 +611,171 @@ async function separateWithOptionalChunking( secondaryStem, }; } + +type DemucsStemName = 'Vocals' | 'Drums' | 'Bass' | 'Others'; + +function toDemucsStemMap(result: Awaited>): Record { + return { + Vocals: [result.vocals.left, result.vocals.right], + Drums: [result.drums.left, result.drums.right], + Bass: [result.bass.left, result.bass.right], + Others: [result.other.left, result.other.right], + }; +} + +async function runDemucsLocalSeparator(options: { + session: ort.InferenceSession; + runtimeProvider: LocalRuntimeProvider; + modelConfig: LocalSeparatorDemucsModelConfig; + audioBuffer: ArrayBuffer; + chunkDurationSeconds: number | null; + onProgress: (progress: LocalSeparatorProgress) => void; +}): Promise<{ + stems: Array<{ name: string; blob: Blob }>; + providerLabel: string; + debugSummary: Record; +}> { + const timing = new LocalSeparatorTimingCollector('local-demucs-separation'); + const decoded = await timing.measureAsync('decode', () => decodeAudioToStereo(options.audioBuffer)); + log(`Running browser Demucs separation on ${getProviderLabel(options.runtimeProvider)}...`); + + const processor = new DemucsProcessor({ + ort, + onProgress: ({ progress, currentSegment, totalSegments }) => { + options.onProgress({ + stage: 'main', + passLabel: 'Demucs pass', + percent: progress * 100, + processedChunks: currentSegment, + totalChunks: totalSegments, + }); + }, + onLog: (phase, message) => log(`demucs:${phase}`, message), + }); + processor.session = options.session; + + const outputMap = await separateDemucsWithOptionalChunking( + processor, + decoded, + timing, + getChunkDurationSeconds(options.chunkDurationSeconds, options.modelConfig), + options.modelConfig, + options.onProgress, + ); + + const stems = options.modelConfig.outputStemNames.map(name => ({ + name, + blob: timing.measureSync(`wavEncode:${name}`, () => channelsToWavBlob(outputMap[name as DemucsStemName])), + })); + + return { + stems, + providerLabel: getProviderLabel(options.runtimeProvider), + debugSummary: timing.getSummary({ + model: options.modelConfig.filename, + runtimeProvider: options.runtimeProvider, + stemCount: stems.length, + }), + }; +} + +async function separateDemucsWithOptionalChunking( + processor: DemucsProcessor, + decoded: StereoChannels, + timing: LocalSeparatorTimingCollector, + chunkDurationSeconds: number | null, + modelConfig: LocalSeparatorDemucsModelConfig, + onProgress: (progress: LocalSeparatorProgress) => void, +): Promise> { + if (!chunkDurationSeconds) { + return timing.measureAsync('demucsSeparate', async () => toDemucsStemMap(await processor.separate(decoded[0], decoded[1]))); + } + + const chunkSamples = Math.max(1, Math.floor(chunkDurationSeconds * SAMPLE_RATE)); + if (decoded[0].length <= chunkSamples) { + return timing.measureAsync('demucsSeparate', async () => toDemucsStemMap(await processor.separate(decoded[0], decoded[1]))); + } + + const totalChunks = Math.ceil(decoded[0].length / chunkSamples); + const chunkedOutputs: Record = { + Vocals: [], + Drums: [], + Bass: [], + Others: [], + }; + + for (let index = 0; index < totalChunks; index += 1) { + const start = index * chunkSamples; + const end = Math.min(start + chunkSamples, decoded[0].length); + const chunk = sliceChannels(decoded, start, end); + + onProgress({ + stage: 'chunk-prep', + passLabel: `Audio chunk ${index + 1}/${totalChunks}: preparing ${Math.round((end - start) / SAMPLE_RATE)}s chunk...`, + percent: (index / totalChunks) * 100, + processedChunks: index, + totalChunks, + }); + + const chunkResult = await timing.measureAsync( + 'demucsChunkedSeparate', + async () => toDemucsStemMap(await processor.separate(chunk[0], chunk[1])), + ); + + (Object.keys(chunkedOutputs) as DemucsStemName[]).forEach(stemName => { + chunkedOutputs[stemName].push(chunkResult[stemName]); + }); + + onProgress({ + stage: 'chunk-complete', + passLabel: `Audio chunk ${index + 1}/${totalChunks}: Demucs pass`, + percent: ((index + 1) / totalChunks) * 100, + processedChunks: index + 1, + totalChunks, + }); + } + + return { + Vocals: concatChannelPairs(chunkedOutputs.Vocals), + Drums: concatChannelPairs(chunkedOutputs.Drums), + Bass: concatChannelPairs(chunkedOutputs.Bass), + Others: concatChannelPairs(chunkedOutputs.Others), + }; +} + +export async function runLocalSeparator(options: { + session: ort.InferenceSession; + runtimeProvider: LocalRuntimeProvider; + modelConfig: LocalSeparatorModelConfig; + audioBuffer: ArrayBuffer; + chunkDurationSeconds: number | null; + overlap: number; + onProgress: (progress: LocalSeparatorProgress) => void; + onProviderChange?: (provider: string) => void; +}): Promise<{ + stems: Array<{ name: string; blob: Blob }>; + providerLabel: string; + debugSummary: Record; +}> { + if (options.modelConfig.kind === 'demucs') { + return runDemucsLocalSeparator({ + session: options.session, + runtimeProvider: options.runtimeProvider, + modelConfig: options.modelConfig, + audioBuffer: options.audioBuffer, + chunkDurationSeconds: options.chunkDurationSeconds, + onProgress: options.onProgress, + }); + } + + return runMdxLocalSeparator({ + session: options.session, + runtimeProvider: options.runtimeProvider, + modelConfig: options.modelConfig, + audioBuffer: options.audioBuffer, + chunkDurationSeconds: options.chunkDurationSeconds, + overlap: options.overlap, + onProgress: options.onProgress, + onProviderChange: options.onProviderChange, + }); +} diff --git a/src/util/local-separator/types.ts b/src/util/local-separator/types.ts index a39326c..9ece80c 100644 --- a/src/util/local-separator/types.ts +++ b/src/util/local-separator/types.ts @@ -1,5 +1,8 @@ export type StereoChannels = [Float32Array, Float32Array]; +export type LocalSeparatorModelId = 'UVR-MDX-NET-Inst_HQ_3.onnx' | 'htdemucs_4s.onnx'; +export type LocalSeparatorModelKind = 'mdx' | 'demucs'; + export interface LocalSeparatorModelDefaults { sampleRate: number; hopLength: number; @@ -13,7 +16,7 @@ export interface LocalSeparatorModelDefaults { matchMixOverlap: number; } -export interface LocalSeparatorModelMetadata { +export interface LocalSeparatorMdxMetadata { compensate: number; mdx_dim_f_set: number; mdx_dim_t_set: number; @@ -21,14 +24,36 @@ export interface LocalSeparatorModelMetadata { primary_stem: string; } -export interface LocalSeparatorModelConfig { +export interface LocalSeparatorModelDownloadConfig { + configKey: 'general.uvr5_web_runtime.mdx_net_model_url' | 'general.uvr5_web_runtime.htdemucs_4s_model_url'; + defaultUrl: string; + expectedSizeBytes: number; +} + +interface LocalSeparatorModelConfigBase { + id: LocalSeparatorModelId; filename: string; + kind: LocalSeparatorModelKind; displayName: string; status: 'ready'; + outputStemNames: string[]; + defaultChunkDurationSeconds: number | null; defaults: LocalSeparatorModelDefaults; - metadata: LocalSeparatorModelMetadata; + download: LocalSeparatorModelDownloadConfig; } +export interface LocalSeparatorMdxModelConfig extends LocalSeparatorModelConfigBase { + kind: 'mdx'; + metadata: LocalSeparatorMdxMetadata; +} + +export interface LocalSeparatorDemucsModelConfig extends LocalSeparatorModelConfigBase { + kind: 'demucs'; + metadata: null; +} + +export type LocalSeparatorModelConfig = LocalSeparatorMdxModelConfig | LocalSeparatorDemucsModelConfig; + export interface LocalSeparatorProgress { stage: string; passLabel: string;