mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-04 09:35:16 -07:00
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:
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,7 @@
|
|||||||
"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
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -26,9 +23,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"supported-platforms": {
|
"supported-platforms": {
|
||||||
"circles" : [
|
"circles": ["watchOS"],
|
||||||
"watchOS"
|
|
||||||
],
|
|
||||||
"squares": "shared"
|
"squares": "shared"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
@@ -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",
|
||||||
|
|||||||
@@ -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'],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user