Merge branch 'main' into channels

This commit is contained in:
Jamie Pine
2026-01-27 15:24:18 -08:00
46 changed files with 977 additions and 325 deletions
+10 -4
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import voiceboxLogo from '@/assets/voicebox-logo.png';
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
import { GenerationForm } from '@/components/Generation/GenerationForm';
// import { GenerationForm } from '@/components/Generation/GenerationForm';
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
import { HistoryTable } from '@/components/History/HistoryTable';
import ShinyText from '@/components/ShinyText';
import { Sidebar } from '@/components/Sidebar';
@@ -20,6 +21,7 @@ import {
setupWindowCloseHandler,
startServer,
} from '@/lib/tauri';
import { usePlayerStore } from '@/stores/playerStore';
import { useServerStore } from '@/stores/serverStore';
// Track if server is starting to prevent duplicate starts
@@ -52,6 +54,7 @@ function App() {
const [activeTab, setActiveTab] = useState('main');
const [serverReady, setServerReady] = useState(false);
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
const audioUrl = usePlayerStore((state) => state.audioUrl);
// Monitor active downloads/generations and show toasts for them
const activeDownloads = useRestoreActiveTasks();
@@ -175,7 +178,7 @@ function App() {
<div className="container mx-auto px-8 max-w-[1800px] h-full overflow-hidden flex flex-col">
{activeTab === 'main' && (
// Main view: Profiles top left, Generator bottom left, History right
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden relative">
{/* Left Column */}
<div className="flex flex-col gap-6 min-h-0 overflow-y-auto pb-32">
{/* Profiles - Top Left */}
@@ -184,15 +187,18 @@ function App() {
</div>
{/* Generator - Bottom Left */}
<div className="shrink-0">
{/* <div className="shrink-0">
<GenerationForm />
</div>
</div> */}
</div>
{/* Right Column - History */}
<div className="flex flex-col min-h-0 overflow-hidden">
<HistoryTable />
</div>
{/* Floating Generate Box */}
<FloatingGenerateBox isPlayerOpen={!!audioUrl} />
</div>
)}
{activeTab === 'voices' && <VoicesTab />}
-1
View File
@@ -1 +0,0 @@
# Voice generation components
@@ -0,0 +1,282 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion';
import { Loader2, Sparkles } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
import { useGeneration } from '@/lib/hooks/useGeneration';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { useProfile } from '@/lib/hooks/useProfiles';
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useUIStore } from '@/stores/uiStore';
const generationSchema = z.object({
text: z.string().min(1, 'Text is required').max(5000),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
modelSize: z.enum(['1.7B', '0.6B']).optional(),
});
type GenerationFormValues = z.infer<typeof generationSchema>;
interface FloatingGenerateBoxProps {
isPlayerOpen: boolean;
}
export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps) {
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const { data: selectedProfile } = useProfile(selectedProfileId || '');
const generation = useGeneration();
const { toast } = useToast();
const setAudio = usePlayerStore((state) => state.setAudio);
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useModelDownloadToast({
modelName: downloadingModelName || '',
displayName: downloadingDisplayName || '',
enabled: !!downloadingModelName,
});
const form = useForm<GenerationFormValues>({
resolver: zodResolver(generationSchema),
defaultValues: {
text: '',
language: 'en',
modelSize: '1.7B',
},
});
// Click away handler to collapse the box
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
// Don't collapse if clicking inside the container
if (containerRef.current?.contains(target)) {
return;
}
// Don't collapse if clicking on a Select dropdown (which renders in a portal)
if (
target.closest('[role="listbox"]') ||
target.closest('[data-radix-popper-content-wrapper]')
) {
return;
}
setIsExpanded(false);
}
if (isExpanded) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isExpanded]);
async function onSubmit(data: GenerationFormValues) {
if (!selectedProfileId) {
toast({
title: 'No profile selected',
description: 'Please select a voice profile from the cards above.',
variant: 'destructive',
});
return;
}
try {
setIsGenerating(true);
const modelName = `qwen-tts-${data.modelSize}`;
const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B';
try {
const modelStatus = await apiClient.getModelStatus();
const model = modelStatus.models.find((m) => m.model_name === modelName);
if (model && !model.downloaded) {
setDownloadingModelName(modelName);
setDownloadingDisplayName(displayName);
}
} catch (error) {
console.error('Failed to check model status:', error);
}
const result = await generation.mutateAsync({
profile_id: selectedProfileId,
text: data.text,
language: data.language,
model_size: data.modelSize,
});
toast({
title: 'Generation complete!',
description: `Audio generated (${result.duration.toFixed(2)}s)`,
});
const audioUrl = apiClient.getAudioUrl(result.id);
setAudio(audioUrl, result.id, data.text.substring(0, 50));
form.reset();
setIsExpanded(false);
} catch (error) {
toast({
title: 'Generation failed',
description: error instanceof Error ? error.message : 'Failed to generate audio',
variant: 'destructive',
});
} finally {
setIsGenerating(false);
setDownloadingModelName(null);
setDownloadingDisplayName(null);
}
}
return (
<motion.div
ref={containerRef}
className="fixed left-[calc(5rem+2rem)] right-auto w-[calc((100%-5rem-4rem)/2-1rem)]"
style={{
bottom: isPlayerOpen ? 'calc(7rem + 1.5rem)' : '1.5rem',
}}
>
<motion.div
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[2rem] shadow-2xl hover:bg-background/40 hover:border-accent/20 transition-all duration-300 overflow-hidden p-3"
transition={{ duration: 0.6, ease: 'easeInOut' }}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex gap-2">
<motion.div
className="flex-1"
// animate={{ marginBottom: isExpanded ? '0.75rem' : '0' }}
transition={{ duration: 0.3, ease: 'easeOut' }}
>
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
placeholder={
selectedProfile
? `Generate speech using ${selectedProfile.name}...`
: 'Select a voice profile above...'
}
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 overflow-hidden transition-all"
style={{
minHeight: isExpanded ? '100px' : '32px',
height: isExpanded ? '100px' : '32px',
}}
disabled={!selectedProfileId}
onClick={() => setIsExpanded(true)}
onFocus={() => setIsExpanded(true)}
{...field}
/>
</FormControl>
<FormMessage className="text-xs" />
</FormItem>
)}
/>
</motion.div>
<Button
type="submit"
disabled={generation.isPending || !selectedProfileId}
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 shrink-0 transition-all duration-200"
size="icon"
>
{generation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
</Button>
</div>
<AnimatePresence>
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
className=" mt-3"
>
<div className="flex items-center gap-2">
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem className="flex-1">
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{LANGUAGE_OPTIONS.map((lang) => (
<SelectItem key={lang.value} value={lang.value} className="text-xs">
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage className="text-xs" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="modelSize"
render={({ field }) => (
<FormItem className="flex-1">
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="1.7B" className="text-xs text-muted-foreground">
Qwen3-TTS 1.7B
</SelectItem>
<SelectItem value="0.6B" className="text-xs text-muted-foreground">
Qwen3-TTS 0.6B
</SelectItem>
</SelectContent>
</Select>
<FormMessage className="text-xs" />
</FormItem>
)}
/>
</div>
</motion.div>
</AnimatePresence>
</form>
</Form>
</motion.div>
</motion.div>
);
}
@@ -1 +0,0 @@
# Server settings and connection components
@@ -8,7 +8,7 @@ import { useAutoUpdater } from '@/hooks/useAutoUpdater';
import { getVersion } from '@tauri-apps/api/app';
export function UpdateStatus() {
const { status, checkForUpdates, downloadAndInstall } = useAutoUpdater(false);
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
const [currentVersion, setCurrentVersion] = useState<string>('');
useEffect(() => {
@@ -30,7 +30,7 @@ export function UpdateStatus() {
</div>
<Button
onClick={checkForUpdates}
disabled={status.checking || status.downloading || status.installing}
disabled={status.checking || status.downloading || status.readyToInstall}
variant="outline"
size="sm"
>
@@ -53,7 +53,7 @@ export function UpdateStatus() {
</div>
)}
{status.available && !status.downloading && !status.installing && (
{status.available && !status.downloading && !status.readyToInstall && (
<div className="space-y-3 p-4 border rounded-lg bg-primary/5">
<div className="flex items-center justify-between">
<div>
@@ -64,28 +64,49 @@ export function UpdateStatus() {
</div>
<Button onClick={downloadAndInstall} className="w-full" size="sm">
<Download className="h-4 w-4 mr-2" />
Install Update
Download 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 className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Download className="h-4 w-4" />
Downloading update...
</div>
{status.downloadProgress !== undefined && (
<span className="text-muted-foreground">
{status.downloadProgress}%
</span>
)}
</div>
<Progress />
<Progress value={status.downloadProgress} />
{status.downloadedBytes !== undefined && status.totalBytes !== undefined && status.totalBytes > 0 && (
<div className="text-xs text-muted-foreground">
{(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB / {(status.totalBytes / 1024 / 1024).toFixed(1)} MB
</div>
)}
</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...
{status.readyToInstall && (
<div className="space-y-3 p-4 border rounded-lg bg-green-500/10 border-green-500/20">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-500" />
<div>
<div className="font-semibold">Update Ready to Install</div>
<div className="text-sm text-muted-foreground">Version {status.version} has been downloaded</div>
</div>
</div>
<div className="text-xs text-muted-foreground">App will restart automatically</div>
<div className="text-sm text-muted-foreground">
The app needs to restart to complete the installation. You can do this now or later at your convenience.
</div>
<Button onClick={restartAndInstall} className="w-full" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Restart Now
</Button>
</div>
)}
@@ -1 +0,0 @@
# Voice profile management components
@@ -1,4 +1,4 @@
import { Mic, Square, Play, Pause } from 'lucide-react';
import { Mic, Pause, Play, Square } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { FormControl, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { formatAudioDuration } from '@/lib/utils/audio';
@@ -35,12 +35,7 @@ export function AudioSampleRecording({
<div className="space-y-4">
{!isRecording && !file && (
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
<Button
type="button"
onClick={onStart}
size="lg"
className="flex items-center gap-2"
>
<Button type="button" onClick={onStart} size="lg" className="flex items-center gap-2">
<Mic className="h-5 w-5" />
Start Recording
</Button>
@@ -81,16 +76,9 @@ export function AudioSampleRecording({
<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: {file.name}
</p>
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
<div className="flex gap-2">
<Button
type="button"
size="icon"
variant="outline"
onClick={onPlayPause}
>
<Button type="button" size="icon" variant="outline" onClick={onPlayPause}>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button
@@ -1,4 +1,4 @@
import { Monitor, Square, Play, Pause, Mic } from 'lucide-react';
import { Mic, Monitor, Pause, Play, Square } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { FormControl, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { formatAudioDuration } from '@/lib/utils/audio';
@@ -35,12 +35,7 @@ export function AudioSampleSystem({
<div className="space-y-4">
{!isRecording && !file && (
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
<Button
type="button"
onClick={onStart}
size="lg"
className="flex items-center gap-2"
>
<Button type="button" onClick={onStart} size="lg" className="flex items-center gap-2">
<Monitor className="h-5 w-5" />
Start Capture
</Button>
@@ -81,16 +76,9 @@ export function AudioSampleSystem({
<Monitor className="h-5 w-5 text-primary" />
<span className="font-medium">Capture complete</span>
</div>
<p className="text-sm text-muted-foreground text-center">
File: {file.name}
</p>
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
<div className="flex gap-2">
<Button
type="button"
size="icon"
variant="outline"
onClick={onPlayPause}
>
<Button type="button" size="icon" variant="outline" onClick={onPlayPause}>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button
@@ -1,4 +1,4 @@
import { Download, Edit, Eye, Mic, Trash2 } from 'lucide-react';
import { Download, Edit, Mic, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -16,14 +16,12 @@ import type { VoiceProfileResponse } from '@/lib/api/types';
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn';
import { useUIStore } from '@/stores/uiStore';
import { ProfileDetail } from './ProfileDetail';
interface ProfileCardProps {
profile: VoiceProfileResponse;
}
export function ProfileCard({ profile }: ProfileCardProps) {
const [detailOpen, setDetailOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const deleteProfile = useDeleteProfile();
const exportProfile = useExportProfile();
@@ -85,14 +83,6 @@ export function ProfileCard({ profile }: ProfileCardProps) {
</Badge>
</div>
<div className="flex gap-0.5 justify-end items-end mt-auto">
<CircleButton
icon={Eye}
onClick={(e) => {
e.stopPropagation();
setDetailOpen(true);
}}
aria-label="View details"
/>
<CircleButton
icon={Download}
onClick={handleExport}
@@ -117,8 +107,6 @@ export function ProfileCard({ profile }: ProfileCardProps) {
</CardContent>
</Card>
<ProfileDetail profileId={profile.id} open={detailOpen} onOpenChange={setDetailOpen} />
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
@@ -1,66 +0,0 @@
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useProfile } from '@/lib/hooks/useProfiles';
import { formatDate } from '@/lib/utils/format';
import { SampleList } from './SampleList';
interface ProfileDetailProps {
profileId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ProfileDetail({ profileId, open, onOpenChange }: ProfileDetailProps) {
const { data: profile, isLoading } = useProfile(profileId);
if (isLoading) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<div className="text-muted-foreground">Loading profile...</div>
</DialogContent>
</Dialog>
);
}
if (!profile) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{profile.name}</DialogTitle>
<DialogDescription>Manage samples and view profile details</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{profile.description && (
<div>
<h3 className="text-sm font-medium mb-1">Description</h3>
<p className="text-sm text-muted-foreground">{profile.description}</p>
</div>
)}
<div className="flex gap-2">
<Badge variant="outline">{profile.language}</Badge>
<span className="text-xs text-muted-foreground">
Created {formatDate(profile.created_at)}
</span>
</div>
<div className="border-t pt-4">
<SampleList profileId={profileId} />
</div>
</div>
</DialogContent>
</Dialog>
);
}
+122 -112
View File
@@ -47,6 +47,7 @@ import { useUIStore } from '@/stores/uiStore';
import { AudioSampleRecording } from './AudioSampleRecording';
import { AudioSampleSystem } from './AudioSampleSystem';
import { AudioSampleUpload } from './AudioSampleUpload';
import { SampleList } from './SampleList';
// Helper function to get audio duration from File
async function getAudioDuration(file: File & { recordedDuration?: number }): Promise<number> {
@@ -325,7 +326,7 @@ export function ProfileForm() {
},
});
toast({
title: 'Profile updated',
title: 'Voice updated',
description: `"${data.name}" has been updated successfully.`,
});
} else {
@@ -446,17 +447,17 @@ export function ProfileForm() {
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>{editingProfileId ? 'Edit Profile' : 'Create Voice Profile'}</DialogTitle>
<DialogTitle>{editingProfileId ? 'Edit Voice' : 'Create Voice Profile'}</DialogTitle>
<DialogDescription>
{editingProfileId
? 'Update your voice profile details.'
? 'Update your voice profile details and manage samples.'
: 'Create a new voice profile with an audio sample to clone the voice.'}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className={`grid gap-6 ${isCreating ? 'grid-cols-2' : 'grid-cols-1'}`}>
<div className="grid gap-6 grid-cols-2">
{/* Left column: Profile info */}
<div className="space-y-4">
<FormField
@@ -513,104 +514,83 @@ export function ProfileForm() {
/>
</div>
{/* 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</h3>
<p className="text-sm text-muted-foreground mb-4">
Provide an audio sample to clone the voice. You can add more samples later.
</p>
</div>
{/* Right column: Sample management */}
<div className="space-y-4 border-l pl-6">
{isCreating ? (
<>
<div>
<h3 className="text-sm font-medium mb-2">Add Sample</h3>
<p className="text-sm text-muted-foreground mb-4">
Provide an audio sample to clone the voice. You can add more samples later.
</p>
</div>
<Tabs
value={sampleMode}
onValueChange={(v) => {
const newMode = v as 'upload' | 'record' | 'system';
// Cancel any active recordings when switching modes
if (isRecording && newMode !== 'record') {
cancelRecording();
}
if (isSystemRecording && newMode !== 'system') {
cancelSystemRecording();
}
setSampleMode(newMode);
}}
>
<TabsList
className={`grid w-full ${isTauri() && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
<Tabs
value={sampleMode}
onValueChange={(v) => {
const newMode = v as 'upload' | 'record' | 'system';
// Cancel any active recordings when switching modes
if (isRecording && newMode !== 'record') {
cancelRecording();
}
if (isSystemRecording && newMode !== 'system') {
cancelSystemRecording();
}
setSampleMode(newMode);
}}
>
<TabsTrigger value="upload" className="flex items-center gap-2">
<Upload className="h-4 w-4 shrink-0" />
Upload
</TabsTrigger>
<TabsTrigger value="record" className="flex items-center gap-2">
<Mic className="h-4 w-4 shrink-0" />
Record
</TabsTrigger>
{isTauri() && isSystemAudioSupported && (
<TabsTrigger value="system" className="flex items-center gap-2">
<Monitor className="h-4 w-4 shrink-0" />
System Audio
<TabsList
className={`grid w-full ${isTauri() && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
>
<TabsTrigger value="upload" className="flex items-center gap-2">
<Upload className="h-4 w-4 shrink-0" />
Upload
</TabsTrigger>
)}
</TabsList>
<TabsContent value="upload" className="space-y-4">
<FormField
control={form.control}
name="sampleFile"
render={({ field: { onChange, name } }) => (
<AudioSampleUpload
file={selectedFile}
onFileChange={onChange}
onTranscribe={handleTranscribe}
onPlayPause={handlePlayPause}
isPlaying={isPlaying}
isValidating={isValidatingAudio}
isTranscribing={transcribe.isPending}
isDisabled={
audioDuration !== null && audioDuration > MAX_AUDIO_DURATION_SECONDS
}
fieldName={name}
/>
<TabsTrigger value="record" className="flex items-center gap-2">
<Mic className="h-4 w-4 shrink-0" />
Record
</TabsTrigger>
{isTauri() && isSystemAudioSupported && (
<TabsTrigger value="system" className="flex items-center gap-2">
<Monitor className="h-4 w-4 shrink-0" />
System Audio
</TabsTrigger>
)}
/>
</TabsContent>
</TabsList>
<TabsContent value="record" className="space-y-4">
<FormField
control={form.control}
name="sampleFile"
render={() => (
<AudioSampleRecording
file={selectedFile}
isRecording={isRecording}
duration={duration}
onStart={startRecording}
onStop={stopRecording}
onCancel={handleCancelRecording}
onTranscribe={handleTranscribe}
onPlayPause={handlePlayPause}
isPlaying={isPlaying}
isTranscribing={transcribe.isPending}
/>
)}
/>
</TabsContent>
<TabsContent value="upload" className="space-y-4">
<FormField
control={form.control}
name="sampleFile"
render={({ field: { onChange, name } }) => (
<AudioSampleUpload
file={selectedFile}
onFileChange={onChange}
onTranscribe={handleTranscribe}
onPlayPause={handlePlayPause}
isPlaying={isPlaying}
isValidating={isValidatingAudio}
isTranscribing={transcribe.isPending}
isDisabled={
audioDuration !== null && audioDuration > MAX_AUDIO_DURATION_SECONDS
}
fieldName={name}
/>
)}
/>
</TabsContent>
{isTauri() && isSystemAudioSupported && (
<TabsContent value="system" className="space-y-4">
<TabsContent value="record" className="space-y-4">
<FormField
control={form.control}
name="sampleFile"
render={() => (
<AudioSampleSystem
<AudioSampleRecording
file={selectedFile}
isRecording={isSystemRecording}
duration={systemDuration}
onStart={startSystemRecording}
onStop={stopSystemRecording}
isRecording={isRecording}
duration={duration}
onStart={startRecording}
onStop={stopRecording}
onCancel={handleCancelRecording}
onTranscribe={handleTranscribe}
onPlayPause={handlePlayPause}
@@ -620,28 +600,58 @@ export function ProfileForm() {
)}
/>
</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}
{isTauri() && isSystemAudioSupported && (
<TabsContent value="system" className="space-y-4">
<FormField
control={form.control}
name="sampleFile"
render={() => (
<AudioSampleSystem
file={selectedFile}
isRecording={isSystemRecording}
duration={systemDuration}
onStart={startSystemRecording}
onStop={stopSystemRecording}
onCancel={handleCancelRecording}
onTranscribe={handleTranscribe}
onPlayPause={handlePlayPause}
isPlaying={isPlaying}
isTranscribing={transcribe.isPending}
/>
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</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>
<FormMessage />
</FormItem>
)}
/>
</>
) : (
// Show sample list when editing
editingProfileId && (
<div>
<SampleList profileId={editingProfileId} />
</div>
)
)}
</div>
</div>
<div className="flex gap-2 justify-end mt-6 pt-4 border-t">
@@ -655,7 +665,7 @@ export function ProfileForm() {
{createProfile.isPending || updateProfile.isPending || addSample.isPending
? 'Saving...'
: editingProfileId
? 'Update Profile'
? 'Save Changes'
: 'Create Profile'}
</Button>
</div>
@@ -109,7 +109,7 @@ export function ProfileList() {
</CardContent>
</Card>
) : (
<div className="grid gap-4 grid-cols-3 auto-rows-auto p-1">
<div className="grid gap-4 grid-cols-3 auto-rows-auto p-1 pb-[150px]">
{allProfiles.map((profile) => (
<ProfileCard key={profile.id} profile={profile} />
))}
@@ -37,7 +37,7 @@ export function SampleList({ profileId }: SampleListProps) {
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Audio Samples</h3>
<Button size="sm" onClick={() => setUploadOpen(true)}>
<Button type="button" size="sm" onClick={() => setUploadOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Sample
</Button>
@@ -60,6 +60,7 @@ export function SampleList({ profileId }: SampleListProps) {
</div>
<div className="flex gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handlePlay(sample.reference_text, sample.id)}
@@ -69,6 +70,7 @@ export function SampleList({ profileId }: SampleListProps) {
Play
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleDelete(sample.id)}
@@ -22,15 +22,15 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
import { useTranscription } from '@/lib/hooks/useTranscription';
import { isTauri } from '@/lib/tauri';
import { AudioSampleUpload } from './AudioSampleUpload';
import { AudioSampleRecording } from './AudioSampleRecording';
import { AudioSampleSystem } from './AudioSampleSystem';
import { AudioSampleUpload } from './AudioSampleUpload';
const sampleSchema = z.object({
file: z.instanceof(File, { message: 'Please select an audio file' }),
+4 -4
View File
@@ -1,3 +1,4 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Edit, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
import { useMemo } from 'react';
import { Button } from '@/components/ui/button';
@@ -15,11 +16,10 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useProfiles, useProfileSamples, useDeleteProfile } from '@/lib/hooks/useProfiles';
import { useHistory } from '@/lib/hooks/useHistory';
import { useUIStore } from '@/stores/uiStore';
import { apiClient } from '@/lib/api/client';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useHistory } from '@/lib/hooks/useHistory';
import { useDeleteProfile, useProfileSamples, useProfiles } from '@/lib/hooks/useProfiles';
import { useUIStore } from '@/stores/uiStore';
export function VoicesTab() {
const { data: profiles, isLoading } = useProfiles();
+77 -12
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { check, type Update } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
import { check, type Update } from '@tauri-apps/plugin-updater';
import { useCallback, useEffect, useState } from 'react';
export interface UpdateStatus {
checking: boolean;
@@ -8,9 +8,18 @@ export interface UpdateStatus {
version?: string;
downloading: boolean;
installing: boolean;
readyToInstall: boolean;
error?: string;
downloadProgress?: number; // 0-100 percentage
downloadedBytes?: number;
totalBytes?: number;
}
// Check if we're on Windows (NSIS installer handles restart automatically)
const isWindows = () => {
return navigator.userAgent.includes('Windows');
};
const isTauri = () => {
return '__TAURI_INTERNALS__' in window;
};
@@ -21,11 +30,12 @@ export function useAutoUpdater(checkOnMount = false) {
available: false,
downloading: false,
installing: false,
readyToInstall: false,
});
const [update, setUpdate] = useState<Update | null>(null);
const checkForUpdates = async () => {
const checkForUpdates = useCallback(async () => {
if (!isTauri()) {
return;
}
@@ -43,6 +53,7 @@ export function useAutoUpdater(checkOnMount = false) {
version: foundUpdate.version,
downloading: false,
installing: false,
readyToInstall: false,
});
} else {
setStatus({
@@ -50,6 +61,7 @@ export function useAutoUpdater(checkOnMount = false) {
available: false,
downloading: false,
installing: false,
readyToInstall: false,
});
}
} catch (error) {
@@ -58,41 +70,93 @@ export function useAutoUpdater(checkOnMount = false) {
available: false,
downloading: false,
installing: false,
readyToInstall: false,
error: error instanceof Error ? error.message : 'Failed to check for updates',
});
}
};
}, []);
// Download the update (but don't install yet)
const downloadAndInstall = async () => {
if (!update || !isTauri()) return;
try {
setStatus((prev) => ({ ...prev, downloading: true, error: undefined }));
await update.downloadAndInstall((event) => {
let downloadedBytes = 0;
let totalBytes = 0;
// Just download the update
await update.download((event) => {
switch (event.event) {
case 'Started':
setStatus((prev) => ({ ...prev, downloading: true }));
totalBytes = event.data.contentLength || 0;
downloadedBytes = 0;
setStatus((prev) => ({
...prev,
downloading: true,
totalBytes,
downloadedBytes: 0,
downloadProgress: 0,
}));
break;
case 'Progress':
console.log(`Downloaded ${event.data.chunkLength} bytes`);
case 'Progress': {
downloadedBytes += event.data.chunkLength;
const progress =
totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : undefined;
setStatus((prev) => ({
...prev,
downloadedBytes,
downloadProgress: progress,
}));
break;
}
case 'Finished':
setStatus((prev) => ({
...prev,
downloading: false,
installing: true,
readyToInstall: true,
downloadProgress: 100,
}));
break;
}
});
await relaunch();
} catch (error) {
setStatus((prev) => ({
...prev,
downloading: false,
installing: false,
readyToInstall: false,
downloadProgress: undefined,
downloadedBytes: undefined,
totalBytes: undefined,
error: error instanceof Error ? error.message : 'Failed to download update',
}));
}
};
// Install the downloaded update and restart the app
const restartAndInstall = async () => {
if (!update || !isTauri()) return;
try {
setStatus((prev) => ({ ...prev, installing: true, error: undefined }));
// Install the update
await update.install();
// On Windows with NSIS, the installer handles the restart automatically.
// The process will be killed by the NSIS installer, so we won't reach here.
// On macOS/Linux, we need to manually relaunch.
if (!isWindows()) {
await relaunch();
}
// If we're on Windows and somehow still running, the NSIS installer
// should have already handled everything. Just wait for the process to end.
} catch (error) {
setStatus((prev) => ({
...prev,
installing: false,
error: error instanceof Error ? error.message : 'Failed to install update',
}));
}
@@ -102,11 +166,12 @@ export function useAutoUpdater(checkOnMount = false) {
if (checkOnMount && isTauri()) {
checkForUpdates();
}
}, [checkOnMount]);
}, [checkOnMount, checkForUpdates]);
return {
status,
checkForUpdates,
downloadAndInstall,
restartAndInstall,
};
}
-1
View File
@@ -1 +0,0 @@
# React Query hooks will be placed here
+22 -8
View File
@@ -1,5 +1,6 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { isTauri } from '@/lib/tauri';
import { convertToWav } from '@/lib/utils/audio';
interface UseAudioRecordingOptions {
maxDurationSeconds?: number;
@@ -85,13 +86,26 @@ export function useAudioRecording({
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
// Pass the actual recorded duration
const recordedDuration = startTimeRef.current
? (Date.now() - startTimeRef.current) / 1000
: undefined;
onRecordingComplete?.(blob, recordedDuration);
mediaRecorder.onstop = async () => {
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
// Convert to WAV format to avoid needing ffmpeg on backend
try {
const wavBlob = await convertToWav(webmBlob);
// Pass the actual recorded duration
const recordedDuration = startTimeRef.current
? (Date.now() - startTimeRef.current) / 1000
: undefined;
onRecordingComplete?.(wavBlob, recordedDuration);
} catch (err) {
console.error('Error converting audio to WAV:', err);
// Fallback to original blob if conversion fails
const recordedDuration = startTimeRef.current
? (Date.now() - startTimeRef.current) / 1000
: undefined;
onRecordingComplete?.(webmBlob, recordedDuration);
}
// Stop all tracks
streamRef.current?.getTracks().forEach((track) => {
+15 -7
View File
@@ -22,6 +22,7 @@ export function useSystemAudioCapture({
const timerRef = useRef<number | null>(null);
const startTimeRef = useRef<number | null>(null);
const stopRecordingRef = useRef<(() => Promise<void>) | null>(null);
const isRecordingRef = useRef(false);
// Check if system audio capture is supported
useEffect(() => {
@@ -62,6 +63,7 @@ export function useSystemAudioCapture({
});
setIsRecording(true);
isRecordingRef.current = true;
startTimeRef.current = Date.now();
// Start timer
@@ -93,6 +95,7 @@ export function useSystemAudioCapture({
try {
setIsRecording(false);
isRecordingRef.current = false;
if (timerRef.current !== null) {
clearInterval(timerRef.current);
@@ -130,32 +133,37 @@ export function useSystemAudioCapture({
}, [stopRecording]);
const cancelRecording = useCallback(async () => {
if (isRecording) {
if (isRecordingRef.current) {
await stopRecording();
}
setIsRecording(false);
isRecordingRef.current = false;
setDuration(0);
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, [isRecording, stopRecording]);
}, [stopRecording]);
// Cleanup on unmount
// Cleanup on unmount only
useEffect(() => {
return () => {
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
// Cancel recording on unmount if still recording
if (isRecording) {
void cancelRecording();
if (isRecordingRef.current && isTauri()) {
// Call stop directly without the callback to avoid stale closure
invoke('stop_system_audio_capture').catch((err) => {
console.error('Error stopping audio capture on unmount:', err);
});
}
};
// biome-ignore lint/correctness/useExhaustiveDependencies: cancelRecording is stable
}, [isRecording]);
// biome-ignore lint/correctness/useExhaustiveDependencies: Only run on unmount
}, []);
return {
isRecording,
+101
View File
@@ -16,3 +16,104 @@ export function formatAudioDuration(seconds: number): string {
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
/**
* Convert any audio blob to WAV format using Web Audio API.
* This ensures compatibility without requiring ffmpeg on the backend.
*/
export async function convertToWav(audioBlob: Blob): Promise<Blob> {
// Create audio context
const audioContext = new AudioContext();
// Read blob as array buffer
const arrayBuffer = await audioBlob.arrayBuffer();
// Decode audio data
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
// Convert to WAV
const wavBlob = audioBufferToWav(audioBuffer);
// Close audio context to free resources
await audioContext.close();
return wavBlob;
}
/**
* Convert AudioBuffer to WAV blob.
*/
function audioBufferToWav(buffer: AudioBuffer): Blob {
const numberOfChannels = buffer.numberOfChannels;
const sampleRate = buffer.sampleRate;
const format = 1; // PCM
const bitDepth = 16;
const bytesPerSample = bitDepth / 8;
const blockAlign = numberOfChannels * bytesPerSample;
// Interleave channels
const interleaved = interleaveChannels(buffer);
// Create WAV file
const dataLength = interleaved.length * bytesPerSample;
const buffer2 = new ArrayBuffer(44 + dataLength);
const view = new DataView(buffer2);
// Write WAV header
writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + dataLength, true);
writeString(view, 8, 'WAVE');
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true); // fmt chunk size
view.setUint16(20, format, true); // audio format (PCM)
view.setUint16(22, numberOfChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * blockAlign, true); // byte rate
view.setUint16(32, blockAlign, true);
view.setUint16(34, bitDepth, true);
writeString(view, 36, 'data');
view.setUint32(40, dataLength, true);
// Write audio data
floatTo16BitPCM(view, 44, interleaved);
return new Blob([buffer2], { type: 'audio/wav' });
}
/**
* Interleave multiple channels into a single array.
*/
function interleaveChannels(buffer: AudioBuffer): Float32Array {
const numberOfChannels = buffer.numberOfChannels;
const length = buffer.length;
const interleaved = new Float32Array(length * numberOfChannels);
for (let channel = 0; channel < numberOfChannels; channel++) {
const channelData = buffer.getChannelData(channel);
for (let i = 0; i < length; i++) {
interleaved[i * numberOfChannels + channel] = channelData[i];
}
}
return interleaved;
}
/**
* Write string to DataView.
*/
function writeString(view: DataView, offset: number, string: string): void {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
/**
* Convert float32 audio data to 16-bit PCM.
*/
function floatTo16BitPCM(view: DataView, offset: number, input: Float32Array): void {
for (let i = 0; i < input.length; i++, offset += 2) {
const s = Math.max(-1, Math.min(1, input[i]));
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
}
}
+1 -3
View File
@@ -28,9 +28,7 @@ export const useAudioChannelStore = create<AudioChannelStore>()(
})),
updateChannel: (id, updates) =>
set((state) => ({
channels: state.channels.map((ch) =>
ch.id === id ? { ...ch, ...updates } : ch,
),
channels: state.channels.map((ch) => (ch.id === id ? { ...ch, ...updates } : ch)),
})),
removeChannel: (id) =>
set((state) => ({