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>
+474 -14
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
async function getAudioDuration(file: File): Promise<number> {
return new Promise((resolve, reject) => {
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), name: z.string().min(1, 'Name is required').max(100),
description: z.string().max(500).optional(), description: z.string().max(500).optional(),
language: z.enum(['en', 'zh']), 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
const sampleFile = form.getValues('sampleFile');
const referenceText = form.getValues('referenceText');
// 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({ toast({
title: 'Profile created', title: 'Profile created',
description: `"${data.name}" has been created successfully.`, 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,23 +340,30 @@ 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)}>
<div className={`grid gap-6 ${isCreating ? 'grid-cols-2' : 'grid-cols-1'}`}>
{/* Left column: Profile info */}
<div className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
@@ -146,7 +383,7 @@ export function ProfileForm() {
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>
@@ -176,13 +413,236 @@ export function ProfileForm() {
</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) {
+5 -6
View File
@@ -3,7 +3,6 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export class CancelError extends Error { export class CancelError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);
this.name = 'CancelError'; this.name = 'CancelError';
@@ -35,8 +34,8 @@ export class CancelablePromise<T> implements Promise<T> {
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;
@@ -86,18 +85,18 @@ export class CancelablePromise<T> implements Promise<T> {
} }
get [Symbol.toStringTag]() { get [Symbol.toStringTag]() {
return "Cancellable Promise"; return 'Cancellable Promise';
} }
public then<TResult1 = T, TResult2 = never>( public then<TResult1 = T, TResult2 = never>(
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null, onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2> { ): Promise<TResult1 | TResult2> {
return this.#promise.then(onFulfilled, onRejected); return this.#promise.then(onFulfilled, onRejected);
} }
public catch<TResult = never>( public catch<TResult = never>(
onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null,
): Promise<T | TResult> { ): Promise<T | TResult> {
return this.#promise.catch(onRejected); return this.#promise.catch(onRejected);
} }
+35 -16
View File
@@ -9,7 +9,9 @@ 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>(
value: T | null | undefined,
): value is Exclude<T, null | undefined> => {
return value !== undefined && value !== null; return value !== undefined && value !== null;
}; };
@@ -57,7 +59,7 @@ export const getQueryString = (params: Record<string, any>): string => {
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') {
@@ -116,7 +118,7 @@ export const getFormData = (options: ApiRequestOptions): FormData | undefined =>
.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);
} }
@@ -129,14 +131,20 @@ export const getFormData = (options: ApiRequestOptions): FormData | 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>(
options: ApiRequestOptions,
resolver?: T | Resolver<T>,
): Promise<T | undefined> => {
if (typeof resolver === 'function') { if (typeof resolver === 'function') {
return (resolver as Resolver<T>)(options); return (resolver as Resolver<T>)(options);
} }
return resolver; return resolver;
}; };
export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions): Promise<Headers> => { export const getHeaders = async (
config: OpenAPIConfig,
options: ApiRequestOptions,
): Promise<Headers> => {
const [token, username, password, additionalHeaders] = await Promise.all([ const [token, username, password, additionalHeaders] = await Promise.all([
resolve(options, config.TOKEN), resolve(options, config.TOKEN),
resolve(options, config.USERNAME), resolve(options, config.USERNAME),
@@ -150,10 +158,13 @@ export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptio
...options.headers, ...options.headers,
}) })
.filter(([_, value]) => isDefined(value)) .filter(([_, value]) => isDefined(value))
.reduce((headers, [key, value]) => ({ .reduce(
(headers, [key, value]) => ({
...headers, ...headers,
[key]: String(value), [key]: String(value),
}), {} as Record<string, string>); }),
{} as Record<string, string>,
);
if (isStringWithValue(token)) { if (isStringWithValue(token)) {
headers['Authorization'] = `Bearer ${token}`; headers['Authorization'] = `Bearer ${token}`;
@@ -182,7 +193,7 @@ export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptio
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 {
@@ -199,7 +210,7 @@ export const sendRequest = async (
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();
@@ -219,7 +230,10 @@ export const sendRequest = async (
return await fetch(url, request); return await fetch(url, request);
}; };
export const getResponseHeader = (response: Response, responseHeader?: string): string | undefined => { export const getResponseHeader = (
response: Response,
responseHeader?: string,
): string | undefined => {
if (responseHeader) { if (responseHeader) {
const content = response.headers.get(responseHeader); const content = response.headers.get(responseHeader);
if (isString(content)) { if (isString(content)) {
@@ -234,8 +248,8 @@ export const getResponseBody = async (response: Response): Promise<any> => {
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 {
@@ -259,7 +273,7 @@ export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult):
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) {
@@ -277,8 +291,10 @@ export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult):
} }
})(); })();
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,7 +306,10 @@ 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>(
config: OpenAPIConfig,
options: ApiRequestOptions,
): CancelablePromise<T> => {
return new CancelablePromise(async (resolve, reject, onCancel) => { return new CancelablePromise(async (resolve, reject, onCancel) => {
try { try {
const url = getUrl(config, options); const url = getUrl(config, options);
@@ -6,4 +6,3 @@ export type Body_add_profile_sample_profiles__profile_id__samples_post = {
file: Blob; file: Blob;
reference_text: string; reference_text: string;
}; };
@@ -4,6 +4,5 @@
/* 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;
}; };
+3 -3
View File
@@ -9,7 +9,7 @@ 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;
}; };
+2 -2
View File
@@ -12,7 +12,7 @@ export type GenerationResponse = {
language: string; language: string;
audio_path: string; audio_path: string;
duration: number; duration: number;
seed: (number | null); seed: number | null;
instruct: string | null;
created_at: string; created_at: string;
}; };
@@ -6,4 +6,3 @@ import type { ValidationError } from './ValidationError';
export type HTTPValidationError = { export type HTTPValidationError = {
detail?: Array<ValidationError>; detail?: Array<ValidationError>;
}; };
+3 -4
View File
@@ -8,9 +8,8 @@
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;
}; };
@@ -10,4 +10,3 @@ export type HistoryListResponse = {
items: Array<HistoryResponse>; items: Array<HistoryResponse>;
total: number; total: number;
}; };
+2 -2
View File
@@ -13,7 +13,7 @@ export type HistoryResponse = {
language: string; language: string;
audio_path: string; audio_path: string;
duration: number; duration: number;
seed: (number | null); seed: number | null;
instruct: string | null;
created_at: string; created_at: string;
}; };
@@ -8,4 +8,3 @@
export type ModelDownloadRequest = { export type ModelDownloadRequest = {
model_name: string; model_name: string;
}; };
+1 -2
View File
@@ -9,7 +9,6 @@ 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;
}; };
@@ -9,4 +9,3 @@ import type { ModelStatus } from './ModelStatus';
export type ModelStatusListResponse = { export type ModelStatusListResponse = {
models: Array<ModelStatus>; models: Array<ModelStatus>;
}; };
@@ -11,4 +11,3 @@ export type ProfileSampleResponse = {
audio_path: string; audio_path: string;
reference_text: string; reference_text: string;
}; };
@@ -9,4 +9,3 @@ export type TranscriptionResponse = {
text: string; text: string;
duration: number; duration: number;
}; };
+1 -2
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;
}; };
+1 -2
View File
@@ -7,7 +7,6 @@
*/ */
export type VoiceProfileCreate = { export type VoiceProfileCreate = {
name: string; name: string;
description?: (string | null); description?: string | null;
language?: string; language?: string;
}; };
@@ -8,9 +8,8 @@
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;
}; };
@@ -11,11 +11,14 @@ export const $Body_transcribe_audio_transcribe_post = {
}, },
language: { language: {
type: 'any-of', type: 'any-of',
contains: [{ contains: [
{
type: 'string', type: 'string',
}, { },
{
type: 'null', type: 'null',
}], },
],
}, },
}, },
} as const; } as const;
+12 -6
View File
@@ -21,20 +21,26 @@ export const $GenerationRequest = {
}, },
seed: { seed: {
type: 'any-of', type: 'any-of',
contains: [{ contains: [
{
type: 'number', type: 'number',
}, { },
{
type: 'null', type: 'null',
}], },
],
}, },
model_size: { model_size: {
type: 'any-of', type: 'any-of',
contains: [{ contains: [
{
type: 'string', type: 'string',
pattern: '^(1\\.7B|0\\.6B)$', pattern: '^(1\\.7B|0\\.6B)$',
}, { },
{
type: 'null', type: 'null',
}], },
],
}, },
}, },
} as const; } as const;
@@ -31,11 +31,14 @@ export const $GenerationResponse = {
}, },
seed: { seed: {
type: 'any-of', type: 'any-of',
contains: [{ contains: [
{
type: 'number', type: 'number',
}, { },
{
type: 'null', type: 'null',
}], },
],
isRequired: true, isRequired: true,
}, },
created_at: { created_at: {
+18 -9
View File
@@ -15,19 +15,25 @@ export const $HealthResponse = {
}, },
model_downloaded: { model_downloaded: {
type: 'any-of', type: 'any-of',
contains: [{ contains: [
{
type: 'boolean', type: 'boolean',
}, { },
{
type: 'null', type: 'null',
}], },
],
}, },
model_size: { model_size: {
type: 'any-of', type: 'any-of',
contains: [{ contains: [
{
type: 'string', type: 'string',
}, { },
{
type: 'null', type: 'null',
}], },
],
}, },
gpu_available: { gpu_available: {
type: 'boolean', type: 'boolean',
@@ -35,11 +41,14 @@ export const $HealthResponse = {
}, },
vram_used_mb: { vram_used_mb: {
type: 'any-of', type: 'any-of',
contains: [{ contains: [
{
type: 'number', type: 'number',
}, { },
{
type: 'null', type: 'null',
}], },
],
}, },
}, },
} as const; } as const;
+6 -3
View File
@@ -35,11 +35,14 @@ export const $HistoryResponse = {
}, },
seed: { seed: {
type: 'any-of', type: 'any-of',
contains: [{ contains: [
{
type: 'number', type: 'number',
}, { },
{
type: 'null', type: 'null',
}], },
],
isRequired: true, isRequired: true,
}, },
created_at: { created_at: {
+6 -3
View File
@@ -19,11 +19,14 @@ export const $ModelStatus = {
}, },
size_mb: { size_mb: {
type: 'any-of', type: 'any-of',
contains: [{ contains: [
{
type: 'number', type: 'number',
}, { },
{
type: 'null', type: 'null',
}], },
],
}, },
loaded: { loaded: {
type: 'boolean', type: 'boolean',
+6 -3
View File
@@ -8,11 +8,14 @@ export const $ValidationError = {
type: 'array', type: 'array',
contains: { contains: {
type: 'any-of', type: 'any-of',
contains: [{ contains: [
{
type: 'string', type: 'string',
}, { },
{
type: 'number', type: 'number',
}], },
],
}, },
isRequired: true, isRequired: true,
}, },
@@ -13,12 +13,15 @@ export const $VoiceProfileCreate = {
}, },
description: { description: {
type: 'any-of', type: 'any-of',
contains: [{ contains: [
{
type: 'string', type: 'string',
maxLength: 500, maxLength: 500,
}, { },
{
type: 'null', type: 'null',
}], },
],
}, },
language: { language: {
type: 'string', type: 'string',
@@ -15,11 +15,14 @@ export const $VoiceProfileResponse = {
}, },
description: { description: {
type: 'any-of', type: 'any-of',
contains: [{ contains: [
{
type: 'string', type: 'string',
}, { },
{
type: 'null', type: 'null',
}], },
],
isRequired: true, isRequired: true,
}, },
language: { language: {
+36 -36
View File
@@ -64,7 +64,7 @@ export class DefaultService {
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',
@@ -85,13 +85,13 @@ export class DefaultService {
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`,
@@ -108,14 +108,14 @@ export class DefaultService {
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',
@@ -133,13 +133,13 @@ export class DefaultService {
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`,
@@ -156,14 +156,14 @@ export class DefaultService {
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',
@@ -181,13 +181,13 @@ export class DefaultService {
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`,
@@ -203,13 +203,13 @@ export class DefaultService {
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`,
@@ -225,7 +225,7 @@ export class DefaultService {
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',
@@ -249,19 +249,19 @@ export class DefaultService {
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`,
@@ -277,13 +277,13 @@ export class DefaultService {
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`,
@@ -299,13 +299,13 @@ export class DefaultService {
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`,
@@ -333,7 +333,7 @@ export class DefaultService {
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',
@@ -354,13 +354,13 @@ export class DefaultService {
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`,
@@ -376,13 +376,13 @@ export class DefaultService {
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`,
@@ -410,13 +410,13 @@ export class DefaultService {
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`,
@@ -444,7 +444,7 @@ export class DefaultService {
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',
+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';
+25 -45
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>
>(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
'rounded-2xl border border-border bg-card backdrop-blur-xl text-card-foreground shadow-lg shadow-black/20', 'rounded-2xl border border-border bg-card backdrop-blur-xl text-card-foreground shadow-lg shadow-black/20',
className 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>
>(({ className, ...props }, ref) => (
<h3 <h3
ref={ref} ref={ref}
className={cn( className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props} {...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>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-4', className)} {...props} /> <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>
); );
+3 -8
View File
@@ -5,11 +5,7 @@ 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) => (
(
{ className, orientation = 'horizontal', decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root <SeparatorPrimitive.Root
ref={ref} ref={ref}
decorative={decorative} decorative={decorative}
@@ -17,12 +13,11 @@ const Separator = React.forwardRef<
className={cn( className={cn(
'shrink-0 bg-border', 'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]', orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className className,
)} )}
{...props} {...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'],
}, },