From 722ba685ebe0525415d210d8c4b5626ec9c5f60e Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:41:35 -0700 Subject: [PATCH] fix: persist separator results until project switch --- src/components/KGOnePanel.test.tsx | 81 ++++++++++- src/components/KGOnePanel.tsx | 219 +++++++++++++++-------------- 2 files changed, 192 insertions(+), 108 deletions(-) diff --git a/src/components/KGOnePanel.test.tsx b/src/components/KGOnePanel.test.tsx index 74a86d7..b895434 100644 --- a/src/components/KGOnePanel.test.tsx +++ b/src/components/KGOnePanel.test.tsx @@ -12,6 +12,8 @@ const { mockLocalSeparatorDownload } = vi.hoisted(() => ({ let kgoneEnabled = false; let selectedRegionIds: string[] = []; +let projectName = 'Test Project'; +let savedProjectName = 'Test Project'; let localModelCached: Record = {}; let localSeparationResult: Array<{ name: string; blob: Blob }> = []; @@ -21,7 +23,8 @@ const mockExecuteCommand = vi.fn(); vi.mock('../stores/projectStore', () => ({ useProjectStore: () => ({ selectedRegionIds, - projectName: 'Test Project', + projectName, + savedProjectName, bpm: 120, keySignature: 'C major', timeSignature: { numerator: 4, denominator: 4 }, @@ -127,6 +130,8 @@ describe('KGOnePanel local separator mode', () => { beforeEach(() => { kgoneEnabled = false; selectedRegionIds = []; + projectName = 'Test Project'; + savedProjectName = 'Test Project'; localModelCached = {}; localSeparationResult = [ { name: 'Instrumental', blob: new Blob(['instrumental'], { type: 'audio/wav' }) }, @@ -185,6 +190,7 @@ describe('KGOnePanel local separator mode', () => { render(); expect(await screen.findByText(/Select an audio region on the timeline/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Separate Stems' })).toBeDisabled(); }); it('renders local separation outputs after processing completes', async () => { @@ -202,6 +208,79 @@ describe('KGOnePanel local separator mode', () => { }); }); + it('keeps stem results visible and disables separation after the selection is cleared', async () => { + localModelCached[LOCAL_SEPARATOR_MODEL_IDS.mdxMedium] = true; + selectedRegionIds = ['audio-region-1']; + + const { rerender } = render(); + + 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(); + }); + + selectedRegionIds = []; + rerender(); + + expect(screen.getByText('Instrumental')).toBeInTheDocument(); + expect(screen.getByText('Vocals')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Import All Stems to Timeline' })).toBeInTheDocument(); + expect(screen.queryByText(/Select an audio region on the timeline/)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Separate Stems' })).toBeDisabled(); + }); + + it('clears stem results when the saved project changes', async () => { + localModelCached[LOCAL_SEPARATOR_MODEL_IDS.mdxMedium] = true; + selectedRegionIds = ['audio-region-1']; + + const { rerender } = render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Separate Stems' })); + + await waitFor(() => { + expect(screen.getByText('Instrumental')).toBeInTheDocument(); + }); + expect(screen.getAllByText('Separation complete.').length).toBeGreaterThan(0); + + savedProjectName = 'Loaded Project'; + projectName = 'Loaded Project'; + selectedRegionIds = []; + rerender(); + + await waitFor(() => { + expect(screen.queryByText('Instrumental')).not.toBeInTheDocument(); + expect(screen.queryByText('Vocals')).not.toBeInTheDocument(); + }); + expect(screen.queryAllByText('Separation complete.')).toHaveLength(0); + expect(screen.queryByRole('button', { name: 'Import All Stems to Timeline' })).not.toBeInTheDocument(); + expect(screen.getByText(/Select an audio region on the timeline/)).toBeInTheDocument(); + }); + + it('preserves stem results when only the project name changes', async () => { + localModelCached[LOCAL_SEPARATOR_MODEL_IDS.mdxMedium] = true; + selectedRegionIds = ['audio-region-1']; + + const { rerender } = render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Separate Stems' })); + + await waitFor(() => { + expect(screen.getByText('Instrumental')).toBeInTheDocument(); + }); + + projectName = 'Renamed Project'; + selectedRegionIds = []; + rerender(); + + expect(screen.getByText('Instrumental')).toBeInTheDocument(); + expect(screen.getByText('Vocals')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Import All Stems to Timeline' })).toBeInTheDocument(); + expect(screen.queryByText(/Select an audio region on the timeline/)).not.toBeInTheDocument(); + }); + it('uses Demucs defaults and renders four local stem players', async () => { localModelCached[LOCAL_SEPARATOR_MODEL_IDS.htdemucs4s] = true; localSeparationResult = [ diff --git a/src/components/KGOnePanel.tsx b/src/components/KGOnePanel.tsx index 64235e6..e4a177e 100644 --- a/src/components/KGOnePanel.tsx +++ b/src/components/KGOnePanel.tsx @@ -856,7 +856,7 @@ function countRepaintTracks(sourceTrackName: string): number { const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { const { t } = useI18n(); - const { selectedRegionIds, projectName, bpm, timeSignature, maxBars, refreshProjectState } = useProjectStore(); + const { selectedRegionIds, projectName, savedProjectName, bpm, timeSignature, maxBars, refreshProjectState } = useProjectStore(); const localOnlyMode = mode === 'local-separator'; const availableSeparatorModels = localOnlyMode ? LOCAL_SEPARATOR_MODEL_OPTIONS : SERVER_SEPARATOR_MODELS; const [model, setModel] = useState(availableSeparatorModels[0].value); @@ -910,12 +910,14 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { setStemAudioUrls([]); setGenStatus('idle'); setGenHint(''); + setLocalProgressPercent(0); + setLocalProgressText(''); setErrorMsg(''); setIsImporting(false); setImportError(''); originalRegionRef.current = null; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectName]); + }, [savedProjectName]); useEffect(() => { setModel(availableSeparatorModels[0].value); @@ -967,6 +969,7 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { } return null; }, [selectedRegionIds]); + const hasStemResults = stemAudioUrls.length > 0; const getConfiguredLocalSeparatorModelUrl = useCallback(() => { const configured = ConfigManager.instance().get(currentLocalModelConfig.download.configKey); @@ -1450,119 +1453,121 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => { )} - {selectedAudioRegion ? ( - <> + <> + {selectedAudioRegion && (
{t('kgone.shared.selectedRegion')}
{selectedAudioRegion.region.getName()}
{t('kgone.shared.track')}
{selectedAudioRegion.trackName}
+ )} -
- - -
- - {localOnlyMode && ( - -
- - setLocalChunkDurationSeconds(e.target.value)} - placeholder={t('kgone.separator.field.chunkDurationPlaceholder')} - /> -
-
- - setLocalOverlap(e.target.value)} - /> -
-
- )} - - {/* Stem audio players — shown once separation is complete */} - {stemAudioUrls.length > 0 && ( -
- {stemAudioUrls.map(stem => ( -
-
{stem.name}
- -
- ))} -
- )} - - {/* Drag-to-track hint — shown after successful separation */} - {genStatus === 'done' && ( -
- )} - - {/* Bulk import button — shown after successful separation */} - {genStatus === 'done' && stemAudioUrls.length > 0 && ( - <> - - {importError &&
{importError}
} - - )} - - {/* Separation error message */} - {genStatus === 'error' && errorMsg && ( -
{errorMsg}
- )} - - - - {/* Status hint below button */} - {(localOnlyMode ? localProgressText : genHint) && ( -
{localOnlyMode ? localProgressText : genHint}
- )} - - ) : ( -
- {localOnlyMode && !isLocalModelCached - ? t('kgone.separator.hint.noRegion.download', { model: getModelLabel(currentLocalModelConfig.id, t) }) - : t('kgone.separator.hint.noRegion.select')} +
+ +
- )} + + {localOnlyMode && ( + +
+ + setLocalChunkDurationSeconds(e.target.value)} + placeholder={t('kgone.separator.field.chunkDurationPlaceholder')} + /> +
+
+ + setLocalOverlap(e.target.value)} + /> +
+
+ )} + + {/* Stem audio players — shown once separation is complete */} + {hasStemResults && ( +
+ {stemAudioUrls.map(stem => ( +
+
{stem.name}
+ +
+ ))} +
+ )} + + {/* Drag-to-track hint — shown after successful separation */} + {hasStemResults && ( +
+ )} + + {/* Bulk import button — shown after successful separation */} + {hasStemResults && ( + <> + + {importError &&
{importError}
} + + )} + + {/* Separation error message */} + {genStatus === 'error' && errorMsg && ( +
{errorMsg}
+ )} + + + + {/* Status hint below button */} + {(localOnlyMode ? localProgressText : genHint) && ( +
{localOnlyMode ? localProgressText : genHint}
+ )} + + {!selectedAudioRegion && !hasStemResults && ( +
+ {localOnlyMode && !isLocalModelCached + ? t('kgone.separator.hint.noRegion.download', { model: getModelLabel(currentLocalModelConfig.id, t) }) + : t('kgone.separator.hint.noRegion.select')} +
+ )} + {!localOnlyMode && (