feat(studio): promote voice modes to workspace tabs

This commit is contained in:
Palash Debnath
2026-09-05 15:22:44 +05:30
parent 53ff367c1f
commit 7a14c31a8e
6 changed files with 228 additions and 169 deletions
+2
View File
@@ -57,6 +57,8 @@
| **Storage** | Voices, projects, settings, and outputs stay on the machine by default |
| **License** | AGPL-3.0 application; downloaded models keep their upstream terms |
The Voice workspace starts with the active engine selector and three tabs: **From audio** for cloning, **By design** for creating a voice, and **Convert** for speech-to-speech conversion. Each tab displays its own workflow below.
<a id="install"></a>
## Install
@@ -21,6 +21,7 @@ export default function EngineQuickSwitch({
// workspace-header chips must open downward or the popover clips off the
// top of the viewport.
dropUp = false,
prominent = false,
}) {
const { t } = useTranslation();
const rootRef = useRef(null);
@@ -115,10 +116,12 @@ export default function EngineQuickSwitch({
family: family.toUpperCase(),
engine: active.display_name,
})}
className="inline-flex h-[20px] items-center gap-[5px] rounded-sm border-0 bg-transparent px-[7px] text-[11px] font-medium text-[color:var(--chrome-fg-muted)] transition-[background,color] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)]"
className={`inline-flex items-center gap-[5px] rounded-sm border-0 bg-transparent px-[7px] font-medium text-[color:var(--chrome-fg-muted)] transition-[background,color] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)] ${prominent ? 'min-h-11 text-sm text-left' : 'h-[20px] text-[11px]'}`}
>
<Cpu size={13} aria-hidden="true" />
<span className="max-w-[124px] truncate">{active.display_name}</span>
<span className={prominent ? 'min-w-0 break-words' : 'max-w-[124px] truncate'}>
{active.display_name}
</span>
</button>
{open && (
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { UploadCloud, X, ArrowRightLeft, Loader } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { Button } from '../../ui';
@@ -26,7 +26,7 @@ import WaveformPlayer from '../WaveformPlayer';
* ScriptPanel (the script IS the source clip) or the shared ActionBar, so it
* owns its source file, target voice, and result state locally.
*/
export default function ConvertMethodPanel({ t, profiles = [] }) {
export default function ConvertMethodPanel({ t, profiles = [], onRecordingBusyChange }) {
const [sourceFile, setSourceFile] = useState(null);
const [voiceId, setVoiceId] = useState('');
const [matchDuration, setMatchDuration] = useState(true);
@@ -79,6 +79,11 @@ export default function ConvertMethodPanel({ t, profiles = [] }) {
stopRecording,
} = useRecording(async (file) => ingestSource(file));
useEffect(() => {
onRecordingBusyChange?.(Boolean(isStartingRecording || isRecording));
return () => onRecordingBusyChange?.(false);
}, [isStartingRecording, isRecording, onRecordingBusyChange]);
const canConvert = !!sourceFile && !!voiceId && !isConverting;
const handleConvert = async () => {
@@ -103,10 +103,20 @@ beforeEach(() => {
describe('ConvertMethodPanel', () => {
it('shows microphone startup instead of a duplicate record action', () => {
recordingState.isStartingRecording = true;
render(<ConvertMethodPanel t={t} profiles={profiles} />);
const onRecordingBusyChange = vi.fn();
const { unmount } = render(
<ConvertMethodPanel
t={t}
profiles={profiles}
onRecordingBusyChange={onRecordingBusyChange}
/>,
);
expect(screen.getByRole('status')).toHaveTextContent('Starting…');
expect(screen.queryByRole('button', { name: 'Record' })).not.toBeInTheDocument();
expect(onRecordingBusyChange).toHaveBeenLastCalledWith(true);
unmount();
expect(onRecordingBusyChange).toHaveBeenLastCalledWith(false);
});
it('keeps Convert disabled until a source clip AND a target voice are set', () => {
+173 -162
View File
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { toast } from 'react-hot-toast';
import { CATEGORIES } from '../utils/constants';
import { Segmented } from '../ui';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui/tabs';
import { useAppStore } from '../store';
import { API, apiPost, apiFetch } from '../api/client';
import { mergeDescribedAttrs, applyVdState } from '../utils/voiceInstruct';
@@ -18,6 +18,7 @@ import ActionBar from '../components/clone/ActionBar';
import EngineQuickSwitch from '../components/EngineQuickSwitch';
export default function CloneDesignTab(props) {
const [convertRecordingBusy, setConvertRecordingBusy] = useState(false);
const {
textAreaRef,
text,
@@ -413,182 +414,192 @@ export default function CloneDesignTab(props) {
};
return (
<div className="flex-1 flex flex-col min-h-0 min-w-0">
{/* #1771 follow-up (item 6): NOT flex-1 — the old always-open 12-row
<Tabs
value={defineMethod}
onValueChange={setDefineMethod}
activationMode="manual"
className="flex-1 min-h-0 min-w-0 gap-0"
>
<div className="relative z-10 flex shrink-0 flex-col gap-3 px-3 pb-4 pt-2 max-[600px]:px-1.5">
<EngineQuickSwitch prominent />
<TabsList
aria-label={t('clone.voice_kicker')}
className="grid h-auto w-full grid-cols-3 gap-1 rounded-lg bg-[var(--chrome-hover-bg)] p-1"
>
{[
{ id: 'audio', label: t('clone.define_from_audio') },
{ id: 'design', label: t('clone.define_by_design') },
{ id: 'convert', label: t('clone.define_convert') },
].map((method) => (
<TabsTrigger
key={method.id}
value={method.id}
disabled={isStartingRecording || isRecording || convertRecordingBusy}
className="min-h-11 h-auto min-w-0 whitespace-normal rounded-md border-0 bg-transparent px-3 py-2 text-sm font-semibold text-[var(--chrome-fg-muted)] data-[state=active]:bg-[var(--chrome-accent-bg)] data-[state=active]:text-[var(--chrome-accent)] data-[state=active]:shadow-none"
>
{method.label}
</TabsTrigger>
))}
</TabsList>
</div>
<TabsContent value={defineMethod} className="flex min-h-0 flex-col">
{/* #1771 follow-up (item 6): NOT flex-1 — the old always-open 12-row
Design block was tall enough that this area routinely filled the
full column height on its own. Now that Details defaults collapsed,
forcing this to grow-fill would strand a dead gap between it and
ActionBar below. Sizing to content (min-h-0 + overflow-y-auto still
in play) lets it shrink for short content and still scroll when
content genuinely exceeds the available height. */}
<div className="flex flex-col gap-[6px] min-h-0 overflow-y-auto">
{/* ═══ SCRIPT — what should it say ═══
<div className="flex flex-col gap-[6px] min-h-0 overflow-y-auto">
{/* ═══ SCRIPT — what should it say ═══
Hidden for Convert: the source clip IS the script (the backend
transcribes it), so a text panel would only mislead. */}
{defineMethod !== 'convert' && (
<ScriptPanel
t={t}
defineMethod={defineMethod}
text={text}
setText={setText}
activePersonality={activePersonality}
demoPresets={demoPresets}
applyDemoPreset={applyDemoPreset}
showDemoCoachmark={showDemoCoachmark}
setShowDemoCoachmark={setShowDemoCoachmark}
selectedProfile={selectedProfile}
DEMO_PROFILE_ID={DEMO_PROFILE_ID}
textAreaRef={textAreaRef}
insertOpen={insertOpen}
setInsertOpen={setInsertOpen}
insertTag={insertTag}
/>
)}
{defineMethod !== 'convert' && (
<ScriptPanel
t={t}
defineMethod={defineMethod}
text={text}
setText={setText}
activePersonality={activePersonality}
demoPresets={demoPresets}
applyDemoPreset={applyDemoPreset}
showDemoCoachmark={showDemoCoachmark}
setShowDemoCoachmark={setShowDemoCoachmark}
selectedProfile={selectedProfile}
DEMO_PROFILE_ID={DEMO_PROFILE_ID}
textAreaRef={textAreaRef}
insertOpen={insertOpen}
setInsertOpen={setInsertOpen}
insertTag={insertTag}
/>
)}
{/* ═══ VOICE — who says it ═══ */}
<div className="flex flex-col gap-[6px] flex-none min-h-0 relative z-[1]">
<div className="flex flex-col min-h-0 overflow-auto bg-[var(--chrome-bg)] border border-transparent rounded-none py-[10px] px-[12px] max-[800px]:px-[10px] max-[600px]:px-[6px] max-[600px]:py-[8px]">
<div className="label-row justify-between">
<span className="label-row mb-0">
<Volume2 className="label-icon" size={14} />{' '}
{t('clone.voice_kicker', { defaultValue: 'Voice' })}
</span>
<div className="flex items-center gap-[6px]">
<EngineQuickSwitch />
<Segmented
size="sm"
value={defineMethod}
onChange={setDefineMethod}
disabled={isStartingRecording || isRecording}
items={[
{
value: 'audio',
label: t('clone.define_from_audio', { defaultValue: 'From audio' }),
},
{
value: 'design',
label: t('clone.define_by_design', { defaultValue: 'By design' }),
},
{
value: 'convert',
label: t('clone.define_convert', { defaultValue: 'Convert' }),
},
]}
/>
{/* ═══ VOICE — who says it ═══ */}
<div className="flex flex-col gap-[6px] flex-none min-h-0 relative z-[1]">
<div className="flex flex-col min-h-0 overflow-auto bg-[var(--chrome-bg)] border border-transparent rounded-none py-[10px] px-[12px] max-[800px]:px-[10px] max-[600px]:px-[6px] max-[600px]:py-[8px]">
<div className="label-row justify-between">
<span className="label-row mb-0">
<Volume2 className="label-icon" size={14} />{' '}
{t('clone.voice_kicker', { defaultValue: 'Voice' })}
</span>
</div>
</div>
{defineMethod === 'audio' ? (
<AudioMethodPanel
t={t}
selectedProfile={selectedProfile}
setSelectedProfile={setSelectedProfile}
profiles={profiles}
ingestRefAudio={ingestRefAudio}
refAudio={refAudio}
isCleaning={isCleaning}
isRecording={isRecording}
isStartingRecording={isStartingRecording}
recordingTime={recordingTime}
audioInputs={audioInputs}
selectedAudioInputId={selectedAudioInputId}
setSelectedAudioInputId={setSelectedAudioInputId}
channelMode={channelMode}
setChannelMode={setChannelMode}
inputLevelStore={inputLevelStore}
startRecording={startRecording}
stopRecording={stopRecording}
refText={refText}
setRefText={setRefText}
instruct={instruct}
setInstruct={setInstruct}
defineMethod={defineMethod}
designSeed={designSeed}
setDesignSeed={setDesignSeed}
keepSeed={keepSeed}
setKeepSeed={setKeepSeed}
showSaveProfile={showSaveProfile}
setShowSaveProfile={setShowSaveProfile}
profileName={profileName}
setProfileName={setProfileName}
handleSaveProfile={handleSaveProfile}
/>
) : defineMethod === 'convert' ? (
<ConvertMethodPanel t={t} profiles={profiles} />
) : (
<DesignMethodPanel
t={t}
describeText={describeText}
onDescribeChange={onDescribeChange}
describeMatchedAny={describeMatchedAny}
describeUnmatched={describeUnmatched}
chipPersonalities={chipPersonalities}
activePersonality={activePersonality}
applyPersonality={applyPersonality}
applyPreset={applyPresetAndInvalidate}
identityOpen={identityOpen}
setIdentityOpen={setIdentityOpen}
identityRecipe={identityRecipe}
vdStates={vdStates}
onVdChange={handleVdChange}
onChipKeyDown={onChipKeyDown}
resetToDescription={resetToDescription}
showSaveProfile={showSaveProfile}
setShowSaveProfile={setShowSaveProfile}
profileName={profileName}
setProfileName={setProfileName}
handleSaveDesignProfile={handleSaveDesignProfile}
instruct={instruct}
language={language}
/>
)}
{defineMethod === 'audio' ? (
<AudioMethodPanel
t={t}
selectedProfile={selectedProfile}
setSelectedProfile={setSelectedProfile}
profiles={profiles}
ingestRefAudio={ingestRefAudio}
refAudio={refAudio}
isCleaning={isCleaning}
isRecording={isRecording}
isStartingRecording={isStartingRecording}
recordingTime={recordingTime}
audioInputs={audioInputs}
selectedAudioInputId={selectedAudioInputId}
setSelectedAudioInputId={setSelectedAudioInputId}
channelMode={channelMode}
setChannelMode={setChannelMode}
inputLevelStore={inputLevelStore}
startRecording={startRecording}
stopRecording={stopRecording}
refText={refText}
setRefText={setRefText}
instruct={instruct}
setInstruct={setInstruct}
defineMethod={defineMethod}
designSeed={designSeed}
setDesignSeed={setDesignSeed}
keepSeed={keepSeed}
setKeepSeed={setKeepSeed}
showSaveProfile={showSaveProfile}
setShowSaveProfile={setShowSaveProfile}
profileName={profileName}
setProfileName={setProfileName}
handleSaveProfile={handleSaveProfile}
/>
) : defineMethod === 'convert' ? (
<ConvertMethodPanel
t={t}
profiles={profiles}
onRecordingBusyChange={setConvertRecordingBusy}
/>
) : (
<DesignMethodPanel
t={t}
describeText={describeText}
onDescribeChange={onDescribeChange}
describeMatchedAny={describeMatchedAny}
describeUnmatched={describeUnmatched}
chipPersonalities={chipPersonalities}
activePersonality={activePersonality}
applyPersonality={applyPersonality}
applyPreset={applyPresetAndInvalidate}
identityOpen={identityOpen}
setIdentityOpen={setIdentityOpen}
identityRecipe={identityRecipe}
vdStates={vdStates}
onVdChange={handleVdChange}
onChipKeyDown={onChipKeyDown}
resetToDescription={resetToDescription}
showSaveProfile={showSaveProfile}
setShowSaveProfile={setShowSaveProfile}
profileName={profileName}
setProfileName={setProfileName}
handleSaveDesignProfile={handleSaveDesignProfile}
instruct={instruct}
language={language}
/>
)}
</div>
</div>
</div>
</div>
{/* ═══ ACTION BAR — pinned to the column bottom ═══
{/* ═══ ACTION BAR — pinned to the column bottom ═══
Hidden for Convert: it drives text synthesis (script + overrides),
and Convert owns its action button inside its panel. */}
{defineMethod !== 'convert' && (
<ActionBar
t={t}
showOverrides={showOverrides}
setShowOverrides={setShowOverrides}
cfg={cfg}
setCfg={setCfg}
speed={speed}
setSpeed={setSpeed}
tShift={tShift}
setTShift={setTShift}
posTemp={posTemp}
setPosTemp={setPosTemp}
classTemp={classTemp}
setClassTemp={setClassTemp}
layerPenalty={layerPenalty}
setLayerPenalty={setLayerPenalty}
duration={duration}
setDuration={setDuration}
denoise={denoise}
setDenoise={setDenoise}
postprocess={postprocess}
setPostprocess={setPostprocess}
language={language}
setLanguage={setLanguage}
steps={steps}
setSteps={setSteps}
showHearDemo={showHearDemo}
playDemoOutput={playDemoOutput}
demoAudioPlaying={demoAudioPlaying}
demoAudioRef={demoAudioRef}
demoReleaseRef={demoReleaseRef}
setDemoAudioPlaying={setDemoAudioPlaying}
outputPlaying={outputPlaying}
isGenerating={isGenerating}
handleGenerate={handleGenerate}
generationTime={generationTime}
wasGeneratingRef={wasGeneratingRef}
/>
)}
</div>
{defineMethod !== 'convert' && (
<ActionBar
t={t}
showOverrides={showOverrides}
setShowOverrides={setShowOverrides}
cfg={cfg}
setCfg={setCfg}
speed={speed}
setSpeed={setSpeed}
tShift={tShift}
setTShift={setTShift}
posTemp={posTemp}
setPosTemp={setPosTemp}
classTemp={classTemp}
setClassTemp={setClassTemp}
layerPenalty={layerPenalty}
setLayerPenalty={setLayerPenalty}
duration={duration}
setDuration={setDuration}
denoise={denoise}
setDenoise={setDenoise}
postprocess={postprocess}
setPostprocess={setPostprocess}
language={language}
setLanguage={setLanguage}
steps={steps}
setSteps={setSteps}
showHearDemo={showHearDemo}
playDemoOutput={playDemoOutput}
demoAudioPlaying={demoAudioPlaying}
demoAudioRef={demoAudioRef}
demoReleaseRef={demoReleaseRef}
setDemoAudioPlaying={setDemoAudioPlaying}
outputPlaying={outputPlaying}
isGenerating={isGenerating}
handleGenerate={handleGenerate}
generationTime={generationTime}
wasGeneratingRef={wasGeneratingRef}
/>
)}
</TabsContent>
</Tabs>
);
}
+30 -2
View File
@@ -14,6 +14,7 @@ import { CATEGORIES } from '../utils/constants';
import { apiPost } from '../api/client';
vi.mock('../api/hooks', () => ({
useArchetypes: () => ({ data: { items: [] } }),
useEngines: () => ({ data: { tts: { backends: [] }, asr: { backends: [] } } }),
useSelectEngine: () => ({ mutate: vi.fn(), isPending: false }),
}));
@@ -125,8 +126,35 @@ describe('CloneDesignTab — Voice Design panel redesign regressions', () => {
['microphone startup is pending', { isStartingRecording: true }],
])('keeps the active voice method mounted while %s', (_label, state) => {
renderDesignTab(state);
expect(screen.getByRole('radio', { name: 'From audio' })).toBeDisabled();
expect(screen.getByRole('radio', { name: 'By design' })).toBeDisabled();
expect(screen.getByRole('tab', { name: 'From audio' })).toBeDisabled();
expect(screen.getByRole('tab', { name: 'By design' })).toBeDisabled();
expect(screen.getByRole('tab', { name: 'Convert' })).toBeDisabled();
});
it('places mode tabs above their associated workspace and switches the whole content', () => {
renderDesignTab({ text: 'Keep my script' });
const design = screen.getByRole('tab', { name: 'By design' });
const panel = screen.getByRole('tabpanel');
expect(design).toHaveAttribute('aria-selected', 'true');
expect(design).toHaveAttribute('aria-controls', panel.id);
expect(panel).toHaveAttribute('aria-labelledby', design.id);
expect(
screen.getByRole('tablist').compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Convert' }), {
button: 0,
ctrlKey: false,
});
expect(screen.getByTestId('convert-method-panel')).toBeInTheDocument();
expect(screen.queryByDisplayValue('Keep my script')).not.toBeInTheDocument();
fireEvent.mouseDown(screen.getByRole('tab', { name: 'From audio' }), {
button: 0,
ctrlKey: false,
});
expect(screen.getByDisplayValue('Keep my script')).toBeInTheDocument();
expect(screen.queryByTestId('convert-method-panel')).not.toBeInTheDocument();
});
it('starts the Details summary collapsed, not expanded', () => {