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:
James Pine
2026-04-20 03:25:17 -07:00
co-authored by Claude Opus 4.7
parent 2e962f6599
commit 0c51132a93
7 changed files with 532 additions and 181 deletions
@@ -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>
</>
+152 -122
View File
@@ -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>
+33 -34
View File
@@ -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>
+155
View File
@@ -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",
+155
View File
@@ -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": "无描述",