feat(i18n): add i18next foundation with English + zh-CN locales

Installs i18next + react-i18next + language detector and wires up a
language selector in the General settings page. Extracts strings from
the highest-visibility surfaces: all settings tabs, model management,
sidebar nav, main editor, and the floating generate box. Remaining
strings (profile forms, history, stories/effects/voices/audio tabs)
can land in follow-up PRs.

Closes #411.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
James Pine
2026-04-20 02:43:50 -07:00
co-authored by Claude Opus 4.7
parent 5aa1677a25
commit a205086733
18 changed files with 1091 additions and 299 deletions
+3
View File
@@ -44,11 +44,14 @@
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"framer-motion": "^12.29.0",
"i18next": "^26.0.6",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.454.0",
"motion": "^12.29.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-hook-form": "^7.53.0",
"react-i18next": "^17.0.4",
"react-sound-visualizer": "^1.4.0",
"tailwind-merge": "^2.5.4",
"wavesurfer.js": "^7.0.0",
@@ -3,6 +3,7 @@ import { useMatchRoute } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
import { Loader2, SlidersHorizontal, Sparkles } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
import {
@@ -34,6 +35,7 @@ export function FloatingGenerateBox({
isPlayerOpen = false,
showVoiceSelector = false,
}: FloatingGenerateBoxProps) {
const { t } = useTranslation();
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
const setSelectedEngine = useUIStore((state) => state.setSelectedEngine);
@@ -276,10 +278,12 @@ export function FloatingGenerateBox({
onChange={field.onChange}
placeholder={
isStoriesRoute && currentStory
? `Generate speech for "${currentStory.name}"... (type / for effects)`
? t('generation.placeholder.storyWithEffects', {
name: currentStory.name,
})
: selectedProfile
? `Type / for effects like [laugh], [sigh]...`
: 'Select a voice profile above...'
? t('generation.placeholder.effectsHint')
: t('generation.placeholder.selectVoice')
}
className="px-3 py-2 resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm w-full"
style={{
@@ -302,10 +306,12 @@ export function FloatingGenerateBox({
}}
placeholder={
isStoriesRoute && currentStory
? `Generate speech for "${currentStory.name}"...`
? t('generation.placeholder.story', { name: currentStory.name })
: selectedProfile
? `Generate speech using ${selectedProfile.name}...`
: 'Select a voice profile above...'
? t('generation.placeholder.profile', {
name: selectedProfile.name,
})
: t('generation.placeholder.selectVoice')
}
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full"
style={{
@@ -334,10 +340,10 @@ export function FloatingGenerateBox({
size="icon"
aria-label={
isPending
? 'Generating...'
? t('generation.button.generating')
: !selectedProfileId
? 'Select a voice profile first'
: 'Generate speech'
? t('generation.button.selectFirst')
: t('generation.button.generate')
}
>
{isPending ? (
@@ -348,10 +354,10 @@ export function FloatingGenerateBox({
</Button>
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
{isPending
? 'Generating...'
? t('generation.button.generating')
: !selectedProfileId
? 'Select a voice profile first'
: 'Generate speech'}
? t('generation.button.selectFirst')
: t('generation.button.generate')}
</span>
</div>
@@ -379,15 +385,15 @@ export function FloatingGenerateBox({
)}
aria-label={
isInstructExpanded
? 'Hide delivery instructions'
: 'Show delivery instructions'
? t('generation.instruct.hide')
: t('generation.instruct.show')
}
aria-pressed={isInstructExpanded}
>
<SlidersHorizontal className="h-4 w-4" />
</Button>
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
Delivery instructions (tone, emotion, pace)
{t('generation.instruct.tooltip')}
</span>
</div>
</motion.div>
@@ -414,7 +420,7 @@ export function FloatingGenerateBox({
<FormControl>
<Textarea
{...field}
placeholder="Delivery instructions — e.g. Speak slowly with warmth, Authoritative and clear..."
placeholder={t('generation.instruct.placeholder')}
className="resize-none bg-transparent border border-accent/20 focus-visible:ring-1 focus-visible:ring-accent/40 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full px-3 py-2"
style={{ minHeight: '60px', maxHeight: '160px' }}
maxLength={500}
@@ -444,7 +450,7 @@ export function FloatingGenerateBox({
onValueChange={(value) => setSelectedProfileId(value || null)}
>
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all w-full">
<SelectValue placeholder="Select a voice..." />
<SelectValue placeholder={t('generation.voiceSelector.placeholder')} />
</SelectTrigger>
<SelectContent>
{profiles?.map((profile) => (
@@ -498,16 +504,16 @@ export function FloatingGenerateBox({
}
>
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
<SelectValue placeholder="No effects" />
<SelectValue placeholder={t('generation.effects.none')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="text-xs">
No effects
{t('generation.effects.none')}
</SelectItem>
{selectedProfile?.effects_chain &&
selectedProfile.effects_chain.length > 0 && (
<SelectItem value="_profile" className="text-xs">
Profile default
{t('generation.effects.profileDefault')}
</SelectItem>
)}
{effectPresets?.map((preset) => (
+13 -23
View File
@@ -1,5 +1,6 @@
import { Sparkles, Upload } from 'lucide-react';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
import { HistoryTable } from '@/components/History/HistoryTable';
import { Button } from '@/components/ui/button';
@@ -20,6 +21,7 @@ import { usePlayerStore } from '@/stores/playerStore';
import { useUIStore } from '@/stores/uiStore';
export function MainEditor() {
const { t } = useTranslation();
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const scrollRef = useRef<HTMLDivElement>(null);
@@ -39,8 +41,8 @@ export function MainEditor() {
if (file) {
if (!file.name.endsWith('.voicebox.zip')) {
toast({
title: 'Invalid file type',
description: 'Please select a valid .voicebox.zip file',
title: t('main.import.invalidTitle'),
description: t('main.import.invalidDescription'),
variant: 'destructive',
});
return;
@@ -60,13 +62,13 @@ export function MainEditor() {
fileInputRef.current.value = '';
}
toast({
title: 'Profile imported',
description: 'Voice profile imported successfully',
title: t('main.import.successTitle'),
description: t('main.import.successDescription'),
});
},
onError: (error) => {
toast({
title: 'Failed to import profile',
title: t('main.import.failedTitle'),
description: error.message,
variant: 'destructive',
});
@@ -76,21 +78,17 @@ export function MainEditor() {
};
return (
// Main view: Profiles top left, Generator bottom left, History right
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-6 h-full min-h-0 overflow-hidden relative">
{/* Left Column */}
<div className="flex flex-col min-h-0 overflow-hidden relative lg:overflow-hidden">
{/* Scroll Mask - Always visible, behind content */}
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
{/* Fixed Header */}
<div className="absolute top-0 left-0 right-0 z-10">
<div className="flex items-center justify-between mb-4 px-1">
<h2 className="text-2xl font-bold">Voicebox</h2>
<div className="flex gap-2">
<Button variant="outline" onClick={handleImportClick}>
<Upload className="mr-2 h-4 w-4" />
Import Voice
{t('main.importVoice')}
</Button>
<input
ref={fileInputRef}
@@ -101,13 +99,12 @@ export function MainEditor() {
/>
<Button onClick={() => setDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
Create Voice
{t('main.createVoice')}
</Button>
</div>
</div>
</div>
{/* Scrollable Content */}
<div
ref={scrollRef}
className={cn('flex-1 min-h-0 overflow-y-auto pt-14 pb-4', isPlayerVisible && 'lg:pb-32')}
@@ -120,25 +117,18 @@ export function MainEditor() {
</div>
</div>
{/* Divider - single column only */}
{/* <div className="border-t border-border -my-3 lg:hidden" /> */}
{/* Right Column - History */}
<div className="flex flex-col min-h-0 overflow-hidden">
<HistoryTable />
</div>
{/* Floating Generate Box */}
<FloatingGenerateBox isPlayerOpen={!!audioUrl} />
{/* Import Dialog */}
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Import Profile</DialogTitle>
<DialogTitle>{t('main.import.dialogTitle')}</DialogTitle>
<DialogDescription>
Import the profile from "{selectedFile?.name}". This will create a new profile with
all samples.
{t('main.import.dialogDescription', { name: selectedFile?.name })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -152,13 +142,13 @@ export function MainEditor() {
}
}}
>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={handleImportConfirm}
disabled={importProfile.isPending || !selectedFile}
>
{importProfile.isPending ? 'Importing...' : 'Import'}
{importProfile.isPending ? t('main.import.importing') : t('main.import.action')}
</Button>
</DialogFooter>
</DialogContent>
@@ -18,6 +18,7 @@ import {
X,
} from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import {
AlertDialog,
AlertDialogAction,
@@ -119,6 +120,7 @@ function formatBytes(bytes: number): string {
}
export function ModelManagement() {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const platform = usePlatform();
@@ -270,8 +272,8 @@ export function ModelManagement() {
setDownloadingModel(null);
setDownloadingDisplayName(null);
toast({
title: 'Download failed',
description: error instanceof Error ? error.message : 'Unknown error',
title: t('models.toast.downloadFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
variant: 'destructive',
});
}
@@ -309,8 +311,8 @@ export function ModelManagement() {
setDownloadingModel(prevDownloadingModel);
setDownloadingDisplayName(prevDownloadingDisplayName);
toast({
title: 'Cancel failed',
description: 'Could not cancel the download task.',
title: t('models.toast.cancelFailed'),
description: t('models.toast.cancelFailedDescription'),
variant: 'destructive',
});
},
@@ -336,8 +338,10 @@ export function ModelManagement() {
},
onSuccess: async () => {
toast({
title: 'Model deleted',
description: `${modelToDelete?.displayName || 'Model'} has been deleted successfully.`,
title: t('models.toast.deleted'),
description: t('models.toast.deletedDescription', {
name: modelToDelete?.displayName || t('models.defaultName'),
}),
});
setDeleteDialogOpen(false);
setModelToDelete(null);
@@ -348,7 +352,7 @@ export function ModelManagement() {
},
onError: (error: Error) => {
toast({
title: 'Delete failed',
title: t('models.toast.deleteFailed'),
description: error.message,
variant: 'destructive',
});
@@ -361,15 +365,15 @@ export function ModelManagement() {
},
onSuccess: async (_data, modelName) => {
toast({
title: 'Model unloaded',
description: `${modelName} has been unloaded from memory.`,
title: t('models.toast.unloaded'),
description: t('models.toast.unloadedDescription', { name: modelName }),
});
await queryClient.invalidateQueries({ queryKey: ['modelStatus'], refetchType: 'all' });
await queryClient.refetchQueries({ queryKey: ['modelStatus'] });
},
onError: (error: Error) => {
toast({
title: 'Unload failed',
title: t('models.toast.unloadFailed'),
description: error.message,
variant: 'destructive',
});
@@ -377,7 +381,7 @@ export function ModelManagement() {
});
const formatSize = (sizeMb?: number): string => {
if (!sizeMb) return 'Unknown size';
if (!sizeMb) return t('models.unknownSize');
if (sizeMb < 1024) return `${sizeMb.toFixed(1)} MB`;
return `${(sizeMb / 1024).toFixed(2)} GB`;
};
@@ -410,8 +414,8 @@ export function ModelManagement() {
// Build sections
const sections: { label: string; models: ModelStatus[] }[] = [
{ label: 'Voice Generation', models: voiceModels },
{ label: 'Transcription', models: whisperModels },
{ label: t('models.sections.voiceGeneration'), models: voiceModels },
{ label: t('models.sections.transcription'), models: whisperModels },
];
// Get detail modal state for selected model
@@ -433,10 +437,8 @@ export function ModelManagement() {
<div className="flex flex-col h-full">
{/* Header */}
<div className="shrink-0 pb-4">
<h1 className="text-lg font-semibold">Models</h1>
<p className="text-sm text-muted-foreground">
Download and manage AI models for voice generation and transcription
</p>
<h1 className="text-lg font-semibold">{t('models.title')}</h1>
<p className="text-sm text-muted-foreground">{t('models.subtitle')}</p>
</div>
{/* Model storage location */}
@@ -444,7 +446,7 @@ export function ModelManagement() {
<div className="shrink-0 pb-4 border-b mb-4">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<span className="text-xs text-muted-foreground">Storage location</span>
<span className="text-xs text-muted-foreground">{t('models.storage.location')}</span>
<p
className="text-xs font-mono text-muted-foreground/70 truncate"
title={cacheDir.path}
@@ -461,12 +463,12 @@ export function ModelManagement() {
try {
await platform.filesystem.openPath(cacheDir.path);
} catch {
toast({ title: 'Failed to open model folder', variant: 'destructive' });
toast({ title: t('models.toast.openFolderFailed'), variant: 'destructive' });
}
}}
>
<FolderOpen className="h-3 w-3" />
Open
{t('models.storage.open')}
</Button>
<Button
variant="ghost"
@@ -475,12 +477,12 @@ export function ModelManagement() {
onClick={async () => {
try {
const newDir = await platform.filesystem.pickDirectory(
'Choose model storage folder',
t('models.storage.pickerTitle'),
);
if (!newDir) return;
setPendingMigrateDir(newDir);
} catch {
toast({ title: 'Failed to open folder picker', variant: 'destructive' });
toast({ title: t('models.toast.pickerFailed'), variant: 'destructive' });
}
}}
disabled={migrating}
@@ -490,7 +492,7 @@ export function ModelManagement() {
) : (
<FolderOpen className="h-3 w-3" />
)}
{migrating ? 'Migrating...' : 'Change'}
{migrating ? t('models.storage.migrating') : t('models.storage.change')}
</Button>
{customModelsDir && (
<Button
@@ -500,13 +502,13 @@ export function ModelManagement() {
disabled={migrating}
onClick={async () => {
setCustomModelsDir(null);
toast({ title: 'Reset to default location. Restarting server...' });
toast({ title: t('models.toast.resetToDefault') });
await platform.lifecycle.restartServer('');
queryClient.invalidateQueries();
}}
>
<RotateCcw className="h-3 w-3" />
Reset
{t('models.storage.reset')}
</Button>
)}
</div>
@@ -565,7 +567,7 @@ export function ModelManagement() {
<div className="text-[10px] text-muted-foreground truncate">
{hasProgress
? `${formatBytes(dl.current ?? 0)} / ${formatBytes(dl.total!)} (${pct.toFixed(0)}%)`
: dl?.filename || 'Connecting...'}
: dl?.filename || t('models.progress.connecting')}
</div>
</div>
);
@@ -576,12 +578,12 @@ export function ModelManagement() {
<div className="shrink-0 flex items-center gap-2">
{hasError && (
<Badge variant="destructive" className="text-[10px] h-5">
Error
{t('common.error')}
</Badge>
)}
{model.loaded && (
<Badge className="text-[10px] h-5 bg-accent/15 text-accent border-accent/30 hover:bg-accent/15">
Loaded
{t('models.status.loaded')}
</Badge>
)}
{model.downloaded && !isDownloading && !hasError && (
@@ -613,7 +615,7 @@ export function ModelManagement() {
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
<span>Problems</span>
<span>{t('models.problems.title')}</span>
<Badge variant="destructive" className="text-[10px] h-4 px-1.5 rounded-full">
{errorCount}
</Badge>
@@ -626,7 +628,7 @@ export function ModelManagement() {
disabled={clearAllMutation.isPending}
>
<RotateCcw className="h-3 w-3 mr-1" />
Clear All
{t('models.problems.clearAll')}
</Button>
</div>
{consoleOpen && (
@@ -645,13 +647,13 @@ export function ModelManagement() {
) : (
<>
{': '}
<span className="text-[#808080]">
No error details available. Try downloading again.
</span>
<span className="text-[#808080]">{t('models.problems.noDetails')}</span>
</>
)}
<div className="text-[#6a9955] mt-0.5">
started at {new Date(dl.started_at).toLocaleString()}
{t('models.problems.startedAt', {
time: new Date(dl.started_at).toLocaleString(),
})}
</div>
</div>
))}
@@ -692,13 +694,13 @@ export function ModelManagement() {
{freshSelectedModel.loaded && (
<Badge className="text-xs bg-accent/15 text-accent border-accent/30 hover:bg-accent/15">
<CircleCheck className="h-3 w-3 mr-1" />
Loaded
{t('models.status.loaded')}
</Badge>
)}
{selectedState?.hasError && (
<Badge variant="destructive" className="text-xs">
<CircleX className="h-3 w-3 mr-1" />
Error
{t('common.error')}
</Badge>
)}
</div>
@@ -707,7 +709,7 @@ export function ModelManagement() {
{hfLoading && freshSelectedModel.hf_repo_id && (
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
<Loader2 className="h-3 w-3 animate-spin" />
Loading model info...
{t('models.detail.loadingInfo')}
</div>
)}
@@ -734,23 +736,29 @@ export function ModelManagement() {
)}
{hfModelInfo.author && (
<Badge variant="outline" className="text-[10px]">
by {hfModelInfo.author}
{t('models.detail.byAuthor', { author: hfModelInfo.author })}
</Badge>
)}
</div>
{/* Stats row */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1" title="Downloads">
<span
className="flex items-center gap-1"
title={t('models.detail.downloads')}
>
<Download className="h-3.5 w-3.5" />
{formatDownloads(hfModelInfo.downloads)}
</span>
<span className="flex items-center gap-1" title="Likes">
<span className="flex items-center gap-1" title={t('models.detail.likes')}>
<Heart className="h-3.5 w-3.5" />
{formatDownloads(hfModelInfo.likes)}
</span>
{license && (
<span className="flex items-center gap-1" title="License">
<span
className="flex items-center gap-1"
title={t('models.detail.license')}
>
<Scale className="h-3.5 w-3.5" />
{formatLicense(license)}
</span>
@@ -762,8 +770,12 @@ export function ModelManagement() {
<div>
<span className="text-xs text-muted-foreground">
{hfModelInfo.cardData.language.length > 10
? `${hfModelInfo.cardData.language.length} languages supported`
: `Languages: ${hfModelInfo.cardData.language.join(', ')}`}
? t('models.detail.languagesCount', {
count: hfModelInfo.cardData.language.length,
})
: t('models.detail.languagesList', {
list: hfModelInfo.cardData.language.join(', '),
})}
</span>
</div>
)}
@@ -774,7 +786,9 @@ export function ModelManagement() {
{freshSelectedModel.downloaded && freshSelectedModel.size_mb && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<HardDrive className="h-3.5 w-3.5" />
<span>{formatSize(freshSelectedModel.size_mb)} on disk</span>
<span>
{t('models.detail.onDisk', { size: formatSize(freshSelectedModel.size_mb) })}
</span>
</div>
)}
@@ -796,7 +810,7 @@ export function ModelManagement() {
className="flex-1"
>
<Download className="h-4 w-4 mr-2" />
Retry Download
{t('models.actions.retry')}
</Button>
<Button
size="sm"
@@ -825,7 +839,7 @@ export function ModelManagement() {
<div className="text-xs text-muted-foreground">
{hasProgress
? `${formatBytes(dl.current ?? 0)} / ${formatBytes(dl.total!)} (${pct.toFixed(1)}%)`
: dl?.filename || 'Connecting to HuggingFace...'}
: dl?.filename || t('models.progress.connectingHf')}
</div>
</>
);
@@ -858,7 +872,9 @@ export function ModelManagement() {
) : (
<Unplug className="h-4 w-4 mr-2" />
)}
{unloadMutation.isPending ? 'Unloading...' : 'Unload'}
{unloadMutation.isPending
? t('models.actions.unloading')
: t('models.actions.unload')}
</Button>
)}
<Button
@@ -875,13 +891,13 @@ export function ModelManagement() {
disabled={freshSelectedModel.loaded}
title={
freshSelectedModel.loaded
? 'Unload model before deleting'
: 'Delete model'
? t('models.actions.unloadFirst')
: t('models.actions.deleteModel')
}
className="flex-1"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Model
{t('models.actions.deleteModel')}
</Button>
</div>
) : (
@@ -891,7 +907,7 @@ export function ModelManagement() {
className="flex-1"
>
<Download className="h-4 w-4 mr-2" />
Download
{t('models.actions.download')}
</Button>
)}
</div>
@@ -905,20 +921,23 @@ export function ModelManagement() {
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Model</AlertDialogTitle>
<AlertDialogTitle>{t('models.deleteDialog.title')}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete <strong>{modelToDelete?.displayName}</strong>?
<Trans
i18nKey="models.deleteDialog.body"
values={{ name: modelToDelete?.displayName }}
components={{ strong: <strong /> }}
/>
{modelToDelete?.sizeMb && (
<>
{' '}
This will free up {formatSize(modelToDelete.sizeMb)} of disk space. The model will
need to be re-downloaded if you want to use it again.
{t('models.deleteDialog.sizeNote', { size: formatSize(modelToDelete.sizeMb) })}
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (modelToDelete) {
@@ -931,10 +950,10 @@ export function ModelManagement() {
{deleteMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Deleting...
{t('models.deleteDialog.deleting')}
</>
) : (
'Delete'
t('common.delete')
)}
</AlertDialogAction>
</AlertDialogFooter>
@@ -948,11 +967,8 @@ export function ModelManagement() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Move models to new location?</AlertDialogTitle>
<AlertDialogDescription>
The server will shut down while models are being moved to the new folder. It will
restart automatically once the migration is complete.
</AlertDialogDescription>
<AlertDialogTitle>{t('models.migrateDialog.title')}</AlertDialogTitle>
<AlertDialogDescription>{t('models.migrateDialog.description')}</AlertDialogDescription>
</AlertDialogHeader>
<div
className="text-xs font-mono text-muted-foreground bg-muted/50 rounded px-3 py-2 truncate"
@@ -961,7 +977,7 @@ export function ModelManagement() {
{pendingMigrateDir}
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
if (!pendingMigrateDir) return;
@@ -973,7 +989,7 @@ export function ModelManagement() {
total: 0,
progress: 0,
status: 'downloading',
filename: 'Preparing...',
filename: t('models.migrateDialog.preparing'),
});
try {
// Start the migration (background task)
@@ -984,8 +1000,8 @@ export function ModelManagement() {
setMigrating(false);
setMigrationProgress(null);
toast({
title: 'No models to migrate',
description: 'Download at least one model before changing the storage location.',
title: t('models.toast.noModelsToMigrate'),
description: t('models.toast.noModelsToMigrateDescription'),
});
setPendingMigrateDir(null);
return;
@@ -1003,7 +1019,7 @@ export function ModelManagement() {
resolve();
} else if (data.status === 'error') {
es.close();
reject(new Error(data.error || 'Migration failed'));
reject(new Error(data.error || t('models.toast.migrationFailed')));
}
} catch {
/* ignore parse errors */
@@ -1011,7 +1027,7 @@ export function ModelManagement() {
};
es.onerror = () => {
es.close();
reject(new Error('Lost connection during migration'));
reject(new Error(t('models.toast.migrationConnectionLost')));
};
});
@@ -1021,15 +1037,16 @@ export function ModelManagement() {
total: 1,
progress: 100,
status: 'complete',
filename: 'Restarting server...',
filename: t('models.migrateDialog.restartingServer'),
});
await platform.lifecycle.restartServer(newDir);
queryClient.invalidateQueries();
toast({ title: 'Models moved successfully' });
toast({ title: t('models.toast.migrated') });
} catch (e) {
toast({
title: 'Migration failed',
description: e instanceof Error ? e.message : 'Failed to migrate models',
title: t('models.toast.migrationFailed'),
description:
e instanceof Error ? e.message : t('models.toast.migrationFailedGeneric'),
variant: 'destructive',
});
} finally {
@@ -1038,7 +1055,7 @@ export function ModelManagement() {
}
}}
>
Move Models
{t('models.migrateDialog.action')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1050,11 +1067,11 @@ export function ModelManagement() {
<div className="w-full max-w-md px-8 space-y-6 text-center">
<div className="space-y-2">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-muted-foreground" />
<h2 className="text-lg font-semibold">Moving models</h2>
<h2 className="text-lg font-semibold">{t('models.migrate.title')}</h2>
<p className="text-sm text-muted-foreground">
{migrationProgress.status === 'complete'
? 'Restarting server...'
: 'The server is offline while models are being moved.'}
? t('models.migrateDialog.restartingServer')
: t('models.migrate.offline')}
</p>
</div>
{migrationProgress.total > 0 && (
@@ -1075,4 +1092,3 @@ export function ModelManagement() {
</div>
);
}
+19 -13
View File
@@ -1,6 +1,7 @@
import { ArrowUpRight } from 'lucide-react';
import type { CSSProperties, ReactNode } from 'react';
import { useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import voiceboxLogo from '@/assets/voicebox-logo.png';
import { usePlatform } from '@/platform/PlatformContext';
@@ -16,6 +17,7 @@ function FadeIn({ delay = 0, children }: { delay?: number; children: ReactNode }
}
export function AboutPage() {
const { t } = useTranslation();
const platform = usePlatform();
const [version, setVersion] = useState('');
@@ -57,14 +59,13 @@ export function AboutPage() {
<FadeIn delay={160}>
<p className="text-sm text-muted-foreground leading-relaxed max-w-sm">
The open-source voice synthesis studio. Clone voices, generate speech, apply effects,
and build voice-powered apps all running locally on your machine.
{t('settings.about.tagline')}
</p>
</FadeIn>
<FadeIn delay={240}>
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<span>Created by</span>
<span>{t('settings.about.createdBy')}</span>
<a
href="https://github.com/jamiepine"
target="_blank"
@@ -92,7 +93,7 @@ export function AboutPage() {
>
<path d="m20.216 6.415-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 0 0-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 0 0-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 0 1-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 0 1 3.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 0 1-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 0 1-4.743.295 37.059 37.059 0 0 1-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0 0 11.343.376.483.483 0 0 1 .535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 0 1 .39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 0 1-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 0 1-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 0 0-1.322-.238c-.826 0-1.491.284-2.26.613z" />
</svg>
Buy me a coffee
{t('settings.about.buyCoffee')}
<ArrowUpRight className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
</a>
<a
@@ -117,15 +118,20 @@ export function AboutPage() {
<FadeIn delay={400}>
<p className="text-xs text-muted-foreground/40 pt-4">
Licensed under{' '}
<a
href="https://github.com/jamiepine/voicebox/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="hover:text-muted-foreground/60 transition-colors"
>
MIT
</a>
<Trans
i18nKey="settings.about.license"
components={{
link: (
// biome-ignore lint/a11y/useAnchorContent: Trans fills content at runtime
<a
href="https://github.com/jamiepine/voicebox/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="hover:text-muted-foreground/60 transition-colors"
/>
),
}}
/>
</p>
</FadeIn>
</div>
@@ -1,5 +1,6 @@
import changelogRaw from 'virtual:changelog';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { type ChangelogEntry, parseChangelog } from '@/lib/utils/parseChangelog';
@@ -176,6 +177,7 @@ function inlineMarkdown(text: string): React.ReactNode {
}
function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const content = useMemo(() => renderMarkdown(entry.body), [entry.body]);
const isLong = entry.body.split('\n').length > 12;
@@ -185,7 +187,9 @@ function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
<div className="flex items-baseline gap-3 mb-3">
<h3 className="text-xl font-semibold tracking-tight">{entry.version}</h3>
{entry.date && <span className="text-xs text-muted-foreground">{entry.date}</span>}
{entry.version === 'Unreleased' && <Badge variant="outline">dev</Badge>}
{entry.version === 'Unreleased' && (
<Badge variant="outline">{t('settings.changelog.devBadge')}</Badge>
)}
</div>
<div className={isLong && !expanded ? 'max-h-48 overflow-hidden relative' : ''}>
@@ -200,7 +204,7 @@ function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
onClick={() => setExpanded(!expanded)}
className="text-xs text-accent hover:underline mt-2"
>
{expanded ? 'Show less' : 'Show more'}
{expanded ? t('settings.changelog.showLess') : t('settings.changelog.showMore')}
</button>
)}
</div>
+88 -58
View File
@@ -2,6 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { AlertCircle, ArrowUpRight, Book, Download, Loader2, RefreshCw } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
@@ -13,15 +14,19 @@ import { useAutoUpdater } from '@/hooks/useAutoUpdater';
import { useServerHealth } from '@/lib/hooks/useServer';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
import { LanguageSelect } from './LanguageSelect';
import { SettingRow, SettingSection } from './SettingRow';
const connectionSchema = z.object({
serverUrl: z.string().url('Please enter a valid URL'),
});
function makeConnectionSchema(invalidUrl: string) {
return z.object({
serverUrl: z.string().url(invalidUrl),
});
}
type ConnectionFormValues = z.infer<typeof connectionSchema>;
type ConnectionFormValues = { serverUrl: string };
export function GeneralPage() {
const { t } = useTranslation();
const platform = usePlatform();
const serverUrl = useServerStore((state) => state.serverUrl);
const setServerUrl = useServerStore((state) => state.setServerUrl);
@@ -33,7 +38,7 @@ export function GeneralPage() {
const { data: health, isLoading, error: healthError } = useServerHealth();
const form = useForm<ConnectionFormValues>({
resolver: zodResolver(connectionSchema),
resolver: zodResolver(makeConnectionSchema(t('settings.general.serverUrl.invalidUrl'))),
defaultValues: { serverUrl },
});
@@ -47,8 +52,8 @@ export function GeneralPage() {
setServerUrl(data.serverUrl);
form.reset(data);
toast({
title: 'Server URL updated',
description: `Connected to ${data.serverUrl}`,
title: t('settings.general.serverUrl.updatedTitle'),
description: t('settings.general.serverUrl.updatedDescription', { url: data.serverUrl }),
});
}
@@ -63,7 +68,7 @@ export function GeneralPage() {
>
<Book className="h-5 w-5 shrink-0 text-accent" strokeWidth={2.5} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">Read the Docs</div>
<div className="text-sm font-medium">{t('settings.general.docs.title')}</div>
<div className="text-xs text-muted-foreground">docs.voicebox.sh</div>
</div>
<ArrowUpRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
@@ -83,8 +88,10 @@ export function GeneralPage() {
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">Join the Discord</div>
<div className="text-xs text-muted-foreground">Get help & share voices</div>
<div className="text-sm font-medium">{t('settings.general.discord.title')}</div>
<div className="text-xs text-muted-foreground">
{t('settings.general.discord.subtitle')}
</div>
</div>
<ArrowUpRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
</a>
@@ -92,8 +99,8 @@ export function GeneralPage() {
<SettingSection>
<SettingRow
title="Server URL"
description="The address of your voicebox backend server."
title={t('settings.general.serverUrl.title')}
description={t('settings.general.serverUrl.description')}
action={
<ConnectionStatus health={health} isLoading={isLoading} healthError={healthError} />
}
@@ -114,7 +121,7 @@ export function GeneralPage() {
/>
{isDirty && (
<Button type="submit" size="sm">
Save
{t('common.save')}
</Button>
)}
</form>
@@ -122,8 +129,8 @@ export function GeneralPage() {
</SettingRow>
<SettingRow
title="Keep server running when app closes"
description="The server will continue running in the background after closing the app."
title={t('settings.general.keepServerRunning.title')}
description={t('settings.general.keepServerRunning.description')}
htmlFor="keepServerRunning"
action={
<Toggle
@@ -135,17 +142,17 @@ export function GeneralPage() {
console.error('Failed to sync setting to Rust:', error);
setKeepServerRunningOnClose(!checked);
toast({
title: 'Failed to update setting',
description: 'Could not sync setting to backend.',
title: t('settings.general.keepServerRunning.failedTitle'),
description: t('settings.general.keepServerRunning.failedDescription'),
variant: 'destructive',
});
return;
});
toast({
title: 'Setting updated',
title: t('settings.general.keepServerRunning.updatedTitle'),
description: checked
? 'Server will continue running when app closes'
: 'Server will stop when app closes',
? t('settings.general.keepServerRunning.runningDescription')
: t('settings.general.keepServerRunning.stoppedDescription'),
});
}}
/>
@@ -154,8 +161,8 @@ export function GeneralPage() {
{platform.metadata.isTauri && (
<SettingRow
title="Allow network access"
description="Makes the server accessible from other devices on your network. Restart the app after changing."
title={t('settings.general.networkAccess.title')}
description={t('settings.general.networkAccess.description')}
htmlFor="allowNetworkAccess"
action={
<Toggle
@@ -164,16 +171,22 @@ export function GeneralPage() {
onCheckedChange={(checked: boolean) => {
setMode(checked ? 'remote' : 'local');
toast({
title: 'Setting updated',
title: t('settings.general.keepServerRunning.updatedTitle'),
description: checked
? 'Network access enabled. Restart the app to apply.'
: 'Network access disabled. Restart the app to apply.',
? t('settings.general.networkAccess.enabled')
: t('settings.general.networkAccess.disabled'),
});
}}
/>
}
/>
)}
<SettingRow
title={t('settings.language.label')}
description={t('settings.language.description')}
action={<LanguageSelect />}
/>
</SettingSection>
<ApiReferenceCard serverUrl={serverUrl} />
@@ -192,11 +205,14 @@ function ConnectionStatus({
isLoading: boolean;
healthError: ReturnType<typeof useServerHealth>['error'];
}) {
const { t } = useTranslation();
if (isLoading) {
return (
<div className="flex items-center gap-2 rounded-full border border-border/60 px-3 py-1">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground">Connecting</span>
<span className="text-xs text-muted-foreground">
{t('settings.general.connection.connecting')}
</span>
</div>
);
}
@@ -207,7 +223,7 @@ function ConnectionStatus({
<span className="absolute inline-flex h-full w-full rounded-full bg-destructive/40" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-destructive" />
</span>
<span className="text-xs text-destructive">Offline</span>
<span className="text-xs text-destructive">{t('settings.general.connection.offline')}</span>
</div>
);
}
@@ -218,7 +234,9 @@ function ConnectionStatus({
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent/60" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-accent shadow-[0_0_6px_1px_hsl(var(--accent)/0.5)]" />
</span>
<span className="text-xs text-muted-foreground">Online</span>
<span className="text-xs text-muted-foreground">
{t('settings.general.connection.online')}
</span>
</div>
);
}
@@ -226,6 +244,7 @@ function ConnectionStatus({
}
function UpdatesSection() {
const { t } = useTranslation();
const platform = usePlatform();
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
const [currentVersion, setCurrentVersion] = useState<string>('');
@@ -235,26 +254,29 @@ function UpdatesSection() {
platform.metadata
.getVersion()
.then(setCurrentVersion)
.catch(() => setCurrentVersion('Unknown'));
}, [platform]);
.catch(() => setCurrentVersion(t('common.unknown')));
}, [platform, t]);
return (
<SettingSection title="App Updates" description={`v${currentVersion}${isDev ? ' (dev)' : ''}`}>
<SettingSection
title={t('settings.general.updates.title')}
description={`v${currentVersion}${isDev ? t('settings.general.updates.devSuffix') : ''}`}
>
{isDev ? (
<SettingRow
title="Development mode"
description="Auto-updates are disabled in development mode."
title={t('settings.general.updates.devMode.title')}
description={t('settings.general.updates.devMode.description')}
/>
) : (
<>
<SettingRow
title="Check for updates"
title={t('settings.general.updates.check.title')}
description={
status.available
? `Version ${status.version} available`
? t('settings.general.updates.check.available', { version: status.version })
: status.checking
? 'Checking...'
: "You're up to date"
? t('settings.general.updates.check.checking')
: t('settings.general.updates.check.upToDate')
}
action={
<Button
@@ -266,13 +288,13 @@ function UpdatesSection() {
<RefreshCw
className={`h-3.5 w-3.5 mr-1.5 ${status.checking ? 'animate-spin' : ''}`}
/>
Check
{t('settings.general.updates.check.button')}
</Button>
}
/>
{status.error && (
<SettingRow title="Update error">
<SettingRow title={t('settings.general.updates.error')}>
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{status.error}
@@ -282,19 +304,19 @@ function UpdatesSection() {
{status.available && !status.downloading && !status.readyToInstall && (
<SettingRow
title={`Update to ${status.version}`}
description="Download and install the latest version."
title={t('settings.general.updates.download.title', { version: status.version })}
description={t('settings.general.updates.download.description')}
action={
<Button onClick={downloadAndInstall} size="sm">
<Download className="h-3.5 w-3.5 mr-1.5" />
Download
{t('settings.general.updates.download.button')}
</Button>
}
/>
)}
{status.downloading && (
<SettingRow title="Downloading update...">
<SettingRow title={t('settings.general.updates.downloading')}>
<div className="space-y-1.5">
<Progress value={status.downloadProgress} />
<div className="flex items-center justify-between text-xs text-muted-foreground">
@@ -316,12 +338,14 @@ function UpdatesSection() {
{status.readyToInstall && (
<SettingRow
title="Update ready to install"
description={`Version ${status.version} has been downloaded. Restart to complete.`}
title={t('settings.general.updates.ready.title')}
description={t('settings.general.updates.ready.description', {
version: status.version,
})}
action={
<Button onClick={restartAndInstall} size="sm">
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
Restart Now
{t('settings.general.updates.ready.button')}
</Button>
}
/>
@@ -332,25 +356,31 @@ function UpdatesSection() {
);
}
const API_ENDPOINTS = [
{ method: 'POST', path: '/generate', label: 'Generate speech' },
{ method: 'GET', path: '/health', label: 'Server status' },
{ method: 'GET', path: '/profiles', label: 'List voices' },
{ method: 'GET', path: '/history', label: 'Past generations' },
];
function ApiReferenceCard({ serverUrl }: { serverUrl: string }) {
const { t } = useTranslation();
const endpoints = [
{ method: 'POST', path: '/generate', label: t('settings.general.api.endpoints.generate') },
{ method: 'GET', path: '/health', label: t('settings.general.api.endpoints.health') },
{ method: 'GET', path: '/profiles', label: t('settings.general.api.endpoints.profiles') },
{ method: 'GET', path: '/history', label: t('settings.general.api.endpoints.history') },
];
return (
<div className="rounded-lg border border-border/60 p-4 space-y-3">
<div>
<h3 className="text-sm font-medium">API Access</h3>
<h3 className="text-sm font-medium">{t('settings.general.api.title')}</h3>
<p className="text-sm text-muted-foreground">
Integrate Voicebox into your workflow via the REST API at{' '}
<code className="text-xs bg-muted px-1 py-0.5 rounded font-mono">{serverUrl}</code>
<Trans
i18nKey="settings.general.api.description"
values={{ url: serverUrl }}
components={{
code: <code className="text-xs bg-muted px-1 py-0.5 rounded font-mono" />,
}}
/>
</p>
</div>
<div className="space-y-1">
{API_ENDPOINTS.map((ep) => (
{endpoints.map((ep) => (
<div key={ep.path} className="flex items-center gap-2.5 py-1">
<span
className={`text-[10px] font-mono font-semibold w-9 text-center rounded px-1 py-px ${
@@ -371,7 +401,7 @@ function ApiReferenceCard({ serverUrl }: { serverUrl: string }) {
rel="noopener noreferrer"
className="text-accent hover:underline"
>
View the full API reference
{t('settings.general.api.viewReference')}
</a>
</p>
</div>
+21 -17
View File
@@ -1,5 +1,6 @@
import { FolderOpen } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Toggle } from '@/components/ui/toggle';
@@ -8,6 +9,7 @@ import { useServerStore } from '@/stores/serverStore';
import { SettingRow, SettingSection } from './SettingRow';
export function GenerationPage() {
const { t } = useTranslation();
const platform = usePlatform();
const serverUrl = useServerStore((state) => state.serverUrl);
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
@@ -48,15 +50,15 @@ export function GenerationPage() {
return (
<div className="space-y-8 max-w-2xl">
<SettingSection
title="Generation"
description="Controls for long text generation. These settings apply to all engines."
title={t('settings.generation.title')}
description={t('settings.generation.description')}
>
<SettingRow
title="Auto-chunking limit"
description="Long text is split into chunks at sentence boundaries. Lower values can improve quality for long outputs."
title={t('settings.generation.chunkLimit.title')}
description={t('settings.generation.chunkLimit.description')}
action={
<span className="text-sm tabular-nums text-muted-foreground">
{maxChunkChars} chars
{t('settings.generation.chunkLimit.value', { count: maxChunkChars })}
</span>
}
>
@@ -67,16 +69,18 @@ export function GenerationPage() {
min={100}
max={5000}
step={50}
aria-label="Auto-chunking character limit"
aria-label={t('settings.generation.chunkLimit.title')}
/>
</SettingRow>
<SettingRow
title="Chunk crossfade"
description="Blends audio between chunks to smooth transitions. Set to 0 for a hard cut."
title={t('settings.generation.crossfade.title')}
description={t('settings.generation.crossfade.description')}
action={
<span className="text-sm tabular-nums text-muted-foreground">
{crossfadeMs === 0 ? 'Cut' : `${crossfadeMs}ms`}
{crossfadeMs === 0
? t('settings.generation.crossfade.cut')
: t('settings.generation.crossfade.ms', { ms: crossfadeMs })}
</span>
}
>
@@ -87,13 +91,13 @@ export function GenerationPage() {
min={0}
max={200}
step={10}
aria-label="Chunk crossfade duration"
aria-label={t('settings.generation.crossfade.title')}
/>
</SettingRow>
<SettingRow
title="Normalize audio"
description="Adjusts output volume to a consistent level across generations."
title={t('settings.generation.normalize.title')}
description={t('settings.generation.normalize.description')}
htmlFor="normalizeAudio"
action={
<Toggle
@@ -105,8 +109,8 @@ export function GenerationPage() {
/>
<SettingRow
title="Autoplay on generate"
description="Automatically play audio when a generation completes."
title={t('settings.generation.autoplay.title')}
description={t('settings.generation.autoplay.description')}
htmlFor="autoplayOnGenerate"
action={
<Toggle
@@ -118,8 +122,8 @@ export function GenerationPage() {
/>
<SettingRow
title="Generations folder"
description={generationsPath ?? 'Where generated audio files are stored on disk.'}
title={t('settings.generation.folder.title')}
description={generationsPath ?? t('settings.generation.folder.description')}
action={
<Button
variant="outline"
@@ -128,7 +132,7 @@ export function GenerationPage() {
disabled={opening || !generationsPath}
>
<FolderOpen className="h-3.5 w-3.5 mr-1.5" />
Open
{t('settings.generation.folder.open')}
</Button>
}
/>
+42 -46
View File
@@ -1,6 +1,7 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertCircle, Cpu, Download, Loader2, RotateCw, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { apiClient } from '@/lib/api/client';
@@ -40,9 +41,9 @@ function GpuIcon({ className }: { className?: string }) {
}
function GpuInfoCard({ health }: { health: HealthResponse }) {
const { t } = useTranslation();
const hasGpu = health.gpu_available && health.gpu_type;
// Parse GPU name from type string like "CUDA (NVIDIA RTX 4090)" or "MPS (Apple M2 Pro)"
const gpuName = hasGpu
? health.gpu_type!.replace(/^(CUDA|ROCm|MPS|Metal|XPU|DirectML)\s*\((.+)\)$/, '$2') ||
health.gpu_type!
@@ -64,7 +65,7 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
<Cpu className="h-5 w-5 shrink-0 text-muted-foreground" />
)}
<div className="flex-1 min-w-0 space-y-0.5">
<div className="text-sm font-medium">{hasGpu ? gpuName : 'CPU Only'}</div>
<div className="text-sm font-medium">{hasGpu ? gpuName : t('settings.gpu.cpuOnly')}</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{hasGpu ? (
<>
@@ -78,12 +79,14 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
{health.vram_used_mb != null && health.vram_used_mb > 0 && (
<>
<span className="text-border">|</span>
<span>{health.vram_used_mb.toFixed(0)} MB VRAM</span>
<span>
{t('settings.gpu.vramUsed', { mb: health.vram_used_mb.toFixed(0) })}
</span>
</>
)}
</>
) : (
<span>No GPU acceleration detected</span>
<span>{t('settings.gpu.noAcceleration')}</span>
)}
</div>
</div>
@@ -93,7 +96,9 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent/60" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-accent shadow-[0_0_4px_1px_hsl(var(--accent)/0.4)]" />
</span>
<span className="text-[10px] font-medium text-muted-foreground">Active</span>
<span className="text-[10px] font-medium text-muted-foreground">
{t('settings.gpu.active')}
</span>
</div>
)}
</div>
@@ -102,6 +107,7 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
}
export function GpuPage() {
const { t } = useTranslation();
const platform = usePlatform();
const queryClient = useQueryClient();
const serverUrl = useServerStore((state) => state.serverUrl);
@@ -153,7 +159,7 @@ export function GpuPage() {
refetchCudaStatus();
} else if (data.status === 'error') {
eventSource.close();
setError(data.error || 'Download failed');
setError(data.error || t('settings.gpu.errors.downloadFailed'));
setDownloadProgress(null);
refetchCudaStatus();
}
@@ -169,7 +175,7 @@ export function GpuPage() {
return () => {
eventSource.close();
};
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
}, [cudaDownloading, serverUrl, refetchCudaStatus, t]);
const clearHealthPolling = useCallback(() => {
if (healthPollRef.current) {
@@ -218,7 +224,7 @@ export function GpuPage() {
await apiClient.downloadCudaBackend();
refetchCudaStatus();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Failed to start download';
const msg = e instanceof Error ? e.message : t('settings.gpu.errors.downloadStart');
if (msg.includes('already downloaded')) {
refetchCudaStatus();
} else {
@@ -230,9 +236,9 @@ export function GpuPage() {
const handleRestart = async () => {
setError(null);
try {
await restartServerWithPolling('Restart failed');
await restartServerWithPolling(t('settings.gpu.errors.restartFailed'));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Restart failed');
setError(e instanceof Error ? e.message : t('settings.gpu.errors.restartFailed'));
}
};
@@ -241,9 +247,9 @@ export function GpuPage() {
setRestartPhase('stopping');
try {
await apiClient.deleteCudaBackend();
await restartServerWithPolling('Failed to switch to CPU');
await restartServerWithPolling(t('settings.gpu.errors.switchCpu'));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to switch to CPU');
setError(e instanceof Error ? e.message : t('settings.gpu.errors.switchCpu'));
refetchCudaStatus();
}
};
@@ -254,7 +260,7 @@ export function GpuPage() {
await apiClient.deleteCudaBackend();
refetchCudaStatus();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to delete CUDA backend');
setError(e instanceof Error ? e.message : t('settings.gpu.errors.deleteCuda'));
}
};
@@ -278,21 +284,21 @@ export function GpuPage() {
<div className="space-y-8 max-w-2xl">
<GpuInfoCard health={health} />
{/* CUDA section — only when no native GPU and not already on CUDA */}
{!hasNativeGpu && !isCurrentlyCuda && (
<SettingSection
title="CUDA Backend"
description="NVIDIA GPU acceleration via a downloadable CUDA backend."
title={t('settings.gpu.cuda.title')}
description={t('settings.gpu.cuda.description')}
>
{/* Download progress */}
{cudaDownloading && downloadProgress && (
<SettingRow title="Downloading CUDA backend...">
<SettingRow title={t('settings.gpu.cuda.downloading')}>
<div className="space-y-1.5">
<Progress value={downloadProgress.progress} className="h-2" />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{downloadProgress.filename ||
(cudaAvailable ? 'Updating...' : 'Downloading...')}
(cudaAvailable
? t('settings.gpu.cuda.updating')
: t('settings.gpu.cuda.downloadingShort'))}
</span>
<span>
{downloadProgress.total > 0
@@ -304,23 +310,21 @@ export function GpuPage() {
</SettingRow>
)}
{/* Restart in progress */}
{restartPhase !== 'idle' && (
<SettingRow
title={
restartPhase === 'ready'
? 'Server restarted successfully'
? t('settings.gpu.restart.ready')
: restartPhase === 'waiting'
? 'Restarting server...'
: 'Stopping server...'
? t('settings.gpu.restart.waiting')
: t('settings.gpu.restart.stopping')
}
action={<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
/>
)}
{/* Error */}
{error && (
<SettingRow title="Error">
<SettingRow title={t('common.error')}>
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span>
@@ -328,17 +332,16 @@ export function GpuPage() {
</SettingRow>
)}
{/* Actions */}
{restartPhase === 'idle' && !cudaDownloading && (
<>
{!cudaAvailable && !isCurrentlyCuda && (
<SettingRow
title="Download CUDA backend"
description="~2.4 GB download. Requires an NVIDIA GPU with CUDA support."
title={t('settings.gpu.download.title')}
description={t('settings.gpu.download.description')}
action={
<Button onClick={handleDownload} size="sm">
<Download className="h-3.5 w-3.5 mr-1.5" />
Download
{t('settings.gpu.download.button')}
</Button>
}
/>
@@ -346,12 +349,12 @@ export function GpuPage() {
{cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && (
<SettingRow
title="Switch to CUDA backend"
description="CUDA backend is downloaded and ready. Restart to enable."
title={t('settings.gpu.switchToCuda.title')}
description={t('settings.gpu.switchToCuda.description')}
action={
<Button onClick={handleRestart} size="sm">
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
Restart
{t('settings.gpu.switchToCuda.button')}
</Button>
}
/>
@@ -359,12 +362,12 @@ export function GpuPage() {
{isCurrentlyCuda && platform.metadata.isTauri && (
<SettingRow
title="Switch to CPU backend"
description="Disable GPU acceleration. You can re-download CUDA later."
title={t('settings.gpu.switchToCpu.title')}
description={t('settings.gpu.switchToCpu.description')}
action={
<Button onClick={handleSwitchToCpu} variant="outline" size="sm">
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
Switch
{t('settings.gpu.switchToCpu.button')}
</Button>
}
/>
@@ -372,8 +375,8 @@ export function GpuPage() {
{cudaAvailable && !isCurrentlyCuda && (
<SettingRow
title="Remove CUDA backend"
description="Delete the downloaded CUDA binary to free disk space."
title={t('settings.gpu.remove.title')}
description={t('settings.gpu.remove.description')}
action={
<Button
onClick={handleDelete}
@@ -382,7 +385,7 @@ export function GpuPage() {
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
Remove
{t('settings.gpu.remove.button')}
</Button>
}
/>
@@ -392,14 +395,7 @@ export function GpuPage() {
</SettingSection>
)}
<p className="text-xs text-muted-foreground/60 leading-relaxed">
Voicebox automatically detects and uses the best available GPU on your system. On Apple
Silicon Macs, the MLX backend runs natively on the Neural Engine and GPU via Metal
Performance Shaders (MPS), with no additional setup required. On Windows and Linux with
NVIDIA GPUs, you can download an optional CUDA backend for hardware-accelerated inference.
AMD ROCm, Intel XPU, and DirectML are also supported where available through PyTorch. When
no GPU is detected, Voicebox falls back to CPU all engines still work, just slower.
</p>
<p className="text-xs text-muted-foreground/60 leading-relaxed">{t('settings.gpu.footer')}</p>
</div>
);
}
@@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { SUPPORTED_LANGUAGES } from '@/i18n';
export function LanguageSelect() {
const { i18n } = useTranslation();
const current = SUPPORTED_LANGUAGES.find((l) => l.code === i18n.resolvedLanguage)?.code ?? 'en';
return (
<Select value={current} onValueChange={(value) => i18n.changeLanguage(value)}>
<SelectTrigger className="h-9 w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPORTED_LANGUAGES.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
+8 -11
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils/cn';
import { type LogEntry, useLogStore } from '@/stores/logStore';
@@ -32,6 +33,7 @@ function LogLine({ entry }: { entry: LogEntry }) {
}
export function LogsPage() {
const { t } = useTranslation();
const entries = useLogStore((s) => s.entries);
const clear = useLogStore((s) => s.clear);
const containerRef = useRef<HTMLDivElement>(null);
@@ -56,9 +58,9 @@ export function LogsPage() {
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium">Server Logs</h3>
<h3 className="text-sm font-medium">{t('settings.logs.title')}</h3>
<p className="text-sm text-muted-foreground">
{entries.length} {entries.length === 1 ? 'line' : 'lines'}
{t('settings.logs.lineCount', { count: entries.length })}
</p>
</div>
<div className="flex items-center gap-2">
@@ -71,11 +73,11 @@ export function LogsPage() {
containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight });
}}
>
Scroll to bottom
{t('settings.logs.scrollToBottom')}
</Button>
)}
<Button variant="outline" size="sm" onClick={clear}>
Clear
{t('settings.logs.clear')}
</Button>
</div>
</div>
@@ -87,13 +89,8 @@ export function LogsPage() {
>
{entries.length === 0 ? (
<div className="text-sm text-muted-foreground/50 font-mono space-y-1">
<p>No log output yet.</p>
{!import.meta.env?.PROD && (
<p>
Server logs are only captured when the app manages the server process (production
builds).
</p>
)}
<p>{t('settings.logs.empty')}</p>
{!import.meta.env?.PROD && <p>{t('settings.logs.devHint')}</p>}
</div>
) : (
entries.map((entry) => <LogLine key={entry.id} entry={entry} />)
+10 -8
View File
@@ -1,11 +1,12 @@
import { Link, Outlet, useMatchRoute } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext';
import { usePlayerStore } from '@/stores/playerStore';
interface SettingsTab {
label: string;
labelKey: string;
path:
| '/settings'
| '/settings/generation'
@@ -17,15 +18,16 @@ interface SettingsTab {
}
const tabs: SettingsTab[] = [
{ label: 'General', path: '/settings' },
{ label: 'Generation', path: '/settings/generation' },
{ label: 'GPU', path: '/settings/gpu', tauriOnly: true },
{ label: 'Logs', path: '/settings/logs', tauriOnly: true },
{ label: 'Changelog', path: '/settings/changelog' },
{ label: 'About', path: '/settings/about' },
{ labelKey: 'settings.tabs.general', path: '/settings' },
{ labelKey: 'settings.tabs.generation', path: '/settings/generation' },
{ labelKey: 'settings.tabs.gpu', path: '/settings/gpu', tauriOnly: true },
{ labelKey: 'settings.tabs.logs', path: '/settings/logs', tauriOnly: true },
{ labelKey: 'settings.tabs.changelog', path: '/settings/changelog' },
{ labelKey: 'settings.tabs.about', path: '/settings/about' },
];
export function SettingsLayout() {
const { t } = useTranslation();
const platform = usePlatform();
const isPlayerVisible = !!usePlayerStore((state) => state.audioUrl);
const matchRoute = useMatchRoute();
@@ -52,7 +54,7 @@ export function SettingsLayout() {
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30',
)}
>
{tab.label}
{t(tab.labelKey)}
</Link>
);
})}
+12 -10
View File
@@ -1,6 +1,7 @@
import { Link, useMatchRoute } from '@tanstack/react-router';
import { AudioLines, Box, Mic, Settings, Speaker, Volume2, Wand2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import voiceboxLogo from '@/assets/voicebox-logo.png';
import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext';
@@ -13,16 +14,17 @@ interface SidebarProps {
}
const tabs = [
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' },
{ id: 'stories', path: '/stories', icon: AudioLines, label: 'Stories' },
{ id: 'voices', path: '/voices', icon: Mic, label: 'Voices' },
{ id: 'effects', path: '/effects', icon: Wand2, label: 'Effects' },
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
{ id: 'models', path: '/models', icon: Box, label: 'Models' },
{ id: 'settings', path: '/settings', icon: Settings, label: 'Settings' },
{ id: 'main', path: '/', icon: Volume2, labelKey: 'nav.generate' },
{ id: 'stories', path: '/stories', icon: AudioLines, labelKey: 'nav.stories' },
{ id: 'voices', path: '/voices', icon: Mic, labelKey: 'nav.voices' },
{ id: 'effects', path: '/effects', icon: Wand2, labelKey: 'nav.effects' },
{ id: 'audio', path: '/audio', icon: Speaker, labelKey: 'nav.audio' },
{ id: 'models', path: '/models', icon: Box, labelKey: 'nav.models' },
{ id: 'settings', path: '/settings', icon: Settings, labelKey: 'nav.settings' },
];
export function Sidebar({ isMacOS }: SidebarProps) {
const { t } = useTranslation();
const matchRoute = useMatchRoute();
const isPlayerOpen = !!usePlayerStore((s) => s.audioUrl);
const platform = usePlatform();
@@ -72,8 +74,8 @@ export function Sidebar({ isMacOS }: SidebarProps) {
? 'bg-white/[0.07] text-foreground shadow-lg backdrop-blur-sm border border-white/[0.08]'
: 'text-muted-foreground hover:bg-muted/50',
)}
title={tab.label}
aria-label={tab.label}
title={t(tab.labelKey)}
aria-label={t(tab.labelKey)}
>
{isActive && (
<div
@@ -102,7 +104,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
to="/settings"
className="text-[9px] font-semibold tracking-wide uppercase px-2 py-0.5 rounded-full bg-accent/15 text-accent hover:bg-accent/25 transition-colors"
>
Update
{t('nav.updateBadge')}
</Link>
)}
</div>
+33
View File
@@ -0,0 +1,33 @@
import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next';
import en from './locales/en/translation.json';
import zhCN from './locales/zh-CN/translation.json';
export const SUPPORTED_LANGUAGES = [
{ code: 'en', label: 'English' },
{ code: 'zh-CN', label: '简体中文' },
] as const;
export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]['code'];
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
'zh-CN': { translation: zhCN },
},
fallbackLng: 'en',
supportedLngs: SUPPORTED_LANGUAGES.map((l) => l.code),
nonExplicitSupportedLngs: true,
interpolation: { escapeValue: false },
detection: {
order: ['localStorage', 'navigator'],
lookupLocalStorage: 'voicebox:lang',
caches: ['localStorage'],
},
});
export default i18n;
+329
View File
@@ -0,0 +1,329 @@
{
"common": {
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"close": "Close",
"confirm": "Confirm",
"loading": "Loading…",
"error": "Error",
"unknown": "Unknown",
"unknownError": "Unknown error"
},
"nav": {
"generate": "Generate",
"stories": "Stories",
"voices": "Voices",
"effects": "Effects",
"audio": "Audio",
"models": "Models",
"settings": "Settings",
"updateBadge": "Update"
},
"generation": {
"placeholder": {
"storyWithEffects": "Generate speech for \"{{name}}\"… (type / for effects)",
"story": "Generate speech for \"{{name}}\"…",
"profile": "Generate speech using {{name}}…",
"effectsHint": "Type / for effects like [laugh], [sigh]…",
"selectVoice": "Select a voice profile above…"
},
"button": {
"generate": "Generate speech",
"generating": "Generating…",
"selectFirst": "Select a voice profile first"
},
"instruct": {
"show": "Show delivery instructions",
"hide": "Hide delivery instructions",
"tooltip": "Delivery instructions (tone, emotion, pace)",
"placeholder": "Delivery instructions — e.g. Speak slowly with warmth, Authoritative and clear…"
},
"voiceSelector": {
"placeholder": "Select a voice…"
},
"effects": {
"none": "No effects",
"profileDefault": "Profile default"
}
},
"main": {
"importVoice": "Import Voice",
"createVoice": "Create Voice",
"import": {
"invalidTitle": "Invalid file type",
"invalidDescription": "Please select a valid .voicebox.zip file",
"successTitle": "Profile imported",
"successDescription": "Voice profile imported successfully",
"failedTitle": "Failed to import profile",
"dialogTitle": "Import Profile",
"dialogDescription": "Import the profile from \"{{name}}\". This will create a new profile with all samples.",
"importing": "Importing…",
"action": "Import"
}
},
"settings": {
"tabs": {
"general": "General",
"generation": "Generation",
"gpu": "GPU",
"logs": "Logs",
"changelog": "Changelog",
"about": "About"
},
"language": {
"label": "Language",
"description": "Choose the display language for Voicebox."
},
"general": {
"docs": { "title": "Read the Docs" },
"discord": { "title": "Join the Discord", "subtitle": "Get help & share voices" },
"serverUrl": {
"title": "Server URL",
"description": "The address of your voicebox backend server.",
"invalidUrl": "Please enter a valid URL",
"updatedTitle": "Server URL updated",
"updatedDescription": "Connected to {{url}}"
},
"keepServerRunning": {
"title": "Keep server running when app closes",
"description": "The server will continue running in the background after closing the app.",
"failedTitle": "Failed to update setting",
"failedDescription": "Could not sync setting to backend.",
"updatedTitle": "Setting updated",
"runningDescription": "Server will continue running when app closes",
"stoppedDescription": "Server will stop when app closes"
},
"networkAccess": {
"title": "Allow network access",
"description": "Makes the server accessible from other devices on your network. Restart the app after changing.",
"enabled": "Network access enabled. Restart the app to apply.",
"disabled": "Network access disabled. Restart the app to apply."
},
"connection": {
"connecting": "Connecting",
"offline": "Offline",
"online": "Online"
},
"updates": {
"title": "App Updates",
"devSuffix": " (dev)",
"devMode": {
"title": "Development mode",
"description": "Auto-updates are disabled in development mode."
},
"check": {
"title": "Check for updates",
"available": "Version {{version}} available",
"checking": "Checking…",
"upToDate": "You're up to date",
"button": "Check"
},
"error": "Update error",
"download": {
"title": "Update to {{version}}",
"description": "Download and install the latest version.",
"button": "Download"
},
"downloading": "Downloading update…",
"ready": {
"title": "Update ready to install",
"description": "Version {{version}} has been downloaded. Restart to complete.",
"button": "Restart Now"
}
},
"api": {
"title": "API Access",
"description": "Integrate Voicebox into your workflow via the REST API at <code>{{url}}</code>",
"viewReference": "View the full API reference",
"endpoints": {
"generate": "Generate speech",
"health": "Server status",
"profiles": "List voices",
"history": "Past generations"
}
}
},
"generation": {
"title": "Generation",
"description": "Controls for long text generation. These settings apply to all engines.",
"chunkLimit": {
"title": "Auto-chunking limit",
"description": "Long text is split into chunks at sentence boundaries. Lower values can improve quality for long outputs.",
"value": "{{count}} chars"
},
"crossfade": {
"title": "Chunk crossfade",
"description": "Blends audio between chunks to smooth transitions. Set to 0 for a hard cut.",
"cut": "Cut",
"ms": "{{ms}}ms"
},
"normalize": {
"title": "Normalize audio",
"description": "Adjusts output volume to a consistent level across generations."
},
"autoplay": {
"title": "Autoplay on generate",
"description": "Automatically play audio when a generation completes."
},
"folder": {
"title": "Generations folder",
"description": "Where generated audio files are stored on disk.",
"open": "Open"
}
},
"gpu": {
"cpuOnly": "CPU Only",
"vramUsed": "{{mb}} MB VRAM",
"noAcceleration": "No GPU acceleration detected",
"active": "Active",
"cuda": {
"title": "CUDA Backend",
"description": "NVIDIA GPU acceleration via a downloadable CUDA backend.",
"downloading": "Downloading CUDA backend…",
"downloadingShort": "Downloading…",
"updating": "Updating…"
},
"restart": {
"ready": "Server restarted successfully",
"waiting": "Restarting server…",
"stopping": "Stopping server…"
},
"download": {
"title": "Download CUDA backend",
"description": "~2.4 GB download. Requires an NVIDIA GPU with CUDA support.",
"button": "Download"
},
"switchToCuda": {
"title": "Switch to CUDA backend",
"description": "CUDA backend is downloaded and ready. Restart to enable.",
"button": "Restart"
},
"switchToCpu": {
"title": "Switch to CPU backend",
"description": "Disable GPU acceleration. You can re-download CUDA later.",
"button": "Switch"
},
"remove": {
"title": "Remove CUDA backend",
"description": "Delete the downloaded CUDA binary to free disk space.",
"button": "Remove"
},
"errors": {
"downloadFailed": "Download failed",
"downloadStart": "Failed to start download",
"restartFailed": "Restart failed",
"switchCpu": "Failed to switch to CPU",
"deleteCuda": "Failed to delete CUDA backend"
},
"footer": "Voicebox automatically detects and uses the best available GPU on your system. On Apple Silicon Macs, the MLX backend runs natively on the Neural Engine and GPU via Metal Performance Shaders (MPS), with no additional setup required. On Windows and Linux with NVIDIA GPUs, you can download an optional CUDA backend for hardware-accelerated inference. AMD ROCm, Intel XPU, and DirectML are also supported where available through PyTorch. When no GPU is detected, Voicebox falls back to CPU — all engines still work, just slower."
},
"logs": {
"title": "Server Logs",
"lineCount_one": "{{count}} line",
"lineCount_other": "{{count}} lines",
"scrollToBottom": "Scroll to bottom",
"clear": "Clear",
"empty": "No log output yet.",
"devHint": "Server logs are only captured when the app manages the server process (production builds)."
},
"changelog": {
"devBadge": "dev",
"showLess": "Show less",
"showMore": "Show more"
},
"about": {
"tagline": "The open-source voice synthesis studio. Clone voices, generate speech, apply effects, and build voice-powered apps — all running locally on your machine.",
"createdBy": "Created by",
"buyCoffee": "Buy me a coffee",
"license": "Licensed under <link>MIT</link>"
}
},
"models": {
"title": "Models",
"subtitle": "Download and manage AI models for voice generation and transcription",
"defaultName": "Model",
"unknownSize": "Unknown size",
"sections": {
"voiceGeneration": "Voice Generation",
"transcription": "Transcription"
},
"status": {
"loaded": "Loaded"
},
"storage": {
"location": "Storage location",
"open": "Open",
"change": "Change",
"migrating": "Migrating…",
"reset": "Reset",
"pickerTitle": "Choose model storage folder"
},
"progress": {
"connecting": "Connecting…",
"connectingHf": "Connecting to HuggingFace…"
},
"problems": {
"title": "Problems",
"clearAll": "Clear All",
"noDetails": "No error details available. Try downloading again.",
"startedAt": "started at {{time}}"
},
"detail": {
"loadingInfo": "Loading model info…",
"byAuthor": "by {{author}}",
"downloads": "Downloads",
"likes": "Likes",
"license": "License",
"languagesCount": "{{count}} languages supported",
"languagesList": "Languages: {{list}}",
"onDisk": "{{size}} on disk"
},
"actions": {
"download": "Download",
"retry": "Retry Download",
"unload": "Unload",
"unloading": "Unloading…",
"unloadFirst": "Unload model before deleting",
"deleteModel": "Delete Model"
},
"deleteDialog": {
"title": "Delete Model",
"body": "Are you sure you want to delete <strong>{{name}}</strong>?",
"sizeNote": "This will free up {{size}} of disk space. The model will need to be re-downloaded if you want to use it again.",
"deleting": "Deleting…"
},
"migrateDialog": {
"title": "Move models to new location?",
"description": "The server will shut down while models are being moved to the new folder. It will restart automatically once the migration is complete.",
"action": "Move Models",
"preparing": "Preparing…",
"restartingServer": "Restarting server…"
},
"migrate": {
"title": "Moving models",
"offline": "The server is offline while models are being moved."
},
"toast": {
"downloadFailed": "Download failed",
"cancelFailed": "Cancel failed",
"cancelFailedDescription": "Could not cancel the download task.",
"deleted": "Model deleted",
"deletedDescription": "{{name}} has been deleted successfully.",
"deleteFailed": "Delete failed",
"unloaded": "Model unloaded",
"unloadedDescription": "{{name}} has been unloaded from memory.",
"unloadFailed": "Unload failed",
"openFolderFailed": "Failed to open model folder",
"pickerFailed": "Failed to open folder picker",
"resetToDefault": "Reset to default location. Restarting server…",
"noModelsToMigrate": "No models to migrate",
"noModelsToMigrateDescription": "Download at least one model before changing the storage location.",
"migrated": "Models moved successfully",
"migrationFailed": "Migration failed",
"migrationFailedGeneric": "Failed to migrate models",
"migrationConnectionLost": "Lost connection during migration"
}
}
}
+329
View File
@@ -0,0 +1,329 @@
{
"common": {
"cancel": "取消",
"save": "保存",
"delete": "删除",
"edit": "编辑",
"close": "关闭",
"confirm": "确认",
"loading": "加载中…",
"error": "错误",
"unknown": "未知",
"unknownError": "未知错误"
},
"nav": {
"generate": "生成",
"stories": "故事",
"voices": "声音",
"effects": "效果",
"audio": "音频",
"models": "模型",
"settings": "设置",
"updateBadge": "更新"
},
"generation": {
"placeholder": {
"storyWithEffects": "为 \"{{name}}\" 生成语音… (输入 / 使用效果)",
"story": "为 \"{{name}}\" 生成语音…",
"profile": "使用 {{name}} 生成语音…",
"effectsHint": "输入 / 使用效果,如 [笑声]、[叹息]…",
"selectVoice": "请在上方选择一个声音档案…"
},
"button": {
"generate": "生成语音",
"generating": "生成中…",
"selectFirst": "请先选择声音档案"
},
"instruct": {
"show": "显示传达说明",
"hide": "隐藏传达说明",
"tooltip": "传达说明 (语气、情感、节奏)",
"placeholder": "传达说明——例如:温柔缓慢地说、威严清晰…"
},
"voiceSelector": {
"placeholder": "选择声音…"
},
"effects": {
"none": "无效果",
"profileDefault": "档案默认"
}
},
"main": {
"importVoice": "导入声音",
"createVoice": "创建声音",
"import": {
"invalidTitle": "文件类型无效",
"invalidDescription": "请选择有效的 .voicebox.zip 文件",
"successTitle": "声音已导入",
"successDescription": "成功导入声音档案",
"failedTitle": "导入声音档案失败",
"dialogTitle": "导入声音档案",
"dialogDescription": "从 \"{{name}}\" 导入声音档案。这将创建一个新的声音档案,包含所有样本。",
"importing": "导入中…",
"action": "导入"
}
},
"settings": {
"tabs": {
"general": "常规",
"generation": "生成",
"gpu": "GPU",
"logs": "日志",
"changelog": "更新日志",
"about": "关于"
},
"language": {
"label": "语言",
"description": "选择 Voicebox 的显示语言。"
},
"general": {
"docs": { "title": "阅读文档" },
"discord": { "title": "加入 Discord", "subtitle": "获取帮助 & 分享声音" },
"serverUrl": {
"title": "服务器 URL",
"description": "Voicebox 后端服务器的地址。",
"invalidUrl": "请输入有效的 URL",
"updatedTitle": "服务器 URL 已更新",
"updatedDescription": "已连接到 {{url}}"
},
"keepServerRunning": {
"title": "关闭应用时保持服务器运行",
"description": "关闭应用后,服务器将继续在后台运行。",
"failedTitle": "更新设置失败",
"failedDescription": "无法将设置同步到后端。",
"updatedTitle": "设置已更新",
"runningDescription": "关闭应用时服务器将继续运行",
"stoppedDescription": "关闭应用时服务器将停止"
},
"networkAccess": {
"title": "允许网络访问",
"description": "使网络上的其他设备可以访问服务器。更改后请重启应用。",
"enabled": "已启用网络访问。重启应用以应用更改。",
"disabled": "已禁用网络访问。重启应用以应用更改。"
},
"connection": {
"connecting": "连接中",
"offline": "离线",
"online": "在线"
},
"updates": {
"title": "应用更新",
"devSuffix": "(开发版)",
"devMode": {
"title": "开发模式",
"description": "开发模式下已禁用自动更新。"
},
"check": {
"title": "检查更新",
"available": "版本 {{version}} 可用",
"checking": "检查中…",
"upToDate": "已是最新版本",
"button": "检查"
},
"error": "更新错误",
"download": {
"title": "更新到 {{version}}",
"description": "下载并安装最新版本。",
"button": "下载"
},
"downloading": "下载更新中…",
"ready": {
"title": "更新已准备就绪",
"description": "版本 {{version}} 已下载。重启以完成。",
"button": "立即重启"
}
},
"api": {
"title": "API 访问",
"description": "通过 <code>{{url}}</code> 的 REST API 将 Voicebox 集成到您的工作流程中",
"viewReference": "查看完整的 API 参考",
"endpoints": {
"generate": "生成语音",
"health": "服务器状态",
"profiles": "声音列表",
"history": "历史生成"
}
}
},
"generation": {
"title": "生成",
"description": "长文本生成的控件。这些设置适用于所有引擎。",
"chunkLimit": {
"title": "自动分块上限",
"description": "长文本在句子边界处分块。较低的值可以提高长输出的质量。",
"value": "{{count}} 字符"
},
"crossfade": {
"title": "块间淡入淡出",
"description": "在块之间混合音频以平滑过渡。设为 0 表示硬切换。",
"cut": "切换",
"ms": "{{ms}}毫秒"
},
"normalize": {
"title": "音频归一化",
"description": "将输出音量调整到所有生成结果一致的水平。"
},
"autoplay": {
"title": "生成后自动播放",
"description": "生成完成后自动播放音频。"
},
"folder": {
"title": "生成文件夹",
"description": "生成的音频文件在磁盘上的存储位置。",
"open": "打开"
}
},
"gpu": {
"cpuOnly": "仅 CPU",
"vramUsed": "{{mb}} MB 显存",
"noAcceleration": "未检测到 GPU 加速",
"active": "活动",
"cuda": {
"title": "CUDA 后端",
"description": "通过可下载的 CUDA 后端实现 NVIDIA GPU 加速。",
"downloading": "下载 CUDA 后端中…",
"downloadingShort": "下载中…",
"updating": "更新中…"
},
"restart": {
"ready": "服务器重启成功",
"waiting": "重启服务器中…",
"stopping": "停止服务器中…"
},
"download": {
"title": "下载 CUDA 后端",
"description": "约 2.4 GB 下载。需要支持 CUDA 的 NVIDIA GPU。",
"button": "下载"
},
"switchToCuda": {
"title": "切换到 CUDA 后端",
"description": "CUDA 后端已下载完成。重启以启用。",
"button": "重启"
},
"switchToCpu": {
"title": "切换到 CPU 后端",
"description": "禁用 GPU 加速。您之后可以重新下载 CUDA。",
"button": "切换"
},
"remove": {
"title": "移除 CUDA 后端",
"description": "删除已下载的 CUDA 二进制文件以释放磁盘空间。",
"button": "移除"
},
"errors": {
"downloadFailed": "下载失败",
"downloadStart": "启动下载失败",
"restartFailed": "重启失败",
"switchCpu": "切换到 CPU 失败",
"deleteCuda": "删除 CUDA 后端失败"
},
"footer": "Voicebox 会自动检测并使用系统上可用的最佳 GPU。在 Apple Silicon Mac 上,MLX 后端通过 Metal Performance Shaders (MPS) 在神经引擎和 GPU 上原生运行,无需额外设置。在配备 NVIDIA GPU 的 Windows 和 Linux 上,您可以下载可选的 CUDA 后端以获得硬件加速推理。AMD ROCm、Intel XPU 和 DirectML 也通过 PyTorch 获得支持。未检测到 GPU 时,Voicebox 会退回到 CPU——所有引擎仍可工作,只是速度较慢。"
},
"logs": {
"title": "服务器日志",
"lineCount_one": "{{count}} 行",
"lineCount_other": "{{count}} 行",
"scrollToBottom": "滚动到底部",
"clear": "清除",
"empty": "暂无日志输出。",
"devHint": "仅当应用管理服务器进程(生产构建)时才会捕获服务器日志。"
},
"changelog": {
"devBadge": "开发版",
"showLess": "收起",
"showMore": "展开"
},
"about": {
"tagline": "开源语音合成工作室。克隆声音、生成语音、应用效果、构建语音驱动的应用——全部在您的本地机器上运行。",
"createdBy": "创建者",
"buyCoffee": "请我喝杯咖啡",
"license": "采用 <link>MIT</link> 协议"
}
},
"models": {
"title": "模型",
"subtitle": "下载和管理用于语音生成和转录的 AI 模型",
"defaultName": "模型",
"unknownSize": "未知大小",
"sections": {
"voiceGeneration": "语音生成",
"transcription": "语音转录"
},
"status": {
"loaded": "已加载"
},
"storage": {
"location": "存储位置",
"open": "打开",
"change": "更改",
"migrating": "迁移中…",
"reset": "重置",
"pickerTitle": "选择模型存储文件夹"
},
"progress": {
"connecting": "连接中…",
"connectingHf": "连接到 HuggingFace 中…"
},
"problems": {
"title": "问题",
"clearAll": "全部清除",
"noDetails": "没有可用的错误详情。请重试下载。",
"startedAt": "开始于 {{time}}"
},
"detail": {
"loadingInfo": "加载模型信息中…",
"byAuthor": "由 {{author}}",
"downloads": "下载量",
"likes": "点赞数",
"license": "许可",
"languagesCount": "支持 {{count}} 种语言",
"languagesList": "语言:{{list}}",
"onDisk": "磁盘占用 {{size}}"
},
"actions": {
"download": "下载",
"retry": "重试下载",
"unload": "卸载",
"unloading": "卸载中…",
"unloadFirst": "删除前请先卸载模型",
"deleteModel": "删除模型"
},
"deleteDialog": {
"title": "删除模型",
"body": "确定要删除 <strong>{{name}}</strong> 吗?",
"sizeNote": "这将释放 {{size}} 磁盘空间。如果您想再次使用该模型,需要重新下载。",
"deleting": "删除中…"
},
"migrateDialog": {
"title": "移动模型到新位置?",
"description": "在模型迁移到新文件夹期间,服务器将关闭。迁移完成后会自动重启。",
"action": "移动模型",
"preparing": "准备中…",
"restartingServer": "重启服务器中…"
},
"migrate": {
"title": "移动模型中",
"offline": "模型迁移期间服务器处于离线状态。"
},
"toast": {
"downloadFailed": "下载失败",
"cancelFailed": "取消失败",
"cancelFailedDescription": "无法取消下载任务。",
"deleted": "模型已删除",
"deletedDescription": "{{name}} 已成功删除。",
"deleteFailed": "删除失败",
"unloaded": "模型已卸载",
"unloadedDescription": "{{name}} 已从内存中卸载。",
"unloadFailed": "卸载失败",
"openFolderFailed": "打开模型文件夹失败",
"pickerFailed": "打开文件夹选择器失败",
"resetToDefault": "已重置到默认位置。重启服务器中…",
"noModelsToMigrate": "没有可迁移的模型",
"noModelsToMigrateDescription": "更改存储位置前请先下载至少一个模型。",
"migrated": "模型已成功移动",
"migrationFailed": "迁移失败",
"migrationFailedGeneric": "迁移模型失败",
"migrationConnectionLost": "迁移期间丢失连接"
}
}
}
+1
View File
@@ -3,6 +3,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './i18n';
import './index.css';
import { queryClient } from './lib/queryClient';
+28 -13
View File
@@ -17,7 +17,7 @@
},
"app": {
"name": "@voicebox/app",
"version": "0.4.1",
"version": "0.4.2",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -49,11 +49,14 @@
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"framer-motion": "^12.29.0",
"i18next": "^26.0.6",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.454.0",
"motion": "^12.29.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-hook-form": "^7.53.0",
"react-i18next": "^17.0.4",
"react-sound-visualizer": "^1.4.0",
"tailwind-merge": "^2.5.4",
"wavesurfer.js": "^7.0.0",
@@ -72,7 +75,7 @@
},
"landing": {
"name": "@voicebox/landing",
"version": "0.4.1",
"version": "0.4.2",
"dependencies": {
"@fontsource/space-grotesk": "^5.2.10",
"@icons-pack/react-simple-icons": "^13.13.0",
@@ -101,7 +104,7 @@
},
"tauri": {
"name": "@voicebox/tauri",
"version": "0.4.1",
"version": "0.4.2",
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
@@ -124,7 +127,7 @@
},
"web": {
"name": "@voicebox/web",
"version": "0.4.1",
"version": "0.4.2",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"react": "^18.3.0",
@@ -182,6 +185,8 @@
"@babel/plugin-transform-react-jsx-source": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/runtime": ["@babel/[email protected]", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"@babel/template": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="],
@@ -786,7 +791,7 @@
"fraction.js": ["[email protected]", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"framer-motion": ["framer-motion@12.29.0", "", { "dependencies": { "motion-dom": "^12.29.0", "motion-utils": "^12.27.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1gEFGXHYV2BD42ZPTFmSU9buehppU+bCuOnHU0AD18DKh9j4DuTx47MvqY5ax+NNWRtK32qIcJf1UxKo1WwjWg=="],
"framer-motion": ["framer-motion@12.36.0", "", { "dependencies": { "motion-dom": "^12.36.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-4PqYHAT7gev0ke0wos+PyrcFxI0HScjm3asgU8nSYa8YzJFuwgIvdj3/s3ZaxLq0bUSboIn19A2WS/MHwLCvfw=="],
"fs.realpath": ["[email protected]", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
@@ -814,6 +819,12 @@
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"html-parse-stringify": ["[email protected]", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
"i18next": ["[email protected]", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg=="],
"i18next-browser-languagedetector": ["[email protected]", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="],
"ignore": ["[email protected]", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["[email protected]", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -910,9 +921,9 @@
"motion": ["[email protected]", "", { "dependencies": { "framer-motion": "^12.29.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rjB5CP2N9S2ESAyEFnAFMgTec6X8yvfxLNcz8n12gPq3M48R7ZbBeVYkDOTj8SPMwfvGIFI801SiPSr1+HCr9g=="],
"motion-dom": ["motion-dom@12.29.0", "", { "dependencies": { "motion-utils": "^12.27.2" } }, "sha512-3eiz9bb32yvY8Q6XNM4AwkSOBPgU//EIKTZwsSWgA9uzbPBhZJeScCVcBuwwYVqhfamewpv7ZNmVKTGp5qnzkA=="],
"motion-dom": ["motion-dom@12.36.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-Ep1pq8P88rGJ75om8lTCA13zqd7ywPGwCqwuWwin6BKc0hMLkVfcS6qKlRqEo2+t0DwoUcgGJfXwaiFn4AOcQA=="],
"motion-utils": ["motion-utils@12.27.2", "", {}, "sha512-B55gcoL85Mcdt2IEStY5EEAsrMSVE2sI14xQ/uAdPL+mfQxhKKFaEag9JmfxedJOR4vZpBGoPeC/Gm13I/4g5Q=="],
"motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -988,6 +999,8 @@
"react-hook-form": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="],
"react-i18next": ["[email protected]", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.0.1", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g=="],
"react-is": ["[email protected]", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-loaders": ["[email protected]", "", { "dependencies": { "classnames": "^2.2.3" }, "peerDependencies": { "prop-types": ">=15.6.0", "react": ">=15" } }, "sha512-4igMNqs9Fb3d4Z+0UHIGQNJsw/37gX0nUO8QxupnEKRn1dtyYC1LGwk5GuaoDciMQCQc/MmPwb4Fn6ZfdoX1FQ=="],
@@ -1100,7 +1113,9 @@
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"wavesurfer.js": ["wavesurfer.js@7.12.1", "", {}, "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg=="],
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
"wavesurfer.js": ["[email protected]", "", {}, "sha512-akVYISAHCw2gNw/7n8Pk/zH1Zz91WJyL/2MaNQCLD1XV3A226gKlWoDHWp9UdWqQ3zXnWttDf9ewZQQ3cxbOmQ=="],
"which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -1160,20 +1175,20 @@
"@typescript-eslint/typescript-estree/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"@voicebox/landing/framer-motion": ["[email protected]", "", { "dependencies": { "motion-dom": "^12.36.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-4PqYHAT7gev0ke0wos+PyrcFxI0HScjm3asgU8nSYa8YzJFuwgIvdj3/s3ZaxLq0bUSboIn19A2WS/MHwLCvfw=="],
"@voicebox/landing/lucide-react": ["[email protected]", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, "sha512-dTmYX1H4IXsRfVcj/KUxworV6814ApTl7iXaS21AimK2RUEl4j4AfOmqD3VR8phe5V91m4vEJ8tCK4uT1jE5nA=="],
"@voicebox/landing/tailwind-merge": ["[email protected]", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"@voicebox/landing/tailwindcss": ["[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"@voicebox/landing/wavesurfer.js": ["[email protected].2", "", {}, "sha512-akVYISAHCw2gNw/7n8Pk/zH1Zz91WJyL/2MaNQCLD1XV3A226gKlWoDHWp9UdWqQ3zXnWttDf9ewZQQ3cxbOmQ=="],
"@voicebox/web/wavesurfer.js": ["[email protected].1", "", {}, "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg=="],
"chokidar/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"motion/framer-motion": ["[email protected]", "", { "dependencies": { "motion-dom": "^12.29.0", "motion-utils": "^12.27.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1gEFGXHYV2BD42ZPTFmSU9buehppU+bCuOnHU0AD18DKh9j4DuTx47MvqY5ax+NNWRtK32qIcJf1UxKo1WwjWg=="],
"next/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"sharp/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -1182,8 +1197,8 @@
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"@voicebox/landing/framer-motion/motion-dom": ["motion-dom@12.36.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-Ep1pq8P88rGJ75om8lTCA13zqd7ywPGwCqwuWwin6BKc0hMLkVfcS6qKlRqEo2+t0DwoUcgGJfXwaiFn4AOcQA=="],
"motion/framer-motion/motion-dom": ["motion-dom@12.29.0", "", { "dependencies": { "motion-utils": "^12.27.2" } }, "sha512-3eiz9bb32yvY8Q6XNM4AwkSOBPgU//EIKTZwsSWgA9uzbPBhZJeScCVcBuwwYVqhfamewpv7ZNmVKTGp5qnzkA=="],
"@voicebox/landing/framer-motion/motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
"motion/framer-motion/motion-utils": ["motion-utils@12.27.2", "", {}, "sha512-B55gcoL85Mcdt2IEStY5EEAsrMSVE2sI14xQ/uAdPL+mfQxhKKFaEag9JmfxedJOR4vZpBGoPeC/Gm13I/4g5Q=="],
}
}