Update dependencies and enhance UI components for better functionality

- Added new Tauri plugins: @tauri-apps/plugin-process and @tauri-apps/plugin-updater to improve application capabilities.
- Introduced UpdateStatus component to display update information in the UI.
- Enhanced GenerationForm to include an optional instruct field for additional input.
- Refactored various components for improved styling and responsiveness, including Sidebar, AudioPlayer, and ProfileCard.
- Updated API models and schemas to accommodate new instruct parameter in generation requests and responses.
- Improved documentation for autoupdater setup and usage.
This commit is contained in:
Jamie Pine
2026-01-25 16:38:25 -08:00
parent 45c4c2f488
commit e7e3a0a440
87 changed files with 2421 additions and 1628 deletions
+2
View File
@@ -31,6 +31,8 @@
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"@tanstack/react-query-devtools": "^5.0.0", "@tanstack/react-query-devtools": "^5.0.0",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.9.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
+5
View File
@@ -3,11 +3,13 @@ import { GenerationForm } from '@/components/Generation/GenerationForm';
import { HistoryTable } from '@/components/History/HistoryTable'; import { HistoryTable } from '@/components/History/HistoryTable';
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm'; import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
import { ServerStatus } from '@/components/ServerSettings/ServerStatus'; import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
import { ModelManagement } from '@/components/ServerSettings/ModelManagement'; import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
import { Toaster } from '@/components/ui/toaster'; import { Toaster } from '@/components/ui/toaster';
import { ProfileList } from '@/components/VoiceProfiles/ProfileList'; import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
import { Sidebar } from '@/components/Sidebar'; import { Sidebar } from '@/components/Sidebar';
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer'; import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
import { UpdateNotification } from '@/components/UpdateNotification';
import { isTauri, startServer, setupWindowCloseHandler } from '@/lib/tauri'; import { isTauri, startServer, setupWindowCloseHandler } from '@/lib/tauri';
// Track if server is starting to prevent duplicate starts // Track if server is starting to prevent duplicate starts
@@ -90,12 +92,15 @@ function App() {
<main className="flex-1 ml-20 overflow-hidden flex flex-col"> <main className="flex-1 ml-20 overflow-hidden flex flex-col">
<div className="container mx-auto px-8 py-8 max-w-[1800px] h-full overflow-hidden flex flex-col"> <div className="container mx-auto px-8 py-8 max-w-[1800px] h-full overflow-hidden flex flex-col">
<UpdateNotification />
{activeTab === 'settings' ? ( {activeTab === 'settings' ? (
<div className="space-y-4 overflow-y-auto"> <div className="space-y-4 overflow-y-auto">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<ConnectionForm /> <ConnectionForm />
<ServerStatus /> <ServerStatus />
</div> </div>
{isTauri() && <UpdateStatus />}
<ModelManagement /> <ModelManagement />
</div> </div>
) : ( ) : (
@@ -33,6 +33,7 @@ const generationSchema = z.object({
language: z.enum(['en', 'zh']), language: z.enum(['en', 'zh']),
seed: z.number().int().optional(), seed: z.number().int().optional(),
modelSize: z.enum(['1.7B', '0.6B']).optional(), modelSize: z.enum(['1.7B', '0.6B']).optional(),
instruct: z.string().max(500).optional(),
}); });
type GenerationFormValues = z.infer<typeof generationSchema>; type GenerationFormValues = z.infer<typeof generationSchema>;
@@ -50,6 +51,7 @@ export function GenerationForm() {
language: 'en', language: 'en',
seed: undefined, seed: undefined,
modelSize: '1.7B', modelSize: '1.7B',
instruct: '',
}, },
}); });
@@ -70,6 +72,7 @@ export function GenerationForm() {
language: data.language, language: data.language,
seed: data.seed, seed: data.seed,
model_size: data.modelSize, model_size: data.modelSize,
instruct: data.instruct || undefined,
}); });
toast({ toast({
@@ -119,7 +122,7 @@ export function GenerationForm() {
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="Enter the text you want to generate..." placeholder="Enter the text you want to generate..."
className="min-h-[200px]" className="min-h-[150px]"
{...field} {...field}
/> />
</FormControl> </FormControl>
@@ -129,6 +132,28 @@ export function GenerationForm() {
)} )}
/> />
<FormField
control={form.control}
name="instruct"
render={({ field }) => (
<FormItem>
<FormLabel>Delivery Instructions (optional)</FormLabel>
<FormControl>
<Textarea
placeholder="e.g. Speak slowly with emphasis, Warm and friendly tone, Professional and authoritative..."
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormDescription>
Natural language instructions to control speech delivery (tone, emotion, pace).
Max 500 characters
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<FormField <FormField
control={form.control} control={form.control}
@@ -77,9 +77,7 @@ export function ConnectionForm() {
)} )}
/> />
{isDirty && ( {isDirty && <Button type="submit">Update Connection</Button>}
<Button type="submit">Update Connection</Button>
)}
</form> </form>
</Form> </Form>
@@ -106,8 +104,8 @@ export function ConnectionForm() {
Keep server running when app closes Keep server running when app closes
</label> </label>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
When enabled, the server will continue running in the background after closing the app. When enabled, the server will continue running in the background after closing the
Disabled by default. app. Disabled by default.
</p> </p>
</div> </div>
</div> </div>
@@ -73,7 +73,9 @@ export function ModelManagement() {
<div className="space-y-4"> <div className="space-y-4">
{/* TTS Models */} {/* TTS Models */}
<div> <div>
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">Voice Generation Models</h3> <h3 className="text-sm font-semibold mb-3 text-muted-foreground">
Voice Generation Models
</h3>
<div className="space-y-2"> <div className="space-y-2">
{modelStatus.models {modelStatus.models
.filter((m) => m.model_name.startsWith('qwen-tts')) .filter((m) => m.model_name.startsWith('qwen-tts'))
@@ -91,7 +93,9 @@ export function ModelManagement() {
{/* Whisper Models */} {/* Whisper Models */}
<div> <div>
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">Transcription Models</h3> <h3 className="text-sm font-semibold mb-3 text-muted-foreground">
Transcription Models
</h3>
<div className="space-y-2"> <div className="space-y-2">
{modelStatus.models {modelStatus.models
.filter((m) => m.model_name.startsWith('whisper')) .filter((m) => m.model_name.startsWith('whisper'))
@@ -109,7 +113,9 @@ export function ModelManagement() {
{/* Progress indicators */} {/* Progress indicators */}
<div className="pt-4 border-t"> <div className="pt-4 border-t">
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">Download Progress</h3> <h3 className="text-sm font-semibold mb-3 text-muted-foreground">
Download Progress
</h3>
<div className="space-y-2"> <div className="space-y-2">
{modelStatus.models.map((model) => ( {modelStatus.models.map((model) => (
<ModelProgress <ModelProgress
@@ -147,10 +153,14 @@ function ModelItem({ model, onDownload, isDownloading, formatSize }: ModelItemPr
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-sm">{model.display_name}</span> <span className="font-medium text-sm">{model.display_name}</span>
{model.loaded && ( {model.loaded && (
<Badge variant="default" className="text-xs">Loaded</Badge> <Badge variant="default" className="text-xs">
Loaded
</Badge>
)} )}
{model.downloaded && !model.loaded && ( {model.downloaded && !model.loaded && (
<Badge variant="secondary" className="text-xs">Downloaded</Badge> <Badge variant="secondary" className="text-xs">
Downloaded
</Badge>
)} )}
</div> </div>
{model.downloaded && model.size_mb && ( {model.downloaded && model.size_mb && (
@@ -166,12 +176,7 @@ function ModelItem({ model, onDownload, isDownloading, formatSize }: ModelItemPr
<span>Ready</span> <span>Ready</span>
</div> </div>
) : ( ) : (
<Button <Button size="sm" onClick={onDownload} disabled={isDownloading} variant="outline">
size="sm"
onClick={onDownload}
disabled={isDownloading}
variant="outline"
>
{isDownloading ? ( {isDownloading ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
@@ -51,7 +51,10 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
}, [serverUrl, modelName, isSubscribed]); }, [serverUrl, modelName, isSubscribed]);
// Don't render if no progress or if complete/error and some time has passed // Don't render if no progress or if complete/error and some time has passed
if (!progress || (progress.status === 'complete' && Date.now() - new Date(progress.timestamp).getTime() > 5000)) { if (
!progress ||
(progress.status === 'complete' && Date.now() - new Date(progress.timestamp).getTime() > 5000)
) {
return null; return null;
} }
@@ -111,9 +114,7 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
</span> </span>
)} )}
</div> </div>
{progress.total > 0 && ( {progress.total > 0 && <Progress value={progress.progress} className="h-2" />}
<Progress value={progress.progress} className="h-2" />
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -47,7 +47,9 @@ export function ServerStatus() {
<span className="text-sm">Connected</span> <span className="text-sm">Connected</span>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Badge variant={health.model_loaded || health.model_downloaded ? 'default' : 'secondary'}> <Badge
variant={health.model_loaded || health.model_downloaded ? 'default' : 'secondary'}
>
{health.model_loaded || health.model_downloaded ? 'Model Ready' : 'No Model'} {health.model_loaded || health.model_downloaded ? 'Model Ready' : 'No Model'}
</Badge> </Badge>
<Badge variant={health.gpu_available ? 'default' : 'secondary'}> <Badge variant={health.gpu_available ? 'default' : 'secondary'}>
@@ -0,0 +1,101 @@
import { useState, useEffect } from 'react';
import { RefreshCw, Download, CheckCircle2, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
import { getVersion } from '@tauri-apps/api/app';
export function UpdateStatus() {
const { status, checkForUpdates, downloadAndInstall } = useAutoUpdater(false);
const [currentVersion, setCurrentVersion] = useState<string>('');
useEffect(() => {
getVersion()
.then(setCurrentVersion)
.catch(() => setCurrentVersion('0.1.0'));
}, []);
return (
<Card>
<CardHeader>
<CardTitle>App Updates</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">Current Version</div>
<div className="text-sm text-muted-foreground">v{currentVersion}</div>
</div>
<Button
onClick={checkForUpdates}
disabled={status.checking || status.downloading || status.installing}
variant="outline"
size="sm"
>
<RefreshCw className={`h-4 w-4 mr-2 ${status.checking ? 'animate-spin' : ''}`} />
Check for Updates
</Button>
</div>
{status.checking && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin" />
Checking for updates...
</div>
)}
{status.error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{status.error}
</div>
)}
{status.available && !status.downloading && !status.installing && (
<div className="space-y-3 p-4 border rounded-lg bg-primary/5">
<div className="flex items-center justify-between">
<div>
<div className="font-semibold">Update Available</div>
<div className="text-sm text-muted-foreground">Version {status.version}</div>
</div>
<Badge>New</Badge>
</div>
<Button onClick={downloadAndInstall} className="w-full" size="sm">
<Download className="h-4 w-4 mr-2" />
Install Update
</Button>
</div>
)}
{status.downloading && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Download className="h-4 w-4" />
Downloading update...
</div>
<Progress />
</div>
)}
{status.installing && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<RefreshCw className="h-4 w-4 animate-spin" />
Installing update...
</div>
<div className="text-xs text-muted-foreground">App will restart automatically</div>
</div>
)}
{!status.available && !status.checking && !status.error && status.checking === false && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-green-500" />
You're up to date
</div>
)}
</CardContent>
</Card>
);
}
+3 -5
View File
@@ -26,11 +26,9 @@ export function Sidebar({ activeTab, onTabChange }: SidebarProps) {
type="button" type="button"
onClick={() => onTabChange(tab.id)} onClick={() => onTabChange(tab.id)}
className={cn( className={cn(
"w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200", 'w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200',
"hover:bg-accent hover:text-accent-foreground", 'hover:bg-accent hover:text-accent-foreground',
isActive isActive ? 'bg-primary text-primary-foreground shadow-lg' : 'text-muted-foreground',
? "bg-primary text-primary-foreground shadow-lg"
: "text-muted-foreground"
)} )}
title={tab.label} title={tab.label}
aria-label={tab.label} aria-label={tab.label}
@@ -57,8 +57,8 @@ export function ProfileCard({ profile }: ProfileCardProps) {
<> <>
<Card <Card
className={cn( className={cn(
"cursor-pointer hover:shadow-md transition-all", 'cursor-pointer hover:shadow-md transition-all',
isSelected && "ring-2 ring-primary shadow-md" isSelected && 'ring-2 ring-primary shadow-md',
)} )}
onClick={handleSelect} onClick={handleSelect}
> >
@@ -102,7 +102,9 @@ export function ProfileCard({ profile }: ProfileCardProps) {
<Badge variant="outline" className="text-xs h-5 px-1.5"> <Badge variant="outline" className="text-xs h-5 px-1.5">
{profile.language} {profile.language}
</Badge> </Badge>
<p className="text-xs text-muted-foreground/60 text-right">{formatDate(profile.created_at)}</p> <p className="text-xs text-muted-foreground/60 text-right">
{formatDate(profile.created_at)}
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -121,7 +123,11 @@ export function ProfileCard({ profile }: ProfileCardProps) {
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}> <Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel Cancel
</Button> </Button>
<Button variant="destructive" onClick={handleDeleteConfirm} disabled={deleteProfile.isPending}> <Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={deleteProfile.isPending}
>
{deleteProfile.isPending ? 'Deleting...' : 'Delete'} {deleteProfile.isPending ? 'Deleting...' : 'Delete'}
</Button> </Button>
</DialogFooter> </DialogFooter>
+525 -65
View File
@@ -1,5 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import * as z from 'zod'; import * as z from 'zod';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -13,6 +13,7 @@ import {
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@@ -27,15 +28,64 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { useCreateProfile, useProfile, useUpdateProfile } from '@/lib/hooks/useProfiles'; import {
useCreateProfile,
useProfile,
useUpdateProfile,
useAddSample,
} from '@/lib/hooks/useProfiles';
import { useTranscription } from '@/lib/hooks/useTranscription';
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
import { Mic, Square, Upload } from 'lucide-react';
import { formatAudioDuration } from '@/lib/utils/audio';
const profileSchema = z.object({ // Helper function to get audio duration from File
name: z.string().min(1, 'Name is required').max(100), async function getAudioDuration(file: File): Promise<number> {
description: z.string().max(500).optional(), return new Promise((resolve, reject) => {
language: z.enum(['en', 'zh']), const audio = new Audio();
}); const url = URL.createObjectURL(file);
audio.addEventListener('loadedmetadata', () => {
URL.revokeObjectURL(url);
resolve(audio.duration);
});
audio.addEventListener('error', () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load audio file'));
});
audio.src = url;
});
}
const MAX_AUDIO_DURATION_SECONDS = 30;
const profileSchema = z
.object({
name: z.string().min(1, 'Name is required').max(100),
description: z.string().max(500).optional(),
language: z.enum(['en', 'zh']),
// Sample fields - only required when creating (not editing)
sampleFile: z.instanceof(File).optional(),
referenceText: z.string().max(1000).optional(),
})
.refine(
(data) => {
// If sample file is provided, reference text is required
if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) {
return false;
}
return true;
},
{
message: 'Reference text is required when adding a sample',
path: ['referenceText'],
},
);
type ProfileFormValues = z.infer<typeof profileSchema>; type ProfileFormValues = z.infer<typeof profileSchema>;
@@ -47,7 +97,13 @@ export function ProfileForm() {
const { data: editingProfile } = useProfile(editingProfileId || ''); const { data: editingProfile } = useProfile(editingProfileId || '');
const createProfile = useCreateProfile(); const createProfile = useCreateProfile();
const updateProfile = useUpdateProfile(); const updateProfile = useUpdateProfile();
const addSample = useAddSample();
const transcribe = useTranscription();
const { toast } = useToast(); const { toast } = useToast();
const [sampleMode, setSampleMode] = useState<'upload' | 'record'>('upload');
const [audioDuration, setAudioDuration] = useState<number | null>(null);
const [isValidatingAudio, setIsValidatingAudio] = useState(false);
const isCreating = !editingProfileId;
const form = useForm<ProfileFormValues>({ const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema), resolver: zodResolver(profileSchema),
@@ -58,39 +114,213 @@ export function ProfileForm() {
}, },
}); });
const selectedFile = form.watch('sampleFile');
// Validate audio duration when file is selected
useEffect(() => {
if (selectedFile && selectedFile instanceof File) {
setIsValidatingAudio(true);
getAudioDuration(selectedFile)
.then((duration) => {
setAudioDuration(duration);
if (duration > MAX_AUDIO_DURATION_SECONDS) {
form.setError('sampleFile', {
type: 'manual',
message: `Audio is too long (${formatAudioDuration(duration)}). Maximum duration is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
});
} else {
form.clearErrors('sampleFile');
}
})
.catch((error) => {
console.error('Failed to get audio duration:', error);
setAudioDuration(null);
form.setError('sampleFile', {
type: 'manual',
message: 'Failed to validate audio file. Please try a different file.',
});
})
.finally(() => {
setIsValidatingAudio(false);
});
} else {
setAudioDuration(null);
form.clearErrors('sampleFile');
}
}, [selectedFile, form]);
const {
isRecording,
duration,
error: recordingError,
startRecording,
stopRecording,
cancelRecording,
} = useAudioRecording({
maxDurationSeconds: 30,
onRecordingComplete: (blob) => {
const file = new File([blob], `recording-${Date.now()}.webm`, {
type: blob.type || 'audio/webm',
});
form.setValue('sampleFile', file, { shouldValidate: true });
toast({
title: 'Recording complete',
description: 'Audio has been recorded successfully.',
});
},
});
// Show recording errors
useEffect(() => {
if (recordingError) {
toast({
title: 'Recording error',
description: recordingError,
variant: 'destructive',
});
}
}, [recordingError, toast]);
useEffect(() => { useEffect(() => {
if (editingProfile) { if (editingProfile) {
form.reset({ form.reset({
name: editingProfile.name, name: editingProfile.name,
description: editingProfile.description || '', description: editingProfile.description || '',
language: editingProfile.language as 'en' | 'zh', language: editingProfile.language as 'en' | 'zh',
sampleFile: undefined,
referenceText: undefined,
}); });
} else { } else {
form.reset({ form.reset({
name: '', name: '',
description: '', description: '',
language: 'en', language: 'en',
sampleFile: undefined,
referenceText: undefined,
}); });
setSampleMode('upload');
} }
}, [editingProfile, form]); }, [editingProfile, form]);
async function handleTranscribe() {
const file = form.getValues('sampleFile');
if (!file) {
toast({
title: 'No file selected',
description: 'Please select an audio file first.',
variant: 'destructive',
});
return;
}
try {
const language = form.getValues('language') as 'en' | 'zh' | undefined;
const result = await transcribe.mutateAsync({ file, language });
form.setValue('referenceText', result.text, { shouldValidate: true });
toast({
title: 'Transcription complete',
description: 'Audio has been transcribed successfully.',
});
} catch (error) {
toast({
title: 'Transcription failed',
description: error instanceof Error ? error.message : 'Failed to transcribe audio',
variant: 'destructive',
});
}
}
function handleCancelRecording() {
cancelRecording();
form.resetField('sampleFile');
}
async function onSubmit(data: ProfileFormValues) { async function onSubmit(data: ProfileFormValues) {
try { try {
if (editingProfileId) { if (editingProfileId) {
// Editing: just update profile
await updateProfile.mutateAsync({ await updateProfile.mutateAsync({
profileId: editingProfileId, profileId: editingProfileId,
data, data: {
name: data.name,
description: data.description,
language: data.language,
},
}); });
toast({ toast({
title: 'Profile updated', title: 'Profile updated',
description: `"${data.name}" has been updated successfully.`, description: `"${data.name}" has been updated successfully.`,
}); });
} else { } else {
await createProfile.mutateAsync(data); // Get file and reference text directly from form state to ensure we have the values
toast({ const sampleFile = form.getValues('sampleFile');
title: 'Profile created', const referenceText = form.getValues('referenceText');
description: `"${data.name}" has been created successfully.`,
// Validate audio duration before creating profile
if (sampleFile) {
try {
const duration = await getAudioDuration(sampleFile);
if (duration > MAX_AUDIO_DURATION_SECONDS) {
form.setError('sampleFile', {
type: 'manual',
message: `Audio is too long (${formatAudioDuration(duration)}). Maximum duration is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
});
toast({
title: 'Invalid audio file',
description: `Audio duration is ${formatAudioDuration(duration)}, but maximum is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
variant: 'destructive',
});
return; // Prevent form submission
}
} catch (error) {
form.setError('sampleFile', {
type: 'manual',
message: 'Failed to validate audio file. Please try a different file.',
});
toast({
title: 'Validation error',
description: error instanceof Error ? error.message : 'Failed to validate audio file',
variant: 'destructive',
});
return; // Prevent form submission
}
}
// Creating: create profile, then optionally add sample
const profile = await createProfile.mutateAsync({
name: data.name,
description: data.description,
language: data.language,
}); });
// If sample file and reference text provided, add it
if (sampleFile && referenceText && referenceText.trim().length > 0) {
try {
await addSample.mutateAsync({
profileId: profile.id,
file: sampleFile,
referenceText: referenceText,
});
toast({
title: 'Profile created',
description: `"${data.name}" has been created with a sample.`,
});
} catch (sampleError) {
// Profile was created but sample failed - still show success for profile
toast({
title: 'Profile created',
description: `"${data.name}" has been created, but failed to add sample: ${sampleError instanceof Error ? sampleError.message : 'Unknown error'}`,
variant: 'destructive',
});
}
} else {
toast({
title: 'Profile created',
description: `"${data.name}" has been created successfully. You can add samples later.`,
});
}
} }
form.reset(); form.reset();
@@ -110,79 +340,309 @@ export function ProfileForm() {
if (!open) { if (!open) {
setEditingProfileId(null); setEditingProfileId(null);
form.reset(); form.reset();
setSampleMode('upload');
if (isRecording) {
cancelRecording();
}
} }
} }
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent> <DialogContent className="max-w-4xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{editingProfileId ? 'Edit Profile' : 'Create Voice Profile'}</DialogTitle> <DialogTitle>{editingProfileId ? 'Edit Profile' : 'Create Voice Profile'}</DialogTitle>
<DialogDescription> <DialogDescription>
{editingProfileId {editingProfileId
? 'Update your voice profile details.' ? 'Update your voice profile details.'
: 'Add a new voice profile with samples.'} : 'Create a new voice profile. You can add a sample now or later.'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)}>
<FormField <div className={`grid gap-6 ${isCreating ? 'grid-cols-2' : 'grid-cols-1'}`}>
control={form.control} {/* Left column: Profile info */}
name="name" <div className="space-y-4">
render={({ field }) => ( <FormField
<FormItem> control={form.control}
<FormLabel>Name</FormLabel> name="name"
<FormControl> render={({ field }) => (
<Input placeholder="My Voice" {...field} /> <FormItem>
</FormControl> <FormLabel>Name</FormLabel>
<FormMessage /> <FormControl>
</FormItem> <Input placeholder="My Voice" {...field} />
)} </FormControl>
/> <FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="description" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel>Description (Optional)</FormLabel>
<FormControl> <FormControl>
<Textarea placeholder="Describe this voice..." {...field} /> <Textarea placeholder="Describe this voice..." {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="language" name="language"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Language</FormLabel> <FormLabel>Language</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="en">English</SelectItem> <SelectItem value="en">English</SelectItem>
<SelectItem value="zh">Chinese</SelectItem> <SelectItem value="zh">Chinese</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</div>
<div className="flex gap-2 justify-end"> {/* Right column: Sample upload section - only show when creating */}
{isCreating && (
<div className="space-y-4 border-l pl-6">
<div>
<h3 className="text-sm font-medium mb-2">Add Sample (Optional)</h3>
<p className="text-sm text-muted-foreground mb-4">
Add an audio sample to get started immediately. You can add more samples
later.
</p>
</div>
<Tabs
value={sampleMode}
onValueChange={(v) => setSampleMode(v as 'upload' | 'record')}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="upload" className="flex items-center gap-2">
<Upload className="h-4 w-4" />
Upload
</TabsTrigger>
<TabsTrigger value="record" className="flex items-center gap-2">
<Mic className="h-4 w-4" />
Record
</TabsTrigger>
</TabsList>
<TabsContent value="upload" className="space-y-4">
<FormField
control={form.control}
name="sampleFile"
render={({ field: { onChange, name, ref } }) => (
<FormItem>
<FormLabel>Audio File</FormLabel>
<FormControl>
<div className="flex flex-col gap-2">
<Input
type="file"
accept="audio/*"
name={name}
ref={ref}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
onChange(file);
} else {
onChange(undefined);
}
}}
/>
{selectedFile && (
<>
{isValidatingAudio && (
<p className="text-sm text-muted-foreground">
Validating audio...
</p>
)}
{!isValidatingAudio && audioDuration !== null && (
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Duration:</span>
<span
className={
audioDuration > MAX_AUDIO_DURATION_SECONDS
? 'text-destructive font-medium'
: 'text-foreground'
}
>
{formatAudioDuration(audioDuration)}
</span>
<span className="text-muted-foreground">
/ {formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)} max
</span>
</div>
)}
<Button
type="button"
variant="outline"
onClick={handleTranscribe}
disabled={transcribe.isPending || isValidatingAudio || (audioDuration !== null && audioDuration > MAX_AUDIO_DURATION_SECONDS)}
className="flex items-center gap-2 w-full"
>
<Mic className="h-4 w-4" />
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
</Button>
</>
)}
</div>
</FormControl>
<FormDescription>
Supported formats: WAV, MP3, M4A. Maximum duration:{' '}
{formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}. Click "Transcribe"
to automatically extract text from the audio.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="record" className="space-y-4">
<FormField
control={form.control}
name="sampleFile"
render={() => (
<FormItem>
<FormLabel>Record Audio</FormLabel>
<FormControl>
<div className="space-y-4">
{!isRecording && !selectedFile && (
<div className="flex flex-col items-center gap-4 p-4 border-2 border-dashed rounded-lg">
<Button
type="button"
onClick={startRecording}
size="lg"
className="flex items-center gap-2"
>
<Mic className="h-5 w-5" />
Start Recording
</Button>
<p className="text-sm text-muted-foreground text-center">
Click to start recording. Maximum duration: 30 seconds.
</p>
</div>
)}
{isRecording && (
<div className="flex flex-col items-center gap-4 p-4 border-2 border-destructive rounded-lg bg-destructive/5">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
<span className="text-lg font-mono font-semibold">
{formatAudioDuration(duration)}
</span>
</div>
</div>
<Button
type="button"
onClick={stopRecording}
variant="destructive"
className="flex items-center gap-2"
>
<Square className="h-4 w-4" />
Stop Recording
</Button>
<p className="text-sm text-muted-foreground text-center">
Recording in progress... ({formatAudioDuration(30 - duration)}{' '}
remaining)
</p>
</div>
)}
{selectedFile && !isRecording && (
<div className="flex flex-col items-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5">
<div className="flex items-center gap-2">
<Mic className="h-5 w-5 text-primary" />
<span className="font-medium">Recording complete</span>
</div>
<p className="text-sm text-muted-foreground text-center">
File: {selectedFile.name}
</p>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={handleTranscribe}
disabled={transcribe.isPending}
className="flex items-center gap-2"
>
<Mic className="h-4 w-4" />
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
</Button>
<Button
type="button"
variant="outline"
onClick={handleCancelRecording}
className="flex items-center gap-2"
>
Record Again
</Button>
</div>
</div>
)}
</div>
</FormControl>
<FormDescription>
Record audio directly from your microphone. Maximum duration is 30
seconds.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
</Tabs>
<FormField
control={form.control}
name="referenceText"
render={({ field }) => (
<FormItem>
<FormLabel>Reference Text</FormLabel>
<FormControl>
<Textarea
placeholder="Enter the exact text spoken in the audio..."
className="min-h-[100px]"
{...field}
/>
</FormControl>
<FormDescription>
This should match exactly what is spoken in the audio file. Required if
you add a sample.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
<div className="flex gap-2 justify-end mt-6 pt-4 border-t">
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}> <Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={createProfile.isPending || updateProfile.isPending}> <Button
{createProfile.isPending || updateProfile.isPending type="submit"
disabled={createProfile.isPending || updateProfile.isPending || addSample.isPending}
>
{createProfile.isPending || updateProfile.isPending || addSample.isPending
? 'Saving...' ? 'Saving...'
: editingProfileId : editingProfileId
? 'Update Profile' ? 'Update Profile'
@@ -66,9 +66,7 @@ export function SampleList({ profileId }: SampleListProps) {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handlePlay(sample.audio_path, sample.reference_text, sample.id)} onClick={() => handlePlay(sample.audio_path, sample.reference_text, sample.id)}
className={ className={currentAudioId === sample.id && isPlaying ? 'text-primary' : ''}
currentAudioId === sample.id && isPlaying ? 'text-primary' : ''
}
> >
<Play className="h-4 w-4 mr-1" /> <Play className="h-4 w-4 mr-1" />
Play Play
@@ -223,7 +223,8 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
</div> </div>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Supported formats: WAV, MP3, M4A. Click "Transcribe" to automatically extract text from the audio. Supported formats: WAV, MP3, M4A. Click "Transcribe" to automatically
extract text from the audio.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -277,7 +278,8 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
Stop Recording Stop Recording
</Button> </Button>
<p className="text-sm text-muted-foreground text-center"> <p className="text-sm text-muted-foreground text-center">
Recording in progress... ({formatAudioDuration(30 - duration)} remaining) Recording in progress... ({formatAudioDuration(30 - duration)}{' '}
remaining)
</p> </p>
</div> </div>
)} )}
+3 -4
View File
@@ -1,8 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
export interface CircleButtonProps export interface CircleButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
} }
@@ -16,14 +15,14 @@ const CircleButton = React.forwardRef<HTMLButtonElement, CircleButtonProps>(
'hover:bg-accent transition-colors', 'hover:bg-accent transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50', 'disabled:pointer-events-none disabled:opacity-50',
className className,
)} )}
{...props} {...props}
> >
<Icon className="h-3.5 w-3.5 text-muted-foreground/60" /> <Icon className="h-3.5 w-3.5 text-muted-foreground/60" />
</button> </button>
); );
} },
); );
CircleButton.displayName = 'CircleButton'; CircleButton.displayName = 'CircleButton';
+1 -4
View File
@@ -8,10 +8,7 @@ const Slider = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SliderPrimitive.Root <SliderPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn('relative flex w-full touch-none select-none items-center', className)}
'relative flex w-full touch-none select-none items-center',
className,
)}
{...props} {...props}
> >
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary"> <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
+10 -2
View File
@@ -11,6 +11,10 @@ export interface UpdateStatus {
error?: string; error?: string;
} }
const isTauri = () => {
return '__TAURI_INTERNALS__' in window;
};
export function useAutoUpdater(checkOnMount = false) { export function useAutoUpdater(checkOnMount = false) {
const [status, setStatus] = useState<UpdateStatus>({ const [status, setStatus] = useState<UpdateStatus>({
checking: false, checking: false,
@@ -22,6 +26,10 @@ export function useAutoUpdater(checkOnMount = false) {
const [update, setUpdate] = useState<Update | null>(null); const [update, setUpdate] = useState<Update | null>(null);
const checkForUpdates = async () => { const checkForUpdates = async () => {
if (!isTauri()) {
return;
}
try { try {
setStatus((prev) => ({ ...prev, checking: true, error: undefined })); setStatus((prev) => ({ ...prev, checking: true, error: undefined }));
@@ -56,7 +64,7 @@ export function useAutoUpdater(checkOnMount = false) {
}; };
const downloadAndInstall = async () => { const downloadAndInstall = async () => {
if (!update) return; if (!update || !isTauri()) return;
try { try {
setStatus((prev) => ({ ...prev, downloading: true, error: undefined })); setStatus((prev) => ({ ...prev, downloading: true, error: undefined }));
@@ -91,7 +99,7 @@ export function useAutoUpdater(checkOnMount = false) {
}; };
useEffect(() => { useEffect(() => {
if (checkOnMount) { if (checkOnMount && isTauri()) {
checkForUpdates(); checkForUpdates();
} }
}, [checkOnMount]); }, [checkOnMount]);
+6 -21
View File
@@ -20,10 +20,7 @@ class ApiClient {
return serverUrl; return serverUrl;
} }
private async request<T>( private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
endpoint: string,
options?: RequestInit,
): Promise<T> {
const url = `${this.getBaseUrl()}${endpoint}`; const url = `${this.getBaseUrl()}${endpoint}`;
const response = await fetch(url, { const response = await fetch(url, {
...options, ...options,
@@ -64,10 +61,7 @@ class ApiClient {
return this.request<VoiceProfileResponse>(`/profiles/${profileId}`); return this.request<VoiceProfileResponse>(`/profiles/${profileId}`);
} }
async updateProfile( async updateProfile(profileId: string, data: VoiceProfileCreate): Promise<VoiceProfileResponse> {
profileId: string,
data: VoiceProfileCreate,
): Promise<VoiceProfileResponse> {
return this.request<VoiceProfileResponse>(`/profiles/${profileId}`, { return this.request<VoiceProfileResponse>(`/profiles/${profileId}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(data), body: JSON.stringify(data),
@@ -105,12 +99,8 @@ class ApiClient {
return response.json(); return response.json();
} }
async listProfileSamples( async listProfileSamples(profileId: string): Promise<ProfileSampleResponse[]> {
profileId: string, return this.request<ProfileSampleResponse[]>(`/profiles/${profileId}/samples`);
): Promise<ProfileSampleResponse[]> {
return this.request<ProfileSampleResponse[]>(
`/profiles/${profileId}/samples`,
);
} }
async deleteProfileSample(sampleId: string): Promise<void> { async deleteProfileSample(sampleId: string): Promise<void> {
@@ -120,9 +110,7 @@ class ApiClient {
} }
// Generation // Generation
async generateSpeech( async generateSpeech(data: GenerationRequest): Promise<GenerationResponse> {
data: GenerationRequest,
): Promise<GenerationResponse> {
return this.request<GenerationResponse>('/generate', { return this.request<GenerationResponse>('/generate', {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
@@ -159,10 +147,7 @@ class ApiClient {
} }
// Transcription // Transcription
async transcribeAudio( async transcribeAudio(file: File, language?: 'en' | 'zh'): Promise<TranscriptionResponse> {
file: File,
language?: 'en' | 'zh',
): Promise<TranscriptionResponse> {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
if (language) { if (language) {
+14 -14
View File
@@ -6,20 +6,20 @@ import type { ApiRequestOptions } from './ApiRequestOptions';
import type { ApiResult } from './ApiResult'; import type { ApiResult } from './ApiResult';
export class ApiError extends Error { export class ApiError extends Error {
public readonly url: string; public readonly url: string;
public readonly status: number; public readonly status: number;
public readonly statusText: string; public readonly statusText: string;
public readonly body: any; public readonly body: any;
public readonly request: ApiRequestOptions; public readonly request: ApiRequestOptions;
constructor(request: ApiRequestOptions, response: ApiResult, message: string) { constructor(request: ApiRequestOptions, response: ApiResult, message: string) {
super(message); super(message);
this.name = 'ApiError'; this.name = 'ApiError';
this.url = response.url; this.url = response.url;
this.status = response.status; this.status = response.status;
this.statusText = response.statusText; this.statusText = response.statusText;
this.body = response.body; this.body = response.body;
this.request = request; this.request = request;
} }
} }
+11 -11
View File
@@ -3,15 +3,15 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export type ApiRequestOptions = { export type ApiRequestOptions = {
readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH';
readonly url: string; readonly url: string;
readonly path?: Record<string, any>; readonly path?: Record<string, any>;
readonly cookies?: Record<string, any>; readonly cookies?: Record<string, any>;
readonly headers?: Record<string, any>; readonly headers?: Record<string, any>;
readonly query?: Record<string, any>; readonly query?: Record<string, any>;
readonly formData?: Record<string, any>; readonly formData?: Record<string, any>;
readonly body?: any; readonly body?: any;
readonly mediaType?: string; readonly mediaType?: string;
readonly responseHeader?: string; readonly responseHeader?: string;
readonly errors?: Record<number, string>; readonly errors?: Record<number, string>;
}; };
+5 -5
View File
@@ -3,9 +3,9 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export type ApiResult = { export type ApiResult = {
readonly url: string; readonly url: string;
readonly ok: boolean; readonly ok: boolean;
readonly status: number; readonly status: number;
readonly statusText: string; readonly statusText: string;
readonly body: any; readonly body: any;
}; };
+110 -111
View File
@@ -3,129 +3,128 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export class CancelError extends Error { export class CancelError extends Error {
constructor(message: string) {
super(message);
this.name = 'CancelError';
}
constructor(message: string) { public get isCancelled(): boolean {
super(message); return true;
this.name = 'CancelError'; }
}
public get isCancelled(): boolean {
return true;
}
} }
export interface OnCancel { export interface OnCancel {
readonly isResolved: boolean; readonly isResolved: boolean;
readonly isRejected: boolean; readonly isRejected: boolean;
readonly isCancelled: boolean; readonly isCancelled: boolean;
(cancelHandler: () => void): void; (cancelHandler: () => void): void;
} }
export class CancelablePromise<T> implements Promise<T> { export class CancelablePromise<T> implements Promise<T> {
#isResolved: boolean; #isResolved: boolean;
#isRejected: boolean; #isRejected: boolean;
#isCancelled: boolean; #isCancelled: boolean;
readonly #cancelHandlers: (() => void)[]; readonly #cancelHandlers: (() => void)[];
readonly #promise: Promise<T>; readonly #promise: Promise<T>;
#resolve?: (value: T | PromiseLike<T>) => void; #resolve?: (value: T | PromiseLike<T>) => void;
#reject?: (reason?: any) => void; #reject?: (reason?: any) => void;
constructor( constructor(
executor: ( executor: (
resolve: (value: T | PromiseLike<T>) => void, resolve: (value: T | PromiseLike<T>) => void,
reject: (reason?: any) => void, reject: (reason?: any) => void,
onCancel: OnCancel onCancel: OnCancel,
) => void ) => void,
) { ) {
this.#isResolved = false; this.#isResolved = false;
this.#isRejected = false; this.#isRejected = false;
this.#isCancelled = false; this.#isCancelled = false;
this.#cancelHandlers = []; this.#cancelHandlers = [];
this.#promise = new Promise<T>((resolve, reject) => { this.#promise = new Promise<T>((resolve, reject) => {
this.#resolve = resolve; this.#resolve = resolve;
this.#reject = reject; this.#reject = reject;
const onResolve = (value: T | PromiseLike<T>): void => { const onResolve = (value: T | PromiseLike<T>): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isResolved = true;
if (this.#resolve) this.#resolve(value);
};
const onReject = (reason?: any): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isRejected = true;
if (this.#reject) this.#reject(reason);
};
const onCancel = (cancelHandler: () => void): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#cancelHandlers.push(cancelHandler);
};
Object.defineProperty(onCancel, 'isResolved', {
get: (): boolean => this.#isResolved,
});
Object.defineProperty(onCancel, 'isRejected', {
get: (): boolean => this.#isRejected,
});
Object.defineProperty(onCancel, 'isCancelled', {
get: (): boolean => this.#isCancelled,
});
return executor(onResolve, onReject, onCancel as OnCancel);
});
}
get [Symbol.toStringTag]() {
return "Cancellable Promise";
}
public then<TResult1 = T, TResult2 = never>(
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
): Promise<TResult1 | TResult2> {
return this.#promise.then(onFulfilled, onRejected);
}
public catch<TResult = never>(
onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null
): Promise<T | TResult> {
return this.#promise.catch(onRejected);
}
public finally(onFinally?: (() => void) | null): Promise<T> {
return this.#promise.finally(onFinally);
}
public cancel(): void {
if (this.#isResolved || this.#isRejected || this.#isCancelled) { if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return; return;
} }
this.#isCancelled = true; this.#isResolved = true;
if (this.#cancelHandlers.length) { if (this.#resolve) this.#resolve(value);
try { };
for (const cancelHandler of this.#cancelHandlers) {
cancelHandler();
}
} catch (error) {
console.warn('Cancellation threw an error', error);
return;
}
}
this.#cancelHandlers.length = 0;
if (this.#reject) this.#reject(new CancelError('Request aborted'));
}
public get isCancelled(): boolean { const onReject = (reason?: any): void => {
return this.#isCancelled; if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isRejected = true;
if (this.#reject) this.#reject(reason);
};
const onCancel = (cancelHandler: () => void): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#cancelHandlers.push(cancelHandler);
};
Object.defineProperty(onCancel, 'isResolved', {
get: (): boolean => this.#isResolved,
});
Object.defineProperty(onCancel, 'isRejected', {
get: (): boolean => this.#isRejected,
});
Object.defineProperty(onCancel, 'isCancelled', {
get: (): boolean => this.#isCancelled,
});
return executor(onResolve, onReject, onCancel as OnCancel);
});
}
get [Symbol.toStringTag]() {
return 'Cancellable Promise';
}
public then<TResult1 = T, TResult2 = never>(
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2> {
return this.#promise.then(onFulfilled, onRejected);
}
public catch<TResult = never>(
onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null,
): Promise<T | TResult> {
return this.#promise.catch(onRejected);
}
public finally(onFinally?: (() => void) | null): Promise<T> {
return this.#promise.finally(onFinally);
}
public cancel(): void {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
} }
this.#isCancelled = true;
if (this.#cancelHandlers.length) {
try {
for (const cancelHandler of this.#cancelHandlers) {
cancelHandler();
}
} catch (error) {
console.warn('Cancellation threw an error', error);
return;
}
}
this.#cancelHandlers.length = 0;
if (this.#reject) this.#reject(new CancelError('Request aborted'));
}
public get isCancelled(): boolean {
return this.#isCancelled;
}
} }
+18 -18
View File
@@ -8,25 +8,25 @@ type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
type Headers = Record<string, string>; type Headers = Record<string, string>;
export type OpenAPIConfig = { export type OpenAPIConfig = {
BASE: string; BASE: string;
VERSION: string; VERSION: string;
WITH_CREDENTIALS: boolean; WITH_CREDENTIALS: boolean;
CREDENTIALS: 'include' | 'omit' | 'same-origin'; CREDENTIALS: 'include' | 'omit' | 'same-origin';
TOKEN?: string | Resolver<string> | undefined; TOKEN?: string | Resolver<string> | undefined;
USERNAME?: string | Resolver<string> | undefined; USERNAME?: string | Resolver<string> | undefined;
PASSWORD?: string | Resolver<string> | undefined; PASSWORD?: string | Resolver<string> | undefined;
HEADERS?: Headers | Resolver<Headers> | undefined; HEADERS?: Headers | Resolver<Headers> | undefined;
ENCODE_PATH?: ((path: string) => string) | undefined; ENCODE_PATH?: ((path: string) => string) | undefined;
}; };
export const OpenAPI: OpenAPIConfig = { export const OpenAPI: OpenAPIConfig = {
BASE: '', BASE: '',
VERSION: '0.1.0', VERSION: '0.1.0',
WITH_CREDENTIALS: false, WITH_CREDENTIALS: false,
CREDENTIALS: 'include', CREDENTIALS: 'include',
TOKEN: undefined, TOKEN: undefined,
USERNAME: undefined, USERNAME: undefined,
PASSWORD: undefined, PASSWORD: undefined,
HEADERS: undefined, HEADERS: undefined,
ENCODE_PATH: undefined, ENCODE_PATH: undefined,
}; };
+247 -228
View File
@@ -9,278 +9,294 @@ import { CancelablePromise } from './CancelablePromise';
import type { OnCancel } from './CancelablePromise'; import type { OnCancel } from './CancelablePromise';
import type { OpenAPIConfig } from './OpenAPI'; import type { OpenAPIConfig } from './OpenAPI';
export const isDefined = <T>(value: T | null | undefined): value is Exclude<T, null | undefined> => { export const isDefined = <T>(
return value !== undefined && value !== null; value: T | null | undefined,
): value is Exclude<T, null | undefined> => {
return value !== undefined && value !== null;
}; };
export const isString = (value: any): value is string => { export const isString = (value: any): value is string => {
return typeof value === 'string'; return typeof value === 'string';
}; };
export const isStringWithValue = (value: any): value is string => { export const isStringWithValue = (value: any): value is string => {
return isString(value) && value !== ''; return isString(value) && value !== '';
}; };
export const isBlob = (value: any): value is Blob => { export const isBlob = (value: any): value is Blob => {
return ( return (
typeof value === 'object' && typeof value === 'object' &&
typeof value.type === 'string' && typeof value.type === 'string' &&
typeof value.stream === 'function' && typeof value.stream === 'function' &&
typeof value.arrayBuffer === 'function' && typeof value.arrayBuffer === 'function' &&
typeof value.constructor === 'function' && typeof value.constructor === 'function' &&
typeof value.constructor.name === 'string' && typeof value.constructor.name === 'string' &&
/^(Blob|File)$/.test(value.constructor.name) && /^(Blob|File)$/.test(value.constructor.name) &&
/^(Blob|File)$/.test(value[Symbol.toStringTag]) /^(Blob|File)$/.test(value[Symbol.toStringTag])
); );
}; };
export const isFormData = (value: any): value is FormData => { export const isFormData = (value: any): value is FormData => {
return value instanceof FormData; return value instanceof FormData;
}; };
export const base64 = (str: string): string => { export const base64 = (str: string): string => {
try { try {
return btoa(str); return btoa(str);
} catch (err) { } catch (err) {
// @ts-ignore // @ts-ignore
return Buffer.from(str).toString('base64'); return Buffer.from(str).toString('base64');
} }
}; };
export const getQueryString = (params: Record<string, any>): string => { export const getQueryString = (params: Record<string, any>): string => {
const qs: string[] = []; const qs: string[] = [];
const append = (key: string, value: any) => { const append = (key: string, value: any) => {
qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
}; };
const process = (key: string, value: any) => { const process = (key: string, value: any) => {
if (isDefined(value)) { if (isDefined(value)) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
value.forEach(v => { value.forEach((v) => {
process(key, v); process(key, v);
}); });
} else if (typeof value === 'object') { } else if (typeof value === 'object') {
Object.entries(value).forEach(([k, v]) => { Object.entries(value).forEach(([k, v]) => {
process(`${key}[${k}]`, v); process(`${key}[${k}]`, v);
}); });
} else { } else {
append(key, value); append(key, value);
} }
}
};
Object.entries(params).forEach(([key, value]) => {
process(key, value);
});
if (qs.length > 0) {
return `?${qs.join('&')}`;
} }
};
return ''; Object.entries(params).forEach(([key, value]) => {
process(key, value);
});
if (qs.length > 0) {
return `?${qs.join('&')}`;
}
return '';
}; };
const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => {
const encoder = config.ENCODE_PATH || encodeURI; const encoder = config.ENCODE_PATH || encodeURI;
const path = options.url const path = options.url
.replace('{api-version}', config.VERSION) .replace('{api-version}', config.VERSION)
.replace(/{(.*?)}/g, (substring: string, group: string) => { .replace(/{(.*?)}/g, (substring: string, group: string) => {
if (options.path?.hasOwnProperty(group)) { if (options.path?.hasOwnProperty(group)) {
return encoder(String(options.path[group])); return encoder(String(options.path[group]));
} }
return substring; return substring;
}); });
const url = `${config.BASE}${path}`; const url = `${config.BASE}${path}`;
if (options.query) { if (options.query) {
return `${url}${getQueryString(options.query)}`; return `${url}${getQueryString(options.query)}`;
} }
return url; return url;
}; };
export const getFormData = (options: ApiRequestOptions): FormData | undefined => { export const getFormData = (options: ApiRequestOptions): FormData | undefined => {
if (options.formData) { if (options.formData) {
const formData = new FormData(); const formData = new FormData();
const process = (key: string, value: any) => { const process = (key: string, value: any) => {
if (isString(value) || isBlob(value)) { if (isString(value) || isBlob(value)) {
formData.append(key, value); formData.append(key, value);
} else { } else {
formData.append(key, JSON.stringify(value)); formData.append(key, JSON.stringify(value));
} }
}; };
Object.entries(options.formData) Object.entries(options.formData)
.filter(([_, value]) => isDefined(value)) .filter(([_, value]) => isDefined(value))
.forEach(([key, value]) => { .forEach(([key, value]) => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
value.forEach(v => process(key, v)); value.forEach((v) => process(key, v));
} else { } else {
process(key, value); process(key, value);
} }
}); });
return formData; return formData;
} }
return undefined; return undefined;
}; };
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
export const resolve = async <T>(options: ApiRequestOptions, resolver?: T | Resolver<T>): Promise<T | undefined> => { export const resolve = async <T>(
if (typeof resolver === 'function') { options: ApiRequestOptions,
return (resolver as Resolver<T>)(options); resolver?: T | Resolver<T>,
} ): Promise<T | undefined> => {
return resolver; if (typeof resolver === 'function') {
return (resolver as Resolver<T>)(options);
}
return resolver;
}; };
export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions): Promise<Headers> => { export const getHeaders = async (
const [token, username, password, additionalHeaders] = await Promise.all([ config: OpenAPIConfig,
resolve(options, config.TOKEN), options: ApiRequestOptions,
resolve(options, config.USERNAME), ): Promise<Headers> => {
resolve(options, config.PASSWORD), const [token, username, password, additionalHeaders] = await Promise.all([
resolve(options, config.HEADERS), resolve(options, config.TOKEN),
]); resolve(options, config.USERNAME),
resolve(options, config.PASSWORD),
resolve(options, config.HEADERS),
]);
const headers = Object.entries({ const headers = Object.entries({
Accept: 'application/json', Accept: 'application/json',
...additionalHeaders, ...additionalHeaders,
...options.headers, ...options.headers,
}) })
.filter(([_, value]) => isDefined(value)) .filter(([_, value]) => isDefined(value))
.reduce((headers, [key, value]) => ({ .reduce(
...headers, (headers, [key, value]) => ({
[key]: String(value), ...headers,
}), {} as Record<string, string>); [key]: String(value),
}),
{} as Record<string, string>,
);
if (isStringWithValue(token)) { if (isStringWithValue(token)) {
headers['Authorization'] = `Bearer ${token}`; headers['Authorization'] = `Bearer ${token}`;
}
if (isStringWithValue(username) && isStringWithValue(password)) {
const credentials = base64(`${username}:${password}`);
headers['Authorization'] = `Basic ${credentials}`;
}
if (options.body !== undefined) {
if (options.mediaType) {
headers['Content-Type'] = options.mediaType;
} else if (isBlob(options.body)) {
headers['Content-Type'] = options.body.type || 'application/octet-stream';
} else if (isString(options.body)) {
headers['Content-Type'] = 'text/plain';
} else if (!isFormData(options.body)) {
headers['Content-Type'] = 'application/json';
} }
}
if (isStringWithValue(username) && isStringWithValue(password)) { return new Headers(headers);
const credentials = base64(`${username}:${password}`);
headers['Authorization'] = `Basic ${credentials}`;
}
if (options.body !== undefined) {
if (options.mediaType) {
headers['Content-Type'] = options.mediaType;
} else if (isBlob(options.body)) {
headers['Content-Type'] = options.body.type || 'application/octet-stream';
} else if (isString(options.body)) {
headers['Content-Type'] = 'text/plain';
} else if (!isFormData(options.body)) {
headers['Content-Type'] = 'application/json';
}
}
return new Headers(headers);
}; };
export const getRequestBody = (options: ApiRequestOptions): any => { export const getRequestBody = (options: ApiRequestOptions): any => {
if (options.body !== undefined) { if (options.body !== undefined) {
if (options.mediaType?.includes('/json')) { if (options.mediaType?.includes('/json')) {
return JSON.stringify(options.body) return JSON.stringify(options.body);
} else if (isString(options.body) || isBlob(options.body) || isFormData(options.body)) { } else if (isString(options.body) || isBlob(options.body) || isFormData(options.body)) {
return options.body; return options.body;
} else { } else {
return JSON.stringify(options.body); return JSON.stringify(options.body);
}
} }
return undefined; }
return undefined;
}; };
export const sendRequest = async ( export const sendRequest = async (
config: OpenAPIConfig, config: OpenAPIConfig,
options: ApiRequestOptions, options: ApiRequestOptions,
url: string, url: string,
body: any, body: any,
formData: FormData | undefined, formData: FormData | undefined,
headers: Headers, headers: Headers,
onCancel: OnCancel onCancel: OnCancel,
): Promise<Response> => { ): Promise<Response> => {
const controller = new AbortController(); const controller = new AbortController();
const request: RequestInit = { const request: RequestInit = {
headers, headers,
body: body ?? formData, body: body ?? formData,
method: options.method, method: options.method,
signal: controller.signal, signal: controller.signal,
}; };
if (config.WITH_CREDENTIALS) { if (config.WITH_CREDENTIALS) {
request.credentials = config.CREDENTIALS; request.credentials = config.CREDENTIALS;
} }
onCancel(() => controller.abort()); onCancel(() => controller.abort());
return await fetch(url, request); return await fetch(url, request);
}; };
export const getResponseHeader = (response: Response, responseHeader?: string): string | undefined => { export const getResponseHeader = (
if (responseHeader) { response: Response,
const content = response.headers.get(responseHeader); responseHeader?: string,
if (isString(content)) { ): string | undefined => {
return content; if (responseHeader) {
} const content = response.headers.get(responseHeader);
if (isString(content)) {
return content;
} }
return undefined; }
return undefined;
}; };
export const getResponseBody = async (response: Response): Promise<any> => { export const getResponseBody = async (response: Response): Promise<any> => {
if (response.status !== 204) { if (response.status !== 204) {
try { try {
const contentType = response.headers.get('Content-Type'); const contentType = response.headers.get('Content-Type');
if (contentType) { if (contentType) {
const jsonTypes = ['application/json', 'application/problem+json'] const jsonTypes = ['application/json', 'application/problem+json'];
const isJSON = jsonTypes.some(type => contentType.toLowerCase().startsWith(type)); const isJSON = jsonTypes.some((type) => contentType.toLowerCase().startsWith(type));
if (isJSON) { if (isJSON) {
return await response.json(); return await response.json();
} else { } else {
return await response.text(); return await response.text();
}
}
} catch (error) {
console.error(error);
} }
}
} catch (error) {
console.error(error);
} }
return undefined; }
return undefined;
}; };
export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => {
const errors: Record<number, string> = { const errors: Record<number, string> = {
400: 'Bad Request', 400: 'Bad Request',
401: 'Unauthorized', 401: 'Unauthorized',
403: 'Forbidden', 403: 'Forbidden',
404: 'Not Found', 404: 'Not Found',
500: 'Internal Server Error', 500: 'Internal Server Error',
502: 'Bad Gateway', 502: 'Bad Gateway',
503: 'Service Unavailable', 503: 'Service Unavailable',
...options.errors, ...options.errors,
} };
const error = errors[result.status]; const error = errors[result.status];
if (error) { if (error) {
throw new ApiError(options, result, error); throw new ApiError(options, result, error);
} }
if (!result.ok) { if (!result.ok) {
const errorStatus = result.status ?? 'unknown'; const errorStatus = result.status ?? 'unknown';
const errorStatusText = result.statusText ?? 'unknown'; const errorStatusText = result.statusText ?? 'unknown';
const errorBody = (() => { const errorBody = (() => {
try { try {
return JSON.stringify(result.body, null, 2); return JSON.stringify(result.body, null, 2);
} catch (e) { } catch (e) {
return undefined; return undefined;
} }
})(); })();
throw new ApiError(options, result, throw new ApiError(
`Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` options,
); result,
} `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`,
);
}
}; };
/** /**
@@ -290,33 +306,36 @@ export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult):
* @returns CancelablePromise<T> * @returns CancelablePromise<T>
* @throws ApiError * @throws ApiError
*/ */
export const request = <T>(config: OpenAPIConfig, options: ApiRequestOptions): CancelablePromise<T> => { export const request = <T>(
return new CancelablePromise(async (resolve, reject, onCancel) => { config: OpenAPIConfig,
try { options: ApiRequestOptions,
const url = getUrl(config, options); ): CancelablePromise<T> => {
const formData = getFormData(options); return new CancelablePromise(async (resolve, reject, onCancel) => {
const body = getRequestBody(options); try {
const headers = await getHeaders(config, options); const url = getUrl(config, options);
const formData = getFormData(options);
const body = getRequestBody(options);
const headers = await getHeaders(config, options);
if (!onCancel.isCancelled) { if (!onCancel.isCancelled) {
const response = await sendRequest(config, options, url, body, formData, headers, onCancel); const response = await sendRequest(config, options, url, body, formData, headers, onCancel);
const responseBody = await getResponseBody(response); const responseBody = await getResponseBody(response);
const responseHeader = getResponseHeader(response, options.responseHeader); const responseHeader = getResponseHeader(response, options.responseHeader);
const result: ApiResult = { const result: ApiResult = {
url, url,
ok: response.ok, ok: response.ok,
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
body: responseHeader ?? responseBody, body: responseHeader ?? responseBody,
}; };
catchErrorCodes(options, result); catchErrorCodes(options, result);
resolve(result.body); resolve(result.body);
} }
} catch (error) { } catch (error) {
reject(error); reject(error);
} }
}); });
}; };
@@ -3,7 +3,6 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export type Body_add_profile_sample_profiles__profile_id__samples_post = { export type Body_add_profile_sample_profiles__profile_id__samples_post = {
file: Blob; file: Blob;
reference_text: string; reference_text: string;
}; };
@@ -3,7 +3,6 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export type Body_transcribe_audio_transcribe_post = { export type Body_transcribe_audio_transcribe_post = {
file: Blob; file: Blob;
language?: (string | null); language?: string | null;
}; };
+6 -6
View File
@@ -6,10 +6,10 @@
* Request model for voice generation. * Request model for voice generation.
*/ */
export type GenerationRequest = { export type GenerationRequest = {
profile_id: string; profile_id: string;
text: string; text: string;
language?: string; language?: string;
seed?: (number | null); seed?: number | null;
model_size?: (string | null); model_size?: string | null;
instruct?: string | null;
}; };
+9 -9
View File
@@ -6,13 +6,13 @@
* Response model for voice generation. * Response model for voice generation.
*/ */
export type GenerationResponse = { export type GenerationResponse = {
id: string; id: string;
profile_id: string; profile_id: string;
text: string; text: string;
language: string; language: string;
audio_path: string; audio_path: string;
duration: number; duration: number;
seed: (number | null); seed: number | null;
created_at: string; instruct: string | null;
created_at: string;
}; };
@@ -4,6 +4,5 @@
/* eslint-disable */ /* eslint-disable */
import type { ValidationError } from './ValidationError'; import type { ValidationError } from './ValidationError';
export type HTTPValidationError = { export type HTTPValidationError = {
detail?: Array<ValidationError>; detail?: Array<ValidationError>;
}; };
+6 -7
View File
@@ -6,11 +6,10 @@
* Response model for health check. * Response model for health check.
*/ */
export type HealthResponse = { export type HealthResponse = {
status: string; status: string;
model_loaded: boolean; model_loaded: boolean;
model_downloaded?: (boolean | null); model_downloaded?: boolean | null;
model_size?: (string | null); model_size?: string | null;
gpu_available: boolean; gpu_available: boolean;
vram_used_mb?: (number | null); vram_used_mb?: number | null;
}; };
@@ -7,7 +7,6 @@ import type { HistoryResponse } from './HistoryResponse';
* Response model for history list. * Response model for history list.
*/ */
export type HistoryListResponse = { export type HistoryListResponse = {
items: Array<HistoryResponse>; items: Array<HistoryResponse>;
total: number; total: number;
}; };
+10 -10
View File
@@ -6,14 +6,14 @@
* Response model for history entry (includes profile name). * Response model for history entry (includes profile name).
*/ */
export type HistoryResponse = { export type HistoryResponse = {
id: string; id: string;
profile_id: string; profile_id: string;
profile_name: string; profile_name: string;
text: string; text: string;
language: string; language: string;
audio_path: string; audio_path: string;
duration: number; duration: number;
seed: (number | null); seed: number | null;
created_at: string; instruct: string | null;
created_at: string;
}; };
@@ -6,6 +6,5 @@
* Request model for triggering model download. * Request model for triggering model download.
*/ */
export type ModelDownloadRequest = { export type ModelDownloadRequest = {
model_name: string; model_name: string;
}; };
+5 -6
View File
@@ -6,10 +6,9 @@
* Response model for model status. * Response model for model status.
*/ */
export type ModelStatus = { export type ModelStatus = {
model_name: string; model_name: string;
display_name: string; display_name: string;
downloaded: boolean; downloaded: boolean;
size_mb?: (number | null); size_mb?: number | null;
loaded?: boolean; loaded?: boolean;
}; };
@@ -7,6 +7,5 @@ import type { ModelStatus } from './ModelStatus';
* Response model for model status list. * Response model for model status list.
*/ */
export type ModelStatusListResponse = { export type ModelStatusListResponse = {
models: Array<ModelStatus>; models: Array<ModelStatus>;
}; };
@@ -6,9 +6,8 @@
* Response model for profile sample. * Response model for profile sample.
*/ */
export type ProfileSampleResponse = { export type ProfileSampleResponse = {
id: string; id: string;
profile_id: string; profile_id: string;
audio_path: string; audio_path: string;
reference_text: string; reference_text: string;
}; };
@@ -6,7 +6,6 @@
* Response model for transcription. * Response model for transcription.
*/ */
export type TranscriptionResponse = { export type TranscriptionResponse = {
text: string; text: string;
duration: number; duration: number;
}; };
+3 -4
View File
@@ -3,8 +3,7 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export type ValidationError = { export type ValidationError = {
loc: Array<(string | number)>; loc: Array<string | number>;
msg: string; msg: string;
type: string; type: string;
}; };
+3 -4
View File
@@ -6,8 +6,7 @@
* Request model for creating a voice profile. * Request model for creating a voice profile.
*/ */
export type VoiceProfileCreate = { export type VoiceProfileCreate = {
name: string; name: string;
description?: (string | null); description?: string | null;
language?: string; language?: string;
}; };
@@ -6,11 +6,10 @@
* Response model for voice profile. * Response model for voice profile.
*/ */
export type VoiceProfileResponse = { export type VoiceProfileResponse = {
id: string; id: string;
name: string; name: string;
description: (string | null); description: string | null;
language: string; language: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
@@ -3,15 +3,15 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $Body_add_profile_sample_profiles__profile_id__samples_post = { export const $Body_add_profile_sample_profiles__profile_id__samples_post = {
properties: { properties: {
file: { file: {
type: 'binary', type: 'binary',
isRequired: true, isRequired: true,
format: 'binary', format: 'binary',
},
reference_text: {
type: 'string',
isRequired: true,
},
}, },
reference_text: {
type: 'string',
isRequired: true,
},
},
} as const; } as const;
@@ -3,19 +3,22 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $Body_transcribe_audio_transcribe_post = { export const $Body_transcribe_audio_transcribe_post = {
properties: { properties: {
file: { file: {
type: 'binary', type: 'binary',
isRequired: true, isRequired: true,
format: 'binary', format: 'binary',
},
language: {
type: 'any-of',
contains: [{
type: 'string',
}, {
type: 'null',
}],
},
}, },
language: {
type: 'any-of',
contains: [
{
type: 'string',
},
{
type: 'null',
},
],
},
},
} as const; } as const;
+39 -33
View File
@@ -3,38 +3,44 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $GenerationRequest = { export const $GenerationRequest = {
description: `Request model for voice generation.`, description: `Request model for voice generation.`,
properties: { properties: {
profile_id: { profile_id: {
type: 'string', type: 'string',
isRequired: true, isRequired: true,
},
text: {
type: 'string',
isRequired: true,
maxLength: 5000,
minLength: 1,
},
language: {
type: 'string',
pattern: '^(en|zh)$',
},
seed: {
type: 'any-of',
contains: [{
type: 'number',
}, {
type: 'null',
}],
},
model_size: {
type: 'any-of',
contains: [{
type: 'string',
pattern: '^(1\\.7B|0\\.6B)$',
}, {
type: 'null',
}],
},
}, },
text: {
type: 'string',
isRequired: true,
maxLength: 5000,
minLength: 1,
},
language: {
type: 'string',
pattern: '^(en|zh)$',
},
seed: {
type: 'any-of',
contains: [
{
type: 'number',
},
{
type: 'null',
},
],
},
model_size: {
type: 'any-of',
contains: [
{
type: 'string',
pattern: '^(1\\.7B|0\\.6B)$',
},
{
type: 'null',
},
],
},
},
} as const; } as const;
+43 -40
View File
@@ -3,45 +3,48 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $GenerationResponse = { export const $GenerationResponse = {
description: `Response model for voice generation.`, description: `Response model for voice generation.`,
properties: { properties: {
id: { id: {
type: 'string', type: 'string',
isRequired: true, isRequired: true,
},
profile_id: {
type: 'string',
isRequired: true,
},
text: {
type: 'string',
isRequired: true,
},
language: {
type: 'string',
isRequired: true,
},
audio_path: {
type: 'string',
isRequired: true,
},
duration: {
type: 'number',
isRequired: true,
},
seed: {
type: 'any-of',
contains: [{
type: 'number',
}, {
type: 'null',
}],
isRequired: true,
},
created_at: {
type: 'string',
isRequired: true,
format: 'date-time',
},
}, },
profile_id: {
type: 'string',
isRequired: true,
},
text: {
type: 'string',
isRequired: true,
},
language: {
type: 'string',
isRequired: true,
},
audio_path: {
type: 'string',
isRequired: true,
},
duration: {
type: 'number',
isRequired: true,
},
seed: {
type: 'any-of',
contains: [
{
type: 'number',
},
{
type: 'null',
},
],
isRequired: true,
},
created_at: {
type: 'string',
isRequired: true,
format: 'date-time',
},
},
} as const; } as const;
@@ -3,12 +3,12 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $HTTPValidationError = { export const $HTTPValidationError = {
properties: { properties: {
detail: { detail: {
type: 'array', type: 'array',
contains: { contains: {
type: 'ValidationError', type: 'ValidationError',
}, },
},
}, },
},
} as const; } as const;
+47 -38
View File
@@ -3,43 +3,52 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $HealthResponse = { export const $HealthResponse = {
description: `Response model for health check.`, description: `Response model for health check.`,
properties: { properties: {
status: { status: {
type: 'string', type: 'string',
isRequired: true, isRequired: true,
},
model_loaded: {
type: 'boolean',
isRequired: true,
},
model_downloaded: {
type: 'any-of',
contains: [{
type: 'boolean',
}, {
type: 'null',
}],
},
model_size: {
type: 'any-of',
contains: [{
type: 'string',
}, {
type: 'null',
}],
},
gpu_available: {
type: 'boolean',
isRequired: true,
},
vram_used_mb: {
type: 'any-of',
contains: [{
type: 'number',
}, {
type: 'null',
}],
},
}, },
model_loaded: {
type: 'boolean',
isRequired: true,
},
model_downloaded: {
type: 'any-of',
contains: [
{
type: 'boolean',
},
{
type: 'null',
},
],
},
model_size: {
type: 'any-of',
contains: [
{
type: 'string',
},
{
type: 'null',
},
],
},
gpu_available: {
type: 'boolean',
isRequired: true,
},
vram_used_mb: {
type: 'any-of',
contains: [
{
type: 'number',
},
{
type: 'null',
},
],
},
},
} as const; } as const;
+13 -13
View File
@@ -3,18 +3,18 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $HistoryListResponse = { export const $HistoryListResponse = {
description: `Response model for history list.`, description: `Response model for history list.`,
properties: { properties: {
items: { items: {
type: 'array', type: 'array',
contains: { contains: {
type: 'HistoryResponse', type: 'HistoryResponse',
}, },
isRequired: true, isRequired: true,
},
total: {
type: 'number',
isRequired: true,
},
}, },
total: {
type: 'number',
isRequired: true,
},
},
} as const; } as const;
+47 -44
View File
@@ -3,49 +3,52 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $HistoryResponse = { export const $HistoryResponse = {
description: `Response model for history entry (includes profile name).`, description: `Response model for history entry (includes profile name).`,
properties: { properties: {
id: { id: {
type: 'string', type: 'string',
isRequired: true, isRequired: true,
},
profile_id: {
type: 'string',
isRequired: true,
},
profile_name: {
type: 'string',
isRequired: true,
},
text: {
type: 'string',
isRequired: true,
},
language: {
type: 'string',
isRequired: true,
},
audio_path: {
type: 'string',
isRequired: true,
},
duration: {
type: 'number',
isRequired: true,
},
seed: {
type: 'any-of',
contains: [{
type: 'number',
}, {
type: 'null',
}],
isRequired: true,
},
created_at: {
type: 'string',
isRequired: true,
format: 'date-time',
},
}, },
profile_id: {
type: 'string',
isRequired: true,
},
profile_name: {
type: 'string',
isRequired: true,
},
text: {
type: 'string',
isRequired: true,
},
language: {
type: 'string',
isRequired: true,
},
audio_path: {
type: 'string',
isRequired: true,
},
duration: {
type: 'number',
isRequired: true,
},
seed: {
type: 'any-of',
contains: [
{
type: 'number',
},
{
type: 'null',
},
],
isRequired: true,
},
created_at: {
type: 'string',
isRequired: true,
format: 'date-time',
},
},
} as const; } as const;
@@ -3,11 +3,11 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $ModelDownloadRequest = { export const $ModelDownloadRequest = {
description: `Request model for triggering model download.`, description: `Request model for triggering model download.`,
properties: { properties: {
model_name: { model_name: {
type: 'string', type: 'string',
isRequired: true, isRequired: true,
},
}, },
},
} as const; } as const;
+28 -25
View File
@@ -3,30 +3,33 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $ModelStatus = { export const $ModelStatus = {
description: `Response model for model status.`, description: `Response model for model status.`,
properties: { properties: {
model_name: { model_name: {
type: 'string', type: 'string',
isRequired: true, isRequired: true,
},
display_name: {
type: 'string',
isRequired: true,
},
downloaded: {
type: 'boolean',
isRequired: true,
},
size_mb: {
type: 'any-of',
contains: [{
type: 'number',
}, {
type: 'null',
}],
},
loaded: {
type: 'boolean',
},
}, },
display_name: {
type: 'string',
isRequired: true,
},
downloaded: {
type: 'boolean',
isRequired: true,
},
size_mb: {
type: 'any-of',
contains: [
{
type: 'number',
},
{
type: 'null',
},
],
},
loaded: {
type: 'boolean',
},
},
} as const; } as const;
@@ -3,14 +3,14 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $ModelStatusListResponse = { export const $ModelStatusListResponse = {
description: `Response model for model status list.`, description: `Response model for model status list.`,
properties: { properties: {
models: { models: {
type: 'array', type: 'array',
contains: { contains: {
type: 'ModelStatus', type: 'ModelStatus',
}, },
isRequired: true, isRequired: true,
},
}, },
},
} as const; } as const;
@@ -3,23 +3,23 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $ProfileSampleResponse = { export const $ProfileSampleResponse = {
description: `Response model for profile sample.`, description: `Response model for profile sample.`,
properties: { properties: {
id: { id: {
type: 'string', type: 'string',
isRequired: true, isRequired: true,
},
profile_id: {
type: 'string',
isRequired: true,
},
audio_path: {
type: 'string',
isRequired: true,
},
reference_text: {
type: 'string',
isRequired: true,
},
}, },
profile_id: {
type: 'string',
isRequired: true,
},
audio_path: {
type: 'string',
isRequired: true,
},
reference_text: {
type: 'string',
isRequired: true,
},
},
} as const; } as const;
@@ -3,15 +3,15 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $TranscriptionResponse = { export const $TranscriptionResponse = {
description: `Response model for transcription.`, description: `Response model for transcription.`,
properties: { properties: {
text: { text: {
type: 'string', type: 'string',
isRequired: true, isRequired: true,
},
duration: {
type: 'number',
isRequired: true,
},
}, },
duration: {
type: 'number',
isRequired: true,
},
},
} as const; } as const;
+23 -20
View File
@@ -3,26 +3,29 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $ValidationError = { export const $ValidationError = {
properties: { properties: {
loc: { loc: {
type: 'array', type: 'array',
contains: { contains: {
type: 'any-of', type: 'any-of',
contains: [{ contains: [
type: 'string', {
}, {
type: 'number',
}],
},
isRequired: true,
},
msg: {
type: 'string', type: 'string',
isRequired: true, },
}, {
type: { type: 'number',
type: 'string', },
isRequired: true, ],
}, },
isRequired: true,
}, },
msg: {
type: 'string',
isRequired: true,
},
type: {
type: 'string',
isRequired: true,
},
},
} as const; } as const;
+24 -21
View File
@@ -3,26 +3,29 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $VoiceProfileCreate = { export const $VoiceProfileCreate = {
description: `Request model for creating a voice profile.`, description: `Request model for creating a voice profile.`,
properties: { properties: {
name: { name: {
type: 'string', type: 'string',
isRequired: true, isRequired: true,
maxLength: 100, maxLength: 100,
minLength: 1, minLength: 1,
},
description: {
type: 'any-of',
contains: [{
type: 'string',
maxLength: 500,
}, {
type: 'null',
}],
},
language: {
type: 'string',
pattern: '^(en|zh)$',
},
}, },
description: {
type: 'any-of',
contains: [
{
type: 'string',
maxLength: 500,
},
{
type: 'null',
},
],
},
language: {
type: 'string',
pattern: '^(en|zh)$',
},
},
} as const; } as const;
@@ -3,38 +3,41 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const $VoiceProfileResponse = { export const $VoiceProfileResponse = {
description: `Response model for voice profile.`, description: `Response model for voice profile.`,
properties: { properties: {
id: { id: {
type: 'string', type: 'string',
isRequired: true, isRequired: true,
},
name: {
type: 'string',
isRequired: true,
},
description: {
type: 'any-of',
contains: [{
type: 'string',
}, {
type: 'null',
}],
isRequired: true,
},
language: {
type: 'string',
isRequired: true,
},
created_at: {
type: 'string',
isRequired: true,
format: 'date-time',
},
updated_at: {
type: 'string',
isRequired: true,
format: 'date-time',
},
}, },
name: {
type: 'string',
isRequired: true,
},
description: {
type: 'any-of',
contains: [
{
type: 'string',
},
{
type: 'null',
},
],
isRequired: true,
},
language: {
type: 'string',
isRequired: true,
},
created_at: {
type: 'string',
isRequired: true,
format: 'date-time',
},
updated_at: {
type: 'string',
isRequired: true,
format: 'date-time',
},
},
} as const; } as const;
+437 -437
View File
@@ -19,441 +19,441 @@ import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI'; import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request'; import { request as __request } from '../core/request';
export class DefaultService { export class DefaultService {
/** /**
* Root * Root
* Root endpoint. * Root endpoint.
* @returns any Successful Response * @returns any Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static rootGet(): CancelablePromise<any> { public static rootGet(): CancelablePromise<any> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/', url: '/',
}); });
} }
/** /**
* Health * Health
* Health check endpoint. * Health check endpoint.
* @returns HealthResponse Successful Response * @returns HealthResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static healthHealthGet(): CancelablePromise<HealthResponse> { public static healthHealthGet(): CancelablePromise<HealthResponse> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/health', url: '/health',
}); });
} }
/** /**
* List Profiles * List Profiles
* List all voice profiles. * List all voice profiles.
* @returns VoiceProfileResponse Successful Response * @returns VoiceProfileResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static listProfilesProfilesGet(): CancelablePromise<Array<VoiceProfileResponse>> { public static listProfilesProfilesGet(): CancelablePromise<Array<VoiceProfileResponse>> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/profiles', url: '/profiles',
}); });
} }
/** /**
* Create Profile * Create Profile
* Create a new voice profile. * Create a new voice profile.
* @returns VoiceProfileResponse Successful Response * @returns VoiceProfileResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static createProfileProfilesPost({ public static createProfileProfilesPost({
requestBody, requestBody,
}: { }: {
requestBody: VoiceProfileCreate, requestBody: VoiceProfileCreate;
}): CancelablePromise<VoiceProfileResponse> { }): CancelablePromise<VoiceProfileResponse> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'POST', method: 'POST',
url: '/profiles', url: '/profiles',
body: requestBody, body: requestBody,
mediaType: 'application/json', mediaType: 'application/json',
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Get Profile * Get Profile
* Get a voice profile by ID. * Get a voice profile by ID.
* @returns VoiceProfileResponse Successful Response * @returns VoiceProfileResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static getProfileProfilesProfileIdGet({ public static getProfileProfilesProfileIdGet({
profileId, profileId,
}: { }: {
profileId: string, profileId: string;
}): CancelablePromise<VoiceProfileResponse> { }): CancelablePromise<VoiceProfileResponse> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/profiles/{profile_id}', url: '/profiles/{profile_id}',
path: { path: {
'profile_id': profileId, profile_id: profileId,
}, },
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Update Profile * Update Profile
* Update a voice profile. * Update a voice profile.
* @returns VoiceProfileResponse Successful Response * @returns VoiceProfileResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static updateProfileProfilesProfileIdPut({ public static updateProfileProfilesProfileIdPut({
profileId, profileId,
requestBody, requestBody,
}: { }: {
profileId: string, profileId: string;
requestBody: VoiceProfileCreate, requestBody: VoiceProfileCreate;
}): CancelablePromise<VoiceProfileResponse> { }): CancelablePromise<VoiceProfileResponse> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'PUT', method: 'PUT',
url: '/profiles/{profile_id}', url: '/profiles/{profile_id}',
path: { path: {
'profile_id': profileId, profile_id: profileId,
}, },
body: requestBody, body: requestBody,
mediaType: 'application/json', mediaType: 'application/json',
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Delete Profile * Delete Profile
* Delete a voice profile. * Delete a voice profile.
* @returns any Successful Response * @returns any Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static deleteProfileProfilesProfileIdDelete({ public static deleteProfileProfilesProfileIdDelete({
profileId, profileId,
}: { }: {
profileId: string, profileId: string;
}): CancelablePromise<any> { }): CancelablePromise<any> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'DELETE', method: 'DELETE',
url: '/profiles/{profile_id}', url: '/profiles/{profile_id}',
path: { path: {
'profile_id': profileId, profile_id: profileId,
}, },
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Add Profile Sample * Add Profile Sample
* Add a sample to a voice profile. * Add a sample to a voice profile.
* @returns ProfileSampleResponse Successful Response * @returns ProfileSampleResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static addProfileSampleProfilesProfileIdSamplesPost({ public static addProfileSampleProfilesProfileIdSamplesPost({
profileId, profileId,
formData, formData,
}: { }: {
profileId: string, profileId: string;
formData: Body_add_profile_sample_profiles__profile_id__samples_post, formData: Body_add_profile_sample_profiles__profile_id__samples_post;
}): CancelablePromise<ProfileSampleResponse> { }): CancelablePromise<ProfileSampleResponse> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'POST', method: 'POST',
url: '/profiles/{profile_id}/samples', url: '/profiles/{profile_id}/samples',
path: { path: {
'profile_id': profileId, profile_id: profileId,
}, },
formData: formData, formData: formData,
mediaType: 'multipart/form-data', mediaType: 'multipart/form-data',
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Get Profile Samples * Get Profile Samples
* Get all samples for a profile. * Get all samples for a profile.
* @returns ProfileSampleResponse Successful Response * @returns ProfileSampleResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static getProfileSamplesProfilesProfileIdSamplesGet({ public static getProfileSamplesProfilesProfileIdSamplesGet({
profileId, profileId,
}: { }: {
profileId: string, profileId: string;
}): CancelablePromise<Array<ProfileSampleResponse>> { }): CancelablePromise<Array<ProfileSampleResponse>> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/profiles/{profile_id}/samples', url: '/profiles/{profile_id}/samples',
path: { path: {
'profile_id': profileId, profile_id: profileId,
}, },
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Delete Profile Sample * Delete Profile Sample
* Delete a profile sample. * Delete a profile sample.
* @returns any Successful Response * @returns any Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static deleteProfileSampleProfilesSamplesSampleIdDelete({ public static deleteProfileSampleProfilesSamplesSampleIdDelete({
sampleId, sampleId,
}: { }: {
sampleId: string, sampleId: string;
}): CancelablePromise<any> { }): CancelablePromise<any> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'DELETE', method: 'DELETE',
url: '/profiles/samples/{sample_id}', url: '/profiles/samples/{sample_id}',
path: { path: {
'sample_id': sampleId, sample_id: sampleId,
}, },
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Generate Speech * Generate Speech
* Generate speech from text using a voice profile. * Generate speech from text using a voice profile.
* @returns GenerationResponse Successful Response * @returns GenerationResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static generateSpeechGeneratePost({ public static generateSpeechGeneratePost({
requestBody, requestBody,
}: { }: {
requestBody: GenerationRequest, requestBody: GenerationRequest;
}): CancelablePromise<GenerationResponse> { }): CancelablePromise<GenerationResponse> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'POST', method: 'POST',
url: '/generate', url: '/generate',
body: requestBody, body: requestBody,
mediaType: 'application/json', mediaType: 'application/json',
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* List History * List History
* List generation history with optional filters. * List generation history with optional filters.
* @returns HistoryListResponse Successful Response * @returns HistoryListResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static listHistoryHistoryGet({ public static listHistoryHistoryGet({
profileId, profileId,
search, search,
limit = 50, limit = 50,
offset, offset,
}: { }: {
profileId?: (string | null), profileId?: string | null;
search?: (string | null), search?: string | null;
limit?: number, limit?: number;
offset?: number, offset?: number;
}): CancelablePromise<HistoryListResponse> { }): CancelablePromise<HistoryListResponse> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/history', url: '/history',
query: { query: {
'profile_id': profileId, profile_id: profileId,
'search': search, search: search,
'limit': limit, limit: limit,
'offset': offset, offset: offset,
}, },
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Get Generation * Get Generation
* Get a generation by ID. * Get a generation by ID.
* @returns HistoryResponse Successful Response * @returns HistoryResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static getGenerationHistoryGenerationIdGet({ public static getGenerationHistoryGenerationIdGet({
generationId, generationId,
}: { }: {
generationId: string, generationId: string;
}): CancelablePromise<HistoryResponse> { }): CancelablePromise<HistoryResponse> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/history/{generation_id}', url: '/history/{generation_id}',
path: { path: {
'generation_id': generationId, generation_id: generationId,
}, },
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Delete Generation * Delete Generation
* Delete a generation. * Delete a generation.
* @returns any Successful Response * @returns any Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static deleteGenerationHistoryGenerationIdDelete({ public static deleteGenerationHistoryGenerationIdDelete({
generationId, generationId,
}: { }: {
generationId: string, generationId: string;
}): CancelablePromise<any> { }): CancelablePromise<any> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'DELETE', method: 'DELETE',
url: '/history/{generation_id}', url: '/history/{generation_id}',
path: { path: {
'generation_id': generationId, generation_id: generationId,
}, },
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Get Stats * Get Stats
* Get generation statistics. * Get generation statistics.
* @returns any Successful Response * @returns any Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static getStatsHistoryStatsGet(): CancelablePromise<any> { public static getStatsHistoryStatsGet(): CancelablePromise<any> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/history/stats', url: '/history/stats',
}); });
} }
/** /**
* Transcribe Audio * Transcribe Audio
* Transcribe audio file to text. * Transcribe audio file to text.
* @returns TranscriptionResponse Successful Response * @returns TranscriptionResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static transcribeAudioTranscribePost({ public static transcribeAudioTranscribePost({
formData, formData,
}: { }: {
formData: Body_transcribe_audio_transcribe_post, formData: Body_transcribe_audio_transcribe_post;
}): CancelablePromise<TranscriptionResponse> { }): CancelablePromise<TranscriptionResponse> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'POST', method: 'POST',
url: '/transcribe', url: '/transcribe',
formData: formData, formData: formData,
mediaType: 'multipart/form-data', mediaType: 'multipart/form-data',
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Get Audio * Get Audio
* Serve generated audio file. * Serve generated audio file.
* @returns any Successful Response * @returns any Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static getAudioAudioGenerationIdGet({ public static getAudioAudioGenerationIdGet({
generationId, generationId,
}: { }: {
generationId: string, generationId: string;
}): CancelablePromise<any> { }): CancelablePromise<any> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/audio/{generation_id}', url: '/audio/{generation_id}',
path: { path: {
'generation_id': generationId, generation_id: generationId,
}, },
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Load Model * Load Model
* Manually load TTS model. * Manually load TTS model.
* @returns any Successful Response * @returns any Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static loadModelModelsLoadPost({ public static loadModelModelsLoadPost({
modelSize = '1.7B', modelSize = '1.7B',
}: { }: {
modelSize?: string, modelSize?: string;
}): CancelablePromise<any> { }): CancelablePromise<any> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'POST', method: 'POST',
url: '/models/load', url: '/models/load',
query: { query: {
'model_size': modelSize, model_size: modelSize,
}, },
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Unload Model * Unload Model
* Unload TTS model to free memory. * Unload TTS model to free memory.
* @returns any Successful Response * @returns any Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static unloadModelModelsUnloadPost(): CancelablePromise<any> { public static unloadModelModelsUnloadPost(): CancelablePromise<any> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'POST', method: 'POST',
url: '/models/unload', url: '/models/unload',
}); });
} }
/** /**
* Get Model Progress * Get Model Progress
* Get model download progress via Server-Sent Events. * Get model download progress via Server-Sent Events.
* @returns any Successful Response * @returns any Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static getModelProgressModelsProgressModelNameGet({ public static getModelProgressModelsProgressModelNameGet({
modelName, modelName,
}: { }: {
modelName: string, modelName: string;
}): CancelablePromise<any> { }): CancelablePromise<any> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/models/progress/{model_name}', url: '/models/progress/{model_name}',
path: { path: {
'model_name': modelName, model_name: modelName,
}, },
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
/** /**
* Get Model Status * Get Model Status
* Get status of all available models. * Get status of all available models.
* @returns ModelStatusListResponse Successful Response * @returns ModelStatusListResponse Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static getModelStatusModelsStatusGet(): CancelablePromise<ModelStatusListResponse> { public static getModelStatusModelsStatusGet(): CancelablePromise<ModelStatusListResponse> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/models/status', url: '/models/status',
}); });
} }
/** /**
* Trigger Model Download * Trigger Model Download
* Trigger download of a specific model. * Trigger download of a specific model.
* @returns any Successful Response * @returns any Successful Response
* @throws ApiError * @throws ApiError
*/ */
public static triggerModelDownloadModelsDownloadPost({ public static triggerModelDownloadModelsDownloadPost({
requestBody, requestBody,
}: { }: {
requestBody: ModelDownloadRequest, requestBody: ModelDownloadRequest;
}): CancelablePromise<any> { }): CancelablePromise<any> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'POST', method: 'POST',
url: '/models/download', url: '/models/download',
body: requestBody, body: requestBody,
mediaType: 'application/json', mediaType: 'application/json',
errors: { errors: {
422: `Validation Error`, 422: `Validation Error`,
}, },
}); });
} }
} }
+2 -1
View File
@@ -28,7 +28,8 @@ export function useAudioRecording({
// Check if getUserMedia is available // Check if getUserMedia is available
// In Tauri, navigator.mediaDevices might not be available immediately // In Tauri, navigator.mediaDevices might not be available immediately
if (typeof navigator === 'undefined') { if (typeof navigator === 'undefined') {
const errorMsg = 'Navigator API is not available. This might be a Tauri configuration issue.'; const errorMsg =
'Navigator API is not available. This might be a Tauri configuration issue.';
setError(errorMsg); setError(errorMsg);
throw new Error(errorMsg); throw new Error(errorMsg);
} }
+1 -2
View File
@@ -28,8 +28,7 @@ export const useServerStore = create<ServerStore>()(
setMode: (mode) => set({ mode }), setMode: (mode) => set({ mode }),
keepServerRunningOnClose: false, keepServerRunningOnClose: false,
setKeepServerRunningOnClose: (keepRunning) => setKeepServerRunningOnClose: (keepRunning) => set({ keepServerRunningOnClose: keepRunning }),
set({ keepServerRunningOnClose: keepRunning }),
}), }),
{ {
name: 'voicebox-server', name: 'voicebox-server',
+1
View File
@@ -47,6 +47,7 @@ class Generation(Base):
audio_path = Column(String, nullable=False) audio_path = Column(String, nullable=False)
duration = Column(Float, nullable=False) duration = Column(Float, nullable=False)
seed = Column(Integer) seed = Column(Integer)
instruct = Column(Text)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
+4
View File
@@ -28,6 +28,7 @@ async def create_generation(
duration: float, duration: float,
seed: Optional[int], seed: Optional[int],
db: Session, db: Session,
instruct: Optional[str] = None,
) -> GenerationResponse: ) -> GenerationResponse:
""" """
Create a new generation history entry. Create a new generation history entry.
@@ -40,6 +41,7 @@ async def create_generation(
duration: Audio duration in seconds duration: Audio duration in seconds
seed: Random seed used (if any) seed: Random seed used (if any)
db: Database session db: Database session
instruct: Natural language instruction used (if any)
Returns: Returns:
Created generation entry Created generation entry
@@ -52,6 +54,7 @@ async def create_generation(
audio_path=audio_path, audio_path=audio_path,
duration=duration, duration=duration,
seed=seed, seed=seed,
instruct=instruct,
created_at=datetime.utcnow(), created_at=datetime.utcnow(),
) )
@@ -139,6 +142,7 @@ async def list_generations(
audio_path=generation.audio_path, audio_path=generation.audio_path,
duration=generation.duration, duration=generation.duration,
seed=generation.seed, seed=generation.seed,
instruct=generation.instruct,
created_at=generation.created_at, created_at=generation.created_at,
)) ))
+3
View File
@@ -259,6 +259,7 @@ async def generate_speech(
voice_prompt, voice_prompt,
data.language, data.language,
data.seed, data.seed,
data.instruct,
) )
# Calculate duration # Calculate duration
@@ -280,6 +281,7 @@ async def generate_speech(
duration=duration, duration=duration,
seed=data.seed, seed=data.seed,
db=db, db=db,
instruct=data.instruct,
) )
return generation return generation
@@ -342,6 +344,7 @@ async def get_generation(
audio_path=gen.audio_path, audio_path=gen.audio_path,
duration=gen.duration, duration=gen.duration,
seed=gen.seed, seed=gen.seed,
instruct=gen.instruct,
created_at=gen.created_at, created_at=gen.created_at,
) )
+48
View File
@@ -0,0 +1,48 @@
"""
Database migration script to add instruct column to generations table.
Run this once to update existing databases:
python -m backend.migrate_add_instruct
"""
import sqlite3
import os
from pathlib import Path
def migrate():
"""Add instruct column to generations table if it doesn't exist."""
# Get data directory
data_dir = os.environ.get("VOICEBOX_DATA_DIR")
if data_dir:
db_path = Path(data_dir) / "voicebox.db"
else:
db_path = Path.cwd() / "data" / "voicebox.db"
if not db_path.exists():
print(f"Database not found at {db_path}, skipping migration")
return
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Check if instruct column already exists
cursor.execute("PRAGMA table_info(generations)")
columns = [row[1] for row in cursor.fetchall()]
if 'instruct' in columns:
print("instruct column already exists, skipping migration")
conn.close()
return
# Add instruct column
print("Adding instruct column to generations table...")
cursor.execute("ALTER TABLE generations ADD COLUMN instruct TEXT")
conn.commit()
conn.close()
print("Migration complete!")
if __name__ == "__main__":
migrate()
+3
View File
@@ -50,6 +50,7 @@ class GenerationRequest(BaseModel):
language: str = Field(default="en", pattern="^(en|zh)$") language: str = Field(default="en", pattern="^(en|zh)$")
seed: Optional[int] = Field(None, ge=0) seed: Optional[int] = Field(None, ge=0)
model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B)$") model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B)$")
instruct: Optional[str] = Field(None, max_length=500)
class GenerationResponse(BaseModel): class GenerationResponse(BaseModel):
@@ -61,6 +62,7 @@ class GenerationResponse(BaseModel):
audio_path: str audio_path: str
duration: float duration: float
seed: Optional[int] seed: Optional[int]
instruct: Optional[str]
created_at: datetime created_at: datetime
class Config: class Config:
@@ -85,6 +87,7 @@ class HistoryResponse(BaseModel):
audio_path: str audio_path: str
duration: float duration: float
seed: Optional[int] seed: Optional[int]
instruct: Optional[str]
created_at: datetime created_at: datetime
class Config: class Config:
+3
View File
@@ -241,6 +241,7 @@ class TTSModel:
voice_prompt: dict, voice_prompt: dict,
language: str = "en", language: str = "en",
seed: Optional[int] = None, seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> Tuple[np.ndarray, int]: ) -> Tuple[np.ndarray, int]:
""" """
Generate audio from text using voice prompt. Generate audio from text using voice prompt.
@@ -250,6 +251,7 @@ class TTSModel:
voice_prompt: Voice prompt dictionary from create_voice_prompt voice_prompt: Voice prompt dictionary from create_voice_prompt
language: Language code (en or zh) language: Language code (en or zh)
seed: Random seed for reproducibility seed: Random seed for reproducibility
instruct: Natural language instruction for speech delivery control
Returns: Returns:
Tuple of (audio_array, sample_rate) Tuple of (audio_array, sample_rate)
@@ -266,6 +268,7 @@ class TTSModel:
wavs, sample_rate = self.model.generate_voice_clone( wavs, sample_rate = self.model.generate_voice_clone(
text=text, text=text,
voice_clone_prompt=voice_prompt, voice_clone_prompt=voice_prompt,
instruct=instruct,
) )
audio = wavs[0] # Get first result audio = wavs[0] # Get first result
+6
View File
@@ -33,6 +33,8 @@
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"@tanstack/react-query-devtools": "^5.0.0", "@tanstack/react-query-devtools": "^5.0.0",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.9.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
@@ -540,8 +542,12 @@
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="], "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="],
"@tauri-apps/plugin-process": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="],
"@tauri-apps/plugin-shell": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ktsRWf8wHLD17aZEyqE8c5x98eNAuTizR1FSX475zQ4TxaiJnhwksLygQz+AGwckJL5bfEP13nWrlTNQJUpKpA=="], "@tauri-apps/plugin-shell": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ktsRWf8wHLD17aZEyqE8c5x98eNAuTizR1FSX475zQ4TxaiJnhwksLygQz+AGwckJL5bfEP13nWrlTNQJUpKpA=="],
"@tauri-apps/plugin-updater": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg=="],
"@types/babel__core": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__core": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], "@types/babel__generator": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
+17 -36
View File
@@ -148,47 +148,28 @@ Add your private key to GitHub secrets:
- Add `TAURI_SIGNING_PRIVATE_KEY` with the content of `~/.tauri/voicebox.key` - Add `TAURI_SIGNING_PRIVATE_KEY` with the content of `~/.tauri/voicebox.key`
- Add `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` (empty string if no password) - Add `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` (empty string if no password)
## 5. Implementing Update Check in Frontend ## 5. Frontend Integration (Already Completed)
Add the updater package: The frontend integration is already complete in this project with the following components:
```bash ### Automatic Update Notifications
bun add @tauri-apps/plugin-updater - `app/src/components/UpdateNotification.tsx` - Shows a banner when updates are available
``` - Automatically checks for updates on app startup
- Displays download/install progress
- Only shows in Tauri desktop builds
Example implementation: ### Manual Update Check
- `app/src/components/ServerSettings/UpdateStatus.tsx` - Settings panel for updates
- Allows manual update checks via "Check for Updates" button
- Shows current version and update status
- Located in the Settings tab (only visible in Tauri builds)
```typescript ### Update Hook
import { check } from '@tauri-apps/plugin-updater'; - `app/src/hooks/useAutoUpdater.ts` - React hook for update functionality
import { relaunch } from '@tauri-apps/plugin-process'; - Handles update checking, downloading, and installation
- Includes Tauri context detection (won't run in web builds)
async function checkForUpdates() { The components are already integrated into the main App layout.
try {
const update = await check();
if (update?.available) {
console.log(`Update available: ${update.version}`);
// Show UI to user
const shouldUpdate = confirm(
`Version ${update.version} is available. Install now?`
);
if (shouldUpdate) {
await update.downloadAndInstall();
await relaunch();
}
}
} catch (error) {
console.error('Update check failed:', error);
}
}
// Check on app startup
checkForUpdates();
// Or add a manual check button
```
## Security Notes ## Security Notes
+116
View File
@@ -0,0 +1,116 @@
# Autoupdater Quick Start
The Tauri v2 autoupdater has been fully configured and integrated. Follow these steps to activate it.
## What's Already Done
✅ Rust plugin installed and initialized
✅ Tauri configuration set up with updater settings
✅ Permissions granted for update operations
✅ GitHub Actions workflow updated with signing support
✅ Frontend components created and integrated
✅ Update notifications on app startup
✅ Manual update check in Settings tab
## Required Steps (5 minutes)
### 1. Generate Signing Keys
```bash
bun run generate:keys
```
This creates:
- Private key: `~/.tauri/voicebox.key` (keep secret!)
- Public key: `~/.tauri/voicebox.key.pub` (safe to share)
### 2. Update Tauri Config
Open `tauri/src-tauri/tauri.conf.json` and:
1. Replace `"REPLACE_WITH_YOUR_PUBLIC_KEY"` with the content from `~/.tauri/voicebox.key.pub`
2. Update the endpoint URL with your GitHub username:
```json
"endpoints": [
"https://github.com/YOUR_USERNAME/voicebox/releases/latest/download/latest.json"
]
```
### 3. Add GitHub Secrets
Go to your repo Settings → Secrets and variables → Actions:
1. Add `TAURI_SIGNING_PRIVATE_KEY`:
```bash
cat ~/.tauri/voicebox.key
```
Copy the entire output and paste as the secret value
2. Add `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`:
Leave empty (or add your password if you set one)
### 4. Test the Setup
To test locally before creating a release:
```bash
bun run build:release
```
This will verify your keys are set up correctly.
## How It Works
### For Users
1. App checks for updates on startup (only in Tauri builds)
2. If an update is available, a banner appears at the top
3. Users can click "Install Now" to download and install
4. App restarts automatically after installation
### For Developers
1. Create a new git tag: `git tag v0.2.0 && git push --tags`
2. GitHub Actions builds signed releases for all platforms
3. Uploads installers and generates `latest.json` manifest
4. Users running older versions will be notified automatically
## UI Components
### Update Notification Banner
- Shows at top of app when update is available
- Appears automatically on startup
- Displays download/install progress
### Settings Panel
- Located in Settings tab
- Shows current version
- Manual "Check for Updates" button
- Update status and progress
## Troubleshooting
**"Public key not configured"**
- Make sure you copied the entire content from `voicebox.key.pub`
- The key should start with `dW50cnVzdGVkIGNvbW1lbnQ6`
**"Failed to check for updates"**
- Endpoint URL might be incorrect
- No releases published yet (expected for first setup)
**Build fails with signing error**
- Check that GitHub secrets are set correctly
- Verify private key file exists at `~/.tauri/voicebox.key`
## Next Release Workflow
1. Update version in `tauri/src-tauri/tauri.conf.json`
2. Commit changes
3. Create and push tag: `git tag v0.2.0 && git push --tags`
4. GitHub Actions will automatically build and create a draft release
5. Review the release and publish it
6. Users will be notified of the update
## See Also
- Full documentation: `docs/AUTOUPDATER.md`
- Build script: `scripts/prepare-release.sh`
- GitHub workflow: `.github/workflows/release.yml`
+3 -6
View File
@@ -8,7 +8,8 @@ const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'voicebox - Professional Voice Cloning Desktop App', title: 'voicebox - Professional Voice Cloning Desktop App',
description: 'Near-perfect voice cloning powered by Qwen3-TTS. Desktop app for Mac, Windows, and Linux. Multi-sample support, smart caching, local or remote inference.', description:
'Near-perfect voice cloning powered by Qwen3-TTS. Desktop app for Mac, Windows, and Linux. Multi-sample support, smart caching, local or remote inference.',
keywords: ['voice cloning', 'TTS', 'Qwen3', 'desktop app', 'AI voice'], keywords: ['voice cloning', 'TTS', 'Qwen3', 'desktop app', 'AI voice'],
openGraph: { openGraph: {
title: 'voicebox', title: 'voicebox',
@@ -18,11 +19,7 @@ export const metadata: Metadata = {
}, },
}; };
export default function RootLayout({ export default function RootLayout({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
return ( return (
<html lang="en" suppressHydrationWarning className="dark"> <html lang="en" suppressHydrationWarning className="dark">
<body className={inter.variable}> <body className={inter.variable}>
+29 -15
View File
@@ -14,12 +14,14 @@ export default function Home() {
const features = [ const features = [
{ {
title: 'Near-Perfect Voice Cloning', title: 'Near-Perfect Voice Cloning',
description: 'Powered by Alibaba\'s Qwen3-TTS model for exceptional voice quality and accuracy.', description:
"Powered by Alibaba's Qwen3-TTS model for exceptional voice quality and accuracy.",
icon: <Zap className="h-6 w-6" />, icon: <Zap className="h-6 w-6" />,
}, },
{ {
title: 'Multi-Sample Support', title: 'Multi-Sample Support',
description: 'Combine multiple voice samples for higher quality and more natural-sounding results.', description:
'Combine multiple voice samples for higher quality and more natural-sounding results.',
icon: <Code className="h-6 w-6" />, icon: <Code className="h-6 w-6" />,
}, },
{ {
@@ -29,12 +31,14 @@ export default function Home() {
}, },
{ {
title: 'Local or Remote', title: 'Local or Remote',
description: 'Run GPU inference locally or connect to a remote machine. One-click server setup.', description:
'Run GPU inference locally or connect to a remote machine. One-click server setup.',
icon: <Cloud className="h-6 w-6" />, icon: <Cloud className="h-6 w-6" />,
}, },
{ {
title: 'Audio Transcription', title: 'Audio Transcription',
description: 'Powered by Whisper for accurate speech-to-text. Extract reference text from voice samples automatically.', description:
'Powered by Whisper for accurate speech-to-text. Extract reference text from voice samples automatically.',
icon: <Shield className="h-6 w-6" />, icon: <Shield className="h-6 w-6" />,
}, },
{ {
@@ -58,7 +62,11 @@ export default function Home() {
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full max-w-2xl"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full max-w-2xl">
<Button asChild size="lg" className="w-full px-0"> <Button asChild size="lg" className="w-full px-0">
<a href={DOWNLOAD_LINKS.macArm} download className="flex items-center w-full relative"> <a
href={DOWNLOAD_LINKS.macArm}
download
className="flex items-center w-full relative"
>
<div className="flex items-center gap-2 flex-shrink-0 pl-4"> <div className="flex items-center gap-2 flex-shrink-0 pl-4">
<AppleIcon className="h-5 w-5" /> <AppleIcon className="h-5 w-5" />
<div className="h-5 w-px bg-border" /> <div className="h-5 w-px bg-border" />
@@ -67,7 +75,11 @@ export default function Home() {
</a> </a>
</Button> </Button>
<Button asChild size="lg" className="w-full px-0"> <Button asChild size="lg" className="w-full px-0">
<a href={DOWNLOAD_LINKS.macIntel} download className="flex items-center w-full relative"> <a
href={DOWNLOAD_LINKS.macIntel}
download
className="flex items-center w-full relative"
>
<div className="flex items-center gap-2 flex-shrink-0 pl-4"> <div className="flex items-center gap-2 flex-shrink-0 pl-4">
<AppleIcon className="h-5 w-5" /> <AppleIcon className="h-5 w-5" />
<div className="h-5 w-px bg-border" /> <div className="h-5 w-px bg-border" />
@@ -76,7 +88,11 @@ export default function Home() {
</a> </a>
</Button> </Button>
<Button asChild size="lg" className="w-full px-0"> <Button asChild size="lg" className="w-full px-0">
<a href={DOWNLOAD_LINKS.windows} download className="flex items-center w-full relative"> <a
href={DOWNLOAD_LINKS.windows}
download
className="flex items-center w-full relative"
>
<div className="flex items-center gap-2 flex-shrink-0 pl-4"> <div className="flex items-center gap-2 flex-shrink-0 pl-4">
<WindowsIcon className="h-5 w-5" /> <WindowsIcon className="h-5 w-5" />
<div className="h-5 w-px bg-border" /> <div className="h-5 w-px bg-border" />
@@ -85,7 +101,11 @@ export default function Home() {
</a> </a>
</Button> </Button>
<Button asChild size="lg" className="w-full px-0"> <Button asChild size="lg" className="w-full px-0">
<a href={DOWNLOAD_LINKS.linux} download className="flex items-center w-full relative"> <a
href={DOWNLOAD_LINKS.linux}
download
className="flex items-center w-full relative"
>
<div className="flex items-center gap-2 flex-shrink-0 pl-4"> <div className="flex items-center gap-2 flex-shrink-0 pl-4">
<LinuxIcon className="h-5 w-5" /> <LinuxIcon className="h-5 w-5" />
<div className="h-5 w-px bg-border" /> <div className="h-5 w-px bg-border" />
@@ -94,12 +114,7 @@ export default function Home() {
</a> </a>
</Button> </Button>
</div> </div>
<Button <Button variant="outline" size="lg" asChild className="w-full">
variant="outline"
size="lg"
asChild
className="w-full"
>
<a href={GITHUB_REPO} target="_blank" rel="noopener noreferrer"> <a href={GITHUB_REPO} target="_blank" rel="noopener noreferrer">
<Github className="h-4 w-4 mr-2" /> <Github className="h-4 w-4 mr-2" />
View on GitHub View on GitHub
@@ -126,7 +141,6 @@ export default function Home() {
))} ))}
</div> </div>
</Section> </Section>
</div> </div>
); );
} }
+5 -6
View File
@@ -35,7 +35,10 @@ export function DownloadSection() {
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
{downloads.map(({ platform, icon: Icon, link, description }) => ( {downloads.map(({ platform, icon: Icon, link, description }) => (
<Card key={platform} className="hover:border-primary/20 hover:shadow-lg hover:shadow-primary/3 transition-all duration-200 hover:-translate-y-0.5"> <Card
key={platform}
className="hover:border-primary/20 hover:shadow-lg hover:shadow-primary/3 transition-all duration-200 hover:-translate-y-0.5"
>
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex flex-col items-center text-center space-y-4"> <div className="flex flex-col items-center text-center space-y-4">
<div className="p-3 rounded-xl bg-muted/50 backdrop-blur-sm border border-border"> <div className="p-3 rounded-xl bg-muted/50 backdrop-blur-sm border border-border">
@@ -45,11 +48,7 @@ export function DownloadSection() {
<h3 className="text-lg font-semibold mb-1">{platform}</h3> <h3 className="text-lg font-semibold mb-1">{platform}</h3>
<p className="text-sm text-muted-foreground">{description}</p> <p className="text-sm text-muted-foreground">{description}</p>
</div> </div>
<Button <Button asChild size="lg" className="w-full">
asChild
size="lg"
className="w-full"
>
<a href={link} download> <a href={link} download>
<Download className="h-4 w-4 mr-2" /> <Download className="h-4 w-4 mr-2" />
Download Download
+26 -5
View File
@@ -10,7 +10,8 @@ export function Footer() {
<div> <div>
<h3 className="font-bold text-lg mb-4">voicebox</h3> <h3 className="font-bold text-lg mb-4">voicebox</h3>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
Professional voice cloning powered by Qwen3-TTS. Desktop app for Mac, Windows, and Linux. Professional voice cloning powered by Qwen3-TTS. Desktop app for Mac, Windows, and
Linux.
</p> </p>
</div> </div>
<div> <div>
@@ -27,7 +28,12 @@ export function Footer() {
</a> </a>
</li> </li>
<li> <li>
<Link href={GITHUB_REPO} target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors"> <Link
href={GITHUB_REPO}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
GitHub GitHub
</Link> </Link>
</li> </li>
@@ -37,17 +43,32 @@ export function Footer() {
<h4 className="font-semibold mb-3">Resources</h4> <h4 className="font-semibold mb-3">Resources</h4>
<ul className="space-y-2 text-muted-foreground text-sm"> <ul className="space-y-2 text-muted-foreground text-sm">
<li> <li>
<Link href={GITHUB_REPO} target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors"> <Link
href={GITHUB_REPO}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Source Code Source Code
</Link> </Link>
</li> </li>
<li> <li>
<Link href={`${GITHUB_REPO}/releases`} target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors"> <Link
href={`${GITHUB_REPO}/releases`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Releases Releases
</Link> </Link>
</li> </li>
<li> <li>
<Link href={`${GITHUB_REPO}/issues`} target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors"> <Link
href={`${GITHUB_REPO}/issues`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Issues Issues
</Link> </Link>
</li> </li>
+6 -13
View File
@@ -11,29 +11,22 @@ export function Header() {
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16"> <div className="flex items-center justify-between h-16">
{/* Logo */} {/* Logo */}
<Link href="/" className="flex items-center gap-2 font-bold text-xl sm:text-2xl hover:opacity-80 transition-opacity tracking-tight"> <Link
href="/"
className="flex items-center gap-2 font-bold text-xl sm:text-2xl hover:opacity-80 transition-opacity tracking-tight"
>
voicebox voicebox
</Link> </Link>
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2 sm:gap-4"> <div className="flex items-center gap-2 sm:gap-4">
<Button <Button variant="outline" size="sm" asChild className="hidden sm:flex">
variant="outline"
size="sm"
asChild
className="hidden sm:flex"
>
<a href={GITHUB_REPO} target="_blank" rel="noopener noreferrer"> <a href={GITHUB_REPO} target="_blank" rel="noopener noreferrer">
<Github className="h-4 w-4 mr-2" /> <Github className="h-4 w-4 mr-2" />
GitHub GitHub
</a> </a>
</Button> </Button>
<Button <Button variant="ghost" size="icon" asChild className="sm:hidden">
variant="ghost"
size="icon"
asChild
className="sm:hidden"
>
<a href={GITHUB_REPO} target="_blank" rel="noopener noreferrer" aria-label="GitHub"> <a href={GITHUB_REPO} target="_blank" rel="noopener noreferrer" aria-label="GitHub">
<Github className="h-5 w-5" /> <Github className="h-5 w-5" />
</a> </a>
+3 -3
View File
@@ -1,7 +1,7 @@
export function AppleIcon({ className }: { className?: string }) { export function AppleIcon({ className }: { className?: string }) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor"> <svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/> <path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
</svg> </svg>
); );
} }
@@ -9,7 +9,7 @@ export function AppleIcon({ className }: { className?: string }) {
export function WindowsIcon({ className }: { className?: string }) { export function WindowsIcon({ className }: { className?: string }) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor"> <svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M3 12V6.75l6-1.32v6.48L3 12zm17-9v8.75l-10 .15V5.21L20 3zM3 13l6 .09v7.81l-6-1.15V13zm17 .25V22l-10-1.8v-7.15l10 .15z"/> <path d="M3 12V6.75l6-1.32v6.48L3 12zm17-9v8.75l-10 .15V5.21L20 3zM3 13l6 .09v7.81l-6-1.15V13zm17 .25V22l-10-1.8v-7.15l10 .15z" />
</svg> </svg>
); );
} }
@@ -17,7 +17,7 @@ export function WindowsIcon({ className }: { className?: string }) {
export function LinuxIcon({ className }: { className?: string }) { export function LinuxIcon({ className }: { className?: string }) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor"> <svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.26-.195.69-.133 1.001.054.27.112.553.077.784-.12.794-.3 1.593-.3 2.406 0 .599.18 1.193.3 1.791.12.599.3 1.193.3 1.792 0 .812.18 1.611.3 2.405.035.23-.023.514-.077.783-.062.312-.127.742.133 1.002a.424.424 0 00.11.135c-.123.805.01 1.657.287 2.489.589 1.771 1.831 3.47 2.716 4.521.75 1.067 0.974 1.928 1.05 3.02.065 1.491-1.056 5.965 3.17 6.298.165.013.325.021.48.021.155 0 .315-.008.48-.021 4.226-.333 3.105-4.807 3.17-6.298.076-1.092.3-1.953 1.05-3.02.885-1.051 2.127-2.75 2.716-4.521.278-.832.41-1.684.287-2.489a.424.424 0 00.11-.135c.26-.26.195-.69.133-1.001-.054-.27-.112-.553-.077-.784.12-.794.3-1.593.3-2.406 0-.599-.18-1.193-.3-1.791-.12-.599-.3-1.193-.3-1.792 0-.812-.18-1.611-.3-2.405-.035-.23.023-.514.077-.783.062-.312.127-.742-.133-1.002a.424.424 0 00-.11-.135c.123-.805-.01-1.657-.287-2.489-.589-1.771-1.831-3.47-2.716-4.521-.75-1.067-.974-1.928-1.05-3.02-.065-1.491 1.056-5.965-3.17-6.298C12.819.008 12.659 0 12.504 0z"/> <path d="M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.26-.195.69-.133 1.001.054.27.112.553.077.784-.12.794-.3 1.593-.3 2.406 0 .599.18 1.193.3 1.791.12.599.3 1.193.3 1.792 0 .812.18 1.611.3 2.405.035.23-.023.514-.077.783-.062.312-.127.742.133 1.002a.424.424 0 00.11.135c-.123.805.01 1.657.287 2.489.589 1.771 1.831 3.47 2.716 4.521.75 1.067 0.974 1.928 1.05 3.02.065 1.491-1.056 5.965 3.17 6.298.165.013.325.021.48.021.155 0 .315-.008.48-.021 4.226-.333 3.105-4.807 3.17-6.298.076-1.092.3-1.953 1.05-3.02.885-1.051 2.127-2.75 2.716-4.521.278-.832.41-1.684.287-2.489a.424.424 0 00.11-.135c.26-.26.195-.69.133-1.001-.054-.27-.112-.553-.077-.784.12-.794.3-1.593.3-2.406 0-.599-.18-1.193-.3-1.791-.12-.599-.3-1.193-.3-1.792 0-.812-.18-1.611-.3-2.405-.035-.23.023-.514.077-.783.062-.312.127-.742-.133-1.002a.424.424 0 00-.11-.135c.123-.805-.01-1.657-.287-2.489-.589-1.771-1.831-3.47-2.716-4.521-.75-1.067-.974-1.928-1.05-3.02-.065-1.491 1.056-5.965-3.17-6.298C12.819.008 12.659 0 12.504 0z" />
</svg> </svg>
); );
} }
+7 -9
View File
@@ -8,14 +8,16 @@ const buttonVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-primary/10 backdrop-blur-sm border border-border text-primary hover:bg-primary/11 hover:border-primary/15 shadow-lg shadow-black/20 active:scale-[0.99]', default:
'bg-primary/10 backdrop-blur-sm border border-border text-primary hover:bg-primary/11 hover:border-primary/15 shadow-lg shadow-black/20 active:scale-[0.99]',
destructive: destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-lg shadow-destructive/20 active:scale-[0.98]', 'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-lg shadow-destructive/20 active:scale-[0.98]',
outline: outline:
'border border-border bg-background/50 backdrop-blur-sm hover:bg-foreground/5 transition-all active:scale-[0.99]', 'border border-border bg-background/50 backdrop-blur-sm hover:bg-foreground/5 transition-all active:scale-[0.99]',
secondary: secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/70 backdrop-blur-sm active:scale-[0.99]', 'bg-secondary text-secondary-foreground hover:bg-secondary/70 backdrop-blur-sm active:scale-[0.99]',
ghost: 'hover:bg-accent/30 hover:text-accent-foreground backdrop-blur-sm active:scale-[0.99]', ghost:
'hover:bg-accent/30 hover:text-accent-foreground backdrop-blur-sm active:scale-[0.99]',
link: 'text-primary underline-offset-4 hover:underline', link: 'text-primary underline-offset-4 hover:underline',
}, },
size: { size: {
@@ -29,7 +31,7 @@ const buttonVariants = cva(
variant: 'default', variant: 'default',
size: 'default', size: 'default',
}, },
} },
); );
export interface ButtonProps export interface ButtonProps
@@ -42,13 +44,9 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'; const Comp = asChild ? Slot : 'button';
return ( return (
<Comp <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
); );
} },
); );
Button.displayName = 'Button'; Button.displayName = 'Button';
+37 -57
View File
@@ -1,78 +1,58 @@
import * as React from 'react'; import * as React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const Card = React.forwardRef< const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement> <div
>(({ className, ...props }, ref) => ( ref={ref}
<div className={cn(
ref={ref} 'rounded-2xl border border-border bg-card backdrop-blur-xl text-card-foreground shadow-lg shadow-black/20',
className={cn( className,
'rounded-2xl border border-border bg-card backdrop-blur-xl text-card-foreground shadow-lg shadow-black/20', )}
className {...props}
)} />
{...props} ),
/> );
));
Card.displayName = 'Card'; Card.displayName = 'Card';
const CardHeader = React.forwardRef< const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement> <div ref={ref} className={cn('flex flex-col space-y-1.5 p-6 pb-4', className)} {...props} />
>(({ className, ...props }, ref) => ( ),
<div );
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6 pb-4', className)}
{...props}
/>
));
CardHeader.displayName = 'CardHeader'; CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef< const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
HTMLParagraphElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLHeadingElement> <h3
>(({ className, ...props }, ref) => ( ref={ref}
<h3 className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
ref={ref} {...props}
className={cn( />
'text-2xl font-semibold leading-none tracking-tight', ),
className );
)}
{...props}
/>
));
CardTitle.displayName = 'CardTitle'; CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef< const CardDescription = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement> React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<p <p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)); ));
CardDescription.displayName = 'CardDescription'; CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef< const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement> <div ref={ref} className={cn('p-6 pt-4', className)} {...props} />
>(({ className, ...props }, ref) => ( ),
<div ref={ref} className={cn('p-6 pt-4', className)} {...props} /> );
));
CardContent.displayName = 'CardContent'; CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef< const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement> <div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
>(({ className, ...props }, ref) => ( ),
<div );
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
));
CardFooter.displayName = 'CardFooter'; CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+7 -4
View File
@@ -11,7 +11,12 @@ interface FeatureCardProps {
export function FeatureCard({ title, description, icon, className }: FeatureCardProps) { export function FeatureCard({ title, description, icon, className }: FeatureCardProps) {
return ( return (
<Card className={cn('text-center hover:border-primary/20 hover:shadow-lg hover:shadow-primary/3 transition-all duration-200 hover:-translate-y-0.5', className)}> <Card
className={cn(
'text-center hover:border-primary/20 hover:shadow-lg hover:shadow-primary/3 transition-all duration-200 hover:-translate-y-0.5',
className,
)}
>
<CardHeader> <CardHeader>
{icon && ( {icon && (
<div className="flex justify-center mb-3"> <div className="flex justify-center mb-3">
@@ -23,9 +28,7 @@ export function FeatureCard({ title, description, icon, className }: FeatureCard
<CardTitle className="text-xl sm:text-2xl">{title}</CardTitle> <CardTitle className="text-xl sm:text-2xl">{title}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<CardDescription className="text-sm sm:text-base"> <CardDescription className="text-sm sm:text-base">{description}</CardDescription>
{description}
</CardDescription>
</CardContent> </CardContent>
</Card> </Card>
); );
+1 -3
View File
@@ -39,9 +39,7 @@ export function Hero({ title, description, actions, className, showLogo = true }
</div> </div>
{/* Right side - Actions */} {/* Right side - Actions */}
<div className="flex flex-col items-start lg:items-end gap-4"> <div className="flex flex-col items-start lg:items-end gap-4">{actions}</div>
{actions}
</div>
</div> </div>
</section> </section>
); );
+6 -1
View File
@@ -17,7 +17,12 @@ export function Section({ children, className, id }: SectionProps) {
export function SectionTitle({ children, className }: { children: ReactNode; className?: string }) { export function SectionTitle({ children, className }: { children: ReactNode; className?: string }) {
return ( return (
<h2 className={cn('text-2xl sm:text-3xl md:text-4xl font-bold text-center md:text-left', className)}> <h2
className={cn(
'text-2xl sm:text-3xl md:text-4xl font-bold text-center md:text-left',
className,
)}
>
{children} {children}
</h2> </h2>
); );
+13 -18
View File
@@ -5,24 +5,19 @@ import { cn } from '@/lib/utils';
const Separator = React.forwardRef< const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>, React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>( >(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
( <SeparatorPrimitive.Root
{ className, orientation = 'horizontal', decorative = true, ...props }, ref={ref}
ref decorative={decorative}
) => ( orientation={orientation}
<SeparatorPrimitive.Root className={cn(
ref={ref} 'shrink-0 bg-border',
decorative={decorative} orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
orientation={orientation} className,
className={cn( )}
'shrink-0 bg-border', {...props}
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]', />
className ));
)}
{...props}
/>
)
);
Separator.displayName = SeparatorPrimitive.Root.displayName; Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator }; export { Separator };
+8 -4
View File
@@ -3,10 +3,14 @@
export const LATEST_VERSION = 'v0.1.0'; export const LATEST_VERSION = 'v0.1.0';
export const DOWNLOAD_LINKS = { export const DOWNLOAD_LINKS = {
macArm: 'https://github.com/USERNAME/voicebox/releases/download/v0.1.0/voicebox_aarch64-apple-darwin.dmg', macArm:
macIntel: 'https://github.com/USERNAME/voicebox/releases/download/v0.1.0/voicebox_x86_64-apple-darwin.dmg', 'https://github.com/USERNAME/voicebox/releases/download/v0.1.0/voicebox_aarch64-apple-darwin.dmg',
windows: 'https://github.com/USERNAME/voicebox/releases/download/v0.1.0/voicebox_x86_64-pc-windows-msvc.msi', macIntel:
linux: 'https://github.com/USERNAME/voicebox/releases/download/v0.1.0/voicebox_x86_64-unknown-linux-gnu.AppImage', 'https://github.com/USERNAME/voicebox/releases/download/v0.1.0/voicebox_x86_64-apple-darwin.dmg',
windows:
'https://github.com/USERNAME/voicebox/releases/download/v0.1.0/voicebox_x86_64-pc-windows-msvc.msi',
linux:
'https://github.com/USERNAME/voicebox/releases/download/v0.1.0/voicebox_x86_64-unknown-linux-gnu.AppImage',
} as const; } as const;
export const GITHUB_REPO = 'https://github.com/USERNAME/voicebox'; export const GITHUB_REPO = 'https://github.com/USERNAME/voicebox';
+3 -11
View File
@@ -1,11 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2020",
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -23,9 +19,7 @@
} }
], ],
"paths": { "paths": {
"@/*": [ "@/*": ["./src/*"]
"./src/*"
]
} }
}, },
"include": [ "include": [
@@ -35,7 +29,5 @@
".next/types/**/*.ts", ".next/types/**/*.ts",
".next/dev/types/**/*.ts" ".next/dev/types/**/*.ts"
], ],
"exclude": [ "exclude": ["node_modules"]
"node_modules"
]
} }
+17 -22
View File
@@ -1,34 +1,29 @@
{ {
"fill" : "automatic", "fill": "automatic",
"groups" : [ "groups": [
{ {
"layers" : [ "layers": [
{ {
"image-name" : "Voicebox_Microphone.png", "image-name": "Voicebox_Microphone.png",
"name" : "Voicebox_Microphone", "name": "Voicebox_Microphone",
"position" : { "position": {
"scale" : 0.36, "scale": 0.36,
"translation-in-points" : [ "translation-in-points": [0.140625, 270.1875]
0.140625,
270.1875
]
} }
} }
], ],
"shadow" : { "shadow": {
"kind" : "neutral", "kind": "neutral",
"opacity" : 0.5 "opacity": 0.5
}, },
"translucency" : { "translucency": {
"enabled" : true, "enabled": true,
"value" : 0.5 "value": 0.5
} }
} }
], ],
"supported-platforms" : { "supported-platforms": {
"circles" : [ "circles": ["watchOS"],
"watchOS" "squares": "shared"
],
"squares" : "shared"
} }
} }
Binary file not shown.
+1 -3
View File
@@ -13,9 +13,7 @@
"active": true, "active": true,
"targets": "all", "targets": "all",
"createUpdaterArtifacts": true, "createUpdaterArtifacts": true,
"externalBin": [ "externalBin": ["binaries/voicebox-server"],
"binaries/voicebox-server"
],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
+6 -3
View File
@@ -8,11 +8,14 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, '../app/src'), '@': path.resolve(__dirname, '../app/src'),
'react': path.resolve(__dirname, '../app/node_modules/react'), react: path.resolve(__dirname, '../app/node_modules/react'),
'react-dom': path.resolve(__dirname, '../app/node_modules/react-dom'), 'react-dom': path.resolve(__dirname, '../app/node_modules/react-dom'),
'@tanstack/react-query': path.resolve(__dirname, '../app/node_modules/@tanstack/react-query'), '@tanstack/react-query': path.resolve(__dirname, '../app/node_modules/@tanstack/react-query'),
'@tanstack/react-query-devtools': path.resolve(__dirname, '../app/node_modules/@tanstack/react-query-devtools'), '@tanstack/react-query-devtools': path.resolve(
'zustand': path.resolve(__dirname, '../app/node_modules/zustand'), __dirname,
'../app/node_modules/@tanstack/react-query-devtools',
),
zustand: path.resolve(__dirname, '../app/node_modules/zustand'),
}, },
dedupe: ['react', 'react-dom', '@tanstack/react-query', 'zustand'], dedupe: ['react', 'react-dom', '@tanstack/react-query', 'zustand'],
}, },