fix: persist separator results until project switch

This commit is contained in:
Xiaohan-Tian
2026-07-04 23:41:35 -07:00
parent a8c68d97ed
commit 722ba685eb
2 changed files with 192 additions and 108 deletions
+80 -1
View File
@@ -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<string, boolean> = {};
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(<KGOnePanel isVisible={true} />);
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(<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();
});
selectedRegionIds = [];
rerender(<KGOnePanel isVisible={true} />);
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(<KGOnePanel isVisible={true} />);
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(<KGOnePanel isVisible={true} />);
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(<KGOnePanel isVisible={true} />);
fireEvent.click(await screen.findByRole('button', { name: 'Separate Stems' }));
await waitFor(() => {
expect(screen.getByText('Instrumental')).toBeInTheDocument();
});
projectName = 'Renamed Project';
selectedRegionIds = [];
rerender(<KGOnePanel isVisible={true} />);
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 = [
+14 -9
View File
@@ -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<string>(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,14 +1453,15 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => {
</div>
)}
{selectedAudioRegion ? (
<>
{selectedAudioRegion && (
<div className="kgone-region-info">
<div className="kgone-region-info-label">{t('kgone.shared.selectedRegion')}</div>
<div className="kgone-region-info-value">{selectedAudioRegion.region.getName()}</div>
<div className="kgone-region-info-label" style={{ marginTop: 4 }}>{t('kgone.shared.track')}</div>
<div className="kgone-region-info-value">{selectedAudioRegion.trackName}</div>
</div>
)}
<div className="kgone-field">
<label className="kgone-label">{t('kgone.separator.field.separationModel')}</label>
@@ -1500,7 +1504,7 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => {
)}
{/* Stem audio players — shown once separation is complete */}
{stemAudioUrls.length > 0 && (
{hasStemResults && (
<div className="kgone-stems">
{stemAudioUrls.map(stem => (
<div key={stem.name} className="kgone-stem-player">
@@ -1517,12 +1521,12 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => {
)}
{/* Drag-to-track hint — shown after successful separation */}
{genStatus === 'done' && (
{hasStemResults && (
<div className="kgone-hint" dangerouslySetInnerHTML={{ __html: t('kgone.separator.hint.drag') }} />
)}
{/* Bulk import button — shown after successful separation */}
{genStatus === 'done' && stemAudioUrls.length > 0 && (
{hasStemResults && (
<>
<button
className="dialog-btn dialog-btn-primary kgone-btn-generate"
@@ -1544,7 +1548,7 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => {
<button
className="dialog-btn dialog-btn-primary kgone-btn-generate"
disabled={isGenerating || (localOnlyMode && !isLocalModelCached)}
disabled={!selectedAudioRegion || isGenerating || (localOnlyMode && !isLocalModelCached)}
onClick={handleSeparate}
>
{isGenerating && <FaCircleNotch className="kgone-spinner" />}
@@ -1555,14 +1559,15 @@ const SeparatorTab: React.FC<{ mode: KGOneMode }> = ({ mode }) => {
{(localOnlyMode ? localProgressText : genHint) && (
<div className="kgone-gen-hint">{localOnlyMode ? localProgressText : genHint}</div>
)}
</>
) : (
{!selectedAudioRegion && !hasStemResults && (
<div className="kgone-separator-hint">
{localOnlyMode && !isLocalModelCached
? t('kgone.separator.hint.noRegion.download', { model: getModelLabel(currentLocalModelConfig.id, t) })
: t('kgone.separator.hint.noRegion.select')}
</div>
)}
</>
{!localOnlyMode && (
<div className="kgone-powered-by">