From 0c51132a9318d308120a769009106b1c5165139f Mon Sep 17 00:00:00 2001 From: James Pine Date: Mon, 20 Apr 2026 03:25:17 -0700 Subject: [PATCH] feat(i18n): localize Create/Edit Voice modal and audio sample panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../VoiceProfiles/AudioSampleRecording.tsx | 22 +- .../VoiceProfiles/AudioSampleSystem.tsx | 22 +- .../VoiceProfiles/AudioSampleUpload.tsx | 18 +- .../components/VoiceProfiles/ProfileForm.tsx | 274 ++++++++++-------- .../components/VoiceProfiles/SampleList.tsx | 67 +++-- app/src/i18n/locales/en/translation.json | 155 ++++++++++ app/src/i18n/locales/zh-CN/translation.json | 155 ++++++++++ 7 files changed, 532 insertions(+), 181 deletions(-) diff --git a/app/src/components/VoiceProfiles/AudioSampleRecording.tsx b/app/src/components/VoiceProfiles/AudioSampleRecording.tsx index 4b41f458..d718b0d0 100644 --- a/app/src/components/VoiceProfiles/AudioSampleRecording.tsx +++ b/app/src/components/VoiceProfiles/AudioSampleRecording.tsx @@ -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(null); // Request microphone access when component mounts @@ -90,10 +92,10 @@ export function AudioSampleRecording({ className="relative z-10 flex items-center gap-2" > - Start Recording + {t('audioSample.startRecording')}

- Click to start recording. Maximum duration: 30 seconds. + {t('audioSample.recordHint')}

)} @@ -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" > - Stop Recording + {t('audioSample.stopRecording')}

- {formatAudioDuration(30 - duration)} remaining + {t('audioSample.remaining', { time: formatAudioDuration(30 - duration) })}

)} @@ -127,16 +129,18 @@ export function AudioSampleRecording({
- Recording complete + {t('audioSample.recordingComplete')}
-

File: {file.name}

+

+ {t('audioSample.fileLabel', { name: file.name })} +

@@ -148,7 +152,7 @@ export function AudioSampleRecording({ className="flex items-center gap-2" > - {isTranscribing ? 'Transcribing...' : 'Transcribe'} + {isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
diff --git a/app/src/components/VoiceProfiles/AudioSampleSystem.tsx b/app/src/components/VoiceProfiles/AudioSampleSystem.tsx index 8d012447..c7978d47 100644 --- a/app/src/components/VoiceProfiles/AudioSampleSystem.tsx +++ b/app/src/components/VoiceProfiles/AudioSampleSystem.tsx @@ -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 ( @@ -36,10 +38,10 @@ export function AudioSampleSystem({

- Capture audio from your system. Maximum duration: 30 seconds. + {t('audioSample.systemHint')}

)} @@ -61,10 +63,10 @@ export function AudioSampleSystem({ className="flex items-center gap-2" > - Stop Capture + {t('audioSample.stopCapture')}

- {formatAudioDuration(30 - duration)} remaining + {t('audioSample.remaining', { time: formatAudioDuration(30 - duration) })}

)} @@ -73,16 +75,18 @@ export function AudioSampleSystem({
- Capture complete + {t('audioSample.captureComplete')}
-

File: {file.name}

+

+ {t('audioSample.fileLabel', { name: file.name })} +

@@ -94,7 +98,7 @@ export function AudioSampleSystem({ className="flex items-center gap-2" > - {isTranscribing ? 'Transcribing...' : 'Transcribe'} + {isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
diff --git a/app/src/components/VoiceProfiles/AudioSampleUpload.tsx b/app/src/components/VoiceProfiles/AudioSampleUpload.tsx index 53fcb718..44fbf289 100644 --- a/app/src/components/VoiceProfiles/AudioSampleUpload.tsx +++ b/app/src/components/VoiceProfiles/AudioSampleUpload.tsx @@ -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(null); @@ -90,19 +92,21 @@ export function AudioSampleUpload({ className="flex items-center gap-2" > - Choose File + {t('audioSample.chooseFile')}

- Click to choose a file or drag and drop. Maximum duration: 30 seconds. + {t('audioSample.uploadHint')}

) : ( <>
- File uploaded + {t('audioSample.fileUploaded')}
-

File: {file.name}

+

+ {t('audioSample.fileLabel', { name: file.name })} +

@@ -122,7 +126,7 @@ export function AudioSampleUpload({ className="flex items-center gap-2" > - {isTranscribing ? 'Transcribing...' : 'Transcribe'} + {isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
diff --git a/app/src/components/VoiceProfiles/ProfileForm.tsx b/app/src/components/VoiceProfiles/ProfileForm.tsx index 50b8cb57..c3d76815 100644 --- a/app/src/components/VoiceProfiles/ProfileForm.tsx +++ b/app/src/components/VoiceProfiles/ProfileForm.tsx @@ -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; +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 { @@ -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(''); const form = useForm({ - 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() {
- {editingProfileId ? 'Edit Voice' : 'Create Voice'} + {editingProfileId ? t('profileForm.editTitle') : t('profileForm.createTitle')} {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')} {isCreating && profileFormDraft && (
- Draft restored + + {t('profileForm.draftRestored')} +
)} @@ -822,7 +851,7 @@ export function ProfileForm() { }`} > - Clone from audio + {t('profileForm.source.clone')}
{voiceSource === 'builtin' ? (
- - Choose a pre-built voice. These don't require an audio sample. - + {t('profileForm.builtin.hint')} - {/* Engine selector */} - Engine + {t('profileForm.fields.engine')}