mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 09:05:17 -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 { Mic, Pause, Play, Square } from 'lucide-react';
|
||||||
import { memo, useEffect, useState } from 'react';
|
import { memo, useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Visualizer } from 'react-sound-visualizer';
|
import { Visualizer } from 'react-sound-visualizer';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||||
@@ -48,6 +49,7 @@ export function AudioSampleRecording({
|
|||||||
isTranscribing = false,
|
isTranscribing = false,
|
||||||
showWaveform = true,
|
showWaveform = true,
|
||||||
}: AudioSampleRecordingProps) {
|
}: AudioSampleRecordingProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [audioStream, setAudioStream] = useState<MediaStream | null>(null);
|
const [audioStream, setAudioStream] = useState<MediaStream | null>(null);
|
||||||
|
|
||||||
// Request microphone access when component mounts
|
// Request microphone access when component mounts
|
||||||
@@ -90,10 +92,10 @@ export function AudioSampleRecording({
|
|||||||
className="relative z-10 flex items-center gap-2"
|
className="relative z-10 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Mic className="h-5 w-5" />
|
<Mic className="h-5 w-5" />
|
||||||
Start Recording
|
{t('audioSample.startRecording')}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||||
Click to start recording. Maximum duration: 30 seconds.
|
{t('audioSample.recordHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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"
|
className="relative z-10 flex items-center gap-2 bg-accent text-accent-foreground hover:bg-accent/90"
|
||||||
>
|
>
|
||||||
<Square className="h-4 w-4" />
|
<Square className="h-4 w-4" />
|
||||||
Stop Recording
|
{t('audioSample.stopRecording')}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||||
{formatAudioDuration(30 - duration)} remaining
|
{t('audioSample.remaining', { time: formatAudioDuration(30 - duration) })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 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">
|
<div className="flex items-center gap-2">
|
||||||
<Mic className="h-5 w-5 text-primary" />
|
<Mic className="h-5 w-5 text-primary" />
|
||||||
<span className="font-medium">Recording complete</span>
|
<span className="font-medium">{t('audioSample.recordingComplete')}</span>
|
||||||
</div>
|
</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">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onPlayPause}
|
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" />}
|
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -148,7 +152,7 @@ export function AudioSampleRecording({
|
|||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Mic className="h-4 w-4" />
|
<Mic className="h-4 w-4" />
|
||||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -156,7 +160,7 @@ export function AudioSampleRecording({
|
|||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
Record Again
|
{t('audioSample.recordAgain')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Mic, Monitor, Pause, Play, Square } from 'lucide-react';
|
import { Mic, Monitor, Pause, Play, Square } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
@@ -28,6 +29,7 @@ export function AudioSampleSystem({
|
|||||||
isPlaying,
|
isPlaying,
|
||||||
isTranscribing = false,
|
isTranscribing = false,
|
||||||
}: AudioSampleSystemProps) {
|
}: AudioSampleSystemProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<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]">
|
<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">
|
<Button type="button" onClick={onStart} size="lg" className="flex items-center gap-2">
|
||||||
<Monitor className="h-5 w-5" />
|
<Monitor className="h-5 w-5" />
|
||||||
Start Capture
|
{t('audioSample.startCapture')}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
Capture audio from your system. Maximum duration: 30 seconds.
|
{t('audioSample.systemHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -61,10 +63,10 @@ export function AudioSampleSystem({
|
|||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Square className="h-4 w-4" />
|
<Square className="h-4 w-4" />
|
||||||
Stop Capture
|
{t('audioSample.stopCapture')}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
{formatAudioDuration(30 - duration)} remaining
|
{t('audioSample.remaining', { time: formatAudioDuration(30 - duration) })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 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">
|
<div className="flex items-center gap-2">
|
||||||
<Monitor className="h-5 w-5 text-primary" />
|
<Monitor className="h-5 w-5 text-primary" />
|
||||||
<span className="font-medium">Capture complete</span>
|
<span className="font-medium">{t('audioSample.captureComplete')}</span>
|
||||||
</div>
|
</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">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onPlayPause}
|
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" />}
|
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -94,7 +98,7 @@ export function AudioSampleSystem({
|
|||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Mic className="h-4 w-4" />
|
<Mic className="h-4 w-4" />
|
||||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -102,7 +106,7 @@ export function AudioSampleSystem({
|
|||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
Capture Again
|
{t('audioSample.captureAgain')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Mic, Pause, Play, Upload } from 'lucide-react';
|
import { Mic, Pause, Play, Upload } from 'lucide-react';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ export function AudioSampleUpload({
|
|||||||
isDisabled = false,
|
isDisabled = false,
|
||||||
fieldName,
|
fieldName,
|
||||||
}: AudioSampleUploadProps) {
|
}: AudioSampleUploadProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -90,19 +92,21 @@ export function AudioSampleUpload({
|
|||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Upload className="h-5 w-5" />
|
<Upload className="h-5 w-5" />
|
||||||
Choose File
|
{t('audioSample.chooseFile')}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
<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>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Upload className="h-5 w-5 text-primary" />
|
<Upload className="h-5 w-5 text-primary" />
|
||||||
<span className="font-medium">File uploaded</span>
|
<span className="font-medium">{t('audioSample.fileUploaded')}</span>
|
||||||
</div>
|
</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">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -110,7 +114,7 @@ export function AudioSampleUpload({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onPlayPause}
|
onClick={onPlayPause}
|
||||||
disabled={isValidating}
|
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" />}
|
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -122,7 +126,7 @@ export function AudioSampleUpload({
|
|||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Mic className="h-4 w-4" />
|
<Mic className="h-4 w-4" />
|
||||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -134,7 +138,7 @@ export function AudioSampleUpload({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Remove
|
{t('audioSample.remove')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { Edit2, Mic, Monitor, Music, Upload, X } from 'lucide-react';
|
import { Edit2, Mic, Monitor, Music, Upload, X } from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -71,30 +72,38 @@ const DEFAULT_ENGINE_OPTIONS = [
|
|||||||
{ value: 'kokoro', label: 'Kokoro 82M' },
|
{ value: 'kokoro', label: 'Kokoro 82M' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const baseProfileSchema = z.object({
|
function makeProfileSchema(t: (key: string) => string) {
|
||||||
name: z.string().min(1, 'Name is required').max(100),
|
const baseProfileSchema = z.object({
|
||||||
description: z.string().max(500).optional(),
|
name: z.string().min(1, t('profileForm.validation.nameRequired')).max(100),
|
||||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
description: z.string().max(500).optional(),
|
||||||
sampleFile: z.instanceof(File).optional(),
|
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||||
referenceText: z.string().max(1000).optional(),
|
sampleFile: z.instanceof(File).optional(),
|
||||||
avatarFile: z.instanceof(File).optional(),
|
referenceText: z.string().max(1000).optional(),
|
||||||
});
|
avatarFile: z.instanceof(File).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const profileSchema = baseProfileSchema.refine(
|
return baseProfileSchema.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
// If sample file is provided, reference text is required
|
if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) {
|
||||||
if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) {
|
return false;
|
||||||
return false;
|
}
|
||||||
}
|
return true;
|
||||||
return true;
|
},
|
||||||
},
|
{
|
||||||
{
|
message: t('profileForm.validation.referenceRequired'),
|
||||||
message: 'Reference text is required when adding a sample',
|
path: ['referenceText'],
|
||||||
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
|
// Helper to convert File to base64
|
||||||
async function fileToBase64(file: File): Promise<string> {
|
async function fileToBase64(file: File): Promise<string> {
|
||||||
@@ -119,6 +128,7 @@ function base64ToFile(base64: string, fileName: string, fileType: string): File
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileForm() {
|
export function ProfileForm() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
const open = useUIStore((state) => state.profileDialogOpen);
|
const open = useUIStore((state) => state.profileDialogOpen);
|
||||||
const setOpen = useUIStore((state) => state.setProfileDialogOpen);
|
const setOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||||
@@ -151,7 +161,7 @@ export function ProfileForm() {
|
|||||||
const [defaultEngine, setDefaultEngine] = useState<string>('');
|
const [defaultEngine, setDefaultEngine] = useState<string>('');
|
||||||
|
|
||||||
const form = useForm<ProfileFormValues>({
|
const form = useForm<ProfileFormValues>({
|
||||||
resolver: zodResolver(profileSchema),
|
resolver: zodResolver(makeProfileSchema(t)),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -175,7 +185,10 @@ export function ProfileForm() {
|
|||||||
if (duration > MAX_AUDIO_DURATION_SECONDS) {
|
if (duration > MAX_AUDIO_DURATION_SECONDS) {
|
||||||
form.setError('sampleFile', {
|
form.setError('sampleFile', {
|
||||||
type: 'manual',
|
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 {
|
} else {
|
||||||
form.clearErrors('sampleFile');
|
form.clearErrors('sampleFile');
|
||||||
@@ -184,14 +197,13 @@ export function ProfileForm() {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to get audio duration:', error);
|
console.error('Failed to get audio duration:', error);
|
||||||
setAudioDuration(null);
|
setAudioDuration(null);
|
||||||
// For recordings, we auto-stop at max duration, so we can skip validation errors
|
|
||||||
const isRecordedFile =
|
const isRecordedFile =
|
||||||
selectedFile.name.startsWith('recording-') ||
|
selectedFile.name.startsWith('recording-') ||
|
||||||
selectedFile.name.startsWith('system-audio-');
|
selectedFile.name.startsWith('system-audio-');
|
||||||
if (!isRecordedFile) {
|
if (!isRecordedFile) {
|
||||||
form.setError('sampleFile', {
|
form.setError('sampleFile', {
|
||||||
type: 'manual',
|
type: 'manual',
|
||||||
message: 'Failed to validate audio file. Please try a different file.',
|
message: t('profileForm.validation.audioFailed'),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Clear any existing errors for recorded files
|
// Clear any existing errors for recorded files
|
||||||
@@ -205,7 +217,7 @@ export function ProfileForm() {
|
|||||||
setAudioDuration(null);
|
setAudioDuration(null);
|
||||||
form.clearErrors('sampleFile');
|
form.clearErrors('sampleFile');
|
||||||
}
|
}
|
||||||
}, [selectedFile, form]);
|
}, [selectedFile, form, t]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isRecording,
|
isRecording,
|
||||||
@@ -226,8 +238,8 @@ export function ProfileForm() {
|
|||||||
}
|
}
|
||||||
form.setValue('sampleFile', file, { shouldValidate: true });
|
form.setValue('sampleFile', file, { shouldValidate: true });
|
||||||
toast({
|
toast({
|
||||||
title: 'Recording complete',
|
title: t('profileForm.toast.recordingComplete'),
|
||||||
description: 'Audio has been recorded successfully.',
|
description: t('profileForm.toast.recordingCompleteDescription'),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -252,8 +264,8 @@ export function ProfileForm() {
|
|||||||
}
|
}
|
||||||
form.setValue('sampleFile', file, { shouldValidate: true });
|
form.setValue('sampleFile', file, { shouldValidate: true });
|
||||||
toast({
|
toast({
|
||||||
title: 'System audio captured',
|
title: t('profileForm.toast.systemAudioCaptured'),
|
||||||
description: 'Audio has been captured successfully.',
|
description: t('profileForm.toast.systemAudioCapturedDescription'),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -282,23 +294,22 @@ export function ProfileForm() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (recordingError) {
|
if (recordingError) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Recording error',
|
title: t('profileForm.toast.recordingError'),
|
||||||
description: recordingError,
|
description: recordingError,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [recordingError, toast]);
|
}, [recordingError, toast, t]);
|
||||||
|
|
||||||
// Show system audio recording errors
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (systemRecordingError) {
|
if (systemRecordingError) {
|
||||||
toast({
|
toast({
|
||||||
title: 'System audio capture error',
|
title: t('profileForm.toast.systemAudioError'),
|
||||||
description: systemRecordingError,
|
description: systemRecordingError,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [systemRecordingError, toast]);
|
}, [systemRecordingError, toast, t]);
|
||||||
|
|
||||||
// Handle avatar preview
|
// Handle avatar preview
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -388,8 +399,8 @@ export function ProfileForm() {
|
|||||||
const file = form.getValues('sampleFile');
|
const file = form.getValues('sampleFile');
|
||||||
if (!file) {
|
if (!file) {
|
||||||
toast({
|
toast({
|
||||||
title: 'No file selected',
|
title: t('profileForm.toast.noFile'),
|
||||||
description: 'Please select an audio file first.',
|
description: t('profileForm.toast.noFileDescription'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -402,8 +413,9 @@ export function ProfileForm() {
|
|||||||
form.setValue('referenceText', result.text, { shouldValidate: true });
|
form.setValue('referenceText', result.text, { shouldValidate: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Transcription failed',
|
title: t('profileForm.toast.transcribeFailed'),
|
||||||
description: error instanceof Error ? error.message : 'Failed to transcribe audio',
|
description:
|
||||||
|
error instanceof Error ? error.message : t('profileForm.toast.transcribeFailedFallback'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -429,16 +441,16 @@ export function ProfileForm() {
|
|||||||
if (file) {
|
if (file) {
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Invalid file type',
|
title: t('profileForm.toast.invalidFile'),
|
||||||
description: 'Please select an image file (PNG, JPG, or WebP)',
|
description: t('profileForm.toast.invalidImageFormat'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
toast({
|
toast({
|
||||||
title: 'File too large',
|
title: t('profileForm.toast.fileTooLarge'),
|
||||||
description: 'Image must be less than 5MB',
|
description: t('profileForm.toast.imageTooLargeDescription'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -452,13 +464,13 @@ export function ProfileForm() {
|
|||||||
try {
|
try {
|
||||||
await deleteAvatar.mutateAsync(editingProfileId);
|
await deleteAvatar.mutateAsync(editingProfileId);
|
||||||
toast({
|
toast({
|
||||||
title: 'Avatar removed',
|
title: t('profileForm.toast.avatarRemoved'),
|
||||||
description: 'Avatar image has been removed successfully.',
|
description: t('profileForm.toast.avatarRemovedDescription'),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Failed to remove avatar',
|
title: t('profileForm.toast.avatarRemoveFailed'),
|
||||||
description: error instanceof Error ? error.message : 'Unknown error',
|
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -493,9 +505,11 @@ export function ProfileForm() {
|
|||||||
});
|
});
|
||||||
} catch (avatarError) {
|
} catch (avatarError) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Avatar upload failed',
|
title: t('profileForm.toast.avatarUploadFailed'),
|
||||||
description:
|
description:
|
||||||
avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
avatarError instanceof Error
|
||||||
|
? avatarError.message
|
||||||
|
: t('profileForm.toast.avatarUploadFailedFallback'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -510,9 +524,11 @@ export function ProfileForm() {
|
|||||||
);
|
);
|
||||||
} catch (fxError) {
|
} catch (fxError) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Effects update failed',
|
title: t('profileForm.toast.effectsUpdateFailed'),
|
||||||
description:
|
description:
|
||||||
fxError instanceof Error ? fxError.message : 'Failed to save effects chain',
|
fxError instanceof Error
|
||||||
|
? fxError.message
|
||||||
|
: t('profileForm.toast.effectsUpdateFailedFallback'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -520,15 +536,15 @@ export function ProfileForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Voice updated',
|
title: t('profileForm.toast.voiceUpdated'),
|
||||||
description: `"${data.name}" has been updated successfully.`,
|
description: t('profileForm.toast.voiceUpdatedDescription', { name: data.name }),
|
||||||
});
|
});
|
||||||
} else if (voiceSource === 'builtin') {
|
} else if (voiceSource === 'builtin') {
|
||||||
// Creating preset profile from built-in voice
|
// Creating preset profile from built-in voice
|
||||||
if (!selectedPresetVoiceId) {
|
if (!selectedPresetVoiceId) {
|
||||||
toast({
|
toast({
|
||||||
title: 'No voice selected',
|
title: t('profileForm.toast.noVoiceSelected'),
|
||||||
description: 'Please select a built-in voice.',
|
description: t('profileForm.toast.noVoiceSelectedDescription'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -553,17 +569,19 @@ export function ProfileForm() {
|
|||||||
});
|
});
|
||||||
} catch (avatarError) {
|
} catch (avatarError) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Avatar upload failed',
|
title: t('profileForm.toast.avatarUploadFailed'),
|
||||||
description:
|
description:
|
||||||
avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
avatarError instanceof Error
|
||||||
|
? avatarError.message
|
||||||
|
: t('profileForm.toast.avatarUploadFailedFallback'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Profile created',
|
title: t('profileForm.toast.profileCreated'),
|
||||||
description: `"${data.name}" has been created with a built-in voice.`,
|
description: t('profileForm.toast.profileCreatedBuiltin', { name: data.name }),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Creating cloned profile: require sample file and reference text
|
// Creating cloned profile: require sample file and reference text
|
||||||
@@ -573,11 +591,11 @@ export function ProfileForm() {
|
|||||||
if (!sampleFile) {
|
if (!sampleFile) {
|
||||||
form.setError('sampleFile', {
|
form.setError('sampleFile', {
|
||||||
type: 'manual',
|
type: 'manual',
|
||||||
message: 'Audio sample is required',
|
message: t('profileForm.validation.sampleRequired'),
|
||||||
});
|
});
|
||||||
toast({
|
toast({
|
||||||
title: 'Audio sample required',
|
title: t('profileForm.toast.sampleRequired'),
|
||||||
description: 'Please provide an audio sample to create the voice profile.',
|
description: t('profileForm.toast.sampleRequiredDescription'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -586,42 +604,48 @@ export function ProfileForm() {
|
|||||||
if (!referenceText || referenceText.trim().length === 0) {
|
if (!referenceText || referenceText.trim().length === 0) {
|
||||||
form.setError('referenceText', {
|
form.setError('referenceText', {
|
||||||
type: 'manual',
|
type: 'manual',
|
||||||
message: 'Reference text is required',
|
message: t('profileForm.validation.referenceTextRequired'),
|
||||||
});
|
});
|
||||||
toast({
|
toast({
|
||||||
title: 'Reference text required',
|
title: t('profileForm.toast.referenceTextRequired'),
|
||||||
description: 'Please provide the reference text for the audio sample.',
|
description: t('profileForm.toast.referenceTextRequiredDescription'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate audio duration before creating profile
|
|
||||||
try {
|
try {
|
||||||
const duration = await getAudioDuration(sampleFile);
|
const duration = await getAudioDuration(sampleFile);
|
||||||
if (duration > MAX_AUDIO_DURATION_SECONDS) {
|
if (duration > MAX_AUDIO_DURATION_SECONDS) {
|
||||||
form.setError('sampleFile', {
|
form.setError('sampleFile', {
|
||||||
type: 'manual',
|
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({
|
toast({
|
||||||
title: 'Invalid audio file',
|
title: t('profileForm.toast.invalidAudio'),
|
||||||
description: `Audio duration is ${formatAudioDuration(duration)}, but maximum is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
|
description: t('profileForm.toast.invalidAudioDescription', {
|
||||||
|
duration: formatAudioDuration(duration),
|
||||||
|
max: formatAudioDuration(MAX_AUDIO_DURATION_SECONDS),
|
||||||
|
}),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
return; // Prevent form submission
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
form.setError('sampleFile', {
|
form.setError('sampleFile', {
|
||||||
type: 'manual',
|
type: 'manual',
|
||||||
message: 'Failed to validate audio file. Please try a different file.',
|
message: t('profileForm.validation.audioFailed'),
|
||||||
});
|
});
|
||||||
toast({
|
toast({
|
||||||
title: 'Validation error',
|
title: t('profileForm.toast.validationError'),
|
||||||
description: error instanceof Error ? error.message : 'Failed to validate audio file',
|
description:
|
||||||
|
error instanceof Error ? error.message : t('profileForm.validation.audioFailed'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
return; // Prevent form submission
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creating: create profile, then add sample
|
// Creating: create profile, then add sample
|
||||||
@@ -670,8 +694,8 @@ export function ProfileForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Profile created',
|
title: t('profileForm.toast.profileCreated'),
|
||||||
description: `"${data.name}" has been created with a sample.`,
|
description: t('profileForm.toast.profileCreatedSample', { name: data.name }),
|
||||||
});
|
});
|
||||||
} catch (sampleError) {
|
} catch (sampleError) {
|
||||||
let rollbackSucceeded = false;
|
let rollbackSucceeded = false;
|
||||||
@@ -680,23 +704,26 @@ export function ProfileForm() {
|
|||||||
rollbackSucceeded = true;
|
rollbackSucceeded = true;
|
||||||
} catch (rollbackError) {
|
} catch (rollbackError) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Rollback failed',
|
title: t('profileForm.toast.rollbackFailed'),
|
||||||
description:
|
description:
|
||||||
rollbackError instanceof Error
|
rollbackError instanceof Error
|
||||||
? rollbackError.message
|
? rollbackError.message
|
||||||
: 'Created profile could not be removed after sample upload failure.',
|
: t('profileForm.toast.rollbackFailedDescription'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rollbackSuffix = rollbackSucceeded
|
||||||
|
? ` ${t('profileForm.toast.profileRolledBack')}`
|
||||||
|
: '';
|
||||||
toast({
|
toast({
|
||||||
title: 'Failed to add sample',
|
title: t('profileForm.toast.sampleFailed'),
|
||||||
description:
|
description:
|
||||||
sampleError instanceof Error
|
sampleError instanceof Error
|
||||||
? `${sampleError.message}${rollbackSucceeded ? ' The profile was rolled back.' : ''}`
|
? `${sampleError.message}${rollbackSuffix}`
|
||||||
: rollbackSucceeded
|
: rollbackSucceeded
|
||||||
? 'Failed to add sample. The profile was rolled back.'
|
? t('profileForm.toast.sampleFailedRolledBack')
|
||||||
: 'Failed to add sample.',
|
: t('profileForm.toast.sampleFailedDescription'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -710,8 +737,8 @@ export function ProfileForm() {
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: t('common.error'),
|
||||||
description: error instanceof Error ? error.message : 'Failed to save profile',
|
description: error instanceof Error ? error.message : t('profileForm.toast.saveFailed'),
|
||||||
variant: 'destructive',
|
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">
|
<div className="max-w-5xl h-[85vh] mx-auto my-auto w-full flex flex-col overflow-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-2xl">
|
<DialogTitle className="text-2xl">
|
||||||
{editingProfileId ? 'Edit Voice' : 'Create Voice'}
|
{editingProfileId ? t('profileForm.editTitle') : t('profileForm.createTitle')}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{editingProfileId
|
{editingProfileId
|
||||||
? 'Update your voice profile details and manage samples.'
|
? t('profileForm.editDescription')
|
||||||
: 'Create a new voice profile from an audio sample or a built-in voice.'}
|
: t('profileForm.createDescription')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
{isCreating && profileFormDraft && (
|
{isCreating && profileFormDraft && (
|
||||||
<div className="flex items-center gap-2 pt-2">
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -796,7 +825,7 @@ export function ProfileForm() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3 mr-1" />
|
<X className="h-3 w-3 mr-1" />
|
||||||
Discard
|
{t('profileForm.discard')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -822,7 +851,7 @@ export function ProfileForm() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Mic className="h-3.5 w-3.5" />
|
<Mic className="h-3.5 w-3.5" />
|
||||||
Clone from audio
|
{t('profileForm.source.clone')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -834,20 +863,17 @@ export function ProfileForm() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Music className="h-3.5 w-3.5" />
|
<Music className="h-3.5 w-3.5" />
|
||||||
Built-in voice
|
{t('profileForm.source.builtin')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{voiceSource === 'builtin' ? (
|
{voiceSource === 'builtin' ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<FormDescription>
|
<FormDescription>{t('profileForm.builtin.hint')}</FormDescription>
|
||||||
Choose a pre-built voice. These don't require an audio sample.
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
{/* Engine selector */}
|
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Engine</FormLabel>
|
<FormLabel>{t('profileForm.fields.engine')}</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
value={selectedPresetEngine}
|
value={selectedPresetEngine}
|
||||||
onValueChange={setSelectedPresetEngine}
|
onValueChange={setSelectedPresetEngine}
|
||||||
@@ -866,7 +892,7 @@ export function ProfileForm() {
|
|||||||
|
|
||||||
{/* Voice picker */}
|
{/* Voice picker */}
|
||||||
<FormItem>
|
<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">
|
<div className="grid grid-cols-2 gap-1.5 max-h-[340px] overflow-y-auto pr-1">
|
||||||
{presetVoices.map((voice: PresetVoice) => (
|
{presetVoices.map((voice: PresetVoice) => (
|
||||||
<button
|
<button
|
||||||
@@ -921,16 +947,16 @@ export function ProfileForm() {
|
|||||||
>
|
>
|
||||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||||
<Upload className="h-4 w-4 shrink-0" />
|
<Upload className="h-4 w-4 shrink-0" />
|
||||||
Upload
|
{t('profileForm.sampleTabs.upload')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="record" className="flex items-center gap-2">
|
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||||
<Mic className="h-4 w-4 shrink-0" />
|
<Mic className="h-4 w-4 shrink-0" />
|
||||||
Record
|
{t('profileForm.sampleTabs.record')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||||
<Monitor className="h-4 w-4 shrink-0" />
|
<Monitor className="h-4 w-4 shrink-0" />
|
||||||
System Audio
|
{t('profileForm.sampleTabs.system')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -1008,10 +1034,10 @@ export function ProfileForm() {
|
|||||||
name="referenceText"
|
name="referenceText"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Reference Text</FormLabel>
|
<FormLabel>{t('profileForm.fields.referenceText')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Enter the exact text spoken in the audio..."
|
placeholder={t('profileForm.fields.referenceTextPlaceholder')}
|
||||||
className="min-h-[100px]"
|
className="min-h-[100px]"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
@@ -1031,7 +1057,7 @@ export function ProfileForm() {
|
|||||||
<div className="space-y-4 pt-4">
|
<div className="space-y-4 pt-4">
|
||||||
<div className="rounded-lg border border-border p-4 space-y-3">
|
<div className="rounded-lg border border-border p-4 space-y-3">
|
||||||
<div className="text-sm font-medium text-muted-foreground">
|
<div className="text-sm font-medium text-muted-foreground">
|
||||||
Built-in Voice
|
{t('profileForm.builtin.badge')}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="text-lg font-semibold">
|
<div className="text-lg font-semibold">
|
||||||
@@ -1060,8 +1086,7 @@ export function ProfileForm() {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
This profile uses a built-in voice. The voice cannot be changed after
|
{t('profileForm.builtin.note')}
|
||||||
creation.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1087,7 +1112,7 @@ export function ProfileForm() {
|
|||||||
{avatarPreview ? (
|
{avatarPreview ? (
|
||||||
<img
|
<img
|
||||||
src={avatarPreview}
|
src={avatarPreview}
|
||||||
alt="Avatar preview"
|
alt={t('profileForm.avatar.alt')}
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -1131,9 +1156,9 @@ export function ProfileForm() {
|
|||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name</FormLabel>
|
<FormLabel>{t('profileForm.fields.name')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="My Voice" {...field} />
|
<Input placeholder={t('profileForm.fields.namePlaceholder')} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -1145,9 +1170,12 @@ export function ProfileForm() {
|
|||||||
name="description"
|
name="description"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Description (Optional)</FormLabel>
|
<FormLabel>{t('profileForm.fields.descriptionLabel')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea placeholder="Describe this voice..." {...field} />
|
<Textarea
|
||||||
|
placeholder={t('profileForm.fields.descriptionPlaceholder')}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -1159,7 +1187,7 @@ export function ProfileForm() {
|
|||||||
name="language"
|
name="language"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Language</FormLabel>
|
<FormLabel>{t('profileForm.fields.language')}</FormLabel>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -1180,7 +1208,7 @@ export function ProfileForm() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Default Engine</FormLabel>
|
<FormLabel>{t('profileForm.fields.defaultEngine')}</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
value={defaultEngine || '_none'}
|
value={defaultEngine || '_none'}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
@@ -1192,11 +1220,13 @@ export function ProfileForm() {
|
|||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="No preference" />
|
<SelectValue placeholder={t('profileForm.fields.noPreference')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="_none">No preference</SelectItem>
|
<SelectItem value="_none">
|
||||||
|
{t('profileForm.fields.noPreference')}
|
||||||
|
</SelectItem>
|
||||||
{availableDefaultEngines.map((option) => (
|
{availableDefaultEngines.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
@@ -1205,15 +1235,15 @@ export function ProfileForm() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Auto-selects this engine when the profile is chosen.
|
{t('profileForm.fields.defaultEngineHint')}
|
||||||
</p>
|
</p>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
||||||
{editingProfileId && (
|
{editingProfileId && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<FormLabel>Default Effects</FormLabel>
|
<FormLabel>{t('profileForm.fields.defaultEffects')}</FormLabel>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Effects applied automatically to all new generations with this voice.
|
{t('profileForm.fields.defaultEffectsHint')}
|
||||||
</p>
|
</p>
|
||||||
<EffectsChainEditor
|
<EffectsChainEditor
|
||||||
value={profileEffectsChain}
|
value={profileEffectsChain}
|
||||||
@@ -1230,7 +1260,7 @@ export function ProfileForm() {
|
|||||||
|
|
||||||
<div className="flex gap-2 justify-end mt-6 pt-4 border-t">
|
<div className="flex gap-2 justify-end mt-6 pt-4 border-t">
|
||||||
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -1239,10 +1269,10 @@ export function ProfileForm() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{createProfile.isPending || updateProfile.isPending || addSample.isPending
|
{createProfile.isPending || updateProfile.isPending || addSample.isPending
|
||||||
? 'Saving...'
|
? t('profileForm.actions.saving')
|
||||||
: editingProfileId
|
: editingProfileId
|
||||||
? 'Save Changes'
|
? t('profileForm.actions.saveChanges')
|
||||||
: 'Create Profile'}
|
: t('profileForm.actions.createProfile')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Check, Edit, Pause, Play, Plus, Trash2, Volume2, X } from 'lucide-react';
|
import { Check, Edit, Pause, Play, Plus, Trash2, Volume2, X } from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { CircleButton } from '@/components/ui/circle-button';
|
import { CircleButton } from '@/components/ui/circle-button';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +25,7 @@ interface MiniSamplePlayerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
@@ -102,7 +104,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
|||||||
className="h-7 w-7 shrink-0"
|
className="h-7 w-7 shrink-0"
|
||||||
onClick={handlePlayPause}
|
onClick={handlePlayPause}
|
||||||
disabled={isLoading}
|
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" />}
|
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 ml-0.5" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -114,8 +116,11 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
|||||||
max={100}
|
max={100}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
aria-label="Sample playback position"
|
aria-label={t('sampleList.player.position')}
|
||||||
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
|
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]">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 min-w-[70px]">
|
||||||
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
|
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
|
||||||
@@ -130,8 +135,8 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 shrink-0"
|
className="h-7 w-7 shrink-0"
|
||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
title="Stop"
|
title={t('sampleList.player.stop')}
|
||||||
aria-label="Stop playback"
|
aria-label={t('sampleList.player.stopAria')}
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -145,6 +150,7 @@ interface SampleListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SampleList({ profileId }: SampleListProps) {
|
export function SampleList({ profileId }: SampleListProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: samples, isLoading } = useProfileSamples(profileId);
|
const { data: samples, isLoading } = useProfileSamples(profileId);
|
||||||
const deleteSample = useDeleteSample();
|
const deleteSample = useDeleteSample();
|
||||||
const updateSample = useUpdateSample();
|
const updateSample = useUpdateSample();
|
||||||
@@ -181,8 +187,8 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
const handleSaveEdit = async (sampleId: string) => {
|
const handleSaveEdit = async (sampleId: string) => {
|
||||||
if (!editedText.trim()) {
|
if (!editedText.trim()) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Invalid text',
|
title: t('sampleList.toast.invalidText'),
|
||||||
description: 'Reference text cannot be empty.',
|
description: t('sampleList.toast.invalidTextDescription'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -191,22 +197,23 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
try {
|
try {
|
||||||
await updateSample.mutateAsync({ sampleId, referenceText: editedText.trim() });
|
await updateSample.mutateAsync({ sampleId, referenceText: editedText.trim() });
|
||||||
toast({
|
toast({
|
||||||
title: 'Sample updated',
|
title: t('sampleList.toast.updated'),
|
||||||
description: 'Reference text has been updated successfully.',
|
description: t('sampleList.toast.updatedDescription'),
|
||||||
});
|
});
|
||||||
setEditingSampleId(null);
|
setEditingSampleId(null);
|
||||||
setEditedText('');
|
setEditedText('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Update failed',
|
title: t('sampleList.toast.updateFailed'),
|
||||||
description: error instanceof Error ? error.message : 'Failed to update sample',
|
description:
|
||||||
|
error instanceof Error ? error.message : t('sampleList.toast.updateFailedFallback'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
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 (
|
return (
|
||||||
@@ -214,10 +221,8 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
{samples && samples.length === 0 ? (
|
{samples && samples.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
|
<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" />
|
<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-sm text-muted-foreground">{t('sampleList.empty.title')}</p>
|
||||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
<p className="text-xs text-muted-foreground/70 mt-1">{t('sampleList.empty.hint')}</p>
|
||||||
Add your first audio sample to get started
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -237,13 +242,13 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
<div className="p-4 space-y-3">
|
<div className="p-4 space-y-3">
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
|
||||||
<Edit className="h-3 w-3" />
|
<Edit className="h-3 w-3" />
|
||||||
<span>Editing transcription</span>
|
<span>{t('sampleList.editing')}</span>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={editedText}
|
value={editedText}
|
||||||
onChange={(e) => setEditedText(e.target.value)}
|
onChange={(e) => setEditedText(e.target.value)}
|
||||||
className="min-h-[100px] text-sm resize-none"
|
className="min-h-[100px] text-sm resize-none"
|
||||||
placeholder="Enter reference text..."
|
placeholder={t('sampleList.placeholder')}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-end gap-2 pt-1">
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
@@ -255,7 +260,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
disabled={updateSample.isPending}
|
disabled={updateSample.isPending}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 mr-1" />
|
<X className="h-4 w-4 mr-1" />
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -264,7 +269,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
disabled={updateSample.isPending}
|
disabled={updateSample.isPending}
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4 mr-1" />
|
<Check className="h-4 w-4 mr-1" />
|
||||||
{updateSample.isPending ? 'Saving...' : 'Save'}
|
{updateSample.isPending ? t('sampleList.saving') : t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<CircleButton
|
<CircleButton
|
||||||
icon={Edit}
|
icon={Edit}
|
||||||
title="Edit transcription"
|
title={t('sampleList.editTranscription')}
|
||||||
onClick={() => handleStartEdit(sample.id, sample.reference_text)}
|
onClick={() => handleStartEdit(sample.id, sample.reference_text)}
|
||||||
/>
|
/>
|
||||||
<CircleButton
|
<CircleButton
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
title="Delete sample"
|
title={t('sampleList.deleteSample')}
|
||||||
onClick={() => handleDeleteClick(sample.id)}
|
onClick={() => handleDeleteClick(sample.id)}
|
||||||
disabled={deleteSample.isPending}
|
disabled={deleteSample.isPending}
|
||||||
/>
|
/>
|
||||||
@@ -317,24 +322,18 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
onClick={() => setUploadOpen(true)}
|
onClick={() => setUploadOpen(true)}
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Sample
|
{t('sampleList.addSample')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground text-center px-2">
|
<p className="text-xs text-muted-foreground text-center px-2">{t('sampleList.note')}</p>
|
||||||
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>
|
|
||||||
|
|
||||||
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
|
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||||
|
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete Sample</DialogTitle>
|
<DialogTitle>{t('sampleList.deleteDialog.title')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>{t('sampleList.deleteDialog.description')}</DialogDescription>
|
||||||
Are you sure you want to delete this audio sample? This action cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
@@ -344,14 +343,14 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
setSampleToDelete(null);
|
setSampleToDelete(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={handleDeleteConfirm}
|
onClick={handleDeleteConfirm}
|
||||||
disabled={deleteSample.isPending}
|
disabled={deleteSample.isPending}
|
||||||
>
|
>
|
||||||
{deleteSample.isPending ? 'Deleting...' : 'Delete'}
|
{deleteSample.isPending ? t('sampleList.deleteDialog.deleting') : t('common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -21,6 +21,161 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"updateBadge": "Update"
|
"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": {
|
"profiles": {
|
||||||
"card": {
|
"card": {
|
||||||
"noDescription": "No description",
|
"noDescription": "No description",
|
||||||
|
|||||||
@@ -21,6 +21,161 @@
|
|||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"updateBadge": "更新"
|
"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": {
|
"profiles": {
|
||||||
"card": {
|
"card": {
|
||||||
"noDescription": "无描述",
|
"noDescription": "无描述",
|
||||||
|
|||||||
Reference in New Issue
Block a user