Skip to content

Commit 53ff367

Browse files
authored
fix(studio): simplify voice cloning setup (debpalash#1817)
* fix(studio): simplify voice cloning setup * docs: link cloning redesign changelog * fix(studio): keep clone recording controls available * fix(studio): lock clone capture transitions
1 parent f2302e8 commit 53ff367

38 files changed

Lines changed: 758 additions & 321 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ the frozen-backend fallback mirror it for their toolchains.
1010

1111
**Highlights**
1212

13+
- Voice cloning now starts with a clear upload-or-record choice, reveals recording and reference details only when needed, and keeps sampling controls under Production Overrides (#1817)
14+
1315
### Changed
1416

1517
### Added

frontend/src/App.jsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,7 @@ function App() {
453453
// ═══ MIC RECORDING ═══
454454
const {
455455
isRecording,
456+
isStartingRecording,
456457
isCleaning,
457458
recordingTime,
458459
audioInputs,
@@ -1686,6 +1687,7 @@ function App() {
16861687
handlePreviewVoice={handlePreviewVoice}
16871688
handleUnlockProfile={handleUnlockProfile}
16881689
openVoiceProfile={openVoiceProfile}
1690+
selectionDisabled={isStartingRecording || isRecording}
16891691
onOpenVoicePreview={(profileId) => {
16901692
setVoicePreviewProfileId(profileId || '');
16911693
setIsVoicePreviewOpen(true);
@@ -1738,6 +1740,7 @@ function App() {
17381740
showSaveProfile={showSaveProfile}
17391741
setShowSaveProfile={setShowSaveProfile}
17401742
isRecording={isRecording}
1743+
isStartingRecording={isStartingRecording}
17411744
isCleaning={isCleaning}
17421745
recordingTime={recordingTime}
17431746
audioInputs={audioInputs}

frontend/src/components/WorkspaceVoices.jsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export default function WorkspaceVoices({
4242
handleUnlockProfile,
4343
openVoiceProfile,
4444
onOpenVoicePreview,
45+
selectionDisabled = false,
4546
}) {
4647
const { t } = useTranslation();
4748
const setDefineMethod = useAppStore((s) => s.setDefineMethod);
@@ -108,6 +109,7 @@ export default function WorkspaceVoices({
108109
<button
109110
type="button"
110111
className="history-action-btn"
112+
disabled={selectionDisabled}
111113
onClick={() => setSelectedProfile?.(null)}
112114
>
113115
<Plus size={10} /> {t('voices.new', { defaultValue: 'New voice' })}
@@ -170,7 +172,10 @@ export default function WorkspaceVoices({
170172
key={proj.id}
171173
className={`history-item ${selectedProfile === proj.id ? 'project-active' : ''}`}
172174
style={{ '--row-accent': accent }}
173-
onClick={() => handleSelectProfile(proj)}
175+
aria-disabled={selectionDisabled}
176+
onClick={() => {
177+
if (!selectionDisabled) handleSelectProfile(proj);
178+
}}
174179
>
175180
<div className="flex items-center justify-between gap-2 min-w-0">
176181
<span
@@ -224,9 +229,10 @@ export default function WorkspaceVoices({
224229
)}
225230
<button
226231
className="history-action-btn"
232+
disabled={selectionDisabled}
227233
onClick={(e) => {
228234
e.stopPropagation();
229-
handleSelectProfile(proj);
235+
if (!selectionDisabled) handleSelectProfile(proj);
230236
}}
231237
>
232238
<Check size={10} /> {t('sidebar.select')}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React from 'react';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import { fireEvent, render, screen } from '@testing-library/react';
4+
5+
vi.mock('./WaveformPlayer', () => ({ default: () => null }));
6+
7+
import WorkspaceVoices from './WorkspaceVoices';
8+
9+
const profile = { id: 'voice-1', name: 'Studio voice', instruct: '' };
10+
11+
function renderVoices(overrides = {}) {
12+
const handleSelectProfile = vi.fn();
13+
const props = {
14+
defineMethod: 'audio',
15+
profiles: [profile],
16+
selectedProfile: '',
17+
setSelectedProfile: vi.fn(),
18+
previewLoading: '',
19+
handleSelectProfile,
20+
handleDeleteProfile: vi.fn(),
21+
handlePreviewVoice: vi.fn(),
22+
handleUnlockProfile: vi.fn(),
23+
...overrides,
24+
};
25+
return { ...render(<WorkspaceVoices {...props} />), handleSelectProfile };
26+
}
27+
28+
describe('WorkspaceVoices recording lock', () => {
29+
it('cannot replace the active reference while microphone startup or capture is active', () => {
30+
const { handleSelectProfile, rerender } = renderVoices({ selectionDisabled: true });
31+
32+
fireEvent.click(screen.getByText(profile.name).closest('.history-item'));
33+
expect(screen.getByRole('button', { name: 'Select' })).toBeDisabled();
34+
expect(handleSelectProfile).not.toHaveBeenCalled();
35+
36+
rerender(
37+
<WorkspaceVoices
38+
defineMethod="audio"
39+
profiles={[profile]}
40+
selectedProfile=""
41+
setSelectedProfile={vi.fn()}
42+
previewLoading=""
43+
handleSelectProfile={handleSelectProfile}
44+
handleDeleteProfile={vi.fn()}
45+
handlePreviewVoice={vi.fn()}
46+
handleUnlockProfile={vi.fn()}
47+
/>,
48+
);
49+
fireEvent.click(screen.getByText(profile.name).closest('.history-item'));
50+
expect(handleSelectProfile).toHaveBeenCalledWith(profile);
51+
});
52+
});

frontend/src/components/clone/ActionBar.jsx

Lines changed: 23 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,26 @@ export default function ActionBar({
5656
{showOverrides && (
5757
<div className="override-content">
5858
<div className="grid [grid-template-columns:repeat(auto-fit,minmax(120px,1fr))] gap-[6px] max-[500px]:grid-cols-2">
59+
<label className="min-w-0">
60+
<span className="label-row justify-between">
61+
<span className="inline-flex items-center gap-[6px]">
62+
<SlidersHorizontal size={12} className="label-icon" />
63+
{t('clone.steps')}
64+
</span>
65+
<span className="text-[0.65rem] bg-black/35 px-[5px] py-px rounded-[3px] [border:1px_solid_rgba(255,255,255,0.04)] [font-variant-numeric:tabular-nums]">
66+
{steps}
67+
</span>
68+
</span>
69+
<input
70+
className="w-full"
71+
type="range"
72+
aria-label={t('clone.steps')}
73+
min="8"
74+
max="64"
75+
value={steps}
76+
onChange={(event) => setSteps(Number(event.target.value))}
77+
/>
78+
</label>
5979
<div>
6080
<div className="label-row justify-between">
6181
<span>CFG</span>
@@ -186,8 +206,8 @@ export default function ActionBar({
186206
</div>
187207
)}
188208

189-
{/* Controls row: language · steps · overrides disclosure */}
190-
<div className="flex items-center gap-[16px] min-w-0">
209+
{/* Keep the everyday row focused; sampling controls live in overrides. */}
210+
<div className="flex items-center gap-3 min-w-0 max-[520px]:flex-wrap">
191211
<div className="flex items-center gap-[6px] flex-[1_1_220px] min-w-[140px] [&>:last-child]:flex-1 [&>:last-child]:min-w-0">
192212
<Globe size={12} className="label-icon" />
193213
<SearchableSelect
@@ -198,33 +218,9 @@ export default function ActionBar({
198218
onChange={setLanguage}
199219
/>
200220
</div>
201-
{/* Inference/sampling steps (#1771 follow-up): this slider used to
202-
carry only a hover title, so its value ("16") read as an
203-
unlabelled control sitting between the language picker and
204-
Production Overrides — give it the same visible label treatment
205-
as everything else in the bar. */}
206-
<label
207-
className="flex items-center gap-[6px] flex-[1_1_160px] min-w-[120px] [&_input]:flex-1 [&_input]:min-w-[60px]"
208-
title={t('clone.steps')}
209-
>
210-
<SlidersHorizontal size={12} className="label-icon" />
211-
<span className="text-[0.7rem] text-[var(--chrome-fg-muted)] whitespace-nowrap">
212-
{t('clone.steps')}
213-
</span>
214-
<input
215-
type="range"
216-
min="8"
217-
max="64"
218-
value={steps}
219-
onChange={(e) => setSteps(Number(e.target.value))}
220-
/>
221-
<span className="text-[0.65rem] bg-black/35 px-[5px] py-px rounded-[3px] [border:1px_solid_rgba(255,255,255,0.04)] [font-variant-numeric:tabular-nums]">
222-
{steps}
223-
</span>
224-
</label>
225221
<button
226222
type="button"
227-
className="inline-flex items-center gap-[4px] px-[10px] py-[4px] text-[0.7rem] text-[var(--chrome-fg-muted)] bg-transparent border border-transparent rounded-[var(--chrome-radius-pill)] cursor-pointer whitespace-nowrap flex-none transition-[color,border-color] duration-[var(--dur-fast)] hover:text-[var(--chrome-fg)] hover:border-transparent focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]"
223+
className="inline-flex min-h-9 items-center gap-[4px] px-[10px] py-[4px] text-[0.7rem] text-[var(--chrome-fg-muted)] bg-transparent border border-transparent rounded-md cursor-pointer whitespace-nowrap flex-none transition-[color,border-color] duration-[var(--dur-fast)] hover:text-[var(--chrome-fg)] hover:bg-[var(--chrome-hover-bg)] focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]"
228224
onClick={() => setShowOverrides(!showOverrides)}
229225
aria-expanded={showOverrides}
230226
>
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React, { useState } from 'react';
2+
import { fireEvent, render, screen } from '@testing-library/react';
3+
import { describe, expect, it, vi } from 'vitest';
4+
5+
vi.mock('../SearchableSelect', () => ({
6+
default: () => <button type="button">Language</button>,
7+
}));
8+
9+
import ActionBar from './ActionBar';
10+
11+
const setter = vi.fn();
12+
const baseProps = {
13+
t: (key) => key,
14+
cfg: 2,
15+
setCfg: setter,
16+
speed: 1,
17+
setSpeed: setter,
18+
tShift: 0.5,
19+
setTShift: setter,
20+
posTemp: 1,
21+
setPosTemp: setter,
22+
classTemp: 1,
23+
setClassTemp: setter,
24+
layerPenalty: 1,
25+
setLayerPenalty: setter,
26+
duration: '',
27+
setDuration: setter,
28+
denoise: false,
29+
setDenoise: setter,
30+
postprocess: false,
31+
setPostprocess: setter,
32+
language: 'Auto',
33+
setLanguage: setter,
34+
steps: 16,
35+
setSteps: setter,
36+
showHearDemo: false,
37+
outputPlaying: false,
38+
isGenerating: false,
39+
handleGenerate: setter,
40+
generationTime: 0,
41+
wasGeneratingRef: { current: false },
42+
};
43+
44+
function Harness() {
45+
const [showOverrides, setShowOverrides] = useState(false);
46+
return (
47+
<ActionBar {...baseProps} showOverrides={showOverrides} setShowOverrides={setShowOverrides} />
48+
);
49+
}
50+
51+
describe('ActionBar', () => {
52+
it('keeps sampling steps inside Production Overrides', () => {
53+
render(<Harness />);
54+
55+
expect(screen.queryByRole('slider', { name: 'clone.steps' })).not.toBeInTheDocument();
56+
fireEvent.click(screen.getByRole('button', { name: /clone.production_overrides/ }));
57+
expect(screen.getByRole('slider', { name: 'clone.steps' })).toBeInTheDocument();
58+
});
59+
});

0 commit comments

Comments
 (0)