Compare commits

...
76 changed files with 2196 additions and 971 deletions
+18 -1
View File
@@ -10,17 +10,34 @@ the frozen-backend fallback mirror it for their toolchains.
**Highlights**
- Voice modes now lead the workspace as full-width tabs, with the active engine above them and Synthesize pinned below the scrolling form (#1823)
- Voice modes use themed tabs, with Synthesize and Convert pinned below their scrolling forms (#1823)
- 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)
### Changed
- Export uses grouped format settings, themed track menus and switches, with a pinned filename summary and download action (#1823)
- Dubbing output settings use icon-labelled switches, themed track and speaker menus, and clearer timing/transcript controls (#1823)
- Casting voice menus use searchable themed options with SVG preset icons instead of native dropdowns (#1823)
- Dubbing groups casting and translation controls with readable labels, SVG icons, searchable menus, and compact timeline spacing (#1823)
- Production Overrides use readable icon-labelled controls and accessible Denoise/Postprocess switches (#1823)
- Expanded navigation uses a theme-accent tint with subtle static wave gradients (#1823)
- Convert groups source audio, target voice, and timing options into clearer controls; design choices include theme-matched SVG icons (#1823)
- The expandable sidebar reveals workspace labels with restrained active states; language menus adapt to multiple columns on wider screens (#1823)
- Voice design and recording use themed, keyboard-accessible selectors with clearer spacing and labels (#1823)
- Voice tabs and upload/record controls have subtle SVG motion; Text adds clipboard paste and the upload area fills available height (#1823)
- The title-bar label cycles through active speech, transcription, and LLM engines; bundled model labels correctly say OmniVoice (#1823)
- The top-bar Engines panel groups Speech, Transcription, and LLM choices into tabs, with compact memory controls and no duplicate pickers (#1823)
### Added
### Docs
### Fixed
- Voice dropdowns match their field width, use theme accents, and show recent voices only once (#1823)
- Language menus no longer show a pale frame around their search header (#1823)
- The notification count stays inside the title bar instead of clipping above the bell (#1823)
- The workspace engine menu opens beside its button instead of at the opposite edge of the page (#1823)
- Cloning reuses the dubbing language picker with flags, search, and single selection, opening above the pinned synthesis controls (#1823)
## [0.5.2] — 2026-09-02
+2 -2
View File
@@ -57,7 +57,7 @@
| **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. In From audio and By design, Synthesize Audio stays at the bottom while the form scrolls above it.
The Voice workspace starts with 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, with Synthesize Audio or Convert pinned below the scrolling form. The top-bar **Engines** panel combines engine selection, loaded models, and unload/flush controls; <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd> opens it. The searchable language picker shares Dubbings flags and language list layout, selects one output language, and retains Auto and the full cloning catalogue. Language options flow into multiple columns when space allows. Expand **Workspaces** in the sidebar to reveal navigation labels; Escape collapses it.
<a id="install"></a>
@@ -131,7 +131,7 @@ The desktop launcher configures Python dependencies on first run via `uv` automa
|---|---|
| **Voice Cloning** | Zero-shot synthesis from a short reference clip ([guide](docs/engines/README.md)) |
| **Voice Design** | Create a voice from age, accent, pitch, style, and delivery instructions ([expressive speech](docs/expressive-speech.md)) |
| **Video Dubbing** | Transcribe, translate, preserve speakers, synthesize, and export video ([export guide](docs/dubbing/export.md)) |
| **Video Dubbing** | Transcribe, translate, preserve speakers, synthesize, and export video; grouped casting and translation controls include searchable language and engine menus ([export guide](docs/dubbing/export.md)) |
| **Stories and audiobooks** | Multi-voice scripts · EPUB/PDF import · chapter rendering · `.m4b` export |
| **[Dictation Widget](docs/features/dictation.md)** | System-wide shortcut, live transcription, optional local-LLM cleanup |
| **Vocal Isolation** | Demucs speech/background separation |
+21 -22
View File
@@ -2,7 +2,10 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
import { useTranslation } from 'react-i18next';
import { List } from 'react-window';
import DubSegmentRow from './DubSegmentRow';
import { Table, Select } from '../ui';
import { Table } from '../ui';
import { Headphones } from 'lucide-react';
import SearchableSelect from './SearchableSelect';
import DubToggle from './dub/DubToggle';
import { useAppStore } from '../store';
import { visibleMergeAvailability } from '../utils/segmentParts';
import useDubLivePreview from '../hooks/useDubLivePreview';
@@ -315,29 +318,25 @@ export default function DubSegmentTable({
searchPlaceholder={t('segment.search_placeholder')}
meta={meta}
>
<label className="dub-live-toggle" title={t('dub.live_preview_title')}>
<input
type="checkbox"
className="accent-[var(--color-brand)]"
checked={!!livePreviewOn}
onChange={(e) => setDubLivePreview(e.target.checked)}
/>
{t('dub.live_preview')}
</label>
<DubToggle
label={t('dub.live_preview')}
title={t('dub.live_preview_title')}
Icon={Headphones}
checked={livePreviewOn}
onChange={setDubLivePreview}
/>
{speakers.length > 1 && (
<Select
size="sm"
<SearchableSelect
menuPortal
ariaLabel={t('segment.all_speakers')}
value={speakerFilter}
onChange={(e) => setSpeakerFilter(e.target.value)}
className="dub-segment-table__spk-filter"
>
<option value="">{t('segment.all_speakers')}</option>
{speakers.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</Select>
onChange={setSpeakerFilter}
buttonClassName="min-h-10 rounded-lg border-0 px-3 text-sm bg-[var(--chrome-hover-bg)] text-[var(--chrome-fg)]"
options={[
{ value: '', label: t('segment.all_speakers') },
...speakers.map((s) => ({ value: s, label: s })),
]}
/>
)}
</Table.Toolbar>
+81 -43
View File
@@ -7,6 +7,7 @@ import { useEngines, useSelectEngine } from '../api/hooks';
import { notifyEngineSelected } from '../utils/engineSelectToast';
import { useAppStore } from '../store';
import { MENU_SURFACE } from './computeTarget';
import { engineDisplayName } from '../utils/engineDisplayName';
/**
* A compact TTS/ASR/LLM picker for chrome that needs to expose the active
@@ -22,6 +23,7 @@ export default function EngineQuickSwitch({
// top of the viewport.
dropUp = false,
prominent = false,
embedded = false,
}) {
const { t } = useTranslation();
const rootRef = useRef(null);
@@ -34,7 +36,7 @@ export default function EngineQuickSwitch({
queryFn: listLoadedModels,
staleTime: 10_000,
retry: false,
enabled: open,
enabled: open || embedded,
});
const familyData = engines?.[family];
@@ -99,38 +101,47 @@ export default function EngineQuickSwitch({
};
return (
<div className={`relative inline-flex shrink-0 items-center ${className}`} ref={rootRef}>
<button
type="button"
onClick={() => {
setSwitchError('');
setOpen((value) => !value);
}}
aria-haspopup="dialog"
aria-expanded={open}
title={t('engines.activeEngine', {
family: family.toUpperCase(),
engine: active.display_name,
})}
aria-label={t('engines.activeEngine', {
family: family.toUpperCase(),
engine: active.display_name,
})}
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={prominent ? 'min-w-0 break-words' : 'max-w-[124px] truncate'}>
{active.display_name}
</span>
</button>
<div
className={`${embedded ? 'block w-full' : 'relative inline-flex shrink-0 items-center'} ${prominent ? 'self-start max-w-full' : ''} ${className}`}
ref={rootRef}
>
{!embedded && (
<button
type="button"
onClick={() => {
setSwitchError('');
setOpen((value) => !value);
}}
aria-haspopup="dialog"
aria-expanded={open}
title={t('engines.activeEngine', {
family: family.toUpperCase(),
engine: active.display_name,
})}
aria-label={t('engines.activeEngine', {
family: family.toUpperCase(),
engine: active.display_name,
})}
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={prominent ? 'min-w-0 break-words' : 'max-w-[124px] truncate'}>
{active.display_name}
</span>
</button>
)}
{open && (
{(open || embedded) && (
<div
role="dialog"
role={embedded ? 'group' : 'dialog'}
aria-label={t('engines.engineCompatLabel', { family: family.toUpperCase() })}
className={`absolute right-0 z-[60] flex w-[272px] flex-col gap-[4px] p-[8px] ${
dropUp ? 'bottom-[calc(100%+8px)]' : 'top-[calc(100%+8px)]'
} ${MENU_SURFACE}`}
className={
embedded
? 'flex w-full min-w-0 flex-col gap-1 py-2'
: `absolute ${prominent ? 'left-0' : 'right-0'} z-[60] flex w-[272px] max-w-[calc(100vw-32px)] flex-col gap-[4px] p-[8px] ${
dropUp ? 'bottom-[calc(100%+8px)]' : 'top-[calc(100%+8px)]'
} ${MENU_SURFACE}`
}
>
{locked && (
<p className="m-[4px] text-[11px] leading-[1.4] text-[color:var(--chrome-fg-muted)]">
@@ -140,20 +151,45 @@ export default function EngineQuickSwitch({
{available.map((engine) => {
const isActive = engine.id === familyData.active;
const warm = residentIds.has(engine.id);
const displayName = engineDisplayName(engine.display_name);
const parts = displayName.match(/^(.+?)\s*\((.*)\)$/);
return (
<button
key={engine.id}
type="button"
disabled={isActive || locked || selectMutation.isPending}
onClick={() => choose(engine.id)}
className="flex w-full items-center gap-[8px] rounded-[5px] border-0 bg-transparent px-[7px] py-[6px] text-left text-[11px] text-[color:var(--chrome-fg)] hover:bg-[var(--chrome-hover-bg)] disabled:cursor-default disabled:opacity-60"
className={`flex w-full items-center gap-2 rounded-md border-0 px-2 py-3 text-left text-xs text-[color:var(--chrome-fg)] hover:bg-[var(--chrome-hover-bg)] disabled:cursor-default ${embedded && isActive ? 'bg-[var(--chrome-accent-bg)]' : 'bg-transparent'}`}
>
<span className="w-[12px] shrink-0">
{isActive && <Check size={12} aria-label={t('engines.active')} />}
</span>
<span className="min-w-0 flex-1 truncate">{engine.display_name}</span>
<span
className={
embedded
? 'min-w-0 flex-1 whitespace-normal break-words'
: 'min-w-0 flex-1 truncate'
}
>
{embedded && parts ? (
<>
<span className="block font-medium text-sm">{parts[1]}</span>
<span className="block mt-1 text-xs leading-relaxed text-[var(--chrome-fg-muted)]">
{parts[2]}
</span>
</>
) : (
displayName
)}
</span>
<span className="shrink-0 text-[10px] text-[color:var(--chrome-fg-muted)]">
{warm ? t('engines.inMemory') : t('engines.available')}
{warm
? t('engines.inMemory')
: isActive
? t('engines.active')
: embedded
? ''
: t('engines.available')}
</span>
</button>
);
@@ -166,16 +202,18 @@ export default function EngineQuickSwitch({
{switchError}
</p>
)}
<button
type="button"
onClick={() => {
setOpen(false);
useAppStore.getState().openCatalogue({ pane: 'engines', family });
}}
className="mt-[3px] flex items-center gap-[3px] border-0 bg-transparent px-[7px] py-[5px] text-left text-[11px] text-[color:var(--chrome-fg-muted)] hover:text-[color:var(--chrome-fg)]"
>
{t('settings.engines')} <ChevronRight size={12} aria-hidden="true" />
</button>
{!embedded && (
<button
type="button"
onClick={() => {
setOpen(false);
useAppStore.getState().openCatalogue({ pane: 'engines', family });
}}
className="mt-[3px] flex items-center gap-[3px] border-0 bg-transparent px-[7px] py-[5px] text-left text-[11px] text-[color:var(--chrome-fg-muted)] hover:text-[color:var(--chrome-fg)]"
>
{t('settings.engines')} <ChevronRight size={12} aria-hidden="true" />
</button>
)}
</div>
)}
</div>
@@ -39,6 +39,19 @@ function renderPicker(props = {}) {
}
describe('EngineQuickSwitch', () => {
it('embeds engine choices without another popup trigger', async () => {
renderPicker({ embedded: true });
expect(await screen.findByText('IndexTTS 2')).toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /active tts:/i })).not.toBeInTheDocument();
});
it('anchors the prominent menu to the left-hand engine button', async () => {
renderPicker({ prominent: true });
fireEvent.click(await screen.findByRole('button', { name: /active tts: omnivoice/i }));
expect(screen.getByRole('dialog')).toHaveClass('left-0');
expect(screen.getByRole('dialog')).not.toHaveClass('right-0');
});
beforeEach(() => {
vi.clearAllMocks();
listEngines.mockResolvedValue(inventory());
+18
View File
@@ -0,0 +1,18 @@
.export-drawer > header { padding: 18px 20px; border: 0; }
.export-drawer > header > span:not([aria-hidden]) { min-width: 0; }
.export-drawer > header > button { min-width: 36px; min-height: 36px; flex-shrink: 0; }
.export-tabs button { justify-content: center; min-height: 42px; border: 0; border-radius: 6px; }
.export-tabs button svg { width: 16px; height: 16px; }
.export-tabs .export-tab-active { background: var(--chrome-accent-bg); }
.export-fields > .grid { grid-template-columns: repeat(auto-fit,minmax(min(100%,260px),1fr)); gap: 12px; }
.export-field .ui-seg { flex-wrap: wrap; align-self: flex-start; border-radius: 8px; }
.export-field .ui-seg button { min-height: 34px; font-size: 12px; }
.export-field .dub-setting-toggle { text-align: left; line-height: 1.5; padding-inline: 0; }
.export-summary > div:first-child { flex: 1; flex-direction: column; align-items: flex-start; }
.export-summary code { max-width: 100%; white-space: normal; overflow-wrap: anywhere; font-size: 12px; }
.export-summary button { min-height: 40px; }
@media (max-width: 520px) {
.export-summary { flex-direction: column; align-items: stretch; }
.export-summary > div:last-child { justify-content: flex-end; }
.export-tabs button { padding-inline: 4px; font-size: 12px; gap: 4px; }
}
+92 -106
View File
@@ -16,17 +16,16 @@ import {
} from 'lucide-react';
import { Button, Segmented } from '../ui';
import TrackManager from './dub/TrackManager';
import DubToggle from './dub/DubToggle';
import SearchableSelect from './SearchableSelect';
import './ExportModal.css';
const TAB_BASE =
'inline-flex items-center gap-[6px] px-[12px] py-[6px] bg-transparent border-0 border-b-2 cursor-pointer text-[length:var(--text-sm)] transition-[color,border-color] duration-[var(--dur-fast)]';
const tabCls = (active) =>
active
? `${TAB_BASE} border-b-[var(--chrome-accent)] text-[var(--chrome-accent)]`
? `${TAB_BASE} export-tab-active border-b-[var(--chrome-accent)] text-[var(--chrome-accent)]`
: `${TAB_BASE} border-b-transparent text-[var(--chrome-fg-muted)] hover:text-[var(--chrome-fg)]`;
const TOGGLE_CLS =
'inline-flex items-center gap-[6px] text-[length:var(--text-sm)] text-[var(--chrome-fg)] cursor-pointer [&_input]:accent-[var(--color-brand)]';
const TOGGLE_INDENT_CLS =
'inline-flex items-center gap-[6px] ml-[var(--space-4)] text-[length:var(--text-sm)] text-[var(--chrome-fg-muted)] cursor-pointer [&_input]:accent-[var(--color-brand)]';
/**
* ExportModal — comprehensive export panel for the dubbing studio.
@@ -278,7 +277,7 @@ export default function ExportModal({
aria-label={t('exportModal.export_options')}
>
<div
className="pointer-events-auto flex w-[min(880px,calc(100vw-24px))] max-h-[min(70vh,560px)] flex-col overflow-hidden rounded-t-lg border border-b-0 border-transparent bg-[var(--chrome-bg)] shadow-[0_-8px_24px_rgba(0,0,0,0.45),0_-1px_0_var(--chrome-border)_inset] animate-in fade-in slide-in-from-bottom-full duration-200"
className="export-drawer pointer-events-auto flex w-[min(880px,calc(100vw-24px))] max-h-[85vh] flex-col overflow-hidden rounded-t-xl border-0 bg-[var(--chrome-bg)] shadow-xl animate-in fade-in duration-200 motion-reduce:animate-none"
ref={drawerRef}
>
<header className="relative flex items-center gap-[var(--space-3)] p-[6px_var(--space-4)_10px] [border-bottom:1px_solid_var(--chrome-border)] [background:linear-gradient(180deg,rgba(255,255,255,0.02),transparent)]">
@@ -303,7 +302,7 @@ export default function ExportModal({
<X size={13} />
</button>
</header>
<div className="flex flex-col gap-[var(--space-4)] overflow-y-auto p-[var(--space-3)_var(--space-4)_var(--space-4)]">
<div className="export-body flex min-h-0 flex-col gap-4 overflow-y-auto p-4">
{/* Preset chips */}
<div className="flex flex-wrap items-center gap-[var(--space-2)] pb-[var(--space-3)] [border-bottom:1px_solid_var(--chrome-border)]">
<span className="inline-flex items-center gap-[4px] uppercase [font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]">
@@ -332,7 +331,7 @@ export default function ExportModal({
/>
{/* Tabs */}
<div className="flex gap-[var(--space-1)] [border-bottom:1px_solid_var(--chrome-border)]">
<div className="export-tabs grid grid-cols-4 gap-1 rounded-lg bg-[var(--chrome-hover-bg)] p-1">
<button
type="button"
className={tabCls(tab === 'video')}
@@ -356,7 +355,7 @@ export default function ExportModal({
</div>
{/* Tab body */}
<div className="min-h-[160px]">
<div className="export-fields">
{tab === 'video' && (
<div className="grid grid-cols-2 gap-x-[var(--space-5)] gap-y-[var(--space-4)]">
<Field label={t('exportModal.container')}>
@@ -371,42 +370,38 @@ export default function ExportModal({
label={t('exportModal.default_audio_track')}
hint={t('exportModal.default_audio_hint')}
>
<select
className="input-base input-base--xs"
<SearchableSelect
menuPortal
ariaLabel={t('exportModal.default_audio_track')}
value={defaultTrack}
onChange={(e) => setDefaultTrack(e.target.value)}
>
{exportTracks['original'] !== false && (
<option value="original">{t('exportModal.original')}</option>
)}
{(dubTracks || [])
.filter((code) => exportTracks[code] !== false)
.map((code) => (
<option key={code} value={code}>
{code.toUpperCase()} {t('exportModal.dub_suffix')}
</option>
))}
</select>
onChange={setDefaultTrack}
buttonClassName="min-h-10 rounded-lg border-0 bg-[var(--chrome-hover-bg)] px-3 text-sm text-[var(--chrome-fg)]"
options={[
...(exportTracks.original !== false
? [{ value: 'original', label: t('exportModal.original') }]
: []),
...(dubTracks || [])
.filter((code) => exportTracks[code] !== false)
.map((code) => ({
value: code,
label: code.toUpperCase() + ' ' + t('exportModal.dub_suffix'),
})),
]}
/>
</Field>
<Field label={t('exportModal.bg_audio')}>
<label className={TOGGLE_CLS}>
<input
type="checkbox"
checked={preserveBg}
onChange={(e) => setPreserveBg(e.target.checked)}
/>
{t('exportModal.mix_bg_video')}
</label>
<DubToggle
label={t('exportModal.mix_bg_video')}
checked={preserveBg}
onChange={setPreserveBg}
/>
</Field>
<Field label={t('exportModal.subs_in_video')}>
<label className={TOGGLE_CLS}>
<input
type="checkbox"
checked={burnSubs}
onChange={(e) => setBurnSubs(e.target.checked)}
/>
{t('exportModal.hardsub')}
</label>
<DubToggle
label={t('exportModal.hardsub')}
checked={burnSubs}
onChange={setBurnSubs}
/>
{burnSubs && (
<Segmented
size="sm"
@@ -425,14 +420,11 @@ export default function ExportModal({
/>
)}
{burnSubs && (
<label className={TOGGLE_INDENT_CLS}>
<input
type="checkbox"
checked={!!dualSubs}
onChange={(e) => setDualSubs(e.target.checked)}
/>
{t('exportModal.dual_subs_video')}
</label>
<DubToggle
label={t('exportModal.dual_subs_video')}
checked={!!dualSubs}
onChange={setDualSubs}
/>
)}
</Field>
{(timingStrategy === 'smart_fit' || timingStrategy === 'stretch_video') && (
@@ -482,28 +474,25 @@ export default function ExportModal({
]}
/>
{audioBatch === 'primary' && (
<select
className="input-base input-base--xs mt-[6px]"
<SearchableSelect
menuPortal
ariaLabel={t('exportModal.languages')}
value={audioPrimaryLang}
onChange={(e) => setAudioPrimaryLang(e.target.value)}
>
{(dubTracks || []).map((code) => (
<option key={code} value={code}>
{code.toUpperCase()}
</option>
))}
</select>
onChange={setAudioPrimaryLang}
buttonClassName="min-h-10 rounded-lg border-0 bg-[var(--chrome-hover-bg)] px-3 text-sm text-[var(--chrome-fg)]"
options={(dubTracks || []).map((code) => ({
value: code,
label: code.toUpperCase(),
}))}
/>
)}
</Field>
<Field label={t('exportModal.bg_audio')}>
<label className={TOGGLE_CLS}>
<input
type="checkbox"
checked={preserveBg}
onChange={(e) => setPreserveBg(e.target.checked)}
/>
{t('exportModal.mix_bg_audio')}
</label>
<DubToggle
label={t('exportModal.mix_bg_audio')}
checked={preserveBg}
onChange={setPreserveBg}
/>
</Field>
</div>
)}
@@ -602,44 +591,43 @@ export default function ExportModal({
.
</span>
</div>
{/* Summary footer */}
<div className="flex items-center justify-between gap-[var(--space-3)] pt-[var(--space-3)] [border-top:1px_solid_var(--chrome-border)]">
<div className="flex min-w-0 items-center gap-[var(--space-2)]">
<span className="inline-flex items-center gap-[4px] uppercase [font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]">
{t('exportModal.output')}
</span>
<code
className="max-w-[340px] overflow-hidden text-ellipsis whitespace-nowrap rounded-[2px] bg-[var(--chrome-hover-bg)] px-[8px] py-[3px] [font-family:var(--chrome-font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg)]"
title={filenamePreview}
>
{filenamePreview}
</code>
</div>
<div className="inline-flex gap-[var(--space-2)]">
{tab !== 'pkg' && (
<>
<Button variant="ghost" size="sm" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
size="sm"
onClick={active.fn}
disabled={!active.can}
leading={<Download size={11} />}
title={active.can ? '' : t('exportModal.nothing_selected')}
>
{active.label}
</Button>
</>
)}
{tab === 'pkg' && (
</div>
{/* Summary footer stays visible while format settings scroll. */}
<div className="export-summary flex shrink-0 items-center justify-between gap-3 p-4 bg-[var(--chrome-hover-bg)]">
<div className="flex min-w-0 items-center gap-[var(--space-2)]">
<span className="inline-flex items-center gap-[4px] uppercase [font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]">
{t('exportModal.output')}
</span>
<code
className="max-w-[340px] overflow-hidden text-ellipsis whitespace-nowrap rounded-[2px] bg-[var(--chrome-hover-bg)] px-[8px] py-[3px] [font-family:var(--chrome-font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg)]"
title={filenamePreview}
>
{filenamePreview}
</code>
</div>
<div className="inline-flex gap-[var(--space-2)]">
{tab !== 'pkg' && (
<>
<Button variant="ghost" size="sm" onClick={onClose}>
{t('common.close')}
{t('common.cancel')}
</Button>
)}
</div>
<Button
variant="primary"
size="sm"
onClick={active.fn}
disabled={!active.can}
leading={<Download size={11} />}
title={active.can ? '' : t('exportModal.nothing_selected')}
>
{active.label}
</Button>
</>
)}
{tab === 'pkg' && (
<Button variant="ghost" size="sm" onClick={onClose}>
{t('common.close')}
</Button>
)}
</div>
</div>
</div>
@@ -650,11 +638,9 @@ export default function ExportModal({
function Field({ label, hint, children }) {
return (
<div className="flex flex-col gap-[6px]">
<div className="export-field flex min-w-0 flex-col gap-3 rounded-lg bg-[var(--chrome-hover-bg)] p-4">
<div className="flex flex-col gap-[2px]">
<span className="uppercase [font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]">
{label}
</span>
<span className="text-sm font-medium text-[var(--chrome-fg)]">{label}</span>
{hint && (
<span className="text-[length:var(--text-xs)] text-[var(--chrome-fg-dim)] leading-[1.4]">
{hint}
+13 -2
View File
@@ -3,8 +3,8 @@
// inside `.map(t => …)` callbacks where `t` was the loop variable, shadowing the
// useTranslation `t`. Rendering with a dub track that equals the primary
// dubLangCode exercises the exact crashing branch.
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '../i18n';
import ExportModal from './ExportModal';
@@ -41,6 +41,17 @@ function renderModal(extra = {}) {
}
describe('ExportModal (regression #183)', () => {
it('keeps export actions outside the scrolling settings and uses themed track choices', () => {
const setDefaultTrack = vi.fn();
renderModal({ setDefaultTrack });
expect(document.querySelector('.export-body')).not.toContainElement(
document.querySelector('.export-summary'),
);
fireEvent.click(screen.getByRole('button', { name: /default audio track/i }));
fireEvent.mouseDown(screen.getByRole('option', { name: /^ES/ }));
expect(setDefaultTrack).toHaveBeenCalledWith('es');
expect(screen.getAllByRole('switch')).toHaveLength(2);
});
it('renders with dub tracks incl. the primary dub without throwing', () => {
expect(() => renderModal()).not.toThrow();
});
+151 -27
View File
@@ -1,6 +1,9 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import GpuTarget from './GpuTarget';
import EngineQuickSwitch from './EngineQuickSwitch';
import { engineDisplayName } from '../utils/engineDisplayName';
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
import { createPortal } from 'react-dom';
import {
Globe,
@@ -27,7 +30,7 @@ import NotificationPanel from './NotificationPanel';
import TitleTabs from './TitleTabs';
import VoiceStudioMark from './brand/VoiceStudioMark';
import { useAppStore } from '../store';
import { useSysinfo } from '../api/hooks';
import { useSysinfo, useEngines } from '../api/hooks';
import { reloadAfterApplicationPersistence } from '../utils/persistenceLifecycle';
const VIEW_META = {
@@ -162,6 +165,23 @@ export default function Header({
const showLiveStats = useAppStore((s) => s.showHeaderLiveStats);
const [flushing, setFlushing] = useState(false);
const [flushOpen, setFlushOpen] = useState(false);
const [engineFamily, setEngineFamily] = useState('tts');
const { data: engines } = useEngines();
const [engineLabelIndex, setEngineLabelIndex] = useState(0);
const [engineLabelPaused, setEngineLabelPaused] = useState(false);
const activeEngines = ['tts', 'asr', 'llm'].flatMap((family) => {
const inventory = engines?.[family];
const active = inventory?.backends?.find((engine) => engine.id === inventory.active);
return active ? [{ family, name: engineDisplayName(active.display_name) }] : [];
});
const displayedEngine = activeEngines[engineLabelIndex % (activeEngines.length || 1)];
const activeEngineName = displayedEngine?.name;
const activeEngineShortName = activeEngineName?.split(' (')[0];
useEffect(() => {
if (flushOpen || engineLabelPaused || activeEngines.length < 2) return;
const timer = setInterval(() => setEngineLabelIndex((index) => index + 1), 4000);
return () => clearInterval(timer);
}, [flushOpen, engineLabelPaused, activeEngines.length]);
const [loadedModels, setLoadedModels] = useState([]);
const [unloading, setUnloading] = useState(null);
const flushRef = useRef(null);
@@ -172,8 +192,8 @@ export default function Header({
const computePos = useCallback(() => {
if (!flushBtnRef.current) return;
const rect = flushBtnRef.current.getBoundingClientRect();
const dropW = 260;
const dropH = 220; // approximate max height
const dropW = Math.min(420, window.innerWidth - 16);
const dropH = Math.min(640, window.innerHeight - 80);
const pad = 6;
// Default: below button, right-aligned
@@ -182,13 +202,13 @@ export default function Header({
// Flip up if too close to bottom
if (top + dropH > window.innerHeight - 10) {
top = rect.top - dropH - pad;
top = Math.max(8, rect.top - dropH - pad);
}
// Clamp left so it doesn't go off-screen
if (left < 8) left = 8;
if (left + dropW > window.innerWidth - 8) left = window.innerWidth - dropW - 8;
setDropdownPos({ top, left });
setDropdownPos({ top, left, width: dropW });
}, []);
// Recompute on open, resize, and scroll
@@ -221,6 +241,18 @@ export default function Header({
// Click outside to close (must check both the button wrapper AND the portal dropdown)
const dropdownRef = useRef(null);
useEffect(() => {
if (!flushOpen) return;
const frame = requestAnimationFrame(() =>
dropdownRef.current?.querySelector('button')?.focus(),
);
return () => cancelAnimationFrame(frame);
}, [flushOpen]);
useEffect(() => {
const show = () => setFlushOpen(true);
window.addEventListener('engine-quick-switch', show);
return () => window.removeEventListener('engine-quick-switch', show);
}, []);
useEffect(() => {
if (!flushOpen) return;
const handler = (e) => {
@@ -229,7 +261,17 @@ export default function Header({
if (!inBtn && !inDrop) setFlushOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
const escape = (event) => {
if (event.key === 'Escape') {
setFlushOpen(false);
flushBtnRef.current?.focus();
}
};
document.addEventListener('keydown', escape);
return () => {
document.removeEventListener('mousedown', handler);
document.removeEventListener('keydown', escape);
};
}, [flushOpen]);
const unloadModel = async (modelId) => {
@@ -327,7 +369,7 @@ export default function Header({
active={modelStatus === 'ready' || modelStatus === 'loading'}
/>
{sysStats && (
<div className="flex items-center gap-[10px] [font-family:var(--chrome-font-mono)] text-[10.5px] text-[var(--chrome-fg-dim)] bg-transparent h-[var(--chrome-pill-h)] whitespace-nowrap shrink overflow-hidden tabular-nums slashed-zero max-[851px]:hidden!">
<div className="flex items-center gap-2 [font-family:var(--chrome-font-mono)] text-[10.5px] text-[var(--chrome-fg-dim)] bg-transparent h-[var(--chrome-pill-h)] whitespace-nowrap shrink-0 tabular-nums slashed-zero">
{showLiveStats && (
<>
<span className="max-[1081px]:hidden">
@@ -381,22 +423,99 @@ export default function Header({
ref={flushBtnRef}
variant="subtle"
size="sm"
title={t('header.memory_management')}
title={activeEngineName || t('header.memory_management')}
aria-label={t('settings.engines')}
aria-haspopup="dialog"
aria-expanded={flushOpen}
loading={flushing}
leading={!flushing && <Zap size={8} />}
trailing={<ChevronDown size={8} />}
onClick={() => setFlushOpen((o) => !o)}
onMouseEnter={() => setEngineLabelPaused(true)}
onMouseLeave={() => setEngineLabelPaused(false)}
onFocus={() => setEngineLabelPaused(true)}
onBlur={() => setEngineLabelPaused(false)}
onClick={() => {
if (!flushOpen && displayedEngine) setEngineFamily(displayedEngine.family);
setFlushOpen((o) => !o);
}}
className="ml-[2px]"
>
{t('header.flush')}
<span
key={displayedEngine?.family}
className="engine-title-label w-[190px] text-left truncate max-[600px]:w-[140px]"
>
{displayedEngine && (
<span className="text-[var(--chrome-fg-dim)]">
{t(`models.role_${displayedEngine.family}`)} ·{' '}
</span>
)}
{activeEngineShortName || t('settings.engines')}
</span>
</Button>
{flushOpen &&
createPortal(
<div
className="fixed w-[260px] bg-[var(--color-bg-elev-1)] [border:1px_solid_var(--color-border)] rounded-[var(--radius-lg)] [box-shadow:0_8px_24px_rgba(0,0,0,0.5)] z-[9999] py-[4px] [animation:flush-slide_0.12s_ease-out]"
style={{ top: dropdownPos.top, left: dropdownPos.left }}
role="dialog"
aria-label={t('settings.engines')}
className="fixed overflow-y-auto bg-[var(--color-bg)] border border-[var(--color-border)] rounded-[var(--radius-lg)] shadow-xl z-[9999] p-3"
style={{
top: dropdownPos.top,
left: dropdownPos.left,
width: dropdownPos.width,
maxHeight: `calc(100vh - ${dropdownPos.top + 8}px)`,
}}
ref={dropdownRef}
>
<div className="flex items-center justify-between gap-2 pb-2">
<span className="text-sm font-semibold">{t('settings.engines')}</span>
<button
type="button"
aria-label={t('common.close')}
onClick={() => {
setFlushOpen(false);
flushBtnRef.current?.focus();
}}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border-0 bg-transparent text-[var(--chrome-fg-muted)] cursor-pointer hover:bg-[var(--chrome-hover-bg)] focus-visible:outline-2 focus-visible:outline-[var(--chrome-accent)]"
>
<X size={16} />
</button>
</div>
<Tabs value={engineFamily} onValueChange={setEngineFamily}>
<TabsList
aria-label={t('settings.engines')}
className="w-full h-auto grid grid-cols-3 bg-[var(--chrome-hover-bg)]"
>
{['tts', 'asr', 'llm'].map((family) => (
<TabsTrigger
key={family}
value={family}
className="min-h-11 cursor-pointer whitespace-normal bg-transparent text-xs text-[var(--chrome-fg-muted)] data-[state=active]:bg-[var(--chrome-accent-bg)] data-[state=active]:text-[var(--chrome-accent)] dark:data-[state=active]:bg-[var(--chrome-accent-bg)] dark:data-[state=active]:text-[var(--chrome-accent)] hover:data-[state=inactive]:bg-[var(--chrome-hover-bg)]"
>
{family === 'tts'
? t('header.speech')
: family === 'asr'
? t('projects.transcription')
: t('models.role_llm')}
</TabsTrigger>
))}
</TabsList>
<TabsContent value={engineFamily}>
<EngineQuickSwitch key={engineFamily} family={engineFamily} embedded />
</TabsContent>
</Tabs>
<button
type="button"
className="flex items-center gap-1 border-0 bg-transparent p-2 text-xs text-[var(--chrome-fg-muted)] cursor-pointer hover:text-[var(--chrome-fg)]"
onClick={() => {
setFlushOpen(false);
useAppStore
.getState()
.openCatalogue({ pane: 'engines', family: engineFamily });
}}
>
{t('header.label_catalogue')} <ChevronRight size={12} />
</button>
<div className="h-px bg-[var(--color-border)] my-2" />
<div className="text-[10px] font-semibold text-[var(--color-fg-subtle)] uppercase tracking-[0.5px] pt-[6px] px-[12px] pb-[4px]">
{t('header.loaded_models')}
</div>
@@ -412,7 +531,7 @@ export default function Header({
>
<div className="flex flex-col gap-[1px] min-w-0">
<span className="text-[12px] text-[var(--color-fg)] font-medium">
{m.name}
{engineDisplayName(m.name)}
{/* Resident-but-not-routed engine (e.g. VoiceStudio still in
VRAM after switching to another backend) — say so. */}
{m.is_active_engine === false && (
@@ -453,20 +572,25 @@ export default function Header({
>
<Zap size={10} /> {t('header.flush_caches')}
</button>
<button
className="flex items-center gap-[6px] w-full py-[6px] px-[12px] text-[12px] text-[#fb4934] bg-transparent border-none cursor-pointer text-left hover:bg-[rgba(251,73,52,0.08)]"
onClick={async () => {
setFlushing(true);
setFlushOpen(false);
try {
await onFlushMemory(true);
} finally {
setFlushing(false);
}
}}
>
<Trash2 size={10} /> {t('header.unload_all_flush')}
</button>
<details>
<summary className="cursor-pointer px-3 py-2 text-xs text-[var(--chrome-fg-muted)]">
{t('header.memory_management')}
</summary>
<button
className="flex items-center gap-[6px] w-full py-[6px] px-[12px] text-[12px] text-[#fb4934] bg-transparent border-none cursor-pointer text-left hover:bg-[rgba(251,73,52,0.08)]"
onClick={async () => {
setFlushing(true);
setFlushOpen(false);
try {
await onFlushMemory(true);
} finally {
setFlushing(false);
}
}}
>
<Trash2 size={10} /> {t('header.unload_all_flush')}
</button>
</details>
</div>,
document.body,
)}
-2
View File
@@ -32,7 +32,6 @@ import { useTranslation } from 'react-i18next';
import { useAppStore } from '../store';
import NetworkToggle from './NetworkToggle';
import ComputeQuickSettings from './ComputeQuickSettings';
import EngineQuickSwitch from './EngineQuickSwitch';
import { APP_VERSION, whatsNewPending } from '../utils/appVersion';
import DonateMomentPopover, { DONATE_POPOVER_AUTO_DISMISS_MS } from './DonateMomentPopover';
import { DONATION_MOMENT_EVENT, optOutOfDonationMoments } from '../utils/donationMoments';
@@ -640,7 +639,6 @@ export default function LogsFooter() {
)}
</button>
<ComputeQuickSettings />
<EngineQuickSwitch shortcutTarget dropUp />
<NetworkToggle />
<button
type="button"
+76 -31
View File
@@ -1,6 +1,6 @@
import React, { useState, useMemo, useRef, useEffect, useLayoutEffect, useId } from 'react';
import { createPortal } from 'react-dom';
import { Check, X, Search, Globe } from 'lucide-react';
import { Check, X, Search, Globe, ChevronDown } from 'lucide-react';
import { POPULAR_LANGS } from '../utils/constants';
import { LANG_CODES } from '../utils/languages';
import { useTranslation } from 'react-i18next';
@@ -17,6 +17,9 @@ export default function MultiLangPicker({
onChange, // (newSelected) => void
disabled = false,
progressByCode = {},
single = false,
options = LANG_CODES,
ariaLabel,
}) {
const { t } = useTranslation();
const [dropOpen, setDropOpen] = useState(false);
@@ -116,6 +119,13 @@ export default function MultiLangPicker({
}, [query, selected]);
const addLang = (lang, code) => {
if (single) {
onChange?.([{ lang, code }]);
setDropOpen(false);
setQuery('');
triggerRef.current?.focus();
return;
}
if (selectedCodes.has(code)) return;
onChange?.([...selected, { lang, code }]);
setQuery('');
@@ -127,17 +137,17 @@ export default function MultiLangPicker({
const filteredLangs = useMemo(() => {
const q = query.toLowerCase().trim();
return LANG_CODES.filter(
return options.filter(
(lc) =>
!selectedCodes.has(lc.code) &&
(!q || lc.label.toLowerCase().includes(q) || lc.code.toLowerCase().includes(q)),
);
}, [query, selectedCodes]);
}, [query, selectedCodes, options]);
const popularFiltered = useMemo(() => {
const q = query.toLowerCase().trim();
return POPULAR_LANGS.map((lang) => {
const match = LANG_CODES.find((lc) => lc.label.toLowerCase() === lang.toLowerCase());
const match = options.find((lc) => lc.label.toLowerCase() === lang.toLowerCase());
return match ? { lang, code: match.code } : null;
}).filter(
(item) =>
@@ -145,7 +155,7 @@ export default function MultiLangPicker({
!selectedCodes.has(item.code) &&
(!q || item.lang.toLowerCase().includes(q) || item.code.includes(q)),
);
}, [query, selectedCodes]);
}, [query, selectedCodes, options]);
return (
<div className="relative" ref={dropRef}>
@@ -155,21 +165,32 @@ export default function MultiLangPicker({
className="flex min-h-[30px] max-w-full items-center gap-[7px] rounded-[var(--chrome-radius-pill)] border border-transparent bg-[var(--chrome-hover-bg)] px-[9px] py-[4px] text-left text-[color:var(--chrome-fg)] cursor-pointer transition-colors hover:bg-[var(--chrome-accent-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--chrome-accent)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => setDropOpen((open) => !open)}
disabled={disabled}
style={single ? { minHeight: 44, width: '100%' } : undefined}
aria-haspopup="dialog"
aria-expanded={dropOpen}
aria-controls={dropOpen ? menuId : undefined}
aria-label={t('dub.manage_languages')}
aria-label={ariaLabel || t('dub.manage_languages')}
>
<Globe size={11} className="shrink-0" aria-hidden="true" />
<span className="truncate text-[0.7rem] font-medium">{t('dub.manage_languages')}</span>
<span className="shrink-0 font-mono text-[0.62rem] text-[color:var(--chrome-fg-muted)]">
{t('dub.languages_selected', { count: selected.length })}
</span>
{selected.length > 0 && (
<span className="min-w-0 truncate font-mono text-[0.6rem] text-[color:var(--chrome-fg-dim)]">
{t('dub.languages_done')}: {completedCount} · {t('dub.languages_pending')}:{' '}
{selected.length - completedCount}
</span>
{single ? (
<>
<LanguageFlag code={selected[0]?.code} />
<span className="min-w-0 truncate text-sm">{selected[0]?.lang}</span>
<ChevronDown size={14} className="shrink-0" aria-hidden="true" />
</>
) : (
<>
<Globe size={11} className="shrink-0" aria-hidden="true" />
<span className="truncate text-[0.7rem] font-medium">{t('dub.manage_languages')}</span>
<span className="shrink-0 font-mono text-[0.62rem] text-[color:var(--chrome-fg-muted)]">
{t('dub.languages_selected', { count: selected.length })}
</span>
{selected.length > 0 && (
<span className="min-w-0 truncate font-mono text-[0.6rem] text-[color:var(--chrome-fg-dim)]">
{t('dub.languages_done')}: {completedCount} · {t('dub.languages_pending')}:{' '}
{selected.length - completedCount}
</span>
)}
</>
)}
</button>
@@ -181,7 +202,26 @@ export default function MultiLangPicker({
id={menuId}
className="multi-lang__drop"
role="dialog"
aria-label={t('dub.manage_languages')}
aria-label={ariaLabel || t('dub.manage_languages')}
onKeyDown={(event) => {
if (!single) return;
const buttons = Array.from(menuRef.current.querySelectorAll('button'));
if (event.key === 'Enter' && event.target === inputRef.current) {
event.preventDefault();
buttons[0]?.click();
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const index = buttons.indexOf(document.activeElement);
const next =
event.key === 'ArrowDown'
? index + 1
: index < 0
? buttons.length - 1
: index - 1;
buttons[(next + buttons.length) % buttons.length]?.focus();
}
}}
style={
menuPos
? {
@@ -193,7 +233,7 @@ export default function MultiLangPicker({
: { visibility: 'hidden' }
}
>
<div className="flex items-center gap-[6px] px-[10px] py-[8px] border-b border-solid border-b-transparent text-[color:var(--chrome-fg-muted)]">
<div className="flex items-center gap-[6px] px-[10px] py-[8px] border-0 text-[color:var(--chrome-fg-muted)]">
<Search size={10} aria-hidden="true" />
<input
ref={inputRef}
@@ -207,7 +247,7 @@ export default function MultiLangPicker({
className="flex-1 bg-transparent border-0 text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[0.78rem] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--chrome-accent)]"
/>
</div>
<div className="overflow-y-auto overscroll-contain flex-1 py-[4px]">
<div className="multi-lang-options overflow-y-auto overscroll-contain flex-1 py-[4px]">
{selectedFiltered.length > 0 && (
<>
<div className="[font-family:var(--font-mono)] text-[0.62rem] font-semibold uppercase [letter-spacing:0.04em] text-[color:var(--chrome-fg-dim)] pt-[6px] px-[10px] pb-[2px]">
@@ -219,20 +259,25 @@ export default function MultiLangPicker({
type="button"
className="flex items-center gap-[8px] w-full px-[10px] py-[5px] bg-transparent border-0 text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[0.76rem] cursor-pointer text-left hover:bg-[var(--chrome-hover-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--chrome-accent)]"
style={{ contentVisibility: 'auto', containIntrinsicSize: '30px' }}
onClick={() => removeLang(item.code)}
aria-label={t('common.remove', { term: item.lang })}
onClick={() =>
single ? addLang(item.lang, item.code) : removeLang(item.code)
}
aria-label={single ? item.lang : t('common.remove', { term: item.lang })}
aria-pressed={single ? true : undefined}
>
<Check size={10} className="text-[var(--chrome-accent)]" aria-hidden="true" />
<LanguageFlag code={item.code} />
<span className="min-w-[28px] font-mono text-[0.68rem] font-semibold uppercase text-[var(--chrome-accent)]">
{item.code}
{item.code !== item.lang ? item.code : ''}
</span>
<span className="min-w-0 flex-1 truncate">{item.lang}</span>
<span className="shrink-0 font-mono text-[0.62rem] text-[var(--chrome-fg-dim)]">
{progressByCode[item.code]?.ready || 0}/
{progressByCode[item.code]?.total || 0}
</span>
<X size={10} aria-hidden="true" />
{!single && (
<span className="shrink-0 font-mono text-[0.62rem] text-[var(--chrome-fg-dim)]">
{progressByCode[item.code]?.ready || 0}/
{progressByCode[item.code]?.total || 0}
</span>
)}
{!single && <X size={10} aria-hidden="true" />}
</button>
))}
</>
@@ -252,7 +297,7 @@ export default function MultiLangPicker({
>
<LanguageFlag code={item.code} />
<span className="[font-family:var(--font-mono)] text-[0.68rem] text-[color:var(--chrome-accent)] min-w-[28px] font-semibold">
{item.code}
{item.code !== item.lang ? item.code : ''}
</span>
<span className="min-w-0 truncate">{item.lang}</span>
</button>
@@ -262,7 +307,7 @@ export default function MultiLangPicker({
<div className="[font-family:var(--font-mono)] text-[0.62rem] font-semibold uppercase [letter-spacing:0.04em] text-[color:var(--chrome-fg-dim)] pt-[6px] px-[10px] pb-[2px]">
{t('dub.all_languages')}
</div>
{filteredLangs.slice(0, 50).map((lc) => (
{(single ? filteredLangs : filteredLangs.slice(0, 50)).map((lc) => (
<button
key={lc.code}
type="button"
@@ -272,12 +317,12 @@ export default function MultiLangPicker({
>
<LanguageFlag code={lc.code} />
<span className="[font-family:var(--font-mono)] text-[0.68rem] text-[color:var(--chrome-accent)] min-w-[28px] font-semibold">
{lc.code}
{lc.code !== lc.label ? lc.code : ''}
</span>
<span className="min-w-0 truncate">{lc.label}</span>
</button>
))}
{filteredLangs.length > 50 && (
{!single && filteredLangs.length > 50 && (
<div className="px-[10px] py-[8px] text-[0.7rem] text-[color:var(--chrome-fg-dim)] text-center">
{t('dub.more_to_narrow', { count: filteredLangs.length - 50 })}
</div>
@@ -23,6 +23,27 @@ afterEach(() => {
});
describe('MultiLangPicker viewport-safe menu', () => {
it('replaces a single selection, preserves Auto, and closes after choosing', () => {
const onChange = vi.fn();
render(
<MultiLangPicker
single
selected={[{ lang: 'English', code: 'en' }]}
options={[{ label: 'Auto', code: 'Auto' }, ...LANG_CODES]}
onChange={onChange}
/>,
);
const trigger = screen.getByRole('button', { name: 'Manage languages' });
expect(trigger).toHaveTextContent('English');
fireEvent.click(trigger);
expect(screen.queryByRole('button', { name: 'Remove English' })).not.toBeInTheDocument();
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Auto' } });
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' });
expect(onChange).toHaveBeenCalledWith([{ lang: 'Auto', code: 'Auto' }]);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
it('portals outside clipping ancestors and flips above a bottom-edge trigger', () => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1000 });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 });
+50
View File
@@ -0,0 +1,50 @@
.nav-rail {
position: relative;
width: 48px;
justify-self: start;
min-height: 0;
}
.nav-rail[data-side="right"] { justify-self: end; }
.nav-rail[data-expanded="true"] {
width: min(232px, 80vw);
/* Solid themed base keeps the workspace from showing through. Elliptical
washes form quiet waves without moving behind text or clipping tooltips. */
background-color: color-mix(in srgb, var(--chrome-accent) 2%, var(--chrome-bg));
background-image:
radial-gradient(ellipse at 0% 25%, color-mix(in srgb, var(--chrome-accent) 4%, transparent), transparent 60%),
radial-gradient(ellipse at 110% 100%, color-mix(in srgb, var(--chrome-accent) 3%, transparent), transparent 65%);
box-shadow: 8px 0 24px rgb(0 0 0 / 18%);
}
.nav-rail-waves { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; fill: none; stroke: var(--chrome-accent); stroke-width: .7; opacity: .055; }
.nav-rail[data-expanded="true"] > :not(.nav-rail-waves) { position: relative; }
.nav-rail[data-side="right"][data-expanded="true"] { box-shadow: -8px 0 24px rgb(0 0 0 / 18%); }
.nav-rail[data-expanded="true"] > div { width: 100%; padding-inline: 12px; }
.nav-rail-links { min-height: 0; }
.nav-rail[data-expanded="true"] .nav-rail-links { gap: 4px; overflow-y: auto; }
.nav-rail-item[data-expanded="true"],
.nav-rail[data-expanded="true"] .nav-rail-toggle {
width: 100%;
justify-content: flex-start;
gap: 12px;
padding-inline: 12px;
}
.nav-rail-item { flex-shrink: 0; }
.nav-rail-item[data-expanded="true"] { height: 42px; border-radius: 8px; }
.nav-rail-item[data-expanded="true"] span { font-weight: 450; }
.nav-rail-item[data-expanded="true"][aria-current="page"] {
border-color: transparent;
background: color-mix(in srgb, var(--rail-accent) 13%, var(--color-bg));
}
.nav-rail-item[data-expanded="true"][aria-current="page"] span { font-weight: 600; }
.nav-rail-item[data-expanded="true"]::before { left: 0; right: auto; width: 2px; box-shadow: none; }
.nav-rail[data-side="right"] .nav-rail-item[data-expanded="true"]::before { right: 0; left: auto; }
.nav-rail[data-expanded="true"] .nav-rail-toggle { width: calc(100% - 24px); margin-bottom: 8px; justify-content: space-between; }
.nav-rail[data-expanded="true"] .nav-rail-toggle span { order: -1; font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
.nav-rail[data-expanded="true"] .nav-rail-footer { padding-top: 12px; border-top: 1px solid var(--chrome-border); }
.nav-rail-item:focus-visible, .nav-rail-toggle:focus-visible { outline: 2px solid var(--chrome-accent); outline-offset: 2px; }
.nav-rail-item svg { flex-shrink: 0; transition: transform 160ms ease-out; }
.nav-rail-item:is(:hover, :focus-visible) svg { transform: translateY(-1px) rotate(-5deg); }
.nav-rail-item:active svg { transform: scale(0.94); }
@media (prefers-reduced-motion: reduce) {
.nav-rail, .nav-rail-item svg { transition: none; transform: none !important; }
}
+70 -10
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ArrowLeftRight } from 'lucide-react';
import { ArrowLeftRight, PanelLeftOpen, PanelLeftClose } from 'lucide-react';
import './NavRail.css';
import { NAV_ITEMS as ITEM_DEFS, NAV_FOOTER_ITEMS as FOOTER_DEFS } from './navItems';
// Shared icon-button base for the chrome rail (was `.rail-btn`). `group` enables
@@ -18,7 +19,7 @@ function railLabelCls(side) {
return `pointer-events-none absolute top-1/2 z-[10000] whitespace-nowrap rounded-[var(--chrome-radius-pill)] bg-[var(--chrome-bg)] px-[8px] py-[3px] font-sans text-[11px] font-medium text-[var(--chrome-fg)] opacity-0 [border:1px_solid_var(--chrome-border-strong)] [transition:opacity_0.15s,transform_0.15s] group-hover:opacity-100 group-hover:[transform:translate(0,-50%)] ${sideCls}`;
}
function RailBtn({ active, Icon, label, accent, side, onClick }) {
function RailBtn({ active, Icon, label, accent, side, onClick, expanded }) {
// Active = accent-tinted fill/border + an accent indicator bar (`::before`)
// hanging off the rail edge; flips edges with the rail side.
const stateCls = active
@@ -29,19 +30,40 @@ function RailBtn({ active, Icon, label, accent, side, onClick }) {
return (
<button
onClick={onClick}
title={label}
aria-label={label}
className={`${RAIL_BTN_BASE} ${stateCls}`}
aria-current={active ? 'page' : undefined}
className={`nav-rail-item ${RAIL_BTN_BASE} ${stateCls}`}
data-expanded={expanded}
style={{ '--rail-accent': accent }}
>
<Icon size={18} />
<span className={railLabelCls(side)}>{label}</span>
<Icon size={18} aria-hidden="true" />
<span className={expanded ? 'min-w-0 truncate text-sm' : railLabelCls(side)}>{label}</span>
</button>
);
}
export default function NavRail({ mode, setMode, side = 'left', onFlipSide }) {
const { t } = useTranslation();
const [expanded, setExpanded] = React.useState(false);
const railRef = React.useRef(null);
React.useEffect(() => {
if (!expanded) return;
const closeOutside = (event) => {
if (!railRef.current?.contains(event.target)) setExpanded(false);
};
const escape = (event) => {
if (event.key === 'Escape') {
setExpanded(false);
railRef.current?.querySelector('[aria-expanded]')?.focus();
}
};
document.addEventListener('pointerdown', closeOutside);
document.addEventListener('keydown', escape);
return () => {
document.removeEventListener('pointerdown', closeOutside);
document.removeEventListener('keydown', escape);
};
}, [expanded]);
const items = React.useMemo(
() => ITEM_DEFS.map((d) => ({ ...d, label: t(`nav.${d.tKey}`) })),
[t],
@@ -61,27 +83,65 @@ export default function NavRail({ mode, setMode, side = 'left', onFlipSide }) {
return (
<aside
ref={railRef}
data-expanded={expanded}
data-side={side}
className={`nav-rail z-50 flex select-none flex-col items-center gap-[10px] bg-[var(--chrome-bg)] pb-[10px] pt-[18px] ${asideBorder}`}
>
<div className="flex flex-1 flex-col items-center gap-[9px]">
{expanded && (
<svg
className="nav-rail-waves"
viewBox="0 0 232 1000"
preserveAspectRatio="none"
aria-hidden="true"
focusable="false"
>
{[0, 12, 24, 36].map((offset) => (
<path
key={offset}
transform={`translate(${offset} 0)`}
d="M-90 80 C270 240 -170 450 60 650 S270 860 120 1040"
/>
))}
</svg>
)}
<button
type="button"
aria-label={t('nav.workspaces')}
aria-expanded={expanded}
onClick={() => setExpanded((value) => !value)}
className="nav-rail-toggle inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md border-0 bg-transparent text-[var(--chrome-fg-muted)] cursor-pointer hover:bg-[var(--chrome-hover-bg)]"
>
{expanded ? <PanelLeftClose size={18} /> : <PanelLeftOpen size={18} />}
{expanded && <span className="text-sm">{t('nav.workspaces')}</span>}
</button>
<div className="nav-rail-links flex flex-1 flex-col items-center gap-[9px]">
{items.map((it) => (
<RailBtn
key={it.id}
{...it}
side={side}
expanded={expanded}
active={mode === it.id}
onClick={() => setMode(it.id)}
onClick={() => {
setMode(it.id);
setExpanded(false);
}}
/>
))}
</div>
<div className="flex flex-col items-center gap-[8px]">
<div className="nav-rail-footer flex flex-col items-center gap-[8px]">
{footerItems.map((it) => (
<RailBtn
key={it.id}
{...it}
side={side}
expanded={expanded}
active={mode === it.id}
onClick={() => setMode(it.id)}
onClick={() => {
setMode(it.id);
setExpanded(false);
}}
/>
))}
<button
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import NavRail from './NavRail';
describe('expandable workspace rail', () => {
it('reveals labels without duplicate tooltips and collapses on selection', () => {
const setMode = vi.fn();
render(<NavRail mode="launchpad" setMode={setMode} />);
const toggle = screen.getByRole('button', { name: 'Workspaces' });
fireEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-expanded', 'true');
const voice = screen.getByRole('button', { name: 'Voice', exact: true });
expect(voice).not.toHaveAttribute('title');
expect(voice).toHaveAttribute('data-expanded', 'true');
expect(screen.getByRole('button', { name: 'Launchpad', exact: true })).toHaveAttribute(
'aria-current',
'page',
);
fireEvent.click(voice);
expect(setMode).toHaveBeenCalledWith('studio');
expect(toggle).toHaveAttribute('aria-expanded', 'false');
});
it('closes on Escape and restores toggle focus', () => {
render(<NavRail mode="studio" setMode={vi.fn()} side="right" />);
const toggle = screen.getByRole('button', { name: 'Workspaces' });
fireEvent.click(toggle);
fireEvent.keyDown(document, { key: 'Escape' });
expect(toggle).toHaveAttribute('aria-expanded', 'false');
expect(toggle).toHaveFocus();
});
});
@@ -26,10 +26,10 @@ export default function NotificationPanel() {
aria-label={`Notifications (${count})`}
title="Notifications"
>
<Bell size={14} />
<Bell size={14} className={count > 0 ? '-translate-x-0.5 translate-y-0.5' : ''} />
{count > 0 && (
<span
className={`pointer-events-none absolute -top-[4px] -right-[4px] flex h-[14px] min-w-[14px] items-center justify-center rounded-[7px] px-[3px] font-mono text-[9px] font-bold leading-none text-white shadow-[0_1px_3px_rgba(0,0,0,0.4)] ${!hasErrors && hasWarns ? 'bg-warn' : 'bg-danger'}`}
className={`pointer-events-none absolute top-0 right-0 flex h-[14px] min-w-[14px] items-center justify-center rounded-[7px] px-[3px] font-mono text-[9px] font-bold leading-none text-white shadow-[0_1px_3px_rgba(0,0,0,0.4)] ${!hasErrors && hasWarns ? 'bg-warn' : 'bg-danger'}`}
>
{count}
</span>
@@ -0,0 +1,14 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { expect, it, vi } from 'vitest';
import NotificationPanel from './NotificationPanel';
vi.mock('../api/hooks', () => ({
useVisibleNotifications: () => ({ notifications: [{ level: 'error' }, { level: 'warn' }] }),
}));
it('keeps the badge inside the title-bar button', () => {
render(<NotificationPanel />);
expect(screen.getByText('2')).toHaveClass('top-0', 'right-0');
expect(screen.getByText('2')).not.toHaveClass('-top-[4px]');
});
+35 -18
View File
@@ -128,7 +128,17 @@ export default function SearchableSelect({
return out;
}, [query, recents, popular, byVal]);
const displayed = useMemo(() => filtered.slice(0, MAX_DISPLAY), [filtered]);
const displayed = useMemo(() => {
const seen = new Set(pinned.map(({ o }) => getVal(o)));
return filtered
.filter((option) => {
const key = getVal(option);
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.slice(0, MAX_DISPLAY);
}, [filtered, pinned, getVal]);
const flatItems = useMemo(() => {
const list = [];
@@ -160,8 +170,8 @@ export default function SearchableSelect({
const r = el.getBoundingClientRect();
// Clamp within the viewport so a right-edge (narrow-column) trigger can't
// push the min-220px menu off-screen and force a horizontal scrollbar.
const width = Math.max(r.width, 220);
const left = Math.min(r.left, Math.max(8, window.innerWidth - width - 8));
const width = Math.min(Math.max(r.width, 220), window.innerWidth - 16);
const left = Math.max(8, Math.min(r.left, window.innerWidth - width - 8));
// Flip above the trigger when there isn't enough room below (e.g. the last
// dub segment row, near the viewport bottom) — otherwise a below-anchored
// fixed menu runs off-screen and scrolling just re-pins it there. Also cap
@@ -174,8 +184,8 @@ export default function SearchableSelect({
const listMax = Math.max(120, Math.floor(Math.min(280, openUp ? above : below)));
setMenuPos(
openUp
? { bottom: vh - r.top + GAP, left, width: r.width, listMax }
: { top: r.bottom + GAP, left, width: r.width, listMax },
? { bottom: vh - r.top + GAP, left, width, listMax }
: { top: r.bottom + GAP, left, width, listMax },
);
};
place();
@@ -273,7 +283,7 @@ export default function SearchableSelect({
wrapMenu(
<div
ref={menuRef}
className={`z-[1000] bg-[rgba(29,32,33,0.98)] [border:1px_solid_rgba(255,255,255,0.1)] rounded-[6px] shadow-[0_8px_24px_rgba(0,0,0,0.5)] [backdrop-filter:blur(12px)] overflow-hidden min-w-[220px] max-w-[min(360px,90vw)] ${
className={`z-[1000] bg-[var(--color-bg)] border-0 rounded-lg shadow-xl overflow-hidden max-w-[calc(100vw-16px)] ${
menuPortal ? 'fixed' : 'absolute top-[calc(100%+4px)] left-0 right-0'
}`}
style={
@@ -287,14 +297,15 @@ export default function SearchableSelect({
}
role="listbox"
>
<div className="relative p-[6px] [border-bottom:1px_solid_rgba(255,255,255,0.06)] flex items-center gap-[6px]">
<div className="relative p-3 flex items-center gap-2">
<Search
size={12}
className="absolute left-[12px] top-1/2 -translate-y-1/2 text-[color:var(--text-secondary)] pointer-events-none"
className="absolute left-5 top-1/2 -translate-y-1/2 text-[var(--chrome-fg-muted)] pointer-events-none"
/>
<input
ref={inputRef}
className="flex-1 w-full bg-[rgba(0,0,0,0.25)] [border:1px_solid_rgba(255,255,255,0.08)] rounded-[4px] py-[5px] pr-[8px] pl-[24px] text-[0.72rem] text-[color:var(--text-primary)] outline-none [font-family:inherit] focus:[border-color:rgba(250,189,47,0.4)]"
className="flex-1 min-w-0 w-full min-h-10 bg-[var(--chrome-hover-bg)] border-0 rounded-md py-2 pr-3 pl-8 text-sm text-[var(--chrome-fg)] focus-visible:outline-2 focus-visible:outline-[var(--chrome-accent)] [font-family:inherit]"
aria-label={t('common.search')}
placeholder={t('common.search')}
value={query}
onChange={(e) => {
@@ -357,19 +368,19 @@ export default function SearchableSelect({
// stronger amber wash; highlight (and hover, when neither
// selected nor highlighted) gets the amber accent.
className={[
'flex items-center gap-[6px] py-[5px] px-[10px] text-[0.72rem] cursor-pointer select-none',
'flex min-h-10 items-center gap-2 py-2 px-3 mx-2 my-1 rounded-md text-sm cursor-pointer select-none',
selected
? 'text-[#8ec07c] font-medium'
? 'text-[var(--chrome-accent)] font-medium'
: highlighted
? 'text-[color:var(--accent)]'
: 'text-[color:var(--text-primary)] hover:text-[color:var(--accent)]',
? 'text-[var(--chrome-accent)]'
: 'text-[var(--chrome-fg)] hover:text-[var(--chrome-accent)]',
selected && highlighted
? 'bg-[rgba(250,189,47,0.18)]'
? 'bg-[var(--chrome-accent-bg)]'
: selected
? 'bg-[rgba(142,192,124,0.1)]'
? 'bg-[var(--chrome-accent-bg)]'
: highlighted
? 'bg-[rgba(250,189,47,0.12)]'
: 'hover:bg-[rgba(250,189,47,0.12)]',
? 'bg-[var(--chrome-accent-bg)]'
: 'hover:bg-[var(--chrome-accent-bg)]',
].join(' ')}
onMouseEnter={() => setHighlight(idx)}
onMouseDown={(e) => {
@@ -388,7 +399,13 @@ export default function SearchableSelect({
<span className="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
{renderOption ? renderOption(it.o) : getLabel(it.o)}
</span>
{selected && <Check size={10} className="text-[#8ec07c] shrink-0" />}
{selected && (
<Check
size={14}
aria-hidden="true"
className="text-[var(--chrome-accent)] shrink-0"
/>
)}
</div>
</React.Fragment>
);
@@ -0,0 +1,39 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import SearchableSelect from './SearchableSelect';
describe('SearchableSelect menu', () => {
it('shows pinned options only once and keeps keyboard selection aligned', () => {
const onChange = vi.fn();
render(
<SearchableSelect
options={['Alpha', 'Beta']}
popular={['Alpha']}
onChange={onChange}
ariaLabel="Voice"
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Voice' }));
expect(screen.getAllByRole('option')).toHaveLength(2);
expect(screen.getAllByRole('option', { name: 'Alpha' })).toHaveLength(1);
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'ArrowDown' });
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' });
expect(onChange).toHaveBeenCalledWith('Beta');
});
it('matches a wide trigger without the old 360px menu cap', () => {
const { container } = render(
<SearchableSelect options={['Alpha']} menuPortal ariaLabel="Voice" />,
);
vi.spyOn(container.querySelector('.ss-wrap'), 'getBoundingClientRect').mockReturnValue({
left: 20,
top: 100,
bottom: 140,
width: 700,
});
fireEvent.click(screen.getByRole('button', { name: 'Voice' }));
expect(screen.getByRole('listbox').style.width).toBe('700px');
expect(screen.getByRole('listbox').className).not.toContain('360px');
});
});
@@ -2,7 +2,6 @@ import { useRef } from 'react';
import { BookMarked, BookOpen, FileUp, ListTree, Loader, Sparkles, Square } from 'lucide-react';
import { Button } from '../../ui';
import EngineQuickSwitch from '../EngineQuickSwitch';
/** The inviting front door for the long-form workflow: context, path, and actions. */
export default function AudiobookHero({
@@ -33,7 +32,6 @@ export default function AudiobookHero({
<h2 className="m-0 [font-family:var(--font-serif)] text-[var(--text-lg)] font-semibold text-fg">
{t('audiobook.title')}
</h2>
<EngineQuickSwitch />
</div>
<div className="flex flex-wrap items-center justify-end gap-[4px]">
+150 -150
View File
@@ -1,18 +1,31 @@
import {
Globe,
SlidersHorizontal,
Settings2,
ChevronUp,
ChevronDown,
Play,
Square,
Focus,
Gauge,
Timer,
Thermometer,
Shuffle,
Layers,
Clock,
AudioLines,
Sparkles,
} from 'lucide-react';
import { Button, Progress } from '../../ui';
import SearchableSelect from '../SearchableSelect';
import MultiLangPicker from '../MultiLangPicker';
import ALL_LANGUAGES from '../../languages.json';
import { POPULAR_LANGS } from '../../utils/constants';
import { LANG_CODES } from '../../utils/languages';
import { stopActivePlayback } from '../../utils/playback';
const CLONE_LANGUAGES = ALL_LANGUAGES.map((label) => ({
label,
code: LANG_CODES.find((language) => language.label === label)?.code || label,
}));
export default function ActionBar({
t,
showOverrides,
@@ -55,153 +68,136 @@ export default function ActionBar({
<div className="studio-action-bar overflow-visible relative z-[10]">
{showOverrides && (
<div className="override-content">
<div className="grid [grid-template-columns:repeat(auto-fit,minmax(120px,1fr))] gap-[6px] max-[500px]:grid-cols-2">
<label className="min-w-0">
<span className="label-row justify-between">
<span className="inline-flex items-center gap-[6px]">
<SlidersHorizontal size={12} className="label-icon" />
{t('clone.steps')}
</span>
<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]">
{steps}
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,180px),1fr))] gap-3">
{[
{
label: t('clone.steps'),
Icon: SlidersHorizontal,
value: steps,
set: setSteps,
min: 8,
max: 64,
step: 1,
},
{ label: 'CFG', Icon: Focus, value: cfg, set: setCfg, min: 1, max: 4, step: 0.1 },
{
label: t('clone.speed'),
Icon: Gauge,
value: speed,
set: setSpeed,
min: 0.5,
max: 2,
step: 0.1,
suffix: '×',
},
{
label: t('clone.tshift'),
Icon: Timer,
value: tShift,
set: setTShift,
min: 0,
max: 1,
step: 0.05,
},
{
label: t('clone.pos_temp'),
Icon: Thermometer,
value: posTemp,
set: setPosTemp,
min: 0,
max: 10,
step: 0.5,
},
{
label: t('clone.class_temp'),
Icon: Shuffle,
value: classTemp,
set: setClassTemp,
min: 0,
max: 2,
step: 0.1,
},
{
label: t('clone.layer_pen'),
Icon: Layers,
value: layerPenalty,
set: setLayerPenalty,
min: 0,
max: 10,
step: 0.5,
},
].map(({ label, Icon, value, set, min, max, step, suffix = '' }) => (
<label key={label} className="min-w-0 rounded-lg bg-[var(--chrome-hover-bg)] p-3">
<span className="flex items-center justify-between gap-2 mb-3 text-sm text-[var(--chrome-fg)]">
<span className="inline-flex items-center gap-2">
<Icon size={15} aria-hidden="true" className="text-[var(--chrome-fg-muted)]" />
{label}
</span>
<output className="tabular-nums text-[var(--chrome-accent)] font-medium">
{value}
{suffix}
</output>
</span>
<input
className="w-full"
type="range"
aria-label={label}
min={min}
max={max}
step={step}
value={value}
onChange={(event) => set(Number(event.target.value))}
/>
</label>
))}
<label className="min-w-0 rounded-lg bg-[var(--chrome-hover-bg)] p-3">
<span className="flex items-center gap-2 mb-2 text-sm text-[var(--chrome-fg)]">
<Clock size={15} aria-hidden="true" />
{t('clone.duration')}
</span>
<input
className="w-full"
type="range"
aria-label={t('clone.steps')}
min="8"
max="64"
value={steps}
onChange={(event) => setSteps(Number(event.target.value))}
/>
</label>
<div>
<div className="label-row justify-between">
<span>CFG</span>
<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]">
{cfg}
</span>
</div>
<input
type="range"
min="1.0"
max="4.0"
step="0.1"
value={cfg}
onChange={(e) => setCfg(Number(e.target.value))}
/>
</div>
<div>
<div className="label-row justify-between">
<span>{t('clone.speed')}</span>
<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]">
{speed}x
</span>
</div>
<input
type="range"
min="0.5"
max="2.0"
step="0.1"
value={speed}
onChange={(e) => setSpeed(Number(e.target.value))}
/>
</div>
<div>
<div className="label-row justify-between">
<span>{t('clone.tshift')}</span>
<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]">
{tShift}
</span>
</div>
<input
type="range"
min="0"
max="1.0"
step="0.05"
value={tShift}
onChange={(e) => setTShift(Number(e.target.value))}
/>
</div>
<div>
<div className="label-row justify-between">
<span>{t('clone.pos_temp')}</span>
<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]">
{posTemp}
</span>
</div>
<input
type="range"
min="0"
max="10"
step="0.5"
value={posTemp}
onChange={(e) => setPosTemp(Number(e.target.value))}
/>
</div>
<div>
<div className="label-row justify-between">
<span>{t('clone.class_temp')}</span>
<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]">
{classTemp}
</span>
</div>
<input
type="range"
min="0"
max="2"
step="0.1"
value={classTemp}
onChange={(e) => setClassTemp(Number(e.target.value))}
/>
</div>
<div>
<div className="label-row justify-between">
<span>{t('clone.layer_pen')}</span>
<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]">
{layerPenalty}
</span>
</div>
<input
type="range"
min="0"
max="10"
step="0.5"
value={layerPenalty}
onChange={(e) => setLayerPenalty(Number(e.target.value))}
/>
</div>
<div>
<div className="label-row">
<span>{t('clone.duration')}</span>
</div>
<input
type="text"
className="input-base text-[0.8rem]"
aria-label={t('clone.duration')}
className="input-base text-sm"
value={duration}
onChange={(e) => setDuration(e.target.value)}
onChange={(event) => setDuration(event.target.value)}
placeholder={t('clone.auto')}
/>
</div>
<div className="flex flex-col gap-[6px]">
<label className="text-[0.75rem] flex items-center gap-[6px] cursor-pointer">
<input
type="checkbox"
checked={denoise}
onChange={(e) => setDenoise(e.target.checked)}
/>{' '}
{t('clone.denoise')}
</label>
<label className="text-[0.75rem] flex items-center gap-[6px] cursor-pointer">
<input
type="checkbox"
checked={postprocess}
onChange={(e) => setPostprocess(e.target.checked)}
/>{' '}
{t('clone.postprocess')}
</label>
</div>
</label>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,200px),1fr))] gap-3 mt-3">
{[
{ label: t('clone.denoise'), Icon: AudioLines, checked: denoise, set: setDenoise },
{
label: t('clone.postprocess'),
Icon: Sparkles,
checked: postprocess,
set: setPostprocess,
},
].map(({ label, Icon, checked, set }) => (
<button
key={label}
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => set(!checked)}
className="flex min-h-12 items-center justify-between gap-3 rounded-lg border-0 bg-[var(--chrome-hover-bg)] px-3 py-2 text-sm text-[var(--chrome-fg)] cursor-pointer hover:bg-[var(--chrome-accent-bg)] focus-visible:outline-2 focus-visible:outline-[var(--chrome-accent)]"
>
<span className="inline-flex items-center gap-2">
<Icon size={16} aria-hidden="true" />
{label}
</span>
<span
aria-hidden="true"
className={`relative w-9 h-5 rounded-full transition-colors ${checked ? 'bg-[var(--chrome-accent)]' : 'bg-[var(--chrome-fg-dim)]'}`}
>
<span
className={`absolute top-0.5 left-0.5 size-4 rounded-full bg-[var(--color-bg)] transition-transform motion-reduce:transition-none ${checked ? 'translate-x-4' : ''}`}
/>
</span>
</button>
))}
</div>
</div>
)}
@@ -209,13 +205,17 @@ export default function ActionBar({
{/* Keep the everyday row focused; sampling controls live in overrides. */}
<div className="flex items-center gap-3 min-w-0 max-[520px]:flex-wrap">
<div className="flex items-center gap-[6px] flex-[1_1_220px] min-w-[140px] [&>:last-child]:flex-1 [&>:last-child]:min-w-0">
<Globe size={12} className="label-icon" />
<SearchableSelect
value={language}
options={ALL_LANGUAGES}
popular={POPULAR_LANGS}
recentsKey="omnivoice.recents.genLang"
onChange={setLanguage}
<MultiLangPicker
single
ariaLabel={t('clone.language')}
selected={[
{
lang: language,
code: CLONE_LANGUAGES.find((item) => item.label === language)?.code || language,
},
]}
options={CLONE_LANGUAGES}
onChange={([item]) => setLanguage(item.lang)}
/>
</div>
<button
@@ -1,10 +1,7 @@
import React, { useState } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../SearchableSelect', () => ({
default: () => <button type="button">Language</button>,
}));
import '../../i18n';
import ActionBar from './ActionBar';
@@ -49,6 +46,50 @@ function Harness() {
}
describe('ActionBar', () => {
it('labels every tuning slider and exposes audio cleanup as switches', () => {
const setDenoise = vi.fn();
const setPostprocess = vi.fn();
render(
<ActionBar
{...baseProps}
showOverrides
setShowOverrides={setter}
setDenoise={setDenoise}
setPostprocess={setPostprocess}
/>,
);
for (const slider of screen.getAllByRole('slider')) expect(slider).toHaveAccessibleName();
const denoise = screen.getByRole('switch', { name: 'clone.denoise' });
expect(denoise).toHaveAttribute('aria-checked', 'false');
fireEvent.click(denoise);
expect(setDenoise).toHaveBeenCalledWith(true);
fireEvent.click(screen.getByRole('switch', { name: 'clone.postprocess' }));
expect(setPostprocess).toHaveBeenCalledWith(true);
});
it('opens the language list above the bottom bar outside its clipping ancestors', () => {
const { container } = render(<Harness />);
const wrapper = screen.getByRole('button', { name: 'clone.language' });
vi.spyOn(wrapper, 'getBoundingClientRect').mockReturnValue({
top: 650,
bottom: 680,
left: 20,
right: 320,
width: 300,
height: 30,
});
fireEvent.click(wrapper);
const list = screen.getByRole('dialog');
expect(container).not.toContainElement(list);
expect(list).toHaveClass('multi-lang__drop');
expect(list.style.bottom).not.toBe('');
expect(screen.getAllByTestId('language-flag-es').length).toBeGreaterThan(0);
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Zulu' } });
fireEvent.click(screen.getByRole('button', { name: /Zulu/ }));
expect(setter).toHaveBeenCalledWith('Zulu');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(wrapper).toHaveFocus();
});
it('keeps sampling steps inside Production Overrides', () => {
render(<Harness />);
@@ -1,9 +1,11 @@
import { useState, useSyncExternalStore } from 'react';
import { ChevronDown, Save, UploadCloud, X } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { Button, Input, Segmented, Select } from '../../ui';
import { Button, Input, Segmented } from '../../ui';
import VoiceSelect from './VoiceSelect';
import MicButton from './MicButton';
import WaveformPlayer from '../WaveformPlayer';
import VoiceModeIcon from './VoiceModeIcon';
const EMPTY_LEVEL_STORE = {
getSnapshot: () => 0,
@@ -57,7 +59,7 @@ export default function AudioMethodPanel({
};
return (
<div className="space-y-3">
<div className="flex flex-1 flex-col gap-3">
<div className="flex items-start justify-between gap-3 max-[520px]:flex-col">
<div>
<div className="text-[length:var(--text-sm)] font-semibold text-fg">
@@ -75,15 +77,31 @@ export default function AudioMethodPanel({
disabled={isStartingRecording || isRecording}
aria-label={t('clone.reference_audio')}
items={[
{ value: 'upload', label: t('clone.upload_audio') },
{ value: 'record', label: t('clone.record') },
{
value: 'upload',
label: (
<span className="voice-source-tab inline-flex items-center gap-2">
<VoiceModeIcon mode="upload" />
{t('clone.upload_audio')}
</span>
),
},
{
value: 'record',
label: (
<span className="voice-source-tab inline-flex items-center gap-2">
<VoiceModeIcon mode="record" />
{t('clone.record')}
</span>
),
},
]}
/>
)}
</div>
{!hasReference && sourceMode === 'upload' && (
<div>
<div className="flex flex-1 flex-col min-h-32">
<input
type="file"
accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg"
@@ -96,7 +114,7 @@ export default function AudioMethodPanel({
/>
<label
htmlFor="audio-upload"
className="flex min-h-20 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg bg-[var(--chrome-hover-bg)] px-4 py-3 text-center transition-[background] duration-[var(--dur-fast)] hover:bg-[var(--chrome-accent-bg)] focus-within:bg-[var(--chrome-accent-bg)] [&.is-dragging]:bg-[var(--chrome-accent-bg)]"
className="flex flex-1 min-h-32 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg bg-[var(--chrome-hover-bg)] px-4 py-3 text-center transition-[background] duration-[var(--dur-fast)] hover:bg-[var(--chrome-accent-bg)] focus-within:bg-[var(--chrome-accent-bg)] [&.is-dragging]:bg-[var(--chrome-accent-bg)]"
onDragOver={(event) => {
event.preventDefault();
event.currentTarget.classList.add('is-dragging');
@@ -117,54 +135,57 @@ export default function AudioMethodPanel({
)}
{!hasReference && sourceMode === 'record' && (
<div className="grid grid-cols-[auto_minmax(0,1fr)] items-stretch gap-3 max-[520px]:grid-cols-1">
<MicButton
isCleaning={isCleaning}
isStarting={isStartingRecording}
isRecording={isRecording}
recordingTime={recordingTime}
onStart={startRecording}
onStop={stopRecording}
/>
<div className="grid grid-cols-[minmax(0,2fr)_minmax(0,1fr)] gap-2 max-[520px]:grid-cols-1">
<div className="flex flex-1 flex-col gap-5 rounded-lg bg-[var(--chrome-hover-bg)] p-4">
<div className="flex flex-1 min-h-28 items-center justify-center [&>button]:min-h-24 [&>button]:min-w-32">
<MicButton
isCleaning={isCleaning}
isStarting={isStartingRecording}
isRecording={isRecording}
recordingTime={recordingTime}
onStart={startRecording}
onStop={stopRecording}
/>
</div>
<div className="grid grid-cols-[minmax(0,2fr)_minmax(0,1fr)] gap-4 max-[520px]:grid-cols-1">
<label className="min-w-0 text-[length:var(--text-xs)] text-fg-muted">
<span className="mb-1 block">{t('recording.input_device')}</span>
<Select
size="sm"
className="w-full"
<VoiceSelect
label={t('recording.input_device')}
value={selectedAudioInputId}
onChange={(event) => {
onChange={(value) => {
if (!isStartingRecording && !isRecording && !isCleaning) {
setSelectedAudioInputId?.(event.target.value);
setSelectedAudioInputId?.(value);
}
}}
disabled={isStartingRecording || isRecording || isCleaning}
>
<option value="">{t('recording.default_input')}</option>
{audioInputs.map((device, index) => (
<option key={device.deviceId || `input-${index}`} value={device.deviceId}>
{device.label || t('recording.microphone_number', { number: index + 1 })}
</option>
))}
</Select>
options={[
{ value: '', label: t('recording.default_input') },
...audioInputs
.filter((device) => device.deviceId)
.map((device, index) => ({
value: device.deviceId,
label:
device.label || t('recording.microphone_number', { number: index + 1 }),
})),
]}
/>
</label>
<label className="min-w-0 text-[length:var(--text-xs)] text-fg-muted">
<span className="mb-1 block">{t('recording.channels')}</span>
<Select
size="sm"
className="w-full"
<VoiceSelect
label={t('recording.channels')}
value={channelMode}
onChange={(event) => {
onChange={(value) => {
if (!isStartingRecording && !isRecording && !isCleaning) {
setChannelMode?.(event.target.value);
setChannelMode?.(value);
}
}}
disabled={isStartingRecording || isRecording || isCleaning}
>
<option value="auto">{t('recording.channels_auto')}</option>
<option value="mono">{t('recording.channels_mono')}</option>
<option value="stereo">{t('recording.channels_stereo')}</option>
</Select>
options={['auto', 'mono', 'stereo'].map((value) => ({
value,
label: t(`recording.channels_${value}`),
}))}
/>
</label>
</div>
{isRecording && (
@@ -118,13 +118,13 @@ describe('AudioMethodPanel', () => {
fireEvent.click(screen.getByRole('radio', { name: 'clone.record' }));
fireEvent.change(screen.getByLabelText('recording.input_device'), {
target: { value: 'built-in' },
});
fireEvent.change(screen.getByLabelText('recording.channels'), { target: { value: 'mono' } });
fireEvent.keyDown(screen.getByLabelText('recording.input_device'), { key: 'Enter' });
expect(screen.getByRole('option', { name: 'recording.microphone_number' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('option', { name: 'Built-in microphone' }));
fireEvent.keyDown(screen.getByLabelText('recording.channels'), { key: 'Enter' });
fireEvent.click(screen.getByRole('option', { name: 'recording.channels_mono' }));
expect(setDevice).toHaveBeenCalledWith('built-in');
expect(setChannels).toHaveBeenCalledWith('mono');
expect(screen.getByRole('option', { name: 'recording.microphone_number' })).toBeInTheDocument();
});
it('locks recording settings while microphone startup is pending', () => {
@@ -143,8 +143,8 @@ describe('AudioMethodPanel', () => {
const channels = screen.getByLabelText('recording.channels');
expect(device).toBeDisabled();
expect(channels).toBeDisabled();
fireEvent.change(device, { target: { value: 'built-in' } });
fireEvent.change(channels, { target: { value: 'mono' } });
fireEvent.click(device);
fireEvent.click(channels);
expect(setDevice).not.toHaveBeenCalled();
expect(setChannels).not.toHaveBeenCalled();
});
@@ -1,5 +1,14 @@
import { useEffect, useRef, useState } from 'react';
import { UploadCloud, X, ArrowRightLeft, Loader } from 'lucide-react';
import {
UploadCloud,
X,
ArrowRightLeft,
Loader,
AudioLines,
Fingerprint,
Timer,
Info,
} from 'lucide-react';
import { toast } from 'react-hot-toast';
import { Button } from '../../ui';
import { API } from '../../api/client';
@@ -140,140 +149,177 @@ export default function ConvertMethodPanel({ t, profiles = [], onRecordingBusyCh
};
return (
<div data-testid="convert-method-panel">
{/* ── Source clip: drop / pick / record ── */}
<div className="label-row mt-[6px]">{t('convert.source_kicker')}</div>
<div className="flex gap-[8px] items-stretch">
<input
type="file"
accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg"
onChange={(e) => {
ingestSource(e.target.files[0]);
e.target.value = '';
}}
className="dub-hidden-file"
id="convert-audio-upload"
/>
<label
htmlFor="convert-audio-upload"
className="flex-1 [border:1px_dashed_var(--chrome-border-strong)] rounded-[var(--chrome-radius-pill)] p-[6px] text-center cursor-pointer flex flex-col items-center gap-[4px] bg-transparent [transition:border-color_var(--dur-fast),background_var(--dur-fast)] hover:[border-color:var(--chrome-accent)] hover:bg-[var(--chrome-accent-bg)] [&.is-dragging]:[border-color:var(--chrome-accent)] [&.is-dragging]:bg-[var(--chrome-accent-bg)]"
onDragOver={(e) => {
e.preventDefault();
e.currentTarget.classList.add('is-dragging');
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove('is-dragging');
}}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.classList.remove('is-dragging');
const file = e.dataTransfer.files[0];
const okType =
file &&
(file.type.startsWith('audio/') ||
/\.(mp3|wav|m4a|flac|ogg|aac|webm)$/i.test(file.name));
if (okType) ingestSource(file);
}}
>
<UploadCloud color="#a89984" size={18} />
<p className="m-0 text-[0.72rem] text-[color:var(--chrome-fg-muted)] font-[family-name:var(--font-sans)] font-medium">
{sourceFile ? (
<span className="text-fg">{sourceFile.name}</span>
) : (
t('convert.drop_audio')
)}
</p>
</label>
<MicButton
isCleaning={isCleaning}
isStarting={isStartingRecording}
isRecording={isRecording}
recordingTime={recordingTime}
onStart={startRecording}
onStop={stopRecording}
/>
</div>
{sourceFile && (
<div className="mt-2 flex items-center gap-[8px]">
<div className="flex-1 min-w-0">
<WaveformPlayer src={sourceFile} source="convert-source" height={34} compact />
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
invalidateInFlight();
setSourceFile(null);
setResult(null);
}}
leading={<X size={11} />}
>
{t('clone.clear')}
</Button>
<div data-testid="convert-method-panel" className="flex flex-1 min-h-0 flex-col">
<div
data-testid="convert-form"
className="convert-form flex-1 min-h-0 overflow-y-auto px-3 py-2 space-y-5"
>
{/* ── Source clip: drop / pick / record ── */}
<div className="flex items-center gap-2 text-sm font-medium text-[var(--chrome-fg)]">
<AudioLines size={17} aria-hidden="true" />
{t('convert.source_kicker')}
</div>
)}
{/* ── Target voice ── */}
<div className="label-row mt-[var(--space-4)]">{t('convert.target_voice')}</div>
<VoiceSelector
value={voiceId}
onChange={selectVoice}
profiles={profiles}
engineDefault={false}
gallery={false}
placeholder={t('convert.pick_voice')}
ariaLabel={t('convert.target_voice')}
recentsKey="convert-target"
/>
{/* ── Options + action ── */}
<div className="mt-[var(--space-4)] flex flex-wrap items-center gap-[var(--space-4)]">
<label
className="inline-flex items-center gap-[6px] text-[0.85em] text-fg-muted cursor-pointer select-none whitespace-nowrap"
title={t('convert.match_duration_hint')}
>
<div className="flex flex-wrap gap-3 items-stretch rounded-lg bg-[var(--chrome-hover-bg)] p-4">
<input
type="checkbox"
checked={matchDuration}
type="file"
accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg"
onChange={(e) => {
invalidateInFlight();
setMatchDuration(e.target.checked);
setResult(null);
ingestSource(e.target.files[0]);
e.target.value = '';
}}
className="sr-only"
id="convert-audio-upload"
/>
<span>{t('convert.match_duration')}</span>
</label>
<label
htmlFor="convert-audio-upload"
tabIndex={0}
role="button"
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
document.getElementById('convert-audio-upload')?.click();
}
}}
className="flex-1 basis-56 min-w-0 min-h-48 rounded-lg p-5 text-center cursor-pointer flex flex-col justify-center items-center gap-3 bg-[var(--chrome-hover-bg)] transition-colors hover:bg-[var(--chrome-accent-bg)] focus-visible:outline-2 focus-visible:outline-[var(--chrome-accent)] [&.is-dragging]:bg-[var(--chrome-accent-bg)]"
onDragOver={(e) => {
e.preventDefault();
e.currentTarget.classList.add('is-dragging');
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove('is-dragging');
}}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.classList.remove('is-dragging');
const file = e.dataTransfer.files[0];
const okType =
file &&
(file.type.startsWith('audio/') ||
/\.(mp3|wav|m4a|flac|ogg|aac|webm)$/i.test(file.name));
if (okType) ingestSource(file);
}}
>
<UploadCloud className="text-[var(--chrome-accent)]" size={28} aria-hidden="true" />
<p className="m-0 text-sm text-[color:var(--chrome-fg-muted)] font-[family-name:var(--font-sans)] font-medium">
{sourceFile ? (
<span className="text-fg">{sourceFile.name}</span>
) : (
t('convert.drop_audio')
)}
</p>
</label>
<MicButton
isCleaning={isCleaning}
isStarting={isStartingRecording}
isRecording={isRecording}
recordingTime={recordingTime}
onStart={startRecording}
onStop={stopRecording}
/>
</div>
{sourceFile && (
<div className="mt-2 flex items-center gap-[8px]">
<div className="flex-1 min-w-0">
<WaveformPlayer src={sourceFile} source="convert-source" height={34} compact />
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
invalidateInFlight();
setSourceFile(null);
setResult(null);
}}
leading={<X size={11} />}
>
{t('clone.clear')}
</Button>
</div>
)}
{/* ── Target voice ── */}
<div className="flex items-center gap-2 text-sm font-medium text-[var(--chrome-fg)]">
<Fingerprint size={17} aria-hidden="true" />
{t('convert.target_voice')}
</div>
<div className="rounded-lg bg-[var(--chrome-hover-bg)] p-3">
<VoiceSelector
value={voiceId}
onChange={selectVoice}
profiles={profiles}
engineDefault={false}
gallery={false}
placeholder={t('convert.pick_voice')}
ariaLabel={t('convert.target_voice')}
recentsKey="convert-target"
menuPortal
buttonClassName="min-h-12 px-3 text-sm border-0 rounded-lg bg-transparent text-[var(--chrome-fg)] hover:bg-[var(--chrome-accent-bg)] focus-visible:outline-2 focus-visible:outline-[var(--chrome-accent)]"
/>
</div>
{/* ── Options + action ── */}
<div className="flex flex-col items-start gap-3 rounded-lg bg-[var(--chrome-hover-bg)] p-4">
<label
className="inline-flex items-center gap-[6px] text-[0.85em] text-fg-muted cursor-pointer select-none whitespace-nowrap"
title={t('convert.match_duration_hint')}
>
<input
type="checkbox"
checked={matchDuration}
onChange={(e) => {
invalidateInFlight();
setMatchDuration(e.target.checked);
setResult(null);
}}
/>
<Timer size={16} aria-hidden="true" />
<span>{t('convert.match_duration')}</span>
</label>
<p className="m-0 text-xs text-[var(--chrome-fg-muted)]">
{t('convert.match_duration_hint')}
</p>
{!sourceFile || !voiceId ? (
<span className="inline-flex items-center gap-2 text-xs text-fg-muted">
<Info size={14} aria-hidden="true" />
{t('convert.need_source_and_voice')}
</span>
) : null}
</div>
{/* ── Result: converted take + what the ASR heard ── */}
{result && (
<div className="mt-[var(--space-4)]" data-testid="convert-result">
<div className="label-row">{t('convert.result_kicker')}</div>
<WaveformPlayer
src={`${API}${result.audio_url}`}
source="output"
height={40}
autoPlay
/>
<div className="mt-2 text-[0.78rem] text-fg-muted">
<span className="font-medium">{t('convert.transcript')}:</span> {result.text}
</div>
</div>
)}
</div>
<div className="studio-action-bar" data-testid="convert-action-bar">
<Button
variant="primary"
size="sm"
block
onClick={handleConvert}
disabled={!canConvert}
leading={
isConverting ? (
<Loader size={12} className="animate-[spin_1s_linear_infinite]" />
<Loader size={14} className="animate-spin" />
) : (
<ArrowRightLeft size={12} />
<ArrowRightLeft size={14} />
)
}
>
{isConverting ? t('convert.converting') : t('convert.convert')}
</Button>
{!sourceFile || !voiceId ? (
<span className="text-[0.72rem] text-fg-muted">{t('convert.need_source_and_voice')}</span>
) : null}
</div>
{/* ── Result: converted take + what the ASR heard ── */}
{result && (
<div className="mt-[var(--space-4)]" data-testid="convert-result">
<div className="label-row">{t('convert.result_kicker')}</div>
<WaveformPlayer src={`${API}${result.audio_url}`} source="output" height={40} autoPlay />
<div className="mt-2 text-[0.78rem] text-fg-muted">
<span className="font-medium">{t('convert.transcript')}:</span> {result.text}
</div>
</div>
)}
</div>
);
}
@@ -101,6 +101,13 @@ beforeEach(() => {
});
describe('ConvertMethodPanel', () => {
it('keeps the primary action outside the scrolling form', () => {
render(<ConvertMethodPanel t={t} profiles={profiles} />);
const action = screen.getByTestId('convert-action-bar');
expect(action).toHaveClass('studio-action-bar');
expect(screen.getByTestId('convert-form')).not.toContainElement(action);
expect(action).toContainElement(screen.getByRole('button', { name: 'convert.convert' }));
});
it('shows microphone startup instead of a duplicate record action', () => {
recordingState.isStartingRecording = true;
const onRecordingBusyChange = vi.fn();
@@ -1,5 +1,23 @@
import { useState } from 'react';
import { ChevronUp, ChevronDown, Save } from 'lucide-react';
import {
ChevronUp,
ChevronDown,
Save,
Sparkles,
Users,
User,
Baby,
Clock,
AudioLines,
SlidersHorizontal,
Languages,
Wind,
ArrowDown,
ArrowUp,
ChevronsDown,
ChevronsUp,
Minus,
} from 'lucide-react';
import { Button, Input } from '../../ui';
import { PRESETS, CATEGORIES } from '../../utils/constants';
import {
@@ -10,6 +28,7 @@ import {
stripVoiceEmoji,
} from '../../utils/voiceIcons';
import { buildDesignInstruct } from '../../utils/voiceInstruct';
import VoiceSelect from './VoiceSelect';
// Chip / personality-chip class families migrated from index.css to Tailwind
// utilities (shadcn P4). The token utilities reference the same --chrome-* vars
@@ -20,7 +39,7 @@ import { buildDesignInstruct } from '../../utils/voiceInstruct';
// an opaque accent outline at 1px offset, on top of the app's global ring.
const CHIP_FOCUS =
'focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]';
const PCHIP_BASE = `inline-flex items-center gap-[5px] px-[12px] py-[5px] font-[var(--font-sans)] text-[0.72rem] font-medium rounded-[var(--chrome-radius-pill)] border bg-transparent flex-none cursor-pointer transition-colors duration-[120ms] ${CHIP_FOCUS}`;
const PCHIP_BASE = `inline-flex min-h-11 items-center gap-[5px] px-[12px] py-[5px] font-[var(--font-sans)] text-sm font-medium rounded-[var(--chrome-radius-pill)] border bg-transparent flex-none cursor-pointer transition-colors duration-[120ms] ${CHIP_FOCUS}`;
const PCHIP_INACTIVE =
'border-transparent text-[var(--chrome-fg-muted)] hover:bg-[var(--chrome-hover-bg)] hover:border-transparent hover:text-[var(--chrome-fg)]';
const PCHIP_ACTIVE =
@@ -35,6 +54,32 @@ const CHIP_INACTIVE =
// out as a 2x2 grid; kept as one array so the grid and its labels stay in
// sync if a category is ever added/removed.
const SELECT_CATEGORIES = ['Gender', 'Age', 'Pitch', 'Style'];
const FIELD_ICONS = { Gender: Users, Age: Clock, Pitch: AudioLines, Style: SlidersHorizontal };
function FieldIcon({ category }) {
const Icon = FIELD_ICONS[category] || Languages;
return (
<Icon
size={15}
aria-hidden="true"
className="inline-block mr-2 align-text-bottom text-[var(--chrome-fg-muted)]"
/>
);
}
const PITCH_ICONS = {
'very low pitch': ChevronsDown,
'low pitch': ArrowDown,
'moderate pitch': Minus,
'high pitch': ArrowUp,
'very high pitch': ChevronsUp,
};
const designOptionIcon = (category, value) => {
if (value === 'Auto') return Sparkles;
if (category === 'Pitch') return PITCH_ICONS[value] || AudioLines;
if (category === 'Gender') return User;
if (category === 'Age') return value === 'child' ? Baby : User;
if (category === 'Style') return Wind;
return Languages;
};
// English accent and Chinese dialect are two independent CATEGORIES entries
// (the engine's exclusivity rule lives in voiceInstruct.js's EXCLUSIVE_GROUPS)
// but only one can ever apply, so the picker merges them into ONE <select>
@@ -87,10 +132,10 @@ export default function DesignMethodPanel({
// curated CATEGORIES list guard before .replace() rather than crash;
// 'Auto' matches how the rest of the component treats an unset category.
const optLabel = (val) => {
if (typeof val !== 'string' || !val) return t('clone.opt_Auto');
if (typeof val !== 'string' || !val || val === 'Auto') return t('clone.auto');
const tKey = `clone.opt_${val.replace(/[ -]/g, '_')}`;
const tl = t(tKey);
return tl !== tKey ? tl : val;
return stripVoiceEmoji(tl !== tKey ? tl : val);
};
const accentValue = vdStates.EnglishAccent;
@@ -134,12 +179,13 @@ export default function DesignMethodPanel({
};
return (
<div>
<div className="space-y-3">
{/* Describe your voice (#317) free text drives the controls.
The placeholder explains itself; no extra header (10x §1.2). */}
<div className="mb-[8px]">
<textarea
className="input-base w-full resize-y min-h-[44px] mb-1"
className="input-base w-full resize-y min-h-24 mb-1"
aria-label={t('clone.define_by_design')}
rows={2}
placeholder={t('clone.describe_placeholder')}
value={describeText}
@@ -155,7 +201,7 @@ export default function DesignMethodPanel({
{t('clone.describe_unmatched', { items: describeUnmatched.join(', ') })}
</div>
)}
<div className="text-[0.62rem] text-[var(--chrome-fg-muted)]">
<div className="text-xs leading-relaxed text-[var(--chrome-fg-muted)]">
{t('clone.describe_hint')}
</div>
</div>
@@ -231,7 +277,7 @@ export default function DesignMethodPanel({
All-Auto (first run) starts expanded. */}
<button
type="button"
className="flex items-center gap-[8px] w-full mt-[4px] mb-[8px] px-[10px] py-[6px] bg-[var(--chrome-hover-bg)] border border-transparent rounded-[8px] cursor-pointer text-left transition-[border-color] duration-[var(--dur-fast)] hover:border-transparent focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]"
className="flex min-h-11 items-center gap-[8px] w-full mt-[4px] mb-[8px] px-[10px] py-[6px] bg-[var(--chrome-hover-bg)] border border-transparent rounded-[8px] cursor-pointer text-left transition-[border-color] duration-[var(--dur-fast)] hover:border-transparent focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]"
onClick={() => setIdentityOpen((o) => !o)}
aria-expanded={identityOpen}
aria-controls="design-details-fields"
@@ -249,57 +295,53 @@ export default function DesignMethodPanel({
</button>
{identityOpen && (
<div id="design-details-fields">
<div className="grid grid-cols-2 gap-x-[12px] gap-y-[8px]">
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,220px),1fr))] gap-4">
{SELECT_CATEGORIES.map((key) => (
<div key={key} className="min-w-0">
<label htmlFor={`vd-${key}`} className="label-row text-[0.7rem]">
<label
htmlFor={`vd-${key}`}
className="block mb-2 text-sm font-medium text-[var(--chrome-fg)]"
>
<FieldIcon category={key} />
{t(`clone.cat_${key}`)}
</label>
<select
<VoiceSelect
id={`vd-${key}`}
className="input-base"
label={t(`clone.cat_${key}`)}
value={vdStates[key] || 'Auto'}
onChange={(e) => onVdChange(key, e.target.value)}
>
{CATEGORIES[key].map((opt) => (
<option key={opt} value={opt}>
{optLabel(opt)}
</option>
))}
</select>
onChange={(value) => onVdChange(key, value)}
options={CATEGORIES[key]}
optionLabel={optLabel}
optionIcon={(value) => designOptionIcon(key, value)}
/>
</div>
))}
<div className="col-[1/-1] min-w-0">
<label htmlFor="vd-AccentDialect" className="label-row text-[0.7rem]">
<label
htmlFor="vd-AccentDialect"
className="block mb-2 text-sm font-medium text-[var(--chrome-fg)]"
>
<FieldIcon category="Accent" />
{t('clone.cat_AccentDialect', { defaultValue: 'Accent or Dialect' })}
<span className="ml-[6px] text-[0.58rem] text-[var(--chrome-fg-muted)] font-medium">
<span className="block mt-1 text-xs text-[var(--chrome-fg-muted)] font-normal">
{t('clone.accent_dialect_hint', {
defaultValue: 'one or the other, never both',
})}
</span>
</label>
<select
<VoiceSelect
id="vd-AccentDialect"
className="input-base"
label={t('clone.cat_AccentDialect', { defaultValue: 'Accent or Dialect' })}
value={accentDialectValue}
onChange={(e) => onAccentDialectChange(e.target.value)}
>
<option value="Auto">{optLabel('Auto')}</option>
<optgroup label={t('clone.cat_EnglishAccent')}>
{ACCENT_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{optLabel(opt)}
</option>
))}
</optgroup>
<optgroup label={t('clone.cat_ChineseDialect')}>
{DIALECT_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{optLabel(opt)}
</option>
))}
</optgroup>
</select>
onChange={onAccentDialectChange}
options={['Auto']}
optionLabel={optLabel}
optionIcon={(value) => designOptionIcon('Accent', value)}
groups={[
{ label: t('clone.cat_EnglishAccent'), options: ACCENT_OPTIONS },
{ label: t('clone.cat_ChineseDialect'), options: DIALECT_OPTIONS },
]}
/>
</div>
</div>
@@ -127,7 +127,8 @@ describe('DesignMethodPanel — merged accent/dialect field', () => {
const onVdChange = vi.fn();
setup({ EnglishAccent: 'Auto', ChineseDialect: 'Auto' }, { identityOpen: true, onVdChange });
const select = document.getElementById('vd-AccentDialect');
fireEvent.change(select, { target: { value: 'british accent' } });
fireEvent.keyDown(select, { key: 'Enter' });
fireEvent.click(screen.getByRole('option', { name: 'british accent' }));
expect(onVdChange).toHaveBeenCalledWith('EnglishAccent', 'british accent');
});
@@ -135,7 +136,8 @@ describe('DesignMethodPanel — merged accent/dialect field', () => {
const onVdChange = vi.fn();
setup({ EnglishAccent: 'Auto', ChineseDialect: 'Auto' }, { identityOpen: true, onVdChange });
const select = document.getElementById('vd-AccentDialect');
fireEvent.change(select, { target: { value: '四川话' } });
fireEvent.keyDown(select, { key: 'Enter' });
fireEvent.click(screen.getByRole('option', { name: '四川话' }));
expect(onVdChange).toHaveBeenCalledWith('ChineseDialect', '四川话');
});
@@ -143,7 +145,8 @@ describe('DesignMethodPanel — merged accent/dialect field', () => {
const onVdChange = vi.fn();
setup({ EnglishAccent: 'Auto', ChineseDialect: '四川话' }, { identityOpen: true, onVdChange });
const select = document.getElementById('vd-AccentDialect');
fireEvent.change(select, { target: { value: 'Auto' } });
fireEvent.keyDown(select, { key: 'Enter' });
fireEvent.click(screen.getByRole('option', { name: 'clone.auto' }));
expect(onVdChange).toHaveBeenCalledWith('ChineseDialect', 'Auto');
expect(onVdChange).not.toHaveBeenCalledWith('EnglishAccent', expect.anything());
});
@@ -151,7 +154,7 @@ describe('DesignMethodPanel — merged accent/dialect field', () => {
it('shows the currently-set dialect as the merged select value', () => {
setup({ EnglishAccent: 'Auto', ChineseDialect: '四川话' }, { identityOpen: true });
const select = document.getElementById('vd-AccentDialect');
expect(select.value).toBe('四川话');
expect(select).toHaveTextContent('四川话');
});
});
+46 -4
View File
@@ -1,4 +1,5 @@
import { Command, Plus, ChevronDown } from 'lucide-react';
import { useState } from 'react';
import { AlignLeft, ClipboardPaste, Plus, ChevronDown } from 'lucide-react';
import DemoPresetGrid from '../DemoPresetGrid';
import { TAGS } from '../../utils/constants';
@@ -32,6 +33,31 @@ export default function ScriptPanel({
setInsertOpen,
insertTag,
}) {
const [pasting, setPasting] = useState(false);
const [pasteFailed, setPasteFailed] = useState(false);
const pasteText = async () => {
const field = textAreaRef.current;
const start = field?.selectionStart ?? text.length;
const end = field?.selectionEnd ?? text.length;
setPasting(true);
setPasteFailed(false);
try {
const value = await navigator.clipboard.readText();
if (value) {
setText((current) => current.slice(0, start) + value + current.slice(end));
setShowDemoCoachmark(false);
requestAnimationFrame(() => {
field?.focus();
field?.setSelectionRange(start + value.length, start + value.length);
});
}
} catch {
setPasteFailed(true);
field?.focus();
} finally {
setPasting(false);
}
};
return (
<div className="flex flex-col gap-[6px] flex-none min-h-0 relative z-[2]">
{/* overflow-visible: the Insert popover opens BELOW the textarea and
@@ -42,10 +68,26 @@ export default function ScriptPanel({
screenshot showed the CMU chips clipped). Below always has room
here: the panel is the topmost element in every mount. */}
<div className={`${STUDIO_PANEL} relative z-[10] overflow-visible`}>
<div className="label-row">
<Command className="label-icon" size={14} />{' '}
{t('clone.script', { defaultValue: 'Script' })}
<div className="label-row justify-between">
<span className="inline-flex items-center gap-2">
<AlignLeft className="label-icon" size={14} />
{t('clone.text_label')}
</span>
<button
type="button"
disabled={pasting}
onClick={pasteText}
className="inline-flex min-h-9 items-center gap-2 rounded-md border-0 bg-transparent px-2 text-xs normal-case text-[var(--chrome-fg-muted)] cursor-pointer hover:bg-[var(--chrome-hover-bg)] disabled:opacity-50"
>
<ClipboardPaste size={14} />
{t('clone.paste')}
</button>
</div>
{pasteFailed && (
<p role="alert" className="text-xs text-[var(--chrome-fg-muted)]">
{t('clone.paste_failed')}
</p>
)}
{/* Design-tab empty state: 7-card demo grid until the user
interacts; then it steps aside for the standard form. */}
{defineMethod === 'design' && !text && !activePersonality && demoPresets.length > 0 && (
@@ -0,0 +1,49 @@
import React, { useRef, useState } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import ScriptPanel from './ScriptPanel';
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
afterEach(() => {
if (originalClipboard) Object.defineProperty(navigator, 'clipboard', originalClipboard);
else delete navigator.clipboard;
});
function Harness() {
const [text, setText] = useState('Hello world');
const textAreaRef = useRef(null);
return (
<ScriptPanel
t={(key) => key}
defineMethod="audio"
text={text}
setText={setText}
textAreaRef={textAreaRef}
demoPresets={[]}
setShowDemoCoachmark={() => {}}
/>
);
}
describe('ScriptPanel paste', () => {
it('pastes at the selection without replacing the rest of the text', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { readText: vi.fn().mockResolvedValue('friend') },
});
render(<Harness />);
expect(screen.getByText('clone.text_label')).toBeInTheDocument();
const field = screen.getByRole('textbox');
field.setSelectionRange(6, 11);
fireEvent.click(screen.getByRole('button', { name: 'clone.paste' }));
await waitFor(() => expect(field).toHaveValue('Hello friend'));
});
it('preserves text and offers keyboard paste when clipboard access fails', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { readText: vi.fn().mockRejectedValue(new Error('denied')) },
});
render(<Harness />);
fireEvent.click(screen.getByRole('button', { name: 'clone.paste' }));
expect(await screen.findByRole('alert')).toHaveTextContent('clone.paste_failed');
expect(screen.getByRole('textbox')).toHaveValue('Hello world');
});
});
@@ -0,0 +1,50 @@
.voice-mode-icon {
flex: none;
transition: transform 140ms ease-out;
}
.voice-mode-tab:active:not(:disabled) .voice-mode-icon { transform: scale(0.94); }
.voice-mode-tab:is(:hover, :focus-visible, [data-state="active"]):not(:disabled) .voice-mode-wave {
transform-origin: center;
transform-box: fill-box;
animation: voice-mode-wave 360ms ease-in-out both;
}
.voice-mode-tab:is(:hover, :focus-visible, [data-state="active"]):not(:disabled) .voice-mode-dial {
animation: voice-mode-nudge 380ms ease-in-out;
--voice-mode-offset: 2px;
}
.voice-mode-tab:is(:hover, :focus-visible, [data-state="active"]):not(:disabled) .voice-mode-arrow {
animation: voice-mode-nudge 380ms ease-in-out;
--voice-mode-offset: 1.5px;
}
.voice-mode-tab .voice-mode-dial--second,
.voice-mode-tab .voice-mode-arrow--back { --voice-mode-offset: -2px !important; }
button:is(:hover, :focus-visible, [data-state="on"]):not(:disabled) .voice-mode-upload {
animation: voice-mode-lift 380ms ease-out;
}
button:is(:hover, :focus-visible, [data-state="on"]):not(:disabled) .voice-mode-mic {
animation: voice-mode-listen 400ms ease-out;
}
@keyframes voice-mode-lift {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-2px); }
}
@keyframes voice-mode-listen {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@keyframes voice-mode-wave {
0%, 100% { transform: scaleY(1); }
45% { transform: scaleY(0.65); }
}
@keyframes voice-mode-nudge {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(var(--voice-mode-offset)); }
}
@media (prefers-reduced-motion: reduce) {
button .voice-mode-icon,
button .voice-mode-icon * {
animation: none !important;
transition: none !important;
transform: none !important;
}
}
@@ -0,0 +1,58 @@
import './VoiceModeIcon.css';
export default function VoiceModeIcon({ mode }) {
return (
<svg
className={`voice-mode-icon voice-mode-icon--${mode}`}
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{mode === 'audio' ? (
[4, 8, 12, 16, 20].map((x, index) => (
<path
key={x}
className="voice-mode-wave"
style={{ animationDelay: `${index * 35}ms` }}
d={`M${x} ${[9, 6, 3, 6, 9][index]}v${[6, 12, 18, 12, 6][index]}`}
/>
))
) : mode === 'design' ? (
<>
<path d="M4 7h16M4 17h16" />
<circle className="voice-mode-dial" cx="9" cy="7" r="2.5" fill="var(--chrome-bg)" />
<circle
className="voice-mode-dial voice-mode-dial--second"
cx="15"
cy="17"
r="2.5"
fill="var(--chrome-bg)"
/>
</>
) : mode === 'upload' ? (
<>
<path d="M5 15v5h14v-5" />
<path className="voice-mode-upload" d="M12 16V3m-4 4 4-4 4 4" />
</>
) : mode === 'record' ? (
<>
<rect x="9" y="3" width="6" height="12" rx="3" />
<path className="voice-mode-mic" d="M6 11v1a6 6 0 0 0 12 0v-1" />
<path d="M12 18v3m-3 0h6" />
</>
) : (
<>
<path className="voice-mode-arrow" d="M4 7h15m-4-4 4 4-4 4" />
<path className="voice-mode-arrow voice-mode-arrow--back" d="M20 17H5m4-4-4 4 4 4" />
</>
)}
</svg>
);
}
@@ -0,0 +1,68 @@
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
SelectGroup,
SelectLabel,
} from '../ui/select';
export default function VoiceSelect({
id,
label,
value,
onChange,
options,
groups = [],
optionLabel = (option) => option.label ?? option,
disabled = false,
optionIcon,
}) {
const items = (values) =>
values.map((option) => {
const key = typeof option === 'string' ? option : option.value;
const Icon = optionIcon?.(key);
return (
<SelectItem
key={key}
value={key || '__default_input__'}
className="min-h-10 cursor-pointer px-3 text-sm text-[var(--chrome-fg)] focus:bg-[var(--chrome-accent-bg)] focus:text-[var(--chrome-accent)] data-[state=checked]:text-[var(--chrome-accent)]"
>
<span className="inline-flex items-center gap-2">
{Icon && <Icon size={16} aria-hidden="true" className="shrink-0 opacity-75" />}
<span>{optionLabel(option)}</span>
</span>
</SelectItem>
);
});
return (
<Select
value={value || '__default_input__'}
onValueChange={(next) => onChange(next === '__default_input__' ? '' : next)}
disabled={disabled}
>
<SelectTrigger
id={id}
aria-label={label}
className="w-full min-h-12 border-transparent bg-[var(--chrome-hover-bg)] px-3 text-sm text-[var(--chrome-fg)] shadow-none hover:bg-[var(--chrome-accent-bg)]"
>
<SelectValue />
</SelectTrigger>
<SelectContent
collisionPadding={12}
className="z-[150] max-h-80 bg-[var(--color-bg)] border-[var(--chrome-border-strong)] rounded-lg shadow-xl"
>
{items(options)}
{groups.map((group) => (
<SelectGroup key={group.label}>
<SelectLabel className="text-xs text-[var(--chrome-fg-muted)] px-3 pt-3">
{group.label}
</SelectLabel>
{items(group.options)}
</SelectGroup>
))}
</SelectContent>
</Select>
);
}
+53 -34
View File
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { LayoutGrid } from 'lucide-react';
import { LayoutGrid, Users, UserRound, Fingerprint } from 'lucide-react';
import SearchableSelect from '../SearchableSelect';
import { PRESET_ICONS, FALLBACK_VOICE_ICON, stripVoiceEmoji } from '../../utils/voiceIcons';
import toast from 'react-hot-toast';
import { PRESETS } from '../../utils/constants';
import { autoProfileId, assignSpeakerProfile, castParts, castSpeakers } from '../../utils/segments';
@@ -130,12 +132,13 @@ export default function CastingBoard({ t, dubSegments, setDubSegments, speakerCl
];
return (
<div className="mt-[2px] px-[var(--space-3)] py-[3px] bg-[var(--chrome-bg)] rounded-[var(--chrome-radius-pill)] border border-transparent">
<div className="dub-casting-controls my-3 p-4 bg-[var(--chrome-hover-bg)] rounded-lg border-0">
<div className="flex gap-[var(--space-2)] items-center flex-wrap">
<span
className="font-[family-name:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] text-[var(--chrome-fg-muted)] tracking-[var(--chrome-label-track)] uppercase font-semibold"
title={t('dub.cast_title')}
>
<Users size={16} aria-hidden="true" className="inline-block mr-2 align-text-bottom" />
{t('dub.cast')}
</span>
{speakers.map((spk) => {
@@ -143,40 +146,56 @@ export default function CastingBoard({ t, dubSegments, setDubSegments, speakerCl
return (
<div key={spk} className="dub-cast__pair">
<span className="font-[family-name:var(--chrome-font-mono)] text-[0.62rem] text-[var(--chrome-fg)]">
{spk}:
<UserRound
size={14}
aria-hidden="true"
className="inline-block mr-1 align-text-bottom"
/>
{spk}
</span>
<select
className="input-base dub-cast__select"
<SearchableSelect
ariaLabel={spk}
menuPortal
renderGroupHeaders
buttonClassName="dub-cast__select min-h-10 rounded-lg border-0 bg-[var(--chrome-hover-bg)] px-3 py-2 text-sm text-[var(--chrome-fg)] hover:bg-[var(--chrome-accent-bg)] focus-visible:outline-2 focus-visible:outline-[var(--chrome-accent)]"
value={currentVoice(spk)}
onChange={(e) =>
setDubSegments(assignSpeakerProfile(dubSegments, spk, e.target.value))
}
>
{clone && (
<option value={autoProfileId(spk)}>
{t('dub.from_video', { duration: clone.duration.toFixed(1) })}
</option>
)}
<option value="">{t('dub.default')}</option>
{profiles.length > 0 && (
<optgroup label={t('dub.clone_profiles')}>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</optgroup>
)}
{PRESETS.length > 0 && (
<optgroup label={t('dub.design_presets')}>
{PRESETS.map((p) => (
<option key={p.id} value={`preset:${p.id}`}>
{p.name}
</option>
))}
</optgroup>
)}
</select>
onChange={(value) => setDubSegments(assignSpeakerProfile(dubSegments, spk, value))}
options={[
...(clone
? [
{
value: autoProfileId(spk),
label: t('dub.from_video', { duration: clone.duration.toFixed(1) }),
Icon: UserRound,
},
]
: []),
{ value: '', label: t('dub.default'), Icon: UserRound },
...profiles.map((p) => ({
value: p.id,
label: p.name,
group: 'clones',
groupLabel: t('dub.clone_profiles'),
Icon: Fingerprint,
})),
...PRESETS.map((p) => ({
value: 'preset:' + p.id,
label: stripVoiceEmoji(p.name),
group: 'presets',
groupLabel: t('dub.design_presets'),
Icon: PRESET_ICONS[p.id] || FALLBACK_VOICE_ICON,
})),
]}
renderOption={(option) => {
const Icon = option.Icon;
return (
<span className="inline-flex items-center gap-2">
<Icon size={16} aria-hidden="true" className="shrink-0 opacity-75" />
<span>{option.label}</span>
</span>
);
}}
/>
</div>
);
})}
@@ -175,12 +175,12 @@ describe('CastingBoard ↔ dropdown sync', () => {
openBoard();
const selects = document.querySelectorAll('.dub-cast__select');
expect(selects[1].value).toBe(''); // SPEAKER_2 starts on Default
expect(selects[1]).toHaveTextContent(t('dub.default'));
const row = screen.getByTestId('casting-board').querySelector('[data-speaker="SPEAKER_2"]');
fireEvent.drop(row, { dataTransfer: { getData: () => 'voice-b' } });
expect(document.querySelectorAll('.dub-cast__select')[1].value).toBe('voice-b');
expect(document.querySelectorAll('.dub-cast__select')[1]).toHaveTextContent('Ben');
expect(within(row).getByRole('button')).toHaveTextContent('Ben');
});
});
@@ -220,7 +220,8 @@ describe('CastingBoard auto-clone chip', () => {
).toBeInTheDocument();
const selects = document.querySelectorAll('.dub-cast__select');
fireEvent.change(selects[1], { target: { value: 'voice-b' } });
fireEvent.click(selects[1]);
fireEvent.mouseDown(screen.getByRole('option', { name: 'Ben', exact: true }));
const updated = props.setDubSegments.mock.calls[0][0];
expect(updated[0].profile_id).toBe('voice-a');
@@ -12,7 +12,6 @@ import { Button } from '../../ui';
import FooterBtn from './FooterBtn';
import DubPipelineStepper from './DubPipelineStepper';
import { formatTime } from '../../utils/format';
import EngineQuickSwitch from '../EngineQuickSwitch';
export default function DubHeader({
t,
@@ -90,7 +89,6 @@ export default function DubHeader({
<div className="dub-command-bar__actions [.shell-mini_&]:col-[1/-1] [.shell-mini_&]:row-start-3 [.shell-mini_&]:w-full [.shell-mini_&]:flex-wrap">
<div className="dub-command-bar__utilities">
<EngineQuickSwitch />
<Button
variant="subtle"
size="sm"
@@ -0,0 +1,22 @@
.dub-panel-left .wfm-layout { flex: 0 0 auto; }
.dub-panel-left .wfm-stack { flex: 0 0 auto; }
.dub-panel-left .wfm-controls { padding: 10px 4px; gap: 8px; flex-wrap: wrap; }
.dub-panel-left .wfm-controls button { min-width: 32px; min-height: 32px; border: 0; border-radius: 6px; }
.dub-translation-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 16px; align-items: start; }
.dub-translation-fields > div { min-width: 0; }
.dub-translation-settings label { font-size: 12px; line-height: 1.5; gap: 8px; }
.dub-translation-settings input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--chrome-accent); }
.dub-translation-settings .label-icon { width: 15px; height: 15px; }
.dub-translation-actions { padding-top: 8px; }
.dub-translation-actions button { min-height: 36px; font-size: 12px; }
.dub-casting-controls { padding: 12px; container-type: inline-size; }
.dub-casting-controls > div:first-child { gap: 12px; align-items: center; }
.dub-casting-controls .dub-cast__pair { display: flex; flex: 1 1 250px; flex-direction: row; align-items: center; gap: 8px; min-width: 0; padding: 0; }
.dub-casting-controls .dub-cast__pair > span { display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0; font-family: var(--font-sans); font-size: 12px; }
.dub-casting-controls .dub-cast__pair > .ss-wrap { flex: 1; min-width: 0; }
.dub-casting-controls .dub-cast__select { width: 100%; height: 40px; min-height: 40px; font-size: 12px; padding: 8px 12px; }
.dub-casting-controls .dub-cast__board-toggle { height: 40px; min-height: 40px; font-size: 12px; margin: 0; padding: 8px 10px; white-space: nowrap; }
@container (max-width: 450px) {
.dub-casting-controls .dub-cast__pair { flex-basis: 100%; }
.dub-casting-controls .dub-cast__pair > span { flex-basis: 90px; }
}
+79 -76
View File
@@ -12,6 +12,11 @@ import {
Copy,
ExternalLink,
ArrowRightLeft,
Cpu,
Gauge,
Hash,
MapPin,
RotateCcw,
} from 'lucide-react';
import { Button, Segmented, Progress } from '../../ui';
import { useAppStore } from '../../store';
@@ -21,7 +26,7 @@ import { API } from '../../api/client';
import { dubListTracks } from '../../api/dub';
import { LANG_CODES } from '../../utils/languages';
import ALL_LANGUAGES from '../../languages.json';
import { POPULAR_LANGS } from '../../utils/constants';
import SearchableSelect from '../SearchableSelect';
import { dialectOptionsFor, dialectLabel, dialectMatchesLang } from '../../api/dialects';
import { dubSegmentsText } from '../../api/dub';
import { copyText } from '../../utils/copyText';
@@ -29,6 +34,7 @@ import { openExternal } from '../../api/external';
import { TRANSLATION_ENGINES_DOCS } from '../../utils/errorDocsMap';
import CastingBoard from './CastingBoard';
import toast from 'react-hot-toast';
import './DubLeftColumn.css';
// Translation-settings bar utility class clusters
const SETTINGS_SUMMARY =
@@ -36,12 +42,11 @@ const SETTINGS_SUMMARY =
const SUMMARY_TRIGGER =
'inline-flex items-center gap-[5px] flex-1 min-w-0 bg-transparent border-none text-fg-muted cursor-pointer py-[2px] px-0 [font:inherit] text-left';
const SETTINGS_BAR =
'flex flex-col gap-[3px] max-[900px]:gap-[6px] mb-[4px] px-[8px] py-[4px] bg-[var(--chrome-bg)] border border-transparent rounded-[var(--chrome-radius-pill)]';
const FIELD = 'flex flex-col gap-[1px] min-w-0';
const FIELD_RESP = 'max-[960px]:basis-full max-[960px]:min-w-0';
const FIELD_LABEL =
'label-row !text-[0.58rem] !text-fg-muted !m-0 whitespace-nowrap overflow-hidden text-ellipsis';
const FIELD_INPUT = 'input-base !w-full !text-[0.65rem] !px-[5px] !py-[3px]';
'dub-translation-settings flex flex-col gap-4 mb-2 p-4 bg-[var(--chrome-hover-bg)] border-0 rounded-lg';
const FIELD = 'flex flex-col gap-2 min-w-0';
const FIELD_RESP = '';
const FIELD_LABEL = 'flex items-center gap-2 text-sm font-medium text-[var(--chrome-fg)]';
const FIELD_INPUT = 'input-base !w-full !text-sm !px-3 !py-2 min-h-10';
const ENGINE_CHIP =
'ml-[6px] px-[6px] py-[1px] text-[0.55rem] leading-[1.4] bg-[color-mix(in_srgb,var(--color-brand)_14%,transparent)] border border-transparent text-[var(--color-brand)] rounded-[var(--radius-pill)] whitespace-nowrap transition-colors';
// Highlighted accent Install affordance brand-filled pill, deliberately louder
@@ -420,69 +425,60 @@ export default function DubLeftColumn({
)}
{settingsOpen && (
<div className={SETTINGS_BAR}>
<div className="flex flex-wrap gap-x-[6px] gap-y-[4px] items-end">
<div className="dub-translation-fields">
<button
type="button"
className={`${SUMMARY_TRIGGER} flex-[0_0_auto] !px-[4px] self-center`}
className={`${SUMMARY_TRIGGER} col-span-full !py-2`}
onClick={() => setSettingsOpen(false)}
title={t('dub.collapse_settings')}
>
<ChevronUp size={10} />
<Languages size={16} aria-hidden="true" />
{t('dub.edit_settings')}
<ChevronUp size={14} className="ml-auto" aria-hidden="true" />
</button>
<div className={`${FIELD} flex-[1_1_100px] min-w-[70px] ${FIELD_RESP}`}>
<div className={FIELD_LABEL}>
<Globe className="label-icon" size={9} /> {t('dub.language')}
<Globe size={15} aria-hidden="true" /> {t('dub.language')}
</div>
<select
className={FIELD_INPUT}
value={dubLang}
onChange={(e) => {
const lang = e.target.value;
setDubLang(lang);
<MultiLangPicker
single
ariaLabel={t('dub.language')}
selected={[{ lang: dubLang, code: dubLangCode }]}
options={ALL_LANGUAGES.map((label) => ({
label,
code: LANG_CODES.find((item) => item.label === label)?.code || label,
}))}
onChange={([item]) => {
setDubLang(item.lang);
const match = LANG_CODES.find(
(lc) => lc.label.toLowerCase() === lang.toLowerCase(),
(lc) => lc.label.toLowerCase() === item.lang.toLowerCase(),
);
if (match) {
setDubLangCode(match.code);
// #280: a dialect belongs to one language clear it
// whenever the new target doesn't match.
if (!dialectMatchesLang(dubDialect, match.code)) setDubDialect('');
}
}}
>
<optgroup label={t('dub.popular')}>
{POPULAR_LANGS.map((l) => (
<option key={`p-${l}`} value={l}>
{l}
</option>
))}
</optgroup>
<optgroup label={t('dub.all_languages')}>
{ALL_LANGUAGES.filter((l) => !POPULAR_LANGS.includes(l)).map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</optgroup>
</select>
/>
</div>
<div className={`${FIELD} flex-[0_1_72px] min-w-[52px] ${FIELD_RESP}`}>
<div className={FIELD_LABEL}>{t('dub.iso_code')}</div>
<select
className={FIELD_INPUT}
<div className={FIELD_LABEL}>
<Hash size={15} aria-hidden="true" />
{t('dub.iso_code')}
</div>
<SearchableSelect
ariaLabel={t('dub.iso_code')}
menuPortal
buttonClassName={FIELD_INPUT}
value={dubLangCode}
onChange={(e) => {
const code = e.target.value;
options={LANG_CODES.map((lc) => ({
value: lc.code,
label: lc.code + ' — ' + lc.label,
}))}
onChange={(code) => {
setDubLangCode(code);
if (!dialectMatchesLang(dubDialect, code)) setDubDialect('');
}}
>
{LANG_CODES.map((lc) => (
<option key={lc.code} value={lc.code}>
{lc.code} {lc.label}
</option>
))}
</select>
/>
</div>
{/* #280: regional dialect / vocabulary. Only rendered for
languages with curated variants; region names come from
@@ -490,24 +486,28 @@ export default function DubLeftColumn({
{dialectOptionsFor(dubLangCode).length > 0 && (
<div className={`${FIELD} flex-[0_1_110px] min-w-[80px] ${FIELD_RESP}`}>
<div className={FIELD_LABEL} title={t('dub.dialect_title')}>
<MapPin size={15} aria-hidden="true" />
{t('dub.dialect_label')}
</div>
<select
className={FIELD_INPUT}
<SearchableSelect
ariaLabel={t('dub.dialect_label')}
menuPortal
buttonClassName={FIELD_INPUT}
value={dialectMatchesLang(dubDialect, dubLangCode) ? dubDialect : ''}
onChange={(e) => setDubDialect(e.target.value)}
>
<option value="">{t('dub.dialect_default')}</option>
{dialectOptionsFor(dubLangCode).map((d) => (
<option key={d} value={d}>
{dialectLabel(d, i18n.language)}
</option>
))}
</select>
onChange={setDubDialect}
options={[
{ value: '', label: t('dub.dialect_default') },
...dialectOptionsFor(dubLangCode).map((d) => ({
value: d,
label: dialectLabel(d, i18n.language),
})),
]}
/>
</div>
)}
<div className={`${FIELD} flex-[1.4_1_130px] min-w-[90px] ${FIELD_RESP}`}>
<div className={`${FIELD_LABEL} !overflow-visible flex items-center`}>
<Cpu size={15} aria-hidden="true" />
{t('dub.engine_label')}
{/* FROM-SOURCE lane: pip install works (uv pip install runs
in-process). Promote the muted chip to a highlighted accent
@@ -602,26 +602,27 @@ export default function DubLeftColumn({
</span>
)}
</div>
<select
className={FIELD_INPUT}
<SearchableSelect
ariaLabel={t('dub.engine_label')}
menuPortal
buttonClassName={FIELD_INPUT}
value={translateProvider}
onChange={(e) => setTranslateProvider(e.target.value)}
>
{(engines.length ? engines : []).map((p) => (
<option key={p.id} value={p.id}>
{p.installed
? p.display_name
: `${p.display_name}${t('dub.needs_install_suffix')}`}
</option>
))}
</select>
onChange={setTranslateProvider}
options={engines.map((p) => ({
value: p.id,
label: p.installed
? p.display_name
: p.display_name + t('dub.needs_install_suffix'),
}))}
/>
</div>
<div className={`${FIELD} flex-[0_1_auto] min-w-[80px] ${FIELD_RESP}`}>
<div className={FIELD_LABEL} title={t('dub.quality_title')}>
<Gauge size={15} aria-hidden="true" />
{t('dub.quality_label')}
</div>
<Segmented
className="w-full"
className="w-full [&_button]:min-h-8 [&_button]:text-xs"
size="sm"
value={translateQuality}
onChange={setTranslateQuality}
@@ -684,19 +685,20 @@ export default function DubLeftColumn({
)}
<div className={`${FIELD} flex-[1_1_90px] min-w-[64px] ${FIELD_RESP}`}>
<div className={FIELD_LABEL}>
<UserSquare2 className="label-icon" size={9} /> {t('dub.style')}{' '}
<UserSquare2 size={15} aria-hidden="true" /> {t('dub.style')}{' '}
<span className="text-[0.52rem] text-fg-subtle italic ml-[2px]">
{t('dub.optional')}
</span>
</div>
<input
className={FIELD_INPUT}
aria-label={t('dub.style')}
placeholder={t('dub.style_placeholder')}
value={dubInstruct}
onChange={(e) => setDubInstruct(e.target.value)}
/>
</div>
<div className={`${FIELD} basis-full pt-[3px] border-t border-transparent mt-[1px]`}>
<div className={`${FIELD} col-span-full pt-2`}>
<label className="flex items-center gap-[6px] text-[0.65rem] text-[var(--chrome-fg-muted)] cursor-pointer mb-[2px]">
<input
type="checkbox"
@@ -716,7 +718,7 @@ export default function DubLeftColumn({
)}
</div>
</div>
<div className="flex justify-end gap-[6px] flex-wrap">
<div className="dub-translation-actions flex justify-end gap-2 flex-wrap">
{failedTranslationCount > 0 && (
<>
<Button
@@ -760,6 +762,7 @@ export default function DubLeftColumn({
}
disabled={!dubSegments.some((s) => s.text_original && s.text_original !== s.text)}
title={t('dub.restore_title')}
leading={<RotateCcw size={15} aria-hidden="true" />}
>
{t('dub.restore')}
</Button>
@@ -769,7 +772,7 @@ export default function DubLeftColumn({
onClick={handleCleanupSegments}
disabled={!dubSegments.length || !dubJobId}
title={t('dub.clean_up_title')}
leading={<Wand2 size={10} />}
leading={<Wand2 size={15} aria-hidden="true" />}
>
{t('dub.clean_up')}
</Button>
@@ -0,0 +1,13 @@
.dub-output-settings { padding: 16px; border-radius: 10px; background: var(--chrome-hover-bg); margin-bottom: 12px; }
.dub-output-grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(min(100%,190px),1fr)); gap: 12px; margin-bottom: 16px; align-items: end; }
.dub-output-settings > div:not(.dub-output-grid) { gap: 10px; margin-top: 12px; padding: 0; }
.dub-output-settings > div > span { display: inline-flex; align-items: center; gap: 8px; font-family: var(--font-sans); font-size: 12px; text-transform: none; letter-spacing: normal; }
.dub-output-settings .ui-seg { flex-wrap: wrap; border-radius: 8px; }
.dub-output-settings .ui-seg button { min-height: 34px; font-size: 12px; }
.dub-panel-right .dub-transcript-toggle__inner { width: 100%; min-height: 42px; border: 0; border-radius: 8px; padding: 10px 12px; background: var(--chrome-hover-bg); }
.dub-panel-right .dub-transcript-toggle__inner > span { display: inline-flex; align-items: center; gap: 6px; }
.dub-panel-right .ui-table-toolbar { flex-wrap: wrap; gap: 8px; padding-block: 10px; }
.dub-panel-right .ui-table-toolbar__search { flex: 1 1 160px; }
.dub-panel-right .ui-table-toolbar__search-input { min-height: 40px; font-size: 12px; }
.dub-panel-right .ui-table-toolbar > .ss-wrap { flex: 1 1 160px; }
.dub-panel-right .ui-table-toolbar .dub-setting-toggle { white-space: nowrap; flex-shrink: 0; }
+77 -57
View File
@@ -1,5 +1,21 @@
import { Suspense, lazy, useState } from 'react';
import { ChevronUp, ChevronDown, FileText, ClipboardPaste } from 'lucide-react';
import {
ChevronUp,
ChevronDown,
FileText,
ClipboardPaste,
AudioLines,
Languages,
Captions,
ListMusic,
Timer,
Fingerprint,
BookOpen,
Settings2,
} from 'lucide-react';
import DubToggle from './DubToggle';
import SearchableSelect from '../SearchableSelect';
import './DubRightColumn.css';
import { Button, Segmented } from '../../ui';
import GlossaryPanel from '../GlossaryPanel';
import CheckpointBanner from '../CheckpointBanner';
@@ -15,11 +31,8 @@ const LazyFallback = () => <div className="p-[12px] text-[#6b6657] text-[0.7rem]
// Output-options + bulk-select utility clusters
const OUT_ROW =
'flex items-center gap-[var(--space-3)] mb-[2px] px-[var(--space-2)] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] font-[family-name:var(--font-sans)] flex-wrap';
const OUT_LABEL =
'flex items-center gap-[var(--space-2)] cursor-pointer hover:text-[var(--chrome-fg)]';
const OUT_TITLE =
'font-[family-name:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] uppercase text-[var(--chrome-fg-muted)] font-semibold';
const CHK = 'accent-[var(--color-brand)]';
const BULK_SELECT = 'input-base !text-[0.62rem] !px-[4px] !py-[2px]';
export default function DubRightColumn({
@@ -87,62 +100,63 @@ export default function DubRightColumn({
return (
<div className="studio-panel dub-panel-col dub-panel-right">
{/* Output options + timing — moved to the top of the right section. */}
<div>
<div className={OUT_ROW}>
<span className={OUT_TITLE}>{t('dub.output_options')}</span>
<label className={OUT_LABEL}>
<input
type="checkbox"
className={CHK}
checked={preserveBg}
onChange={(e) => setPreserveBg(e.target.checked)}
/>{' '}
{t('dub.mix_bg_audio')}
</label>
<label className={OUT_LABEL} title={t('dub.dual_subs_title')}>
<input
type="checkbox"
className={CHK}
checked={!!dualSubs}
onChange={(e) => setDualSubs(e.target.checked)}
/>{' '}
{t('dub.dual_subs')}
</label>
<label className={OUT_LABEL} title={t('dub.burn_subs_title')}>
<input
type="checkbox"
className={CHK}
checked={!!burnSubs}
onChange={(e) => setBurnSubs(e.target.checked)}
/>{' '}
{t('dub.burn_subs')}
</label>
<label className={OUT_LABEL}>
{t('dub.default_track')}
<select
className="input-base !text-[0.6rem] !px-[4px] !py-[2px] !w-[120px]"
<div className="dub-output-settings">
<div className="dub-output-grid">
<span className={`${OUT_TITLE} col-span-full inline-flex items-center gap-2`}>
<Settings2 size={16} aria-hidden="true" />
{t('dub.output_options')}
</span>
<DubToggle
label={t('dub.mix_bg_audio')}
Icon={AudioLines}
checked={preserveBg}
onChange={setPreserveBg}
/>
<DubToggle
label={t('dub.dual_subs')}
title={t('dub.dual_subs_title')}
Icon={Languages}
checked={dualSubs}
onChange={setDualSubs}
/>
<DubToggle
label={t('dub.burn_subs')}
title={t('dub.burn_subs_title')}
Icon={Captions}
checked={burnSubs}
onChange={setBurnSubs}
/>
<div className="flex flex-col gap-2 min-w-0">
<span className="inline-flex items-center gap-2 text-sm">
<ListMusic size={15} aria-hidden="true" />
{t('dub.default_track')}
</span>
<SearchableSelect
ariaLabel={t('dub.default_track')}
menuPortal
value={resolveDubDefaultTrack(defaultTrack, dubLangCode, dubTracks)}
onChange={(e) => setDefaultTrack(e.target.value)}
>
<option value="original">{t('dub.original_track')}</option>
{dubLangCode && (
<option value={dubLangCode}>{t('dub.selected_dub', { code: dubLangCode })}</option>
)}
{dubTracks
.filter((tr) => tr !== dubLangCode)
.map((tr) => (
<option key={tr} value={tr}>
{t('dub.dub_track', { code: tr })}
</option>
))}
</select>
</label>
onChange={setDefaultTrack}
buttonClassName="min-h-10 rounded-lg border-0 bg-[var(--chrome-hover-bg)] px-3 text-sm text-[var(--chrome-fg)]"
options={[
{ value: 'original', label: t('dub.original_track') },
...(dubLangCode
? [{ value: dubLangCode, label: t('dub.selected_dub', { code: dubLangCode }) }]
: []),
...dubTracks
.filter((tr) => tr !== dubLangCode)
.map((tr) => ({ value: tr, label: t('dub.dub_track', { code: tr }) })),
]}
/>
</div>
</div>
<div
className={OUT_ROW}
title="Timing strategy — how the dub reconciles natural-rate TTS with the original timeline."
>
<span className={OUT_TITLE}>Timing:</span>
<span className={OUT_TITLE}>
<Timer size={16} aria-hidden="true" />
Timing:
</span>
<Segmented
value={timingStrategy}
onChange={setTimingStrategy}
@@ -176,7 +190,10 @@ export default function DubRightColumn({
(best prosody, identity may drift) or every line of a speaker
shares ONE reference (steady identity). */}
<div className={OUT_ROW} title={t('dub.voice_match_title')}>
<span className={OUT_TITLE}>{t('dub.voice_match')}</span>
<span className={OUT_TITLE}>
<Fingerprint size={16} aria-hidden="true" />
{t('dub.voice_match')}
</span>
<Segmented
value={voiceMatch}
onChange={setVoiceMatch}
@@ -198,7 +215,9 @@ export default function DubRightColumn({
{dubTranscript && (
<div className="mb-[4px]">
<div
<button
type="button"
aria-expanded={showTranscript}
className="override-toggle dub-transcript-toggle__inner"
onClick={() => setShowTranscript(!showTranscript)}
>
@@ -206,7 +225,7 @@ export default function DubRightColumn({
<FileText size={10} className="align-middle mr-[3px]" /> {t('dub.transcript')}
</span>
{showTranscript ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</div>
</button>
{showTranscript && (
<div className="bg-[var(--chrome-bg)] border border-transparent border-t-0 rounded-b-[var(--chrome-radius-pill)] p-[var(--space-3)] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] leading-[1.5] max-h-[80px] overflow-y-auto">
{dubTranscript}
@@ -227,6 +246,7 @@ export default function DubRightColumn({
}}
title={t('dub.glossary_title')}
>
<BookOpen size={15} aria-hidden="true" className="mr-2" />
{t('dub.glossary_btn', { count: glossaryTermCount })}
</button>
)}
@@ -94,6 +94,8 @@ describe('DubRightColumn language targets', () => {
}),
);
expect(screen.getByRole('combobox', { name: 'dub.default_track' })).toHaveValue('es');
expect(screen.getByRole('button', { name: 'dub.default_track' })).toHaveTextContent(
'dub.dub_track',
);
});
});
+26
View File
@@ -0,0 +1,26 @@
export default function DubToggle({ label, title, checked, onChange, Icon }) {
return (
<button
type="button"
role="switch"
aria-checked={!!checked}
aria-label={label}
title={title}
onClick={() => onChange(!checked)}
className="dub-setting-toggle flex items-center justify-between gap-3 min-h-10 px-3 py-2 rounded-lg border-0 bg-[var(--chrome-hover-bg)] text-sm text-[var(--chrome-fg)] cursor-pointer hover:bg-[var(--chrome-accent-bg)] focus-visible:outline-2 focus-visible:outline-[var(--chrome-accent)]"
>
<span className="inline-flex items-center gap-2">
{Icon && <Icon size={15} aria-hidden="true" />}
{label}
</span>
<span
aria-hidden="true"
className={`relative shrink-0 w-8 h-[18px] rounded-full ${checked ? 'bg-[var(--chrome-accent)]' : 'bg-[var(--chrome-fg-dim)]'}`}
>
<span
className={`absolute top-0.5 left-0.5 size-3.5 rounded-full bg-[var(--color-bg)] transition-transform motion-reduce:transition-none ${checked ? 'translate-x-3.5' : ''}`}
/>
</span>
</button>
);
}
@@ -0,0 +1,15 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import DubToggle from './DubToggle';
describe('DubToggle', () => {
it('announces its state and updates through the supplied handler', () => {
const onChange = vi.fn();
const { rerender } = render(<DubToggle label="Preview" checked={false} onChange={onChange} />);
fireEvent.click(screen.getByRole('switch', { name: 'Preview', checked: false }));
expect(onChange).toHaveBeenCalledWith(true);
rerender(<DubToggle label="Preview" checked onChange={onChange} />);
expect(screen.getByRole('switch', { name: 'Preview' })).toHaveAttribute('aria-checked', 'true');
});
});
+4
View File
@@ -539,6 +539,9 @@
"convert_desc": "قلها مرة واحدة — واسمعها بصوت آخر. من كلام إلى كلام، محليًا بالكامل."
},
"clone": {
"text_label": "النص",
"paste": "لصق",
"paste_failed": "تم حظر الوصول إلى الحافظة. الصق مباشرة في حقل النص باستخدام لوحة المفاتيح.",
"prompt": "موجه",
"language": "اللغة",
"steps": "خطوات",
@@ -1861,6 +1864,7 @@
"hint": "8 خطوات · معاينة سريعة"
},
"header": {
"speech": "الكلام",
"kicker_studio": "استوديو",
"kicker_library": "مكتبة",
"kicker_preferences": "التفضيلات",
+4
View File
@@ -539,6 +539,9 @@
"convert_desc": "Einmal sagen — in einer anderen Stimme hören. Sprache zu Sprache, komplett lokal."
},
"clone": {
"text_label": "Text",
"paste": "Einfügen",
"paste_failed": "Der Zugriff auf die Zwischenablage wurde blockiert. Füge den Inhalt mit der Tastatur direkt ins Textfeld ein.",
"prompt": "Prompt",
"language": "Sprache",
"steps": "Schritte",
@@ -1861,6 +1864,7 @@
"hint": "8 Schritte · schnelle Vorschau"
},
"header": {
"speech": "Sprache",
"kicker_studio": "Studio",
"kicker_library": "Bibliothek",
"kicker_preferences": "Präferenzen",
+4
View File
@@ -301,6 +301,9 @@
"convert_desc": "Say it once — hear it in another voice. Speech to speech, fully local."
},
"clone": {
"text_label": "Text",
"paste": "Paste",
"paste_failed": "Clipboard access was blocked. Paste directly into the text field using your keyboard.",
"prompt": "Prompt",
"language": "Language",
"steps": "Steps",
@@ -2395,6 +2398,7 @@
"hint": "8 steps · fast preview"
},
"header": {
"speech": "Speech",
"kicker_studio": "Studio",
"kicker_library": "Library",
"kicker_preferences": "Preferences",
+4
View File
@@ -539,6 +539,9 @@
"convert_desc": "Dilo una vez y escúchalo con otra voz. De voz a voz, totalmente local."
},
"clone": {
"text_label": "Texto",
"paste": "Pegar",
"paste_failed": "Se bloqueó el acceso al portapapeles. Pega directamente en el campo de texto con el teclado.",
"prompt": "rápido",
"language": "Idioma",
"steps": "Pasos",
@@ -1861,6 +1864,7 @@
"hint": "8 pasos · vista previa rápida"
},
"header": {
"speech": "Voz",
"kicker_studio": "Estudio",
"kicker_library": "Biblioteca",
"kicker_preferences": "Preferencias",
+4
View File
@@ -539,6 +539,9 @@
"convert_desc": "Dites-le une fois — écoutez-le avec une autre voix. De la parole à la parole, entièrement en local."
},
"clone": {
"text_label": "Texte",
"paste": "Coller",
"paste_failed": "Laccès au presse-papiers est bloqué. Collez directement dans le champ de texte avec le clavier.",
"prompt": "Invite",
"language": "Langue",
"steps": "Étapes",
@@ -1861,6 +1864,7 @@
"hint": "8 étapes · aperçu rapide"
},
"header": {
"speech": "Parole",
"kicker_studio": "Atelier",
"kicker_library": "Bibliothèque",
"kicker_preferences": "Préférences",
+4
View File
@@ -539,6 +539,9 @@
"convert_desc": "एक बार बोलें — किसी और आवाज़ में सुनें। स्पीच से स्पीच, पूरी तरह लोकल।"
},
"clone": {
"text_label": "पाठ",
"paste": "चिपकाएँ",
"paste_failed": "क्लिपबोर्ड का उपयोग अवरुद्ध है। कीबोर्ड से सीधे पाठ फ़ील्ड में चिपकाएँ।",
"prompt": "शीघ्र",
"language": "भाषा",
"steps": "कदम",
@@ -1861,6 +1864,7 @@
"hint": "8 चरण · तेज़ पूर्वावलोकन"
},
"header": {
"speech": "वाणी",
"kicker_studio": "स्टूडियो",
"kicker_library": "पुस्तकालय",
"kicker_preferences": "प्राथमिकताएँ",
+4
View File
@@ -539,6 +539,9 @@
"convert_desc": "Ucapkan sekali — dengar dengan suara lain. Ucapan ke ucapan, sepenuhnya lokal."
},
"clone": {
"text_label": "Teks",
"paste": "Tempel",
"paste_failed": "Akses papan klip diblokir. Tempel langsung ke kolom teks menggunakan papan ketik.",
"prompt": "Cepat",
"language": "Bahasa",
"steps": "Langkah-langkah",
@@ -1861,6 +1864,7 @@
"hint": "8 langkah · pratinjau cepat"
},
"header": {
"speech": "Ucapan",
"kicker_studio": "Studio",
"kicker_library": "Perpustakaan",
"kicker_preferences": "Preferensi",
+4
View File
@@ -539,6 +539,9 @@
"convert_desc": "Dillo una volta — ascoltalo con un'altra voce. Da voce a voce, tutto in locale."
},
"clone": {
"text_label": "Testo",
"paste": "Incolla",
"paste_failed": "Laccesso agli appunti è bloccato. Incolla direttamente nel campo di testo con la tastiera.",
"prompt": "Richiedi",
"language": "Lingua",
"steps": "Passi",
@@ -1861,6 +1864,7 @@
"hint": "8 passaggi · anteprima veloce"
},
"header": {
"speech": "Voce",
"kicker_studio": "Studio",
"kicker_library": "Biblioteca",
"kicker_preferences": "Preferenze",
+4
View File
@@ -539,6 +539,9 @@
"convert_desc": "一度話すだけで、別の声に。音声から音声へ、すべてローカルで。"
},
"clone": {
"text_label": "テキスト",
"paste": "貼り付け",
"paste_failed": "クリップボードへのアクセスが拒否されました。キーボードでテキスト欄に直接貼り付けてください。",
"prompt": "プロンプト",
"language": "言語",
"steps": "ステップ",
@@ -1861,6 +1864,7 @@
"hint": "8ステップ・高速プレビュー"
},
"header": {
"speech": "音声",
"kicker_studio": "スタジオ",
"kicker_library": "図書館",
"kicker_preferences": "設定",
+4
View File
@@ -775,6 +775,9 @@
"convert_desc": "한 번 말하면 다른 목소리로 들립니다. 음성에서 음성으로, 완전히 로컬."
},
"clone": {
"text_label": "텍스트",
"paste": "붙여넣기",
"paste_failed": "클립보드 접근이 차단되었습니다. 키보드로 텍스트 입력란에 직접 붙여넣으세요.",
"prompt": "프롬프트",
"language": "언어",
"steps": "단계",
@@ -2189,6 +2192,7 @@
"hint": "8단계 · 빠른 미리보기"
},
"header": {
"speech": "음성",
"kicker_studio": "스튜디오",
"kicker_library": "라이브러리",
"kicker_preferences": "환경설정",
+4
View File
@@ -539,6 +539,9 @@
"convert_desc": "Zeg het één keer — hoor het in een andere stem. Spraak naar spraak, volledig lokaal."
},
"clone": {
"text_label": "Tekst",
"paste": "Plakken",
"paste_failed": "Toegang tot het klembord is geblokkeerd. Plak rechtstreeks in het tekstveld met het toetsenbord.",
"prompt": "Prompt",
"language": "Taal",
"steps": "Stappen",
@@ -1861,6 +1864,7 @@
"hint": "8 stappen · snelle preview"
},
"header": {
"speech": "Spraak",
"kicker_studio": "Studio",
"kicker_library": "Bibliotheek",
"kicker_preferences": "Voorkeuren",
+4
View File
@@ -539,6 +539,9 @@
"convert_desc": "Powiedz raz — usłysz innym głosem. Mowa na mowę, w pełni lokalnie."
},
"clone": {
"text_label": "Tekst",
"paste": "Wklej",
"paste_failed": "Dostęp do schowka został zablokowany. Wklej bezpośrednio do pola tekstowego za pomocą klawiatury.",
"prompt": "Podpowiedź",
"language": "Język",
"steps": "Kroki",
@@ -1861,6 +1864,7 @@
"hint": "8 kroków · szybki podgląd"
},
"header": {
"speech": "Mowa",
"kicker_studio": "Studio",
"kicker_library": "Biblioteka",
"kicker_preferences": "Preferencje",
+4
View File
@@ -539,6 +539,9 @@
"convert_desc": "Diga uma vez — ouça em outra voz. Fala para fala, totalmente local."
},
"clone": {
"text_label": "Texto",
"paste": "Colar",
"paste_failed": "O acesso à área de transferência foi bloqueado. Cole diretamente no campo de texto usando o teclado.",
"prompt": "Alerta",
"language": "Idioma",
"steps": "Passos",
@@ -1861,6 +1864,7 @@
"hint": "8 etapas · visualização rápida"
},
"header": {
"speech": "Fala",
"kicker_studio": "Estúdio",
"kicker_library": "Biblioteca",
"kicker_preferences": "Preferências",
+4
View File
@@ -539,6 +539,9 @@
"convert_desc": "Скажите один раз — услышьте другим голосом. Из речи в речь, полностью локально."
},
"clone": {
"text_label": "Текст",
"paste": "Вставить",
"paste_failed": "Доступ к буферу обмена заблокирован. Вставьте текст прямо в поле с помощью клавиатуры.",
"prompt": "Быстрый",
"language": "Язык",
"steps": "Шаги",
@@ -1861,6 +1864,7 @@
"hint": "8 шагов · быстрый просмотр"
},
"header": {
"speech": "Речь",
"kicker_studio": "Студия",
"kicker_library": "Библиотека",
"kicker_preferences": "Предпочтения",
+4
View File
@@ -539,6 +539,9 @@
"convert_desc": "Säg det en gång — hör det med en annan röst. Tal till tal, helt lokalt."
},
"clone": {
"text_label": "Text",
"paste": "Klistra in",
"paste_failed": "Åtkomst till urklipp blockerades. Klistra in direkt i textfältet med tangentbordet.",
"prompt": "Fråga",
"language": "Språk",
"steps": "Steg",
@@ -1861,6 +1864,7 @@
"hint": "8 steg · snabb förhandsgranskning"
},
"header": {
"speech": "Tal",
"kicker_studio": "Studio",
"kicker_library": "Bibliotek",
"kicker_preferences": "Inställningar",
+4
View File
@@ -539,6 +539,9 @@
"convert_desc": "พูดครั้งเดียว — ฟังเป็นอีกเสียงหนึ่ง เสียงพูดสู่เสียงพูด ทำงานในเครื่องทั้งหมด"
},
"clone": {
"text_label": "ข้อความ",
"paste": "วาง",
"paste_failed": "การเข้าถึงคลิปบอร์ดถูกบล็อก โปรดใช้แป้นพิมพ์วางลงในช่องข้อความโดยตรง",
"prompt": "พรอมต์",
"language": "ภาษา",
"steps": "ขั้นตอน",
@@ -1861,6 +1864,7 @@
"hint": "8 ขั้นตอน · ดูตัวอย่างอย่างรวดเร็ว"
},
"header": {
"speech": "เสียงพูด",
"kicker_studio": "สตูดิโอ",
"kicker_library": "ห้องสมุด",
"kicker_preferences": "การตั้งค่า",
+4
View File
@@ -539,6 +539,9 @@
"convert_desc": "Bir kez söyleyin — başka bir sesle duyun. Konuşmadan konuşmaya, tamamen yerel."
},
"clone": {
"text_label": "Metin",
"paste": "Yapıştır",
"paste_failed": "Pano erişimi engellendi. Klavyeyi kullanarak doğrudan metin alanına yapıştırın.",
"prompt": "İstemi",
"language": "Dil",
"steps": "Adımlar",
@@ -1861,6 +1864,7 @@
"hint": "8 adım · hızlı önizleme"
},
"header": {
"speech": "Konuşma",
"kicker_studio": "stüdyo",
"kicker_library": "Kütüphane",
"kicker_preferences": "Tercihler",
+4
View File
@@ -539,6 +539,9 @@
"convert_desc": "Скажіть один раз — почуйте іншим голосом. З мовлення в мовлення, повністю локально."
},
"clone": {
"text_label": "Текст",
"paste": "Вставити",
"paste_failed": "Доступ до буфера обміну заблоковано. Вставте текст безпосередньо в поле за допомогою клавіатури.",
"prompt": "Підкажіть",
"language": "Мова",
"steps": "Кроки",
@@ -1861,6 +1864,7 @@
"hint": "8 кроків · швидкий попередній перегляд"
},
"header": {
"speech": "Мовлення",
"kicker_studio": "Студія",
"kicker_library": "Бібліотека",
"kicker_preferences": "Уподобання",
+4
View File
@@ -539,6 +539,9 @@
"convert_desc": "Nói một lần — nghe bằng giọng khác. Giọng nói sang giọng nói, hoàn toàn cục bộ."
},
"clone": {
"text_label": "Văn bản",
"paste": "Dán",
"paste_failed": "Quyền truy cập bảng nhớ tạm đã bị chặn. Hãy dùng bàn phím để dán trực tiếp vào ô văn bản.",
"prompt": "Lời nhắc",
"language": "Ngôn ngữ",
"steps": "bước",
@@ -1861,6 +1864,7 @@
"hint": "8 bước · xem trước nhanh"
},
"header": {
"speech": "Giọng nói",
"kicker_studio": "Studio",
"kicker_library": "Thư viện",
"kicker_preferences": "Tùy chọn",
+4
View File
@@ -187,6 +187,9 @@
"convert_desc": "说一遍,用另一种声音听到。语音到语音,完全本地运行。"
},
"clone": {
"text_label": "文本",
"paste": "粘贴",
"paste_failed": "剪贴板访问被阻止。请使用键盘直接粘贴到文本框中。",
"prompt": "提示词",
"language": "语言",
"steps": "步数",
@@ -1867,6 +1870,7 @@
"hint": "8 步 · 快速预览"
},
"header": {
"speech": "语音",
"kicker_studio": "工作室",
"kicker_library": "音色库",
"kicker_preferences": "偏好设置",
+4
View File
@@ -539,6 +539,9 @@
"convert_desc": "說一次,用另一種聲音聽到。語音到語音,完全在本機執行。"
},
"clone": {
"text_label": "文字",
"paste": "貼上",
"paste_failed": "剪貼簿存取遭到封鎖。請使用鍵盤直接貼上到文字欄位。",
"prompt": "提示",
"language": "語言",
"steps": "步驟",
@@ -1861,6 +1864,7 @@
"hint": "8步·快速預覽"
},
"header": {
"speech": "語音",
"kicker_studio": "工作室",
"kicker_library": "圖書館",
"kicker_preferences": "偏好設定",
+20 -3
View File
@@ -2470,12 +2470,29 @@ input[type="file"]::file-selector-button:hover {
}
/* from MultiLangPicker (components/MultiLangPicker.css) */
.multi-lang-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr));
align-content: start;
gap: 4px;
}
.multi-lang-options > div { grid-column: 1 / -1; }
@keyframes engine-title-enter {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.engine-title-label { animation: engine-title-enter 180ms ease-out; }
@media (prefers-reduced-motion: reduce) {
.engine-title-label { animation: none; }
}
/* Dropdown */
.multi-lang__drop {
position: fixed;
z-index: var(--z-overlay);
background: var(--chrome-bg);
border: 1px solid var(--chrome-border-strong);
border: 0;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.35);
max-height: 260px;
@@ -4953,12 +4970,12 @@ button.dub-stepper__action:focus-visible {
overflow-y: auto;
}
/* Let the form shrink into the available height on stacked shells too.
/* Reserve a viewport-height workspace before the stacked voice library.
The inner form scrolls independently; the action bar remains visible. */
.shell-narrow .studio-with-history__main,
.shell-mini .studio-with-history__main {
overflow: visible;
flex: 1 1 0%;
flex: 1 0 100%;
min-height: 0;
order: 1;
}
+151 -144
View File
@@ -15,7 +15,7 @@ import AudioMethodPanel from '../components/clone/AudioMethodPanel';
import DesignMethodPanel from '../components/clone/DesignMethodPanel';
import ConvertMethodPanel from '../components/clone/ConvertMethodPanel';
import ActionBar from '../components/clone/ActionBar';
import EngineQuickSwitch from '../components/EngineQuickSwitch';
import VoiceModeIcon from '../components/clone/VoiceModeIcon';
export default function CloneDesignTab(props) {
const [convertRecordingBusy, setConvertRecordingBusy] = useState(false);
@@ -420,11 +420,10 @@ export default function CloneDesignTab(props) {
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 />
<div className="voice-workspace-toolbar relative z-10 flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2 px-3 pb-3 pt-2 max-[600px]:px-1.5">
<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"
className="grid h-auto w-auto min-w-0 flex-[1_1_320px] grid-cols-3 gap-[3px] rounded-[var(--chrome-radius-pill)] border border-transparent bg-[var(--chrome-bg)] p-[3px]"
>
{[
{ id: 'audio', label: t('clone.define_from_audio') },
@@ -434,164 +433,172 @@ export default function CloneDesignTab(props) {
<TabsTrigger
key={method.id}
value={method.id}
data-voice-mode={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"
className="voice-mode-tab min-h-11 h-auto min-w-0 cursor-pointer whitespace-normal rounded-[var(--chrome-radius-pill)] border border-transparent bg-transparent px-3 py-2 text-sm font-medium text-[color:var(--chrome-fg-muted)] transition-colors data-[state=active]:border-[var(--chrome-accent-border)] data-[state=active]:bg-[var(--chrome-accent-bg)] data-[state=active]:font-semibold data-[state=active]:text-[color:var(--chrome-accent)] data-[state=active]:shadow-none dark:data-[state=active]:border-[var(--chrome-accent-border)] dark:data-[state=active]:bg-[var(--chrome-accent-bg)] dark:data-[state=active]:text-[color:var(--chrome-accent)] hover:data-[state=inactive]:bg-[var(--chrome-hover-bg)]"
>
<VoiceModeIcon mode={method.id} />
{method.label}
</TabsTrigger>
))}
</TabsList>
</div>
<TabsContent value={defineMethod} className="flex min-h-0 flex-col">
{/* The form owns the remaining height and scrolls above the action bar. */}
<div className="flex flex-1 flex-col gap-[6px] min-h-0 overflow-y-auto">
{/* SCRIPT what should it say
{defineMethod === 'convert' ? (
<ConvertMethodPanel
t={t}
profiles={profiles}
onRecordingBusyChange={setConvertRecordingBusy}
/>
) : (
<>
{/* The form owns the remaining height and scrolls above the action bar. */}
<div className="flex flex-1 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}
/>
)}
{/* ═══ 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>
{defineMethod === 'audio' ? (
<AudioMethodPanel
{defineMethod !== 'convert' && (
<ScriptPanel
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}
text={text}
setText={setText}
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}
demoPresets={demoPresets}
applyDemoPreset={applyDemoPreset}
showDemoCoachmark={showDemoCoachmark}
setShowDemoCoachmark={setShowDemoCoachmark}
selectedProfile={selectedProfile}
DEMO_PROFILE_ID={DEMO_PROFILE_ID}
textAreaRef={textAreaRef}
insertOpen={insertOpen}
setInsertOpen={setInsertOpen}
insertTag={insertTag}
/>
)}
</div>
</div>
</div>
{/* ACTION BAR pinned to the column bottom
{/* ═══ VOICE — who says it ═══ */}
<div
className={`flex flex-col gap-[6px] ${defineMethod === 'audio' ? 'flex-[1_0_auto]' : 'flex-none'} min-h-0 relative z-[1]`}
>
<div className="flex flex-1 flex-col min-h-0 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>
{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}
/>
) : (
<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>
{/* 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}
/>
{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>
+2 -1
View File
@@ -302,7 +302,8 @@ describe('CloneDesignTab — stale /design/describe response race guard', () =>
// The user hand-picks Gender before that response lands.
fireEvent.click(screen.getByRole('button', { name: /details/i }));
const genderSelect = document.getElementById('vd-Gender');
fireEvent.change(genderSelect, { target: { value: 'male' } });
fireEvent.keyDown(genderSelect, { key: 'Enter' });
fireEvent.click(screen.getByRole('option', { name: /^male$/i }));
expect(setVdStates).toHaveBeenCalledTimes(1);
expect(setVdStates).toHaveBeenLastCalledWith(expect.objectContaining({ Gender: 'male' }));
-2
View File
@@ -11,7 +11,6 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Mic, Copy, Trash2, Search, Clock, Languages, FileText, Download } from 'lucide-react';
import { Button } from '../ui';
import EngineQuickSwitch from '../components/EngineQuickSwitch';
import { toast } from 'react-hot-toast';
import { toMillis } from '../utils/relativeTime';
import { useEffectiveDictationShortcut } from '../hooks/useEffectiveDictationShortcut';
@@ -160,7 +159,6 @@ export default function TranscriptionsPage() {
</span>
</div>
<div className="txn-header__right flex items-center gap-[6px]">
<EngineQuickSwitch family="asr" />
<Button size="sm" variant="primary" onClick={startCapture}>
<Mic size={13} /> {t('transcriptions.capture')}
</Button>
@@ -152,10 +152,8 @@ describe('DubLeftColumn — translation-engine install affordance', () => {
{ id: 'argos', display_name: 'Argos', installed: true, install_command: null },
];
render(<DubLeftColumn {...makeProps({ engines, setTranslateProvider })} />);
// The engine <select> is the only combobox whose current value is 'google'.
const select = screen.getAllByRole('combobox').find((el) => el.value === 'google');
expect(select).toBeTruthy();
fireEvent.change(select, { target: { value: 'argos' } });
fireEvent.click(screen.getByRole('button', { name: 'Engine', exact: true }));
fireEvent.mouseDown(screen.getByRole('option', { name: 'Argos', exact: true }));
expect(setTranslateProvider).toHaveBeenCalledWith('argos');
});
});
+69 -2
View File
@@ -8,7 +8,7 @@
* navigation at all (the rail isn't rendered either).
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
@@ -25,10 +25,13 @@ vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => windowActions
afterEach(() => {
delete window.__TAURI_INTERNALS__;
vi.clearAllMocks();
vi.useRealTimers();
});
function renderHeader(props) {
function renderHeader(props, engines) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(['sysinfo'], {});
if (engines) qc.setQueryData(['engines'], engines);
return render(
<QueryClientProvider client={qc}>
<Header mode="dub" setMode={() => {}} modelStatus="idle" {...props} />
@@ -37,6 +40,70 @@ function renderHeader(props) {
}
describe('Header — rail mode (default)', () => {
it('cycles all three active engines and pauses while the panel is open', () => {
vi.useFakeTimers();
renderHeader(
{ onFlushMemory: vi.fn() },
{
tts: {
active: 'omni',
backends: [
{ id: 'omni', display_name: 'VoiceStudio (k2-fsa/OmniVoice, 600+ languages)' },
],
},
asr: { active: 'whisper', backends: [{ id: 'whisper', display_name: 'Whisper' }] },
llm: { active: 'off', backends: [{ id: 'off', display_name: 'Off (no LLM)' }] },
},
);
const trigger = screen.getByRole('button', { name: 'Engines' });
expect(trigger).toHaveTextContent('TTS · OmniVoice');
act(() => vi.advanceTimersByTime(4000));
expect(trigger).toHaveTextContent('ASR · Whisper');
act(() => vi.advanceTimersByTime(4000));
expect(trigger).toHaveTextContent('LLM · Off');
fireEvent.click(trigger);
act(() => vi.advanceTimersByTime(4000));
expect(trigger).toHaveTextContent('LLM · Off');
});
it('shows the selected engine name on the title-bar trigger', () => {
renderHeader(
{ onFlushMemory: vi.fn() },
{
tts: {
active: 'kitten',
backends: [
{ id: 'kitten', display_name: 'KittenTTS (English, 8 preset voices)', available: true },
],
},
},
);
const trigger = screen.getByRole('button', { name: 'Engines' });
expect(trigger).toHaveTextContent('KittenTTS');
expect(trigger).toHaveAttribute('title', 'KittenTTS (English, 8 preset voices)');
});
it('opens combined engine and memory controls from the global shortcut', () => {
renderHeader({ onFlushMemory: vi.fn() });
fireEvent(window, new Event('engine-quick-switch'));
expect(screen.getByRole('dialog', { name: 'Engines' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Flush caches/ })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Speech' })).toHaveAttribute('aria-selected', 'true');
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Transcription' }), {
button: 0,
ctrlKey: false,
});
expect(screen.getByRole('tab', { name: 'Transcription' })).toHaveAttribute(
'aria-selected',
'true',
);
expect(
screen.getByRole('button', { name: /Unload all/ }).closest('details'),
).not.toHaveAttribute('open');
fireEvent.click(screen.getByText('Memory management'));
expect(screen.getByRole('button', { name: /Unload all/ })).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: 'Engines' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Engines' })).toHaveFocus();
});
it('keeps the breadcrumb and wordmark, and renders no tab strip', () => {
const { container } = renderHeader({});
expect(container.querySelector('.tabstrip')).toBeNull();
@@ -57,6 +57,9 @@ vi.mock('../api/system', async (importOriginal) => ({
vi.mock('../components/NetworkToggle', () => ({ default: () => null }));
import LogsFooter from '../components/LogsFooter';
vi.mock('../components/EngineQuickSwitch', () => ({
default: () => <button>Footer engine switcher</button>,
}));
import { useAppStore } from '../store';
function renderFooter() {
@@ -85,6 +88,12 @@ beforeEach(() => {
});
describe('LogsFooter notifications tab — dismissals', () => {
it('does not duplicate the workspace engine switcher', () => {
renderFooter();
expect(
screen.queryByRole('button', { name: 'Footer engine switcher' }),
).not.toBeInTheDocument();
});
it('shows both notes; only the info note offers a dismiss button', async () => {
renderFooter();
openNotificationsTab();
@@ -156,9 +156,8 @@ describe('review round: partial translations + dialect guard', () => {
/>,
);
fireEvent.change(document.querySelector('.dub-cast__select'), {
target: { value: 'voice-new' },
});
fireEvent.click(document.querySelector('.dub-cast__select'));
fireEvent.mouseDown(screen.getByRole('option', { name: 'New voice', exact: true }));
const update = setDubSegments.mock.calls[0][0][0];
expect(update.profile_id).toBe('voice-new');
@@ -188,9 +187,8 @@ describe('review round: partial translations + dialect guard', () => {
/>,
);
fireEvent.change(document.querySelectorAll('.dub-cast__select')[1], {
target: { value: 'voice-new' },
});
fireEvent.click(document.querySelectorAll('.dub-cast__select')[1]);
fireEvent.mouseDown(screen.getByRole('option', { name: 'New voice', exact: true }));
const update = setDubSegments.mock.calls[0][0];
expect(update[0].profile_id).toBe('voice-anna');
+3
View File
@@ -6,6 +6,9 @@ import { afterEach, beforeEach } from 'vitest';
// assertions on English text stable regardless of detected locale.
import '../i18n';
// Radix menus scroll the keyboard-focused item; JSDOM has no layout scrolling.
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {};
const localStorageMock = (function () {
let store = {};
return {
@@ -50,7 +50,7 @@ describe('workspace narrow-shell reflow (#476 CTA-clipping guard)', () => {
for (const shell of ['shell-mini', 'shell-narrow']) {
const selector = `.${shell} .studio-with-history__main`;
const rule = css.split('}').find((block) => block.split('{')[0].includes(selector));
expect(rule).toMatch(/flex:\s*1 1 0%;/);
expect(rule).toMatch(/flex:\s*1 0 100%;/);
expect(rule).toMatch(/min-height:\s*0;/);
}
expect(indexRaw).toMatch(
+4
View File
@@ -0,0 +1,4 @@
// Older backends used the application name for the bundled OmniVoice model.
export function engineDisplayName(name = '') {
return name.replace(/^VoiceStudio(?= \(k2-fsa\/OmniVoice| TTS$|$)/, 'OmniVoice');
}
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { engineDisplayName } from './engineDisplayName';
describe('engineDisplayName', () => {
it('uses the model name for legacy engine and resident labels', () => {
expect(engineDisplayName('VoiceStudio (k2-fsa/OmniVoice, 600+ languages)')).toBe(
'OmniVoice (k2-fsa/OmniVoice, 600+ languages)',
);
expect(engineDisplayName('VoiceStudio TTS')).toBe('OmniVoice TTS');
expect(engineDisplayName('KittenTTS (English)')).toBe('KittenTTS (English)');
});
});