mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 00:55:14 -07:00
Implement FloatingGenerateBox component and update App layout
- Introduced the FloatingGenerateBox component for audio generation, enhancing user interaction with voice profiles. - Updated App component to integrate FloatingGenerateBox and removed the GenerationForm component. - Enhanced the layout for better responsiveness and added functionality to manage audio playback state. - Updated UpdateStatus component to include new update handling logic and improved UI feedback for update readiness.
This commit is contained in:
+10
-4
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||||
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
|
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 { HistoryTable } from '@/components/History/HistoryTable';
|
||||||
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
||||||
import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
|
import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
setupWindowCloseHandler,
|
setupWindowCloseHandler,
|
||||||
startServer,
|
startServer,
|
||||||
} from '@/lib/tauri';
|
} from '@/lib/tauri';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
// Track if server is starting to prevent duplicate starts
|
// Track if server is starting to prevent duplicate starts
|
||||||
@@ -53,6 +55,7 @@ function App() {
|
|||||||
const [activeTab, setActiveTab] = useState('main');
|
const [activeTab, setActiveTab] = useState('main');
|
||||||
const [serverReady, setServerReady] = useState(false);
|
const [serverReady, setServerReady] = useState(false);
|
||||||
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
||||||
|
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||||
|
|
||||||
// Monitor active downloads/generations and show toasts for them
|
// Monitor active downloads/generations and show toasts for them
|
||||||
const activeDownloads = useRestoreActiveTasks();
|
const activeDownloads = useRestoreActiveTasks();
|
||||||
@@ -196,7 +199,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// Main view: Profiles top left, Generator bottom left, History right
|
// 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 */}
|
{/* Left Column */}
|
||||||
<div className="flex flex-col gap-6 min-h-0 overflow-y-auto pb-32">
|
<div className="flex flex-col gap-6 min-h-0 overflow-y-auto pb-32">
|
||||||
{/* Profiles - Top Left */}
|
{/* Profiles - Top Left */}
|
||||||
@@ -205,15 +208,18 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Generator - Bottom Left */}
|
{/* Generator - Bottom Left */}
|
||||||
<div className="shrink-0">
|
{/* <div className="shrink-0">
|
||||||
<GenerationForm />
|
<GenerationForm />
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column - History */}
|
{/* Right Column - History */}
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||||
<HistoryTable />
|
<HistoryTable />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Generate Box */}
|
||||||
|
<FloatingGenerateBox isPlayerOpen={!!audioUrl} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
|||||||
import { getVersion } from '@tauri-apps/api/app';
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
|
|
||||||
export function UpdateStatus() {
|
export function UpdateStatus() {
|
||||||
const { status, checkForUpdates, downloadAndInstall } = useAutoUpdater(false);
|
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
|
||||||
const [currentVersion, setCurrentVersion] = useState<string>('');
|
const [currentVersion, setCurrentVersion] = useState<string>('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -30,7 +30,7 @@ export function UpdateStatus() {
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={checkForUpdates}
|
onClick={checkForUpdates}
|
||||||
disabled={status.checking || status.downloading || status.installing}
|
disabled={status.checking || status.downloading || status.readyToInstall}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
@@ -53,7 +53,7 @@ export function UpdateStatus() {
|
|||||||
</div>
|
</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="space-y-3 p-4 border rounded-lg bg-primary/5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -64,7 +64,7 @@ export function UpdateStatus() {
|
|||||||
</div>
|
</div>
|
||||||
<Button onClick={downloadAndInstall} className="w-full" size="sm">
|
<Button onClick={downloadAndInstall} className="w-full" size="sm">
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
Install Update
|
Download Update
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -91,13 +91,22 @@ export function UpdateStatus() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status.installing && (
|
{status.readyToInstall && (
|
||||||
<div className="space-y-2">
|
<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 text-sm">
|
<div className="flex items-center gap-2">
|
||||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||||
Installing update...
|
<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>
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -525,94 +525,72 @@ export function ProfileForm() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={sampleMode}
|
value={sampleMode}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
const newMode = v as 'upload' | 'record' | 'system';
|
const newMode = v as 'upload' | 'record' | 'system';
|
||||||
// Cancel any active recordings when switching modes
|
// Cancel any active recordings when switching modes
|
||||||
if (isRecording && newMode !== 'record') {
|
if (isRecording && newMode !== 'record') {
|
||||||
cancelRecording();
|
cancelRecording();
|
||||||
}
|
}
|
||||||
if (isSystemRecording && newMode !== 'system') {
|
if (isSystemRecording && newMode !== 'system') {
|
||||||
cancelSystemRecording();
|
cancelSystemRecording();
|
||||||
}
|
}
|
||||||
setSampleMode(newMode);
|
setSampleMode(newMode);
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
<TabsList
|
|
||||||
className={`grid w-full ${isTauri() && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
|
||||||
>
|
>
|
||||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
<TabsList
|
||||||
<Upload className="h-4 w-4 shrink-0" />
|
className={`grid w-full ${isTauri() && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||||
Upload
|
>
|
||||||
</TabsTrigger>
|
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||||
<TabsTrigger value="record" className="flex items-center gap-2">
|
<Upload className="h-4 w-4 shrink-0" />
|
||||||
<Mic className="h-4 w-4 shrink-0" />
|
Upload
|
||||||
Record
|
|
||||||
</TabsTrigger>
|
|
||||||
{isTauri() && isSystemAudioSupported && (
|
|
||||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
|
||||||
<Monitor className="h-4 w-4 shrink-0" />
|
|
||||||
System Audio
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||||
</TabsList>
|
<Mic className="h-4 w-4 shrink-0" />
|
||||||
|
Record
|
||||||
<TabsContent value="upload" className="space-y-4">
|
</TabsTrigger>
|
||||||
<FormField
|
{isTauri() && isSystemAudioSupported && (
|
||||||
control={form.control}
|
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||||
name="sampleFile"
|
<Monitor className="h-4 w-4 shrink-0" />
|
||||||
render={({ field: { onChange, name } }) => (
|
System Audio
|
||||||
<AudioSampleUpload
|
</TabsTrigger>
|
||||||
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}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
</TabsList>
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="record" className="space-y-4">
|
<TabsContent value="upload" className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="sampleFile"
|
name="sampleFile"
|
||||||
render={() => (
|
render={({ field: { onChange, name } }) => (
|
||||||
<AudioSampleRecording
|
<AudioSampleUpload
|
||||||
file={selectedFile}
|
file={selectedFile}
|
||||||
isRecording={isRecording}
|
onFileChange={onChange}
|
||||||
duration={duration}
|
onTranscribe={handleTranscribe}
|
||||||
onStart={startRecording}
|
onPlayPause={handlePlayPause}
|
||||||
onStop={stopRecording}
|
isPlaying={isPlaying}
|
||||||
onCancel={handleCancelRecording}
|
isValidating={isValidatingAudio}
|
||||||
onTranscribe={handleTranscribe}
|
isTranscribing={transcribe.isPending}
|
||||||
onPlayPause={handlePlayPause}
|
isDisabled={
|
||||||
isPlaying={isPlaying}
|
audioDuration !== null && audioDuration > MAX_AUDIO_DURATION_SECONDS
|
||||||
isTranscribing={transcribe.isPending}
|
}
|
||||||
/>
|
fieldName={name}
|
||||||
)}
|
/>
|
||||||
/>
|
)}
|
||||||
</TabsContent>
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{isTauri() && isSystemAudioSupported && (
|
<TabsContent value="record" className="space-y-4">
|
||||||
<TabsContent value="system" className="space-y-4">
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="sampleFile"
|
name="sampleFile"
|
||||||
render={() => (
|
render={() => (
|
||||||
<AudioSampleSystem
|
<AudioSampleRecording
|
||||||
file={selectedFile}
|
file={selectedFile}
|
||||||
isRecording={isSystemRecording}
|
isRecording={isRecording}
|
||||||
duration={systemDuration}
|
duration={duration}
|
||||||
onStart={startSystemRecording}
|
onStart={startRecording}
|
||||||
onStop={stopSystemRecording}
|
onStop={stopRecording}
|
||||||
onCancel={handleCancelRecording}
|
onCancel={handleCancelRecording}
|
||||||
onTranscribe={handleTranscribe}
|
onTranscribe={handleTranscribe}
|
||||||
onPlayPause={handlePlayPause}
|
onPlayPause={handlePlayPause}
|
||||||
@@ -622,8 +600,30 @@ export function ProfileForm() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
|
||||||
</Tabs>
|
{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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
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';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
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();
|
||||||
|
const { data: historyData } = useHistory({ limit: 1000 });
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||||
|
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
||||||
|
const deleteProfile = useDeleteProfile();
|
||||||
|
|
||||||
|
// Get generation counts per profile
|
||||||
|
const generationCounts = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
if (historyData?.items) {
|
||||||
|
historyData.items.forEach((item) => {
|
||||||
|
counts[item.profile_id] = (counts[item.profile_id] || 0) + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [historyData]);
|
||||||
|
|
||||||
|
// Get channel assignments for each profile
|
||||||
|
const { data: channelAssignments } = useQuery({
|
||||||
|
queryKey: ['profile-channels'],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!profiles) return {};
|
||||||
|
const assignments: Record<string, string[]> = {};
|
||||||
|
for (const profile of profiles) {
|
||||||
|
try {
|
||||||
|
const result = await apiClient.getProfileChannels(profile.id);
|
||||||
|
assignments[profile.id] = result.channel_ids;
|
||||||
|
} catch {
|
||||||
|
assignments[profile.id] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return assignments;
|
||||||
|
},
|
||||||
|
enabled: !!profiles,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all channels
|
||||||
|
const { data: channels } = useQuery({
|
||||||
|
queryKey: ['channels'],
|
||||||
|
queryFn: () => apiClient.listChannels(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEdit = (profileId: string) => {
|
||||||
|
setEditingProfileId(profileId);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (profileId: string) => {
|
||||||
|
if (confirm('Are you sure you want to delete this profile?')) {
|
||||||
|
deleteProfile.mutate(profileId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChannelChange = async (profileId: string, channelIds: string[]) => {
|
||||||
|
try {
|
||||||
|
await apiClient.setProfileChannels(profileId, channelIds);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update channels:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-muted-foreground">Loading voices...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Voices</h1>
|
||||||
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
New Voice
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Language</TableHead>
|
||||||
|
<TableHead>Generations</TableHead>
|
||||||
|
<TableHead>Samples</TableHead>
|
||||||
|
<TableHead>Channels</TableHead>
|
||||||
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{profiles?.map((profile) => (
|
||||||
|
<VoiceRow
|
||||||
|
key={profile.id}
|
||||||
|
profile={profile}
|
||||||
|
generationCount={generationCounts[profile.id] || 0}
|
||||||
|
channelIds={channelAssignments?.[profile.id] || []}
|
||||||
|
channels={channels || []}
|
||||||
|
onChannelChange={(channelIds) => handleChannelChange(profile.id, channelIds)}
|
||||||
|
onEdit={() => handleEdit(profile.id)}
|
||||||
|
onDelete={() => handleDelete(profile.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VoiceRowProps {
|
||||||
|
profile: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
language: string;
|
||||||
|
};
|
||||||
|
generationCount: number;
|
||||||
|
channelIds: string[];
|
||||||
|
channels: Array<{ id: string; name: string; is_default: boolean }>;
|
||||||
|
onChannelChange: (channelIds: string[]) => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VoiceRow({
|
||||||
|
profile,
|
||||||
|
generationCount,
|
||||||
|
channelIds,
|
||||||
|
channels,
|
||||||
|
onChannelChange,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: VoiceRowProps) {
|
||||||
|
const { data: samples } = useProfileSamples(profile.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{profile.name}</div>
|
||||||
|
{profile.description && (
|
||||||
|
<div className="text-sm text-muted-foreground">{profile.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{profile.language}</TableCell>
|
||||||
|
<TableCell>{generationCount}</TableCell>
|
||||||
|
<TableCell>{samples?.length || 0}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<select
|
||||||
|
multiple
|
||||||
|
value={channelIds}
|
||||||
|
onChange={(e) => {
|
||||||
|
const selected = Array.from(e.target.selectedOptions, (opt) => opt.value);
|
||||||
|
onChannelChange(selected);
|
||||||
|
}}
|
||||||
|
className="w-full min-w-[200px] border rounded px-2 py-1 text-sm"
|
||||||
|
size={Math.min(channels.length + 1, 5)}
|
||||||
|
>
|
||||||
|
{channels.map((ch) => (
|
||||||
|
<option key={ch.id} value={ch.id}>
|
||||||
|
{ch.name} {ch.is_default && '(Default)'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem onClick={onEdit}>
|
||||||
|
<Edit className="h-4 w-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onDelete} className="text-destructive">
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,12 +8,18 @@ export interface UpdateStatus {
|
|||||||
version?: string;
|
version?: string;
|
||||||
downloading: boolean;
|
downloading: boolean;
|
||||||
installing: boolean;
|
installing: boolean;
|
||||||
|
readyToInstall: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
downloadProgress?: number; // 0-100 percentage
|
downloadProgress?: number; // 0-100 percentage
|
||||||
downloadedBytes?: number;
|
downloadedBytes?: number;
|
||||||
totalBytes?: number;
|
totalBytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if we're on Windows (NSIS installer handles restart automatically)
|
||||||
|
const isWindows = () => {
|
||||||
|
return navigator.userAgent.includes('Windows');
|
||||||
|
};
|
||||||
|
|
||||||
const isTauri = () => {
|
const isTauri = () => {
|
||||||
return '__TAURI_INTERNALS__' in window;
|
return '__TAURI_INTERNALS__' in window;
|
||||||
};
|
};
|
||||||
@@ -24,6 +30,7 @@ export function useAutoUpdater(checkOnMount = false) {
|
|||||||
available: false,
|
available: false,
|
||||||
downloading: false,
|
downloading: false,
|
||||||
installing: false,
|
installing: false,
|
||||||
|
readyToInstall: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [update, setUpdate] = useState<Update | null>(null);
|
const [update, setUpdate] = useState<Update | null>(null);
|
||||||
@@ -46,6 +53,7 @@ export function useAutoUpdater(checkOnMount = false) {
|
|||||||
version: foundUpdate.version,
|
version: foundUpdate.version,
|
||||||
downloading: false,
|
downloading: false,
|
||||||
installing: false,
|
installing: false,
|
||||||
|
readyToInstall: false,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setStatus({
|
setStatus({
|
||||||
@@ -53,6 +61,7 @@ export function useAutoUpdater(checkOnMount = false) {
|
|||||||
available: false,
|
available: false,
|
||||||
downloading: false,
|
downloading: false,
|
||||||
installing: false,
|
installing: false,
|
||||||
|
readyToInstall: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -61,11 +70,13 @@ export function useAutoUpdater(checkOnMount = false) {
|
|||||||
available: false,
|
available: false,
|
||||||
downloading: false,
|
downloading: false,
|
||||||
installing: false,
|
installing: false,
|
||||||
|
readyToInstall: false,
|
||||||
error: error instanceof Error ? error.message : 'Failed to check for updates',
|
error: error instanceof Error ? error.message : 'Failed to check for updates',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Download the update (but don't install yet)
|
||||||
const downloadAndInstall = async () => {
|
const downloadAndInstall = async () => {
|
||||||
if (!update || !isTauri()) return;
|
if (!update || !isTauri()) return;
|
||||||
|
|
||||||
@@ -75,7 +86,8 @@ export function useAutoUpdater(checkOnMount = false) {
|
|||||||
let downloadedBytes = 0;
|
let downloadedBytes = 0;
|
||||||
let totalBytes = 0;
|
let totalBytes = 0;
|
||||||
|
|
||||||
await update.downloadAndInstall((event) => {
|
// Just download the update
|
||||||
|
await update.download((event) => {
|
||||||
switch (event.event) {
|
switch (event.event) {
|
||||||
case 'Started':
|
case 'Started':
|
||||||
totalBytes = event.data.contentLength || 0;
|
totalBytes = event.data.contentLength || 0;
|
||||||
@@ -104,22 +116,48 @@ export function useAutoUpdater(checkOnMount = false) {
|
|||||||
setStatus((prev) => ({
|
setStatus((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
downloading: false,
|
downloading: false,
|
||||||
installing: true,
|
readyToInstall: true,
|
||||||
downloadProgress: 100
|
downloadProgress: 100
|
||||||
}));
|
}));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await relaunch();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus((prev) => ({
|
setStatus((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
downloading: false,
|
downloading: false,
|
||||||
installing: false,
|
installing: false,
|
||||||
|
readyToInstall: false,
|
||||||
downloadProgress: undefined,
|
downloadProgress: undefined,
|
||||||
downloadedBytes: undefined,
|
downloadedBytes: undefined,
|
||||||
totalBytes: 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',
|
error: error instanceof Error ? error.message : 'Failed to install update',
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -135,5 +173,6 @@ export function useAutoUpdater(checkOnMount = false) {
|
|||||||
status,
|
status,
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
downloadAndInstall,
|
downloadAndInstall,
|
||||||
|
restartAndInstall,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
export interface AudioChannel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
is_default: boolean;
|
||||||
|
device_ids: string[];
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AudioChannelStore {
|
||||||
|
channels: AudioChannel[];
|
||||||
|
setChannels: (channels: AudioChannel[]) => void;
|
||||||
|
addChannel: (channel: AudioChannel) => void;
|
||||||
|
updateChannel: (id: string, channel: Partial<AudioChannel>) => void;
|
||||||
|
removeChannel: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAudioChannelStore = create<AudioChannelStore>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
channels: [],
|
||||||
|
setChannels: (channels) => set({ channels }),
|
||||||
|
addChannel: (channel) =>
|
||||||
|
set((state) => ({
|
||||||
|
channels: [...state.channels, channel],
|
||||||
|
})),
|
||||||
|
updateChannel: (id, updates) =>
|
||||||
|
set((state) => ({
|
||||||
|
channels: state.channels.map((ch) => (ch.id === id ? { ...ch, ...updates } : ch)),
|
||||||
|
})),
|
||||||
|
removeChannel: (id) =>
|
||||||
|
set((state) => ({
|
||||||
|
channels: state.channels.filter((ch) => ch.id !== id),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'voicebox-audio-channels',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""
|
||||||
|
Audio channel management module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
AudioChannelCreate,
|
||||||
|
AudioChannelUpdate,
|
||||||
|
AudioChannelResponse,
|
||||||
|
ChannelVoiceAssignment,
|
||||||
|
ProfileChannelAssignment,
|
||||||
|
)
|
||||||
|
from .database import (
|
||||||
|
AudioChannel as DBAudioChannel,
|
||||||
|
ChannelDeviceMapping as DBChannelDeviceMapping,
|
||||||
|
ProfileChannelMapping as DBProfileChannelMapping,
|
||||||
|
VoiceProfile as DBVoiceProfile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_channels(db: Session) -> List[AudioChannelResponse]:
|
||||||
|
"""List all audio channels."""
|
||||||
|
channels = db.query(DBAudioChannel).all()
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for channel in channels:
|
||||||
|
# Get device IDs for this channel
|
||||||
|
device_mappings = db.query(DBChannelDeviceMapping).filter_by(
|
||||||
|
channel_id=channel.id
|
||||||
|
).all()
|
||||||
|
device_ids = [m.device_id for m in device_mappings]
|
||||||
|
|
||||||
|
result.append(AudioChannelResponse(
|
||||||
|
id=channel.id,
|
||||||
|
name=channel.name,
|
||||||
|
is_default=channel.is_default,
|
||||||
|
device_ids=device_ids,
|
||||||
|
created_at=channel.created_at,
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def get_channel(channel_id: str, db: Session) -> Optional[AudioChannelResponse]:
|
||||||
|
"""Get a channel by ID."""
|
||||||
|
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
|
||||||
|
if not channel:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get device IDs
|
||||||
|
device_mappings = db.query(DBChannelDeviceMapping).filter_by(
|
||||||
|
channel_id=channel.id
|
||||||
|
).all()
|
||||||
|
device_ids = [m.device_id for m in device_mappings]
|
||||||
|
|
||||||
|
return AudioChannelResponse(
|
||||||
|
id=channel.id,
|
||||||
|
name=channel.name,
|
||||||
|
is_default=channel.is_default,
|
||||||
|
device_ids=device_ids,
|
||||||
|
created_at=channel.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_channel(
|
||||||
|
data: AudioChannelCreate,
|
||||||
|
db: Session,
|
||||||
|
) -> AudioChannelResponse:
|
||||||
|
"""Create a new audio channel."""
|
||||||
|
# Check if name already exists
|
||||||
|
existing = db.query(DBAudioChannel).filter_by(name=data.name).first()
|
||||||
|
if existing:
|
||||||
|
raise ValueError(f"Channel with name '{data.name}' already exists")
|
||||||
|
|
||||||
|
# Create channel
|
||||||
|
channel = DBAudioChannel(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
name=data.name,
|
||||||
|
is_default=False,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(channel)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Add device mappings
|
||||||
|
for device_id in data.device_ids:
|
||||||
|
mapping = DBChannelDeviceMapping(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
channel_id=channel.id,
|
||||||
|
device_id=device_id,
|
||||||
|
)
|
||||||
|
db.add(mapping)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(channel)
|
||||||
|
|
||||||
|
return AudioChannelResponse(
|
||||||
|
id=channel.id,
|
||||||
|
name=channel.name,
|
||||||
|
is_default=channel.is_default,
|
||||||
|
device_ids=data.device_ids,
|
||||||
|
created_at=channel.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_channel(
|
||||||
|
channel_id: str,
|
||||||
|
data: AudioChannelUpdate,
|
||||||
|
db: Session,
|
||||||
|
) -> Optional[AudioChannelResponse]:
|
||||||
|
"""Update an audio channel."""
|
||||||
|
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
|
||||||
|
if not channel:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if channel.is_default:
|
||||||
|
raise ValueError("Cannot modify the default channel")
|
||||||
|
|
||||||
|
# Update name if provided
|
||||||
|
if data.name is not None:
|
||||||
|
# Check if name already exists (excluding current channel)
|
||||||
|
existing = db.query(DBAudioChannel).filter(
|
||||||
|
DBAudioChannel.name == data.name,
|
||||||
|
DBAudioChannel.id != channel_id
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise ValueError(f"Channel with name '{data.name}' already exists")
|
||||||
|
channel.name = data.name
|
||||||
|
|
||||||
|
# Update device mappings if provided
|
||||||
|
if data.device_ids is not None:
|
||||||
|
# Delete existing mappings
|
||||||
|
db.query(DBChannelDeviceMapping).filter_by(channel_id=channel_id).delete()
|
||||||
|
|
||||||
|
# Add new mappings
|
||||||
|
for device_id in data.device_ids:
|
||||||
|
mapping = DBChannelDeviceMapping(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
channel_id=channel.id,
|
||||||
|
device_id=device_id,
|
||||||
|
)
|
||||||
|
db.add(mapping)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(channel)
|
||||||
|
|
||||||
|
# Get updated device IDs
|
||||||
|
device_mappings = db.query(DBChannelDeviceMapping).filter_by(
|
||||||
|
channel_id=channel.id
|
||||||
|
).all()
|
||||||
|
device_ids = [m.device_id for m in device_mappings]
|
||||||
|
|
||||||
|
return AudioChannelResponse(
|
||||||
|
id=channel.id,
|
||||||
|
name=channel.name,
|
||||||
|
is_default=channel.is_default,
|
||||||
|
device_ids=device_ids,
|
||||||
|
created_at=channel.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_channel(channel_id: str, db: Session) -> bool:
|
||||||
|
"""Delete an audio channel."""
|
||||||
|
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
|
||||||
|
if not channel:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if channel.is_default:
|
||||||
|
raise ValueError("Cannot delete the default channel")
|
||||||
|
|
||||||
|
# Delete device mappings
|
||||||
|
db.query(DBChannelDeviceMapping).filter_by(channel_id=channel_id).delete()
|
||||||
|
|
||||||
|
# Delete profile-channel mappings
|
||||||
|
db.query(DBProfileChannelMapping).filter_by(channel_id=channel_id).delete()
|
||||||
|
|
||||||
|
# Delete channel
|
||||||
|
db.delete(channel)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def get_channel_voices(channel_id: str, db: Session) -> List[str]:
|
||||||
|
"""Get list of profile IDs assigned to a channel."""
|
||||||
|
mappings = db.query(DBProfileChannelMapping).filter_by(
|
||||||
|
channel_id=channel_id
|
||||||
|
).all()
|
||||||
|
return [m.profile_id for m in mappings]
|
||||||
|
|
||||||
|
|
||||||
|
async def set_channel_voices(
|
||||||
|
channel_id: str,
|
||||||
|
data: ChannelVoiceAssignment,
|
||||||
|
db: Session,
|
||||||
|
) -> None:
|
||||||
|
"""Set which voices are assigned to a channel."""
|
||||||
|
# Verify channel exists
|
||||||
|
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
|
||||||
|
if not channel:
|
||||||
|
raise ValueError(f"Channel {channel_id} not found")
|
||||||
|
|
||||||
|
# Verify all profiles exist
|
||||||
|
for profile_id in data.profile_ids:
|
||||||
|
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
|
||||||
|
if not profile:
|
||||||
|
raise ValueError(f"Profile {profile_id} not found")
|
||||||
|
|
||||||
|
# Delete existing mappings for this channel
|
||||||
|
db.query(DBProfileChannelMapping).filter_by(channel_id=channel_id).delete()
|
||||||
|
|
||||||
|
# Add new mappings
|
||||||
|
for profile_id in data.profile_ids:
|
||||||
|
mapping = DBProfileChannelMapping(
|
||||||
|
profile_id=profile_id,
|
||||||
|
channel_id=channel_id,
|
||||||
|
)
|
||||||
|
db.add(mapping)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_profile_channels(profile_id: str, db: Session) -> List[str]:
|
||||||
|
"""Get list of channel IDs assigned to a profile."""
|
||||||
|
mappings = db.query(DBProfileChannelMapping).filter_by(
|
||||||
|
profile_id=profile_id
|
||||||
|
).all()
|
||||||
|
return [m.channel_id for m in mappings]
|
||||||
|
|
||||||
|
|
||||||
|
async def set_profile_channels(
|
||||||
|
profile_id: str,
|
||||||
|
data: ProfileChannelAssignment,
|
||||||
|
db: Session,
|
||||||
|
) -> None:
|
||||||
|
"""Set which channels a profile is assigned to."""
|
||||||
|
# Verify profile exists
|
||||||
|
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
|
||||||
|
if not profile:
|
||||||
|
raise ValueError(f"Profile {profile_id} not found")
|
||||||
|
|
||||||
|
# Verify all channels exist
|
||||||
|
for channel_id in data.channel_ids:
|
||||||
|
channel = db.query(DBAudioChannel).filter_by(id=channel_id).first()
|
||||||
|
if not channel:
|
||||||
|
raise ValueError(f"Channel {channel_id} not found")
|
||||||
|
|
||||||
|
# Delete existing mappings for this profile
|
||||||
|
db.query(DBProfileChannelMapping).filter_by(profile_id=profile_id).delete()
|
||||||
|
|
||||||
|
# Add new mappings
|
||||||
|
for channel_id in data.channel_ids:
|
||||||
|
mapping = DBProfileChannelMapping(
|
||||||
|
profile_id=profile_id,
|
||||||
|
channel_id=channel_id,
|
||||||
|
)
|
||||||
|
db.add(mapping)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
@@ -36,6 +36,7 @@ windows = { version = "0.62", features = ["Win32_Foundation", "Win32_UI_WindowsA
|
|||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-updater = "2.0"
|
tauri-plugin-updater = "2.0"
|
||||||
|
tauri-plugin-process = "2.0"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# This feature is used for production builds or when `devPath` points to the filesystem
|
# This feature is used for production builds or when `devPath` points to the filesystem
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"shell:allow-execute",
|
"shell:allow-execute",
|
||||||
"shell:allow-spawn",
|
"shell:allow-spawn",
|
||||||
"updater:default",
|
"updater:default",
|
||||||
|
"process:default",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"dialog:allow-save",
|
"dialog:allow-save",
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
|
|||||||
@@ -382,7 +382,10 @@ pub fn run() {
|
|||||||
.manage(audio_capture::AudioCaptureState::new())
|
.manage(audio_capture::AudioCaptureState::new())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
{
|
||||||
|
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||||
|
app.handle().plugin(tauri_plugin_process::init())?;
|
||||||
|
}
|
||||||
|
|
||||||
// Hide title bar icon on Windows
|
// Hide title bar icon on Windows
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
|
|||||||
Reference in New Issue
Block a user