mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-18 22:30:40 -07:00
feat(i18n): localize Create/Edit Voice modal and audio sample panels
ProfileForm now routes its title, description, voice-source toggle (Clone from audio / Built-in voice), field labels (Name, Description, Language, Engine, Voice, Reference Text, Default Engine, Default Effects), sample tabs (Upload / Record / System Audio), action buttons, and every toast + Zod validation message through i18n. Also covers the three AudioSample panels (Upload/Record/System) — the choose-file / start-recording / start-capture call-to-actions, the "N remaining" countdown, "Recording complete" / "Capture complete" states, and the Play / Transcribe / Remove / Record Again buttons. SampleList too — the "No samples yet" empty state, per-sample edit mode, mini-player aria labels, Delete Sample dialog, and toasts. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
2e962f6599
commit
0c51132a93
@@ -1,5 +1,6 @@
|
||||
import { Mic, Pause, Play, Square } from 'lucide-react';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Visualizer } from 'react-sound-visualizer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||
@@ -48,6 +49,7 @@ export function AudioSampleRecording({
|
||||
isTranscribing = false,
|
||||
showWaveform = true,
|
||||
}: AudioSampleRecordingProps) {
|
||||
const { t } = useTranslation();
|
||||
const [audioStream, setAudioStream] = useState<MediaStream | null>(null);
|
||||
|
||||
// Request microphone access when component mounts
|
||||
@@ -90,10 +92,10 @@ export function AudioSampleRecording({
|
||||
className="relative z-10 flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-5 w-5" />
|
||||
Start Recording
|
||||
{t('audioSample.startRecording')}
|
||||
</Button>
|
||||
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||
Click to start recording. Maximum duration: 30 seconds.
|
||||
{t('audioSample.recordHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -115,10 +117,10 @@ export function AudioSampleRecording({
|
||||
className="relative z-10 flex items-center gap-2 bg-accent text-accent-foreground hover:bg-accent/90"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop Recording
|
||||
{t('audioSample.stopRecording')}
|
||||
</Button>
|
||||
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||
{formatAudioDuration(30 - duration)} remaining
|
||||
{t('audioSample.remaining', { time: formatAudioDuration(30 - duration) })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -127,16 +129,18 @@ export function AudioSampleRecording({
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mic className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">Recording complete</span>
|
||||
<span className="font-medium">{t('audioSample.recordingComplete')}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t('audioSample.fileLabel', { name: file.name })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={onPlayPause}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
aria-label={isPlaying ? t('audioSample.pause') : t('audioSample.play')}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
@@ -148,7 +152,7 @@ export function AudioSampleRecording({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -156,7 +160,7 @@ export function AudioSampleRecording({
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Record Again
|
||||
{t('audioSample.recordAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Mic, Monitor, Pause, Play, Square } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
@@ -28,6 +29,7 @@ export function AudioSampleSystem({
|
||||
isPlaying,
|
||||
isTranscribing = false,
|
||||
}: AudioSampleSystemProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
@@ -36,10 +38,10 @@ export function AudioSampleSystem({
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
||||
<Button type="button" onClick={onStart} size="lg" className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5" />
|
||||
Start Capture
|
||||
{t('audioSample.startCapture')}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Capture audio from your system. Maximum duration: 30 seconds.
|
||||
{t('audioSample.systemHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -61,10 +63,10 @@ export function AudioSampleSystem({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop Capture
|
||||
{t('audioSample.stopCapture')}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{formatAudioDuration(30 - duration)} remaining
|
||||
{t('audioSample.remaining', { time: formatAudioDuration(30 - duration) })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -73,16 +75,18 @@ export function AudioSampleSystem({
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">Capture complete</span>
|
||||
<span className="font-medium">{t('audioSample.captureComplete')}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t('audioSample.fileLabel', { name: file.name })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={onPlayPause}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
aria-label={isPlaying ? t('audioSample.pause') : t('audioSample.play')}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
@@ -94,7 +98,7 @@ export function AudioSampleSystem({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -102,7 +106,7 @@ export function AudioSampleSystem({
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Capture Again
|
||||
{t('audioSample.captureAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Mic, Pause, Play, Upload } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||
|
||||
@@ -26,6 +27,7 @@ export function AudioSampleUpload({
|
||||
isDisabled = false,
|
||||
fieldName,
|
||||
}: AudioSampleUploadProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -90,19 +92,21 @@ export function AudioSampleUpload({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
Choose File
|
||||
{t('audioSample.chooseFile')}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Click to choose a file or drag and drop. Maximum duration: 30 seconds.
|
||||
{t('audioSample.uploadHint')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">File uploaded</span>
|
||||
<span className="font-medium">{t('audioSample.fileUploaded')}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t('audioSample.fileLabel', { name: file.name })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -110,7 +114,7 @@ export function AudioSampleUpload({
|
||||
variant="outline"
|
||||
onClick={onPlayPause}
|
||||
disabled={isValidating}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
aria-label={isPlaying ? t('audioSample.pause') : t('audioSample.play')}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
@@ -122,7 +126,7 @@ export function AudioSampleUpload({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -134,7 +138,7 @@ export function AudioSampleUpload({
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
{t('audioSample.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { Edit2, Mic, Monitor, Music, Upload, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as z from 'zod';
|
||||
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -71,30 +72,38 @@ const DEFAULT_ENGINE_OPTIONS = [
|
||||
{ value: 'kokoro', label: 'Kokoro 82M' },
|
||||
] as const;
|
||||
|
||||
const baseProfileSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||
sampleFile: z.instanceof(File).optional(),
|
||||
referenceText: z.string().max(1000).optional(),
|
||||
avatarFile: z.instanceof(File).optional(),
|
||||
});
|
||||
function makeProfileSchema(t: (key: string) => string) {
|
||||
const baseProfileSchema = z.object({
|
||||
name: z.string().min(1, t('profileForm.validation.nameRequired')).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||
sampleFile: z.instanceof(File).optional(),
|
||||
referenceText: z.string().max(1000).optional(),
|
||||
avatarFile: z.instanceof(File).optional(),
|
||||
});
|
||||
|
||||
const profileSchema = baseProfileSchema.refine(
|
||||
(data) => {
|
||||
// If sample file is provided, reference text is required
|
||||
if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Reference text is required when adding a sample',
|
||||
path: ['referenceText'],
|
||||
},
|
||||
);
|
||||
return baseProfileSchema.refine(
|
||||
(data) => {
|
||||
if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t('profileForm.validation.referenceRequired'),
|
||||
path: ['referenceText'],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
type ProfileFormValues = z.infer<typeof profileSchema>;
|
||||
type ProfileFormValues = {
|
||||
name: string;
|
||||
description?: string;
|
||||
language: LanguageCode;
|
||||
sampleFile?: File;
|
||||
referenceText?: string;
|
||||
avatarFile?: File;
|
||||
};
|
||||
|
||||
// Helper to convert File to base64
|
||||
async function fileToBase64(file: File): Promise<string> {
|
||||
@@ -119,6 +128,7 @@ function base64ToFile(base64: string, fileName: string, fileType: string): File
|
||||
}
|
||||
|
||||
export function ProfileForm() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const open = useUIStore((state) => state.profileDialogOpen);
|
||||
const setOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
@@ -151,7 +161,7 @@ export function ProfileForm() {
|
||||
const [defaultEngine, setDefaultEngine] = useState<string>('');
|
||||
|
||||
const form = useForm<ProfileFormValues>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
resolver: zodResolver(makeProfileSchema(t)),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
@@ -175,7 +185,10 @@ export function ProfileForm() {
|
||||
if (duration > MAX_AUDIO_DURATION_SECONDS) {
|
||||
form.setError('sampleFile', {
|
||||
type: 'manual',
|
||||
message: `Audio is too long (${formatAudioDuration(duration)}). Maximum duration is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
|
||||
message: t('profileForm.validation.audioTooLong', {
|
||||
duration: formatAudioDuration(duration),
|
||||
max: formatAudioDuration(MAX_AUDIO_DURATION_SECONDS),
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
form.clearErrors('sampleFile');
|
||||
@@ -184,14 +197,13 @@ export function ProfileForm() {
|
||||
.catch((error) => {
|
||||
console.error('Failed to get audio duration:', error);
|
||||
setAudioDuration(null);
|
||||
// For recordings, we auto-stop at max duration, so we can skip validation errors
|
||||
const isRecordedFile =
|
||||
selectedFile.name.startsWith('recording-') ||
|
||||
selectedFile.name.startsWith('system-audio-');
|
||||
if (!isRecordedFile) {
|
||||
form.setError('sampleFile', {
|
||||
type: 'manual',
|
||||
message: 'Failed to validate audio file. Please try a different file.',
|
||||
message: t('profileForm.validation.audioFailed'),
|
||||
});
|
||||
} else {
|
||||
// Clear any existing errors for recorded files
|
||||
@@ -205,7 +217,7 @@ export function ProfileForm() {
|
||||
setAudioDuration(null);
|
||||
form.clearErrors('sampleFile');
|
||||
}
|
||||
}, [selectedFile, form]);
|
||||
}, [selectedFile, form, t]);
|
||||
|
||||
const {
|
||||
isRecording,
|
||||
@@ -226,8 +238,8 @@ export function ProfileForm() {
|
||||
}
|
||||
form.setValue('sampleFile', file, { shouldValidate: true });
|
||||
toast({
|
||||
title: 'Recording complete',
|
||||
description: 'Audio has been recorded successfully.',
|
||||
title: t('profileForm.toast.recordingComplete'),
|
||||
description: t('profileForm.toast.recordingCompleteDescription'),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -252,8 +264,8 @@ export function ProfileForm() {
|
||||
}
|
||||
form.setValue('sampleFile', file, { shouldValidate: true });
|
||||
toast({
|
||||
title: 'System audio captured',
|
||||
description: 'Audio has been captured successfully.',
|
||||
title: t('profileForm.toast.systemAudioCaptured'),
|
||||
description: t('profileForm.toast.systemAudioCapturedDescription'),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -282,23 +294,22 @@ export function ProfileForm() {
|
||||
useEffect(() => {
|
||||
if (recordingError) {
|
||||
toast({
|
||||
title: 'Recording error',
|
||||
title: t('profileForm.toast.recordingError'),
|
||||
description: recordingError,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [recordingError, toast]);
|
||||
}, [recordingError, toast, t]);
|
||||
|
||||
// Show system audio recording errors
|
||||
useEffect(() => {
|
||||
if (systemRecordingError) {
|
||||
toast({
|
||||
title: 'System audio capture error',
|
||||
title: t('profileForm.toast.systemAudioError'),
|
||||
description: systemRecordingError,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [systemRecordingError, toast]);
|
||||
}, [systemRecordingError, toast, t]);
|
||||
|
||||
// Handle avatar preview
|
||||
useEffect(() => {
|
||||
@@ -388,8 +399,8 @@ export function ProfileForm() {
|
||||
const file = form.getValues('sampleFile');
|
||||
if (!file) {
|
||||
toast({
|
||||
title: 'No file selected',
|
||||
description: 'Please select an audio file first.',
|
||||
title: t('profileForm.toast.noFile'),
|
||||
description: t('profileForm.toast.noFileDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -402,8 +413,9 @@ export function ProfileForm() {
|
||||
form.setValue('referenceText', result.text, { shouldValidate: true });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Transcription failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to transcribe audio',
|
||||
title: t('profileForm.toast.transcribeFailed'),
|
||||
description:
|
||||
error instanceof Error ? error.message : t('profileForm.toast.transcribeFailedFallback'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -429,16 +441,16 @@ export function ProfileForm() {
|
||||
if (file) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast({
|
||||
title: 'Invalid file type',
|
||||
description: 'Please select an image file (PNG, JPG, or WebP)',
|
||||
title: t('profileForm.toast.invalidFile'),
|
||||
description: t('profileForm.toast.invalidImageFormat'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast({
|
||||
title: 'File too large',
|
||||
description: 'Image must be less than 5MB',
|
||||
title: t('profileForm.toast.fileTooLarge'),
|
||||
description: t('profileForm.toast.imageTooLargeDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -452,13 +464,13 @@ export function ProfileForm() {
|
||||
try {
|
||||
await deleteAvatar.mutateAsync(editingProfileId);
|
||||
toast({
|
||||
title: 'Avatar removed',
|
||||
description: 'Avatar image has been removed successfully.',
|
||||
title: t('profileForm.toast.avatarRemoved'),
|
||||
description: t('profileForm.toast.avatarRemovedDescription'),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to remove avatar',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
title: t('profileForm.toast.avatarRemoveFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -493,9 +505,11 @@ export function ProfileForm() {
|
||||
});
|
||||
} catch (avatarError) {
|
||||
toast({
|
||||
title: 'Avatar upload failed',
|
||||
title: t('profileForm.toast.avatarUploadFailed'),
|
||||
description:
|
||||
avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
||||
avatarError instanceof Error
|
||||
? avatarError.message
|
||||
: t('profileForm.toast.avatarUploadFailedFallback'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -510,9 +524,11 @@ export function ProfileForm() {
|
||||
);
|
||||
} catch (fxError) {
|
||||
toast({
|
||||
title: 'Effects update failed',
|
||||
title: t('profileForm.toast.effectsUpdateFailed'),
|
||||
description:
|
||||
fxError instanceof Error ? fxError.message : 'Failed to save effects chain',
|
||||
fxError instanceof Error
|
||||
? fxError.message
|
||||
: t('profileForm.toast.effectsUpdateFailedFallback'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -520,15 +536,15 @@ export function ProfileForm() {
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Voice updated',
|
||||
description: `"${data.name}" has been updated successfully.`,
|
||||
title: t('profileForm.toast.voiceUpdated'),
|
||||
description: t('profileForm.toast.voiceUpdatedDescription', { name: data.name }),
|
||||
});
|
||||
} else if (voiceSource === 'builtin') {
|
||||
// Creating preset profile from built-in voice
|
||||
if (!selectedPresetVoiceId) {
|
||||
toast({
|
||||
title: 'No voice selected',
|
||||
description: 'Please select a built-in voice.',
|
||||
title: t('profileForm.toast.noVoiceSelected'),
|
||||
description: t('profileForm.toast.noVoiceSelectedDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -553,17 +569,19 @@ export function ProfileForm() {
|
||||
});
|
||||
} catch (avatarError) {
|
||||
toast({
|
||||
title: 'Avatar upload failed',
|
||||
title: t('profileForm.toast.avatarUploadFailed'),
|
||||
description:
|
||||
avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
||||
avatarError instanceof Error
|
||||
? avatarError.message
|
||||
: t('profileForm.toast.avatarUploadFailedFallback'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Profile created',
|
||||
description: `"${data.name}" has been created with a built-in voice.`,
|
||||
title: t('profileForm.toast.profileCreated'),
|
||||
description: t('profileForm.toast.profileCreatedBuiltin', { name: data.name }),
|
||||
});
|
||||
} else {
|
||||
// Creating cloned profile: require sample file and reference text
|
||||
@@ -573,11 +591,11 @@ export function ProfileForm() {
|
||||
if (!sampleFile) {
|
||||
form.setError('sampleFile', {
|
||||
type: 'manual',
|
||||
message: 'Audio sample is required',
|
||||
message: t('profileForm.validation.sampleRequired'),
|
||||
});
|
||||
toast({
|
||||
title: 'Audio sample required',
|
||||
description: 'Please provide an audio sample to create the voice profile.',
|
||||
title: t('profileForm.toast.sampleRequired'),
|
||||
description: t('profileForm.toast.sampleRequiredDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -586,42 +604,48 @@ export function ProfileForm() {
|
||||
if (!referenceText || referenceText.trim().length === 0) {
|
||||
form.setError('referenceText', {
|
||||
type: 'manual',
|
||||
message: 'Reference text is required',
|
||||
message: t('profileForm.validation.referenceTextRequired'),
|
||||
});
|
||||
toast({
|
||||
title: 'Reference text required',
|
||||
description: 'Please provide the reference text for the audio sample.',
|
||||
title: t('profileForm.toast.referenceTextRequired'),
|
||||
description: t('profileForm.toast.referenceTextRequiredDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate audio duration before creating profile
|
||||
try {
|
||||
const duration = await getAudioDuration(sampleFile);
|
||||
if (duration > MAX_AUDIO_DURATION_SECONDS) {
|
||||
form.setError('sampleFile', {
|
||||
type: 'manual',
|
||||
message: `Audio is too long (${formatAudioDuration(duration)}). Maximum duration is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
|
||||
message: t('profileForm.validation.audioTooLong', {
|
||||
duration: formatAudioDuration(duration),
|
||||
max: formatAudioDuration(MAX_AUDIO_DURATION_SECONDS),
|
||||
}),
|
||||
});
|
||||
toast({
|
||||
title: 'Invalid audio file',
|
||||
description: `Audio duration is ${formatAudioDuration(duration)}, but maximum is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
|
||||
title: t('profileForm.toast.invalidAudio'),
|
||||
description: t('profileForm.toast.invalidAudioDescription', {
|
||||
duration: formatAudioDuration(duration),
|
||||
max: formatAudioDuration(MAX_AUDIO_DURATION_SECONDS),
|
||||
}),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return; // Prevent form submission
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
form.setError('sampleFile', {
|
||||
type: 'manual',
|
||||
message: 'Failed to validate audio file. Please try a different file.',
|
||||
message: t('profileForm.validation.audioFailed'),
|
||||
});
|
||||
toast({
|
||||
title: 'Validation error',
|
||||
description: error instanceof Error ? error.message : 'Failed to validate audio file',
|
||||
title: t('profileForm.toast.validationError'),
|
||||
description:
|
||||
error instanceof Error ? error.message : t('profileForm.validation.audioFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return; // Prevent form submission
|
||||
return;
|
||||
}
|
||||
|
||||
// Creating: create profile, then add sample
|
||||
@@ -670,8 +694,8 @@ export function ProfileForm() {
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Profile created',
|
||||
description: `"${data.name}" has been created with a sample.`,
|
||||
title: t('profileForm.toast.profileCreated'),
|
||||
description: t('profileForm.toast.profileCreatedSample', { name: data.name }),
|
||||
});
|
||||
} catch (sampleError) {
|
||||
let rollbackSucceeded = false;
|
||||
@@ -680,23 +704,26 @@ export function ProfileForm() {
|
||||
rollbackSucceeded = true;
|
||||
} catch (rollbackError) {
|
||||
toast({
|
||||
title: 'Rollback failed',
|
||||
title: t('profileForm.toast.rollbackFailed'),
|
||||
description:
|
||||
rollbackError instanceof Error
|
||||
? rollbackError.message
|
||||
: 'Created profile could not be removed after sample upload failure.',
|
||||
: t('profileForm.toast.rollbackFailedDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
const rollbackSuffix = rollbackSucceeded
|
||||
? ` ${t('profileForm.toast.profileRolledBack')}`
|
||||
: '';
|
||||
toast({
|
||||
title: 'Failed to add sample',
|
||||
title: t('profileForm.toast.sampleFailed'),
|
||||
description:
|
||||
sampleError instanceof Error
|
||||
? `${sampleError.message}${rollbackSucceeded ? ' The profile was rolled back.' : ''}`
|
||||
? `${sampleError.message}${rollbackSuffix}`
|
||||
: rollbackSucceeded
|
||||
? 'Failed to add sample. The profile was rolled back.'
|
||||
: 'Failed to add sample.',
|
||||
? t('profileForm.toast.sampleFailedRolledBack')
|
||||
: t('profileForm.toast.sampleFailedDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -710,8 +737,8 @@ export function ProfileForm() {
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to save profile',
|
||||
title: t('common.error'),
|
||||
description: error instanceof Error ? error.message : t('profileForm.toast.saveFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -768,16 +795,18 @@ export function ProfileForm() {
|
||||
<div className="max-w-5xl h-[85vh] mx-auto my-auto w-full flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl">
|
||||
{editingProfileId ? 'Edit Voice' : 'Create Voice'}
|
||||
{editingProfileId ? t('profileForm.editTitle') : t('profileForm.createTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingProfileId
|
||||
? 'Update your voice profile details and manage samples.'
|
||||
: 'Create a new voice profile from an audio sample or a built-in voice.'}
|
||||
? t('profileForm.editDescription')
|
||||
: t('profileForm.createDescription')}
|
||||
</DialogDescription>
|
||||
{isCreating && profileFormDraft && (
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<span className="text-xs text-muted-foreground">Draft restored</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('profileForm.draftRestored')}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -796,7 +825,7 @@ export function ProfileForm() {
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
Discard
|
||||
{t('profileForm.discard')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -822,7 +851,7 @@ export function ProfileForm() {
|
||||
}`}
|
||||
>
|
||||
<Mic className="h-3.5 w-3.5" />
|
||||
Clone from audio
|
||||
{t('profileForm.source.clone')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -834,20 +863,17 @@ export function ProfileForm() {
|
||||
}`}
|
||||
>
|
||||
<Music className="h-3.5 w-3.5" />
|
||||
Built-in voice
|
||||
{t('profileForm.source.builtin')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{voiceSource === 'builtin' ? (
|
||||
<div className="space-y-4">
|
||||
<FormDescription>
|
||||
Choose a pre-built voice. These don't require an audio sample.
|
||||
</FormDescription>
|
||||
<FormDescription>{t('profileForm.builtin.hint')}</FormDescription>
|
||||
|
||||
{/* Engine selector */}
|
||||
<FormItem>
|
||||
<FormLabel>Engine</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.engine')}</FormLabel>
|
||||
<Select
|
||||
value={selectedPresetEngine}
|
||||
onValueChange={setSelectedPresetEngine}
|
||||
@@ -866,7 +892,7 @@ export function ProfileForm() {
|
||||
|
||||
{/* Voice picker */}
|
||||
<FormItem>
|
||||
<FormLabel>Voice</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.voice')}</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-1.5 max-h-[340px] overflow-y-auto pr-1">
|
||||
{presetVoices.map((voice: PresetVoice) => (
|
||||
<button
|
||||
@@ -921,16 +947,16 @@ export function ProfileForm() {
|
||||
>
|
||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 shrink-0" />
|
||||
Upload
|
||||
{t('profileForm.sampleTabs.upload')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||
<Mic className="h-4 w-4 shrink-0" />
|
||||
Record
|
||||
{t('profileForm.sampleTabs.record')}
|
||||
</TabsTrigger>
|
||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 shrink-0" />
|
||||
System Audio
|
||||
{t('profileForm.sampleTabs.system')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
@@ -1008,10 +1034,10 @@ export function ProfileForm() {
|
||||
name="referenceText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reference Text</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.referenceText')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter the exact text spoken in the audio..."
|
||||
placeholder={t('profileForm.fields.referenceTextPlaceholder')}
|
||||
className="min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
@@ -1031,7 +1057,7 @@ export function ProfileForm() {
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="rounded-lg border border-border p-4 space-y-3">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Built-in Voice
|
||||
{t('profileForm.builtin.badge')}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-lg font-semibold">
|
||||
@@ -1060,8 +1086,7 @@ export function ProfileForm() {
|
||||
})()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This profile uses a built-in voice. The voice cannot be changed after
|
||||
creation.
|
||||
{t('profileForm.builtin.note')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1087,7 +1112,7 @@ export function ProfileForm() {
|
||||
{avatarPreview ? (
|
||||
<img
|
||||
src={avatarPreview}
|
||||
alt="Avatar preview"
|
||||
alt={t('profileForm.avatar.alt')}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
@@ -1131,9 +1156,9 @@ export function ProfileForm() {
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Voice" {...field} />
|
||||
<Input placeholder={t('profileForm.fields.namePlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -1145,9 +1170,12 @@ export function ProfileForm() {
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description (Optional)</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.descriptionLabel')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="Describe this voice..." {...field} />
|
||||
<Textarea
|
||||
placeholder={t('profileForm.fields.descriptionPlaceholder')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -1159,7 +1187,7 @@ export function ProfileForm() {
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Language</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.language')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
@@ -1180,7 +1208,7 @@ export function ProfileForm() {
|
||||
/>
|
||||
|
||||
<FormItem>
|
||||
<FormLabel>Default Engine</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.defaultEngine')}</FormLabel>
|
||||
<Select
|
||||
value={defaultEngine || '_none'}
|
||||
onValueChange={(v) => {
|
||||
@@ -1192,11 +1220,13 @@ export function ProfileForm() {
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No preference" />
|
||||
<SelectValue placeholder={t('profileForm.fields.noPreference')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none">No preference</SelectItem>
|
||||
<SelectItem value="_none">
|
||||
{t('profileForm.fields.noPreference')}
|
||||
</SelectItem>
|
||||
{availableDefaultEngines.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
@@ -1205,15 +1235,15 @@ export function ProfileForm() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Auto-selects this engine when the profile is chosen.
|
||||
{t('profileForm.fields.defaultEngineHint')}
|
||||
</p>
|
||||
</FormItem>
|
||||
|
||||
{editingProfileId && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Default Effects</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.defaultEffects')}</FormLabel>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Effects applied automatically to all new generations with this voice.
|
||||
{t('profileForm.fields.defaultEffectsHint')}
|
||||
</p>
|
||||
<EffectsChainEditor
|
||||
value={profileEffectsChain}
|
||||
@@ -1230,7 +1260,7 @@ export function ProfileForm() {
|
||||
|
||||
<div className="flex gap-2 justify-end mt-6 pt-4 border-t">
|
||||
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -1239,10 +1269,10 @@ export function ProfileForm() {
|
||||
}
|
||||
>
|
||||
{createProfile.isPending || updateProfile.isPending || addSample.isPending
|
||||
? 'Saving...'
|
||||
? t('profileForm.actions.saving')
|
||||
: editingProfileId
|
||||
? 'Save Changes'
|
||||
: 'Create Profile'}
|
||||
? t('profileForm.actions.saveChanges')
|
||||
: t('profileForm.actions.createProfile')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Check, Edit, Pause, Play, Plus, Trash2, Volume2, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CircleButton } from '@/components/ui/circle-button';
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ interface MiniSamplePlayerProps {
|
||||
}
|
||||
|
||||
function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||
const { t } = useTranslation();
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
@@ -102,7 +104,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={handlePlayPause}
|
||||
disabled={isLoading}
|
||||
aria-label={isPlaying ? 'Pause sample' : 'Play sample'}
|
||||
aria-label={isPlaying ? t('sampleList.player.pause') : t('sampleList.player.play')}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 ml-0.5" />}
|
||||
</Button>
|
||||
@@ -114,8 +116,11 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||
max={100}
|
||||
step={0.1}
|
||||
className="flex-1"
|
||||
aria-label="Sample playback position"
|
||||
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
|
||||
aria-label={t('sampleList.player.position')}
|
||||
aria-valuetext={t('sampleList.player.positionValue', {
|
||||
current: formatAudioDuration(currentTime),
|
||||
total: formatAudioDuration(duration),
|
||||
})}
|
||||
/>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 min-w-[70px]">
|
||||
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
|
||||
@@ -130,8 +135,8 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={handleStop}
|
||||
title="Stop"
|
||||
aria-label="Stop playback"
|
||||
title={t('sampleList.player.stop')}
|
||||
aria-label={t('sampleList.player.stopAria')}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -145,6 +150,7 @@ interface SampleListProps {
|
||||
}
|
||||
|
||||
export function SampleList({ profileId }: SampleListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: samples, isLoading } = useProfileSamples(profileId);
|
||||
const deleteSample = useDeleteSample();
|
||||
const updateSample = useUpdateSample();
|
||||
@@ -181,8 +187,8 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
const handleSaveEdit = async (sampleId: string) => {
|
||||
if (!editedText.trim()) {
|
||||
toast({
|
||||
title: 'Invalid text',
|
||||
description: 'Reference text cannot be empty.',
|
||||
title: t('sampleList.toast.invalidText'),
|
||||
description: t('sampleList.toast.invalidTextDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -191,22 +197,23 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
try {
|
||||
await updateSample.mutateAsync({ sampleId, referenceText: editedText.trim() });
|
||||
toast({
|
||||
title: 'Sample updated',
|
||||
description: 'Reference text has been updated successfully.',
|
||||
title: t('sampleList.toast.updated'),
|
||||
description: t('sampleList.toast.updatedDescription'),
|
||||
});
|
||||
setEditingSampleId(null);
|
||||
setEditedText('');
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Update failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to update sample',
|
||||
title: t('sampleList.toast.updateFailed'),
|
||||
description:
|
||||
error instanceof Error ? error.message : t('sampleList.toast.updateFailedFallback'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading samples...</div>;
|
||||
return <div className="text-sm text-muted-foreground">{t('sampleList.loading')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -214,10 +221,8 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
{samples && samples.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
|
||||
<Volume2 className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No samples yet</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Add your first audio sample to get started
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t('sampleList.empty.title')}</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">{t('sampleList.empty.hint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -237,13 +242,13 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
|
||||
<Edit className="h-3 w-3" />
|
||||
<span>Editing transcription</span>
|
||||
<span>{t('sampleList.editing')}</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={editedText}
|
||||
onChange={(e) => setEditedText(e.target.value)}
|
||||
className="min-h-[100px] text-sm resize-none"
|
||||
placeholder="Enter reference text..."
|
||||
placeholder={t('sampleList.placeholder')}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
@@ -255,7 +260,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
disabled={updateSample.isPending}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -264,7 +269,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
disabled={updateSample.isPending}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
{updateSample.isPending ? 'Saving...' : 'Save'}
|
||||
{updateSample.isPending ? t('sampleList.saving') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,12 +288,12 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<CircleButton
|
||||
icon={Edit}
|
||||
title="Edit transcription"
|
||||
title={t('sampleList.editTranscription')}
|
||||
onClick={() => handleStartEdit(sample.id, sample.reference_text)}
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Trash2}
|
||||
title="Delete sample"
|
||||
title={t('sampleList.deleteSample')}
|
||||
onClick={() => handleDeleteClick(sample.id)}
|
||||
disabled={deleteSample.isPending}
|
||||
/>
|
||||
@@ -317,24 +322,18 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
onClick={() => setUploadOpen(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Sample
|
||||
{t('sampleList.addSample')}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center px-2">
|
||||
Note: A single 30-second sample is the sweet spot. Quality may decrease with multiple
|
||||
samples. In a future update samples might be interchangeable and tagged for varying styles
|
||||
of the same voice.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground text-center px-2">{t('sampleList.note')}</p>
|
||||
|
||||
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Sample</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this audio sample? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('sampleList.deleteDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('sampleList.deleteDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -344,14 +343,14 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
setSampleToDelete(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteSample.isPending}
|
||||
>
|
||||
{deleteSample.isPending ? 'Deleting...' : 'Delete'}
|
||||
{deleteSample.isPending ? t('sampleList.deleteDialog.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -21,6 +21,161 @@
|
||||
"settings": "Settings",
|
||||
"updateBadge": "Update"
|
||||
},
|
||||
"profileForm": {
|
||||
"createTitle": "Create Voice",
|
||||
"editTitle": "Edit Voice",
|
||||
"createDescription": "Create a new voice profile from an audio sample or a built-in voice.",
|
||||
"editDescription": "Update your voice profile details and manage samples.",
|
||||
"draftRestored": "Draft restored",
|
||||
"discard": "Discard",
|
||||
"source": {
|
||||
"clone": "Clone from audio",
|
||||
"builtin": "Built-in voice"
|
||||
},
|
||||
"builtin": {
|
||||
"hint": "Choose a pre-built voice. These don't require an audio sample.",
|
||||
"badge": "Built-in Voice",
|
||||
"note": "This profile uses a built-in voice. The voice cannot be changed after creation."
|
||||
},
|
||||
"sampleTabs": {
|
||||
"upload": "Upload",
|
||||
"record": "Record",
|
||||
"system": "System Audio"
|
||||
},
|
||||
"fields": {
|
||||
"engine": "Engine",
|
||||
"voice": "Voice",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "My Voice",
|
||||
"descriptionLabel": "Description (Optional)",
|
||||
"descriptionPlaceholder": "Describe this voice…",
|
||||
"language": "Language",
|
||||
"referenceText": "Reference Text",
|
||||
"referenceTextPlaceholder": "Enter the exact text spoken in the audio…",
|
||||
"defaultEngine": "Default Engine",
|
||||
"noPreference": "No preference",
|
||||
"defaultEngineHint": "Auto-selects this engine when the profile is chosen.",
|
||||
"defaultEffects": "Default Effects",
|
||||
"defaultEffectsHint": "Effects applied automatically to all new generations with this voice."
|
||||
},
|
||||
"avatar": {
|
||||
"alt": "Avatar preview"
|
||||
},
|
||||
"actions": {
|
||||
"saving": "Saving…",
|
||||
"saveChanges": "Save Changes",
|
||||
"createProfile": "Create Profile"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Name is required",
|
||||
"referenceRequired": "Reference text is required when adding a sample",
|
||||
"sampleRequired": "Audio sample is required",
|
||||
"referenceTextRequired": "Reference text is required",
|
||||
"audioTooLong": "Audio is too long ({{duration}}). Maximum duration is {{max}}.",
|
||||
"audioFailed": "Failed to validate audio file. Please try a different file."
|
||||
},
|
||||
"toast": {
|
||||
"recordingComplete": "Recording complete",
|
||||
"recordingCompleteDescription": "Audio has been recorded successfully.",
|
||||
"recordingError": "Recording error",
|
||||
"systemAudioCaptured": "System audio captured",
|
||||
"systemAudioCapturedDescription": "Audio has been captured successfully.",
|
||||
"systemAudioError": "System audio capture error",
|
||||
"transcribeFailed": "Transcription failed",
|
||||
"transcribeFailedFallback": "Failed to transcribe audio",
|
||||
"noFile": "No file selected",
|
||||
"noFileDescription": "Please select an audio file first.",
|
||||
"invalidFile": "Invalid file type",
|
||||
"invalidImageFormat": "Please select an image file (PNG, JPG, or WebP)",
|
||||
"fileTooLarge": "File too large",
|
||||
"imageTooLargeDescription": "Image must be less than 5MB",
|
||||
"avatarRemoved": "Avatar removed",
|
||||
"avatarRemovedDescription": "Avatar image has been removed successfully.",
|
||||
"avatarRemoveFailed": "Failed to remove avatar",
|
||||
"avatarUploadFailed": "Avatar upload failed",
|
||||
"avatarUploadFailedFallback": "Failed to upload avatar",
|
||||
"effectsUpdateFailed": "Effects update failed",
|
||||
"effectsUpdateFailedFallback": "Failed to save effects chain",
|
||||
"voiceUpdated": "Voice updated",
|
||||
"voiceUpdatedDescription": "\"{{name}}\" has been updated successfully.",
|
||||
"noVoiceSelected": "No voice selected",
|
||||
"noVoiceSelectedDescription": "Please select a built-in voice.",
|
||||
"profileCreated": "Profile created",
|
||||
"profileCreatedBuiltin": "\"{{name}}\" has been created with a built-in voice.",
|
||||
"profileCreatedSample": "\"{{name}}\" has been created with a sample.",
|
||||
"sampleRequired": "Audio sample required",
|
||||
"sampleRequiredDescription": "Please provide an audio sample to create the voice profile.",
|
||||
"referenceTextRequired": "Reference text required",
|
||||
"referenceTextRequiredDescription": "Please provide the reference text for the audio sample.",
|
||||
"invalidAudio": "Invalid audio file",
|
||||
"invalidAudioDescription": "Audio duration is {{duration}}, but maximum is {{max}}.",
|
||||
"validationError": "Validation error",
|
||||
"rollbackFailed": "Rollback failed",
|
||||
"rollbackFailedDescription": "Created profile could not be removed after sample upload failure.",
|
||||
"profileRolledBack": "The profile was rolled back.",
|
||||
"sampleFailed": "Failed to add sample",
|
||||
"sampleFailedDescription": "Failed to add sample.",
|
||||
"sampleFailedRolledBack": "Failed to add sample. The profile was rolled back.",
|
||||
"saveFailed": "Failed to save profile"
|
||||
}
|
||||
},
|
||||
"audioSample": {
|
||||
"chooseFile": "Choose File",
|
||||
"uploadHint": "Click to choose a file or drag and drop. Maximum duration: 30 seconds.",
|
||||
"fileUploaded": "File uploaded",
|
||||
"fileLabel": "File: {{name}}",
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"transcribe": "Transcribe",
|
||||
"transcribing": "Transcribing…",
|
||||
"remove": "Remove",
|
||||
"startRecording": "Start Recording",
|
||||
"recordHint": "Click to start recording. Maximum duration: 30 seconds.",
|
||||
"stopRecording": "Stop Recording",
|
||||
"remaining": "{{time}} remaining",
|
||||
"recordingComplete": "Recording complete",
|
||||
"recordAgain": "Record Again",
|
||||
"startCapture": "Start Capture",
|
||||
"systemHint": "Capture audio from your system. Maximum duration: 30 seconds.",
|
||||
"stopCapture": "Stop Capture",
|
||||
"captureComplete": "Capture complete",
|
||||
"captureAgain": "Capture Again"
|
||||
},
|
||||
"sampleList": {
|
||||
"loading": "Loading samples…",
|
||||
"empty": {
|
||||
"title": "No samples yet",
|
||||
"hint": "Add your first audio sample to get started"
|
||||
},
|
||||
"editing": "Editing transcription",
|
||||
"placeholder": "Enter reference text…",
|
||||
"saving": "Saving…",
|
||||
"editTranscription": "Edit transcription",
|
||||
"deleteSample": "Delete sample",
|
||||
"addSample": "Add Sample",
|
||||
"note": "Note: A single 30-second sample is the sweet spot. Quality may decrease with multiple samples. In a future update samples might be interchangeable and tagged for varying styles of the same voice.",
|
||||
"deleteDialog": {
|
||||
"title": "Delete Sample",
|
||||
"description": "Are you sure you want to delete this audio sample? This action cannot be undone.",
|
||||
"deleting": "Deleting…"
|
||||
},
|
||||
"player": {
|
||||
"play": "Play sample",
|
||||
"pause": "Pause sample",
|
||||
"stop": "Stop",
|
||||
"stopAria": "Stop playback",
|
||||
"position": "Sample playback position",
|
||||
"positionValue": "{{current}} of {{total}}"
|
||||
},
|
||||
"toast": {
|
||||
"invalidText": "Invalid text",
|
||||
"invalidTextDescription": "Reference text cannot be empty.",
|
||||
"updated": "Sample updated",
|
||||
"updatedDescription": "Reference text has been updated successfully.",
|
||||
"updateFailed": "Update failed",
|
||||
"updateFailedFallback": "Failed to update sample"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"card": {
|
||||
"noDescription": "No description",
|
||||
|
||||
@@ -21,6 +21,161 @@
|
||||
"settings": "设置",
|
||||
"updateBadge": "更新"
|
||||
},
|
||||
"profileForm": {
|
||||
"createTitle": "创建声音",
|
||||
"editTitle": "编辑声音",
|
||||
"createDescription": "从音频样本或内置声音创建新的声音档案。",
|
||||
"editDescription": "更新您的声音档案详情并管理样本。",
|
||||
"draftRestored": "已恢复草稿",
|
||||
"discard": "丢弃",
|
||||
"source": {
|
||||
"clone": "从音频克隆",
|
||||
"builtin": "内置声音"
|
||||
},
|
||||
"builtin": {
|
||||
"hint": "选择一个预建的声音。这些不需要音频样本。",
|
||||
"badge": "内置声音",
|
||||
"note": "此档案使用内置声音。创建后声音无法更改。"
|
||||
},
|
||||
"sampleTabs": {
|
||||
"upload": "上传",
|
||||
"record": "录制",
|
||||
"system": "系统音频"
|
||||
},
|
||||
"fields": {
|
||||
"engine": "引擎",
|
||||
"voice": "声音",
|
||||
"name": "名称",
|
||||
"namePlaceholder": "我的声音",
|
||||
"descriptionLabel": "描述(可选)",
|
||||
"descriptionPlaceholder": "描述此声音……",
|
||||
"language": "语言",
|
||||
"referenceText": "参考文本",
|
||||
"referenceTextPlaceholder": "输入音频中所说的准确文字……",
|
||||
"defaultEngine": "默认引擎",
|
||||
"noPreference": "无偏好",
|
||||
"defaultEngineHint": "选择该档案时自动使用此引擎。",
|
||||
"defaultEffects": "默认效果",
|
||||
"defaultEffectsHint": "自动应用于使用此声音的所有新生成的效果。"
|
||||
},
|
||||
"avatar": {
|
||||
"alt": "头像预览"
|
||||
},
|
||||
"actions": {
|
||||
"saving": "保存中…",
|
||||
"saveChanges": "保存更改",
|
||||
"createProfile": "创建档案"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "请输入名称",
|
||||
"referenceRequired": "添加样本时需要参考文本",
|
||||
"sampleRequired": "需要音频样本",
|
||||
"referenceTextRequired": "需要参考文本",
|
||||
"audioTooLong": "音频过长({{duration}})。最大时长为 {{max}}。",
|
||||
"audioFailed": "音频文件验证失败。请尝试其他文件。"
|
||||
},
|
||||
"toast": {
|
||||
"recordingComplete": "录制完成",
|
||||
"recordingCompleteDescription": "音频已成功录制。",
|
||||
"recordingError": "录制错误",
|
||||
"systemAudioCaptured": "系统音频已捕获",
|
||||
"systemAudioCapturedDescription": "音频已成功捕获。",
|
||||
"systemAudioError": "系统音频捕获错误",
|
||||
"transcribeFailed": "转录失败",
|
||||
"transcribeFailedFallback": "无法转录音频",
|
||||
"noFile": "未选择文件",
|
||||
"noFileDescription": "请先选择一个音频文件。",
|
||||
"invalidFile": "文件类型无效",
|
||||
"invalidImageFormat": "请选择图片文件(PNG、JPG 或 WebP)",
|
||||
"fileTooLarge": "文件过大",
|
||||
"imageTooLargeDescription": "图片必须小于 5MB",
|
||||
"avatarRemoved": "头像已移除",
|
||||
"avatarRemovedDescription": "头像图片已成功移除。",
|
||||
"avatarRemoveFailed": "移除头像失败",
|
||||
"avatarUploadFailed": "头像上传失败",
|
||||
"avatarUploadFailedFallback": "无法上传头像",
|
||||
"effectsUpdateFailed": "效果更新失败",
|
||||
"effectsUpdateFailedFallback": "无法保存效果链",
|
||||
"voiceUpdated": "声音已更新",
|
||||
"voiceUpdatedDescription": "\"{{name}}\" 已成功更新。",
|
||||
"noVoiceSelected": "未选择声音",
|
||||
"noVoiceSelectedDescription": "请选择内置声音。",
|
||||
"profileCreated": "档案已创建",
|
||||
"profileCreatedBuiltin": "\"{{name}}\" 已使用内置声音创建。",
|
||||
"profileCreatedSample": "\"{{name}}\" 已使用样本创建。",
|
||||
"sampleRequired": "需要音频样本",
|
||||
"sampleRequiredDescription": "请提供音频样本以创建声音档案。",
|
||||
"referenceTextRequired": "需要参考文本",
|
||||
"referenceTextRequiredDescription": "请提供音频样本的参考文本。",
|
||||
"invalidAudio": "音频文件无效",
|
||||
"invalidAudioDescription": "音频时长为 {{duration}},但最大为 {{max}}。",
|
||||
"validationError": "验证错误",
|
||||
"rollbackFailed": "回滚失败",
|
||||
"rollbackFailedDescription": "样本上传失败后无法移除已创建的档案。",
|
||||
"profileRolledBack": "档案已回滚。",
|
||||
"sampleFailed": "添加样本失败",
|
||||
"sampleFailedDescription": "添加样本失败。",
|
||||
"sampleFailedRolledBack": "添加样本失败。档案已回滚。",
|
||||
"saveFailed": "保存档案失败"
|
||||
}
|
||||
},
|
||||
"audioSample": {
|
||||
"chooseFile": "选择文件",
|
||||
"uploadHint": "点击选择文件或拖放。最大时长:30 秒。",
|
||||
"fileUploaded": "文件已上传",
|
||||
"fileLabel": "文件:{{name}}",
|
||||
"play": "播放",
|
||||
"pause": "暂停",
|
||||
"transcribe": "转录",
|
||||
"transcribing": "转录中…",
|
||||
"remove": "移除",
|
||||
"startRecording": "开始录制",
|
||||
"recordHint": "点击开始录制。最大时长:30 秒。",
|
||||
"stopRecording": "停止录制",
|
||||
"remaining": "剩余 {{time}}",
|
||||
"recordingComplete": "录制完成",
|
||||
"recordAgain": "重新录制",
|
||||
"startCapture": "开始捕获",
|
||||
"systemHint": "从您的系统捕获音频。最大时长:30 秒。",
|
||||
"stopCapture": "停止捕获",
|
||||
"captureComplete": "捕获完成",
|
||||
"captureAgain": "重新捕获"
|
||||
},
|
||||
"sampleList": {
|
||||
"loading": "加载样本中…",
|
||||
"empty": {
|
||||
"title": "暂无样本",
|
||||
"hint": "添加第一个音频样本以开始"
|
||||
},
|
||||
"editing": "正在编辑转录",
|
||||
"placeholder": "输入参考文本……",
|
||||
"saving": "保存中…",
|
||||
"editTranscription": "编辑转录",
|
||||
"deleteSample": "删除样本",
|
||||
"addSample": "添加样本",
|
||||
"note": "注意:单个 30 秒的样本效果最佳。多个样本可能会降低质量。未来版本中样本可能会变得可互换,并为同一声音的不同风格打标签。",
|
||||
"deleteDialog": {
|
||||
"title": "删除样本",
|
||||
"description": "确定要删除此音频样本吗?此操作不可撤销。",
|
||||
"deleting": "删除中…"
|
||||
},
|
||||
"player": {
|
||||
"play": "播放样本",
|
||||
"pause": "暂停样本",
|
||||
"stop": "停止",
|
||||
"stopAria": "停止播放",
|
||||
"position": "样本播放位置",
|
||||
"positionValue": "{{current}} / {{total}}"
|
||||
},
|
||||
"toast": {
|
||||
"invalidText": "文本无效",
|
||||
"invalidTextDescription": "参考文本不能为空。",
|
||||
"updated": "样本已更新",
|
||||
"updatedDescription": "参考文本已成功更新。",
|
||||
"updateFailed": "更新失败",
|
||||
"updateFailedFallback": "更新样本失败"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"card": {
|
||||
"noDescription": "无描述",
|
||||
|
||||
Reference in New Issue
Block a user