mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 00:55:14 -07:00
Merge branch 'main' into channels
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 0.1.1
|
current_version = 0.1.3
|
||||||
commit = True
|
commit = True
|
||||||
tag = True
|
tag = True
|
||||||
tag_name = v{new_version}
|
tag_name = v{new_version}
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ target/
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
tauri/src-tauri/gen/Assets.car
|
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/app",
|
"name": "@voicebox/app",
|
||||||
"version": "0.1.1",
|
"version": "0.1.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+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 ShinyText from '@/components/ShinyText';
|
import ShinyText from '@/components/ShinyText';
|
||||||
import { Sidebar } from '@/components/Sidebar';
|
import { Sidebar } from '@/components/Sidebar';
|
||||||
@@ -20,6 +21,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
|
||||||
@@ -52,6 +54,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();
|
||||||
@@ -175,7 +178,7 @@ function App() {
|
|||||||
<div className="container mx-auto px-8 max-w-[1800px] h-full overflow-hidden flex flex-col">
|
<div className="container mx-auto px-8 max-w-[1800px] h-full overflow-hidden flex flex-col">
|
||||||
{activeTab === 'main' && (
|
{activeTab === 'main' && (
|
||||||
// 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 */}
|
||||||
@@ -184,15 +187,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>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'voices' && <VoicesTab />}
|
{activeTab === 'voices' && <VoicesTab />}
|
||||||
|
|||||||
@@ -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';
|
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,28 +64,49 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status.downloading && (
|
{status.downloading && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<Download className="h-4 w-4" />
|
<div className="flex items-center gap-2">
|
||||||
Downloading update...
|
<Download className="h-4 w-4" />
|
||||||
|
Downloading update...
|
||||||
|
</div>
|
||||||
|
{status.downloadProgress !== undefined && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{status.downloadProgress}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { Button } from '@/components/ui/button';
|
||||||
import { FormControl, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
import { FormControl, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
@@ -35,12 +35,7 @@ export function AudioSampleRecording({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{!isRecording && !file && (
|
{!isRecording && !file && (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
||||||
<Button
|
<Button type="button" onClick={onStart} size="lg" className="flex items-center gap-2">
|
||||||
type="button"
|
|
||||||
onClick={onStart}
|
|
||||||
size="lg"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Mic className="h-5 w-5" />
|
<Mic className="h-5 w-5" />
|
||||||
Start Recording
|
Start Recording
|
||||||
</Button>
|
</Button>
|
||||||
@@ -81,16 +76,9 @@ export function AudioSampleRecording({
|
|||||||
<Mic className="h-5 w-5 text-primary" />
|
<Mic className="h-5 w-5 text-primary" />
|
||||||
<span className="font-medium">Recording complete</span>
|
<span className="font-medium">Recording complete</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||||
File: {file.name}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button type="button" size="icon" variant="outline" onClick={onPlayPause}>
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onPlayPause}
|
|
||||||
>
|
|
||||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
<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 { Button } from '@/components/ui/button';
|
||||||
import { FormControl, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
import { FormControl, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
@@ -35,12 +35,7 @@ export function AudioSampleSystem({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{!isRecording && !file && (
|
{!isRecording && !file && (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
||||||
<Button
|
<Button type="button" onClick={onStart} size="lg" className="flex items-center gap-2">
|
||||||
type="button"
|
|
||||||
onClick={onStart}
|
|
||||||
size="lg"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Monitor className="h-5 w-5" />
|
<Monitor className="h-5 w-5" />
|
||||||
Start Capture
|
Start Capture
|
||||||
</Button>
|
</Button>
|
||||||
@@ -81,16 +76,9 @@ export function AudioSampleSystem({
|
|||||||
<Monitor className="h-5 w-5 text-primary" />
|
<Monitor className="h-5 w-5 text-primary" />
|
||||||
<span className="font-medium">Capture complete</span>
|
<span className="font-medium">Capture complete</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||||
File: {file.name}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button type="button" size="icon" variant="outline" onClick={onPlayPause}>
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onPlayPause}
|
|
||||||
>
|
|
||||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
<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 { useState } from 'react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
import { ProfileDetail } from './ProfileDetail';
|
|
||||||
|
|
||||||
interface ProfileCardProps {
|
interface ProfileCardProps {
|
||||||
profile: VoiceProfileResponse;
|
profile: VoiceProfileResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileCard({ profile }: ProfileCardProps) {
|
export function ProfileCard({ profile }: ProfileCardProps) {
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const deleteProfile = useDeleteProfile();
|
const deleteProfile = useDeleteProfile();
|
||||||
const exportProfile = useExportProfile();
|
const exportProfile = useExportProfile();
|
||||||
@@ -85,14 +83,6 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-0.5 justify-end items-end mt-auto">
|
<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
|
<CircleButton
|
||||||
icon={Download}
|
icon={Download}
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
@@ -117,8 +107,6 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<ProfileDetail profileId={profile.id} open={detailOpen} onOpenChange={setDetailOpen} />
|
|
||||||
|
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -47,6 +47,7 @@ import { useUIStore } from '@/stores/uiStore';
|
|||||||
import { AudioSampleRecording } from './AudioSampleRecording';
|
import { AudioSampleRecording } from './AudioSampleRecording';
|
||||||
import { AudioSampleSystem } from './AudioSampleSystem';
|
import { AudioSampleSystem } from './AudioSampleSystem';
|
||||||
import { AudioSampleUpload } from './AudioSampleUpload';
|
import { AudioSampleUpload } from './AudioSampleUpload';
|
||||||
|
import { SampleList } from './SampleList';
|
||||||
|
|
||||||
// Helper function to get audio duration from File
|
// Helper function to get audio duration from File
|
||||||
async function getAudioDuration(file: File & { recordedDuration?: number }): Promise<number> {
|
async function getAudioDuration(file: File & { recordedDuration?: number }): Promise<number> {
|
||||||
@@ -325,7 +326,7 @@ export function ProfileForm() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast({
|
toast({
|
||||||
title: 'Profile updated',
|
title: 'Voice updated',
|
||||||
description: `"${data.name}" has been updated successfully.`,
|
description: `"${data.name}" has been updated successfully.`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -446,17 +447,17 @@ export function ProfileForm() {
|
|||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="max-w-4xl">
|
<DialogContent className="max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editingProfileId ? 'Edit Profile' : 'Create Voice Profile'}</DialogTitle>
|
<DialogTitle>{editingProfileId ? 'Edit Voice' : 'Create Voice Profile'}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{editingProfileId
|
{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.'}
|
: 'Create a new voice profile with an audio sample to clone the voice.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<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 */}
|
{/* Left column: Profile info */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
@@ -513,104 +514,83 @@ export function ProfileForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right column: Sample upload section - only show when creating */}
|
{/* Right column: Sample management */}
|
||||||
{isCreating && (
|
<div className="space-y-4 border-l pl-6">
|
||||||
<div className="space-y-4 border-l pl-6">
|
{isCreating ? (
|
||||||
<div>
|
<>
|
||||||
<h3 className="text-sm font-medium mb-2">Add Sample</h3>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<h3 className="text-sm font-medium mb-2">Add Sample</h3>
|
||||||
Provide an audio sample to clone the voice. You can add more samples later.
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
</p>
|
Provide an audio sample to clone the voice. You can add more samples later.
|
||||||
</div>
|
</p>
|
||||||
|
</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}
|
||||||
@@ -620,28 +600,58 @@ export function ProfileForm() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<FormField
|
{isTauri() && isSystemAudioSupported && (
|
||||||
control={form.control}
|
<TabsContent value="system" className="space-y-4">
|
||||||
name="referenceText"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="sampleFile"
|
||||||
<FormLabel>Reference Text</FormLabel>
|
render={() => (
|
||||||
<FormControl>
|
<AudioSampleSystem
|
||||||
<Textarea
|
file={selectedFile}
|
||||||
placeholder="Enter the exact text spoken in the audio..."
|
isRecording={isSystemRecording}
|
||||||
className="min-h-[100px]"
|
duration={systemDuration}
|
||||||
{...field}
|
onStart={startSystemRecording}
|
||||||
|
onStop={stopSystemRecording}
|
||||||
|
onCancel={handleCancelRecording}
|
||||||
|
onTranscribe={handleTranscribe}
|
||||||
|
onPlayPause={handlePlayPause}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
isTranscribing={transcribe.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</TabsContent>
|
||||||
<FormMessage />
|
)}
|
||||||
</FormItem>
|
</Tabs>
|
||||||
)}
|
|
||||||
/>
|
<FormField
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
<div className="flex gap-2 justify-end mt-6 pt-4 border-t">
|
<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
|
{createProfile.isPending || updateProfile.isPending || addSample.isPending
|
||||||
? 'Saving...'
|
? 'Saving...'
|
||||||
: editingProfileId
|
: editingProfileId
|
||||||
? 'Update Profile'
|
? 'Save Changes'
|
||||||
: 'Create Profile'}
|
: 'Create Profile'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function ProfileList() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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) => (
|
{allProfiles.map((profile) => (
|
||||||
<ProfileCard key={profile.id} profile={profile} />
|
<ProfileCard key={profile.id} profile={profile} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-lg font-semibold">Audio Samples</h3>
|
<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" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Sample
|
Add Sample
|
||||||
</Button>
|
</Button>
|
||||||
@@ -60,6 +60,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlePlay(sample.reference_text, sample.id)}
|
onClick={() => handlePlay(sample.reference_text, sample.id)}
|
||||||
@@ -69,6 +70,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
Play
|
Play
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDelete(sample.id)}
|
onClick={() => handleDelete(sample.id)}
|
||||||
|
|||||||
@@ -22,15 +22,15 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
|
||||||
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
|
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
|
||||||
|
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
||||||
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
|
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
||||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||||
import { isTauri } from '@/lib/tauri';
|
import { isTauri } from '@/lib/tauri';
|
||||||
import { AudioSampleUpload } from './AudioSampleUpload';
|
|
||||||
import { AudioSampleRecording } from './AudioSampleRecording';
|
import { AudioSampleRecording } from './AudioSampleRecording';
|
||||||
import { AudioSampleSystem } from './AudioSampleSystem';
|
import { AudioSampleSystem } from './AudioSampleSystem';
|
||||||
|
import { AudioSampleUpload } from './AudioSampleUpload';
|
||||||
|
|
||||||
const sampleSchema = z.object({
|
const sampleSchema = z.object({
|
||||||
file: z.instanceof(File, { message: 'Please select an audio file' }),
|
file: z.instanceof(File, { message: 'Please select an audio file' }),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Edit, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
import { Edit, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -15,11 +16,10 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} 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 { 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() {
|
export function VoicesTab() {
|
||||||
const { data: profiles, isLoading } = useProfiles();
|
const { data: profiles, isLoading } = useProfiles();
|
||||||
|
|||||||
@@ -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 { relaunch } from '@tauri-apps/plugin-process';
|
||||||
|
import { check, type Update } from '@tauri-apps/plugin-updater';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
export interface UpdateStatus {
|
export interface UpdateStatus {
|
||||||
checking: boolean;
|
checking: boolean;
|
||||||
@@ -8,9 +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
|
||||||
|
downloadedBytes?: 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;
|
||||||
};
|
};
|
||||||
@@ -21,11 +30,12 @@ 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);
|
||||||
|
|
||||||
const checkForUpdates = async () => {
|
const checkForUpdates = useCallback(async () => {
|
||||||
if (!isTauri()) {
|
if (!isTauri()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -43,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({
|
||||||
@@ -50,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) {
|
||||||
@@ -58,41 +70,93 @@ 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;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setStatus((prev) => ({ ...prev, downloading: true, error: undefined }));
|
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) {
|
switch (event.event) {
|
||||||
case 'Started':
|
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;
|
break;
|
||||||
case 'Progress':
|
case 'Progress': {
|
||||||
console.log(`Downloaded ${event.data.chunkLength} bytes`);
|
downloadedBytes += event.data.chunkLength;
|
||||||
|
const progress =
|
||||||
|
totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : undefined;
|
||||||
|
setStatus((prev) => ({
|
||||||
|
...prev,
|
||||||
|
downloadedBytes,
|
||||||
|
downloadProgress: progress,
|
||||||
|
}));
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'Finished':
|
case 'Finished':
|
||||||
setStatus((prev) => ({
|
setStatus((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
downloading: false,
|
downloading: false,
|
||||||
installing: true,
|
readyToInstall: true,
|
||||||
|
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,
|
||||||
|
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',
|
error: error instanceof Error ? error.message : 'Failed to install update',
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -102,11 +166,12 @@ export function useAutoUpdater(checkOnMount = false) {
|
|||||||
if (checkOnMount && isTauri()) {
|
if (checkOnMount && isTauri()) {
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
}
|
}
|
||||||
}, [checkOnMount]);
|
}, [checkOnMount, checkForUpdates]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
downloadAndInstall,
|
downloadAndInstall,
|
||||||
|
restartAndInstall,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
# React Query hooks will be placed here
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { isTauri } from '@/lib/tauri';
|
import { isTauri } from '@/lib/tauri';
|
||||||
|
import { convertToWav } from '@/lib/utils/audio';
|
||||||
|
|
||||||
interface UseAudioRecordingOptions {
|
interface UseAudioRecordingOptions {
|
||||||
maxDurationSeconds?: number;
|
maxDurationSeconds?: number;
|
||||||
@@ -85,13 +86,26 @@ export function useAudioRecording({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaRecorder.onstop = () => {
|
mediaRecorder.onstop = async () => {
|
||||||
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||||
// Pass the actual recorded duration
|
|
||||||
const recordedDuration = startTimeRef.current
|
// Convert to WAV format to avoid needing ffmpeg on backend
|
||||||
? (Date.now() - startTimeRef.current) / 1000
|
try {
|
||||||
: undefined;
|
const wavBlob = await convertToWav(webmBlob);
|
||||||
onRecordingComplete?.(blob, recordedDuration);
|
|
||||||
|
// 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
|
// Stop all tracks
|
||||||
streamRef.current?.getTracks().forEach((track) => {
|
streamRef.current?.getTracks().forEach((track) => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export function useSystemAudioCapture({
|
|||||||
const timerRef = useRef<number | null>(null);
|
const timerRef = useRef<number | null>(null);
|
||||||
const startTimeRef = useRef<number | null>(null);
|
const startTimeRef = useRef<number | null>(null);
|
||||||
const stopRecordingRef = useRef<(() => Promise<void>) | null>(null);
|
const stopRecordingRef = useRef<(() => Promise<void>) | null>(null);
|
||||||
|
const isRecordingRef = useRef(false);
|
||||||
|
|
||||||
// Check if system audio capture is supported
|
// Check if system audio capture is supported
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -62,6 +63,7 @@ export function useSystemAudioCapture({
|
|||||||
});
|
});
|
||||||
|
|
||||||
setIsRecording(true);
|
setIsRecording(true);
|
||||||
|
isRecordingRef.current = true;
|
||||||
startTimeRef.current = Date.now();
|
startTimeRef.current = Date.now();
|
||||||
|
|
||||||
// Start timer
|
// Start timer
|
||||||
@@ -93,6 +95,7 @@ export function useSystemAudioCapture({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
|
isRecordingRef.current = false;
|
||||||
|
|
||||||
if (timerRef.current !== null) {
|
if (timerRef.current !== null) {
|
||||||
clearInterval(timerRef.current);
|
clearInterval(timerRef.current);
|
||||||
@@ -130,32 +133,37 @@ export function useSystemAudioCapture({
|
|||||||
}, [stopRecording]);
|
}, [stopRecording]);
|
||||||
|
|
||||||
const cancelRecording = useCallback(async () => {
|
const cancelRecording = useCallback(async () => {
|
||||||
if (isRecording) {
|
if (isRecordingRef.current) {
|
||||||
await stopRecording();
|
await stopRecording();
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
|
isRecordingRef.current = false;
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
|
|
||||||
if (timerRef.current !== null) {
|
if (timerRef.current !== null) {
|
||||||
clearInterval(timerRef.current);
|
clearInterval(timerRef.current);
|
||||||
timerRef.current = null;
|
timerRef.current = null;
|
||||||
}
|
}
|
||||||
}, [isRecording, stopRecording]);
|
}, [stopRecording]);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount only
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (timerRef.current !== null) {
|
if (timerRef.current !== null) {
|
||||||
clearInterval(timerRef.current);
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
}
|
}
|
||||||
// Cancel recording on unmount if still recording
|
// Cancel recording on unmount if still recording
|
||||||
if (isRecording) {
|
if (isRecordingRef.current && isTauri()) {
|
||||||
void cancelRecording();
|
// 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
|
// biome-ignore lint/correctness/useExhaustiveDependencies: Only run on unmount
|
||||||
}, [isRecording]);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isRecording,
|
isRecording,
|
||||||
|
|||||||
@@ -16,3 +16,104 @@ export function formatAudioDuration(seconds: number): string {
|
|||||||
const secs = Math.floor(seconds % 60);
|
const secs = Math.floor(seconds % 60);
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,9 +28,7 @@ export const useAudioChannelStore = create<AudioChannelStore>()(
|
|||||||
})),
|
})),
|
||||||
updateChannel: (id, updates) =>
|
updateChannel: (id, updates) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
channels: state.channels.map((ch) =>
|
channels: state.channels.map((ch) => (ch.id === id ? { ...ch, ...updates } : ch)),
|
||||||
ch.id === id ? { ...ch, ...updates } : ch,
|
|
||||||
),
|
|
||||||
})),
|
})),
|
||||||
removeChannel: (id) =>
|
removeChannel: (id) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
|
|||||||
+17
-4
@@ -47,7 +47,7 @@ app.add_middleware(
|
|||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
"""Root endpoint."""
|
"""Root endpoint."""
|
||||||
return {"message": "voicebox API", "version": "0.1.1"}
|
return {"message": "voicebox API", "version": "0.1.3"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", response_model=models.HealthResponse)
|
@app.get("/health", response_model=models.HealthResponse)
|
||||||
@@ -58,10 +58,14 @@ async def health():
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
tts_model = tts.get_tts_model()
|
tts_model = tts.get_tts_model()
|
||||||
gpu_available = torch.cuda.is_available()
|
|
||||||
|
# Check for GPU availability (CUDA or MPS)
|
||||||
|
has_cuda = torch.cuda.is_available()
|
||||||
|
has_mps = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()
|
||||||
|
gpu_available = has_cuda or has_mps
|
||||||
|
|
||||||
vram_used = None
|
vram_used = None
|
||||||
if gpu_available:
|
if has_cuda:
|
||||||
vram_used = torch.cuda.memory_allocated() / 1024 / 1024 # MB
|
vram_used = torch.cuda.memory_allocated() / 1024 / 1024 # MB
|
||||||
|
|
||||||
# Check if model is loaded - use the same logic as model status endpoint
|
# Check if model is loaded - use the same logic as model status endpoint
|
||||||
@@ -1172,13 +1176,22 @@ async def get_active_tasks():
|
|||||||
# STARTUP & SHUTDOWN
|
# STARTUP & SHUTDOWN
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
|
def _get_gpu_status() -> str:
|
||||||
|
"""Get GPU availability status."""
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
return f"CUDA ({torch.cuda.get_device_name(0)})"
|
||||||
|
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
||||||
|
return "MPS (Apple Silicon)"
|
||||||
|
return "None (CPU only)"
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
"""Run on application startup."""
|
"""Run on application startup."""
|
||||||
print("voicebox API starting up...")
|
print("voicebox API starting up...")
|
||||||
database.init_db()
|
database.init_db()
|
||||||
print(f"Database initialized at {database._db_path}")
|
print(f"Database initialized at {database._db_path}")
|
||||||
print(f"GPU available: {torch.cuda.is_available()}")
|
print(f"GPU available: {_get_gpu_status()}")
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ from PyInstaller.utils.hooks import collect_submodules
|
|||||||
from PyInstaller.utils.hooks import copy_metadata
|
from PyInstaller.utils.hooks import copy_metadata
|
||||||
|
|
||||||
datas = []
|
datas = []
|
||||||
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli']
|
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'pkg_resources.extern']
|
||||||
datas += collect_data_files('qwen_tts')
|
datas += collect_data_files('qwen_tts')
|
||||||
datas += copy_metadata('qwen-tts')
|
datas += copy_metadata('qwen-tts')
|
||||||
hiddenimports += collect_submodules('qwen_tts')
|
hiddenimports += collect_submodules('qwen_tts')
|
||||||
|
hiddenimports += collect_submodules('jaraco')
|
||||||
|
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
['server.py'],
|
['server.py'],
|
||||||
pathex=['/Users/jamespine/Projects/voice/Qwen3-TTS'],
|
pathex=['C:\\Users\\ijame\\Projects\\voice\\Qwen3-TTS'],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=datas,
|
datas=datas,
|
||||||
hiddenimports=hiddenimports,
|
hiddenimports=hiddenimports,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/landing",
|
"name": "@voicebox/landing",
|
||||||
"version": "0.1.1",
|
"version": "0.1.3",
|
||||||
"description": "Landing page for voicebox.sh",
|
"description": "Landing page for voicebox.sh",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun --bun next dev --turbo",
|
"dev": "bun --bun next dev --turbo",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "voicebox",
|
"name": "voicebox",
|
||||||
"version": "0.1.1",
|
"version": "0.1.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"app",
|
"app",
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"dev": "cd tauri && bun run tauri dev",
|
"dev": "cd tauri && bun run tauri dev",
|
||||||
"dev:web": "cd web && bun run dev",
|
"dev:web": "cd web && bun run dev",
|
||||||
"dev:landing": "cd landing && bun run dev",
|
"dev:landing": "cd landing && bun run dev",
|
||||||
"dev:server": "source backend/venv/bin/activate && uvicorn backend.main:app --reload --port 8000",
|
"dev:server": "uvicorn backend.main:app --reload --port 8000",
|
||||||
"build": "cd tauri && bun run tauri build",
|
"build": "cd tauri && bun run tauri build",
|
||||||
"build:web": "cd web && bun run build",
|
"build:web": "cd web && bun run build",
|
||||||
"build:landing": "cd landing && bun run build",
|
"build:landing": "cd landing && bun run build",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/tauri",
|
"name": "@voicebox/tauri",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.1",
|
"version": "0.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Generated
+13
-1
@@ -3836,6 +3836,16 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-process"
|
||||||
|
version = "2.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
|
||||||
|
dependencies = [
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-shell"
|
name = "tauri-plugin-shell"
|
||||||
version = "2.3.4"
|
version = "2.3.4"
|
||||||
@@ -4483,13 +4493,14 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "voicebox"
|
name = "voicebox"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"coreaudio-sys",
|
"coreaudio-sys",
|
||||||
"hound",
|
"hound",
|
||||||
"objc",
|
"objc",
|
||||||
|
"scopeguard",
|
||||||
"screencapturekit",
|
"screencapturekit",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -4497,6 +4508,7 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-fs",
|
"tauri-plugin-fs",
|
||||||
|
"tauri-plugin-process",
|
||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "voicebox"
|
name = "voicebox"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
|
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
license = ""
|
license = ""
|
||||||
@@ -24,6 +24,7 @@ hound = "3.5"
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
cpal = "0.15"
|
cpal = "0.15"
|
||||||
symphonia = { version = "0.5", features = ["wav", "pcm"] }
|
symphonia = { version = "0.5", features = ["wav", "pcm"] }
|
||||||
|
scopeguard = "1.2.0"
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
screencapturekit = { version = "1", features = ["async"] }
|
screencapturekit = { version = "1", features = ["async"] }
|
||||||
@@ -33,10 +34,11 @@ core-foundation-sys = "0.8"
|
|||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
wasapi = "0.22"
|
wasapi = "0.22"
|
||||||
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging"] }
|
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_Com"] }
|
||||||
|
|
||||||
[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
|
||||||
|
|||||||
@@ -8,5 +8,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.cs.disable-library-validation</key>
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"default":{"identifier":"default","description":"Default permissions for voicebox","remote":{"urls":["http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","core:window:default","core:window:allow-start-dragging","core:webview:default","core:webview:allow-internal-toggle-devtools","shell:allow-open","shell:allow-execute","shell:allow-spawn","updater:default","dialog:default","dialog:allow-save","dialog:allow-open","fs:default","fs:read-all","fs:write-all"],"platforms":["linux","macOS","windows"]}}
|
{"default":{"identifier":"default","description":"Default permissions for voicebox","remote":{"urls":["http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","core:window:default","core:window:allow-start-dragging","core:webview:default","core:webview:allow-internal-toggle-devtools","shell:allow-open","shell:allow-execute","shell:allow-spawn","updater:default","process:default","dialog:default","dialog:allow-save","dialog:allow-open","fs:default","fs:read-all","fs:write-all"],"platforms":["linux","macOS","windows"]}}
|
||||||
@@ -5948,6 +5948,36 @@
|
|||||||
"const": "fs:write-files",
|
"const": "fs:write-files",
|
||||||
"markdownDescription": "This enables all file write related commands without any pre-configured accessible paths."
|
"markdownDescription": "This enables all file write related commands without any pre-configured accessible paths."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "process:default",
|
||||||
|
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the exit command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "process:allow-exit",
|
||||||
|
"markdownDescription": "Enables the exit command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the restart command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "process:allow-restart",
|
||||||
|
"markdownDescription": "Enables the restart command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the exit command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "process:deny-exit",
|
||||||
|
"markdownDescription": "Denies the exit command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the restart command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "process:deny-restart",
|
||||||
|
"markdownDescription": "Denies the restart command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -5948,6 +5948,36 @@
|
|||||||
"const": "fs:write-files",
|
"const": "fs:write-files",
|
||||||
"markdownDescription": "This enables all file write related commands without any pre-configured accessible paths."
|
"markdownDescription": "This enables all file write related commands without any pre-configured accessible paths."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "process:default",
|
||||||
|
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the exit command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "process:allow-exit",
|
||||||
|
"markdownDescription": "Enables the exit command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the restart command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "process:allow-restart",
|
||||||
|
"markdownDescription": "Enables the restart command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the exit command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "process:deny-exit",
|
||||||
|
"markdownDescription": "Denies the exit command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the restart command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "process:deny-restart",
|
||||||
|
"markdownDescription": "Denies the restart command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -163,25 +163,67 @@ fn extract_audio_samples(sample_buffer: CMSampleBuffer) -> Result<Vec<f32>, Stri
|
|||||||
.audio_buffer_list()
|
.audio_buffer_list()
|
||||||
.ok_or_else(|| "Failed to get audio buffer list".to_string())?;
|
.ok_or_else(|| "Failed to get audio buffer list".to_string())?;
|
||||||
|
|
||||||
let mut samples = Vec::new();
|
let buffers: Vec<_> = audio_buffer_list.iter().collect();
|
||||||
|
let num_buffers = buffers.len();
|
||||||
|
|
||||||
// Iterate through audio buffers
|
if num_buffers == 0 {
|
||||||
for buffer in audio_buffer_list.iter() {
|
return Ok(Vec::new());
|
||||||
// Get raw bytes and interpret as f32 samples
|
}
|
||||||
|
|
||||||
|
// ScreenCaptureKit on macOS provides audio in Float32 format
|
||||||
|
// The audio can be either:
|
||||||
|
// - Interleaved (1 buffer with L,R,L,R,... samples)
|
||||||
|
// - Planar (2 buffers, one for L channel, one for R channel)
|
||||||
|
|
||||||
|
if num_buffers == 1 {
|
||||||
|
// Interleaved stereo or mono in a single buffer
|
||||||
|
let buffer = &buffers[0];
|
||||||
let data_bytes = buffer.data();
|
let data_bytes = buffer.data();
|
||||||
let num_samples = data_bytes.len() / std::mem::size_of::<f32>();
|
let num_samples = data_bytes.len() / std::mem::size_of::<f32>();
|
||||||
|
|
||||||
if num_samples > 0 {
|
if num_samples > 0 {
|
||||||
unsafe {
|
unsafe {
|
||||||
// Interpret bytes as f32 samples
|
|
||||||
let data_ptr = data_bytes.as_ptr() as *const f32;
|
let data_ptr = data_bytes.as_ptr() as *const f32;
|
||||||
let data = std::slice::from_raw_parts(data_ptr, num_samples);
|
let data = std::slice::from_raw_parts(data_ptr, num_samples);
|
||||||
samples.extend_from_slice(data);
|
return Ok(data.to_vec());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Planar format - separate buffer for each channel
|
||||||
|
// We need to interleave them: L0, R0, L1, R1, ...
|
||||||
|
let mut channel_data: Vec<Vec<f32>> = Vec::new();
|
||||||
|
let mut max_samples = 0;
|
||||||
|
|
||||||
|
for buffer in &buffers {
|
||||||
|
let data_bytes = buffer.data();
|
||||||
|
let num_samples = data_bytes.len() / std::mem::size_of::<f32>();
|
||||||
|
|
||||||
|
if num_samples > 0 {
|
||||||
|
unsafe {
|
||||||
|
let data_ptr = data_bytes.as_ptr() as *const f32;
|
||||||
|
let data = std::slice::from_raw_parts(data_ptr, num_samples);
|
||||||
|
channel_data.push(data.to_vec());
|
||||||
|
max_samples = max_samples.max(num_samples);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interleave the channels
|
||||||
|
let mut interleaved = Vec::with_capacity(max_samples * num_buffers);
|
||||||
|
for i in 0..max_samples {
|
||||||
|
for channel in &channel_data {
|
||||||
|
if i < channel.len() {
|
||||||
|
interleaved.push(channel[i]);
|
||||||
|
} else {
|
||||||
|
interleaved.push(0.0); // Pad with silence if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(interleaved);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(samples)
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn samples_to_wav(samples: &[f32], sample_rate: u32, channels: u16) -> Result<Vec<u8>, String> {
|
fn samples_to_wav(samples: &[f32], sample_rate: u32, channels: u16) -> Result<Vec<u8>, String> {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub struct AudioCaptureState {
|
|||||||
pub sample_rate: Arc<Mutex<u32>>,
|
pub sample_rate: Arc<Mutex<u32>>,
|
||||||
pub channels: Arc<Mutex<u16>>,
|
pub channels: Arc<Mutex<u16>>,
|
||||||
pub stop_tx: Arc<Mutex<Option<tokio::sync::mpsc::Sender<()>>>>,
|
pub stop_tx: Arc<Mutex<Option<tokio::sync::mpsc::Sender<()>>>>,
|
||||||
|
pub error: Arc<Mutex<Option<String>>>,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
pub stream: Arc<Mutex<Option<SCStream>>>,
|
pub stream: Arc<Mutex<Option<SCStream>>>,
|
||||||
}
|
}
|
||||||
@@ -29,6 +30,7 @@ impl AudioCaptureState {
|
|||||||
sample_rate: Arc::new(Mutex::new(44100)),
|
sample_rate: Arc::new(Mutex::new(44100)),
|
||||||
channels: Arc::new(Mutex::new(2)),
|
channels: Arc::new(Mutex::new(2)),
|
||||||
stop_tx: Arc::new(Mutex::new(None)),
|
stop_tx: Arc::new(Mutex::new(None)),
|
||||||
|
error: Arc::new(Mutex::new(None)),
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
stream: Arc::new(Mutex::new(None)),
|
stream: Arc::new(Mutex::new(None)),
|
||||||
}
|
}
|
||||||
@@ -36,5 +38,6 @@ impl AudioCaptureState {
|
|||||||
|
|
||||||
pub fn reset(&self) {
|
pub fn reset(&self) {
|
||||||
*self.samples.lock().unwrap() = Vec::new();
|
*self.samples.lock().unwrap() = Vec::new();
|
||||||
|
*self.error.lock().unwrap() = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use std::io::Cursor;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
|
||||||
use wasapi::*;
|
use wasapi::*;
|
||||||
|
use windows::Win32::System::Com::{CoInitializeEx, CoUninitialize, COINIT_MULTITHREADED};
|
||||||
|
|
||||||
pub async fn start_capture(
|
pub async fn start_capture(
|
||||||
state: &AudioCaptureState,
|
state: &AudioCaptureState,
|
||||||
@@ -19,6 +19,7 @@ pub async fn start_capture(
|
|||||||
let sample_rate_arc = state.sample_rate.clone();
|
let sample_rate_arc = state.sample_rate.clone();
|
||||||
let channels_arc = state.channels.clone();
|
let channels_arc = state.channels.clone();
|
||||||
let stop_tx = state.stop_tx.clone();
|
let stop_tx = state.stop_tx.clone();
|
||||||
|
let error_arc = state.error.clone();
|
||||||
|
|
||||||
// Use AtomicBool for stop signal (works with non-Send types)
|
// Use AtomicBool for stop signal (works with non-Send types)
|
||||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||||
@@ -36,13 +37,29 @@ pub async fn start_capture(
|
|||||||
// Spawn capture task on a dedicated thread (WASAPI COM objects are not Send)
|
// Spawn capture task on a dedicated thread (WASAPI COM objects are not Send)
|
||||||
// All WASAPI objects must be created and used on the same thread
|
// All WASAPI objects must be created and used on the same thread
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
|
// Initialize COM for this thread
|
||||||
|
unsafe {
|
||||||
|
let hr = CoInitializeEx(None, COINIT_MULTITHREADED);
|
||||||
|
if hr.is_err() {
|
||||||
|
eprintln!("Failed to initialize COM: {:?}", hr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure COM is uninitialized when thread exits
|
||||||
|
let _com_guard = scopeguard::guard((), |_| unsafe {
|
||||||
|
CoUninitialize();
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize WASAPI on this thread
|
// Initialize WASAPI on this thread
|
||||||
let device = match DeviceEnumerator::new()
|
let device = match DeviceEnumerator::new()
|
||||||
.and_then(|enumerator| enumerator.get_default_device(&Direction::Render))
|
.and_then(|enumerator| enumerator.get_default_device(&Direction::Render))
|
||||||
{
|
{
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to get audio device: {}", e);
|
let error_msg = format!("Failed to get audio device: {}", e);
|
||||||
|
eprintln!("{}", error_msg);
|
||||||
|
*error_arc.lock().unwrap() = Some(error_msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -50,7 +67,9 @@ pub async fn start_capture(
|
|||||||
let mut audio_client = match device.get_iaudioclient() {
|
let mut audio_client = match device.get_iaudioclient() {
|
||||||
Ok(client) => client,
|
Ok(client) => client,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to get audio client: {}", e);
|
let error_msg = format!("Failed to get audio client: {}", e);
|
||||||
|
eprintln!("{}", error_msg);
|
||||||
|
*error_arc.lock().unwrap() = Some(error_msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -58,7 +77,9 @@ pub async fn start_capture(
|
|||||||
let mix_format = match audio_client.get_mixformat() {
|
let mix_format = match audio_client.get_mixformat() {
|
||||||
Ok(format) => format,
|
Ok(format) => format,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to get mix format: {}", e);
|
let error_msg = format!("Failed to get mix format: {}", e);
|
||||||
|
eprintln!("{}", error_msg);
|
||||||
|
*error_arc.lock().unwrap() = Some(error_msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -69,27 +90,53 @@ pub async fn start_capture(
|
|||||||
*sample_rate_arc.lock().unwrap() = mix_format.get_samplespersec();
|
*sample_rate_arc.lock().unwrap() = mix_format.get_samplespersec();
|
||||||
*channels_arc.lock().unwrap() = mix_format.get_nchannels();
|
*channels_arc.lock().unwrap() = mix_format.get_nchannels();
|
||||||
|
|
||||||
|
// Get device period
|
||||||
|
let (_def_period, min_period) = match audio_client.get_device_period() {
|
||||||
|
Ok(periods) => periods,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to get device period: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Initialize audio client for loopback with StreamMode
|
// Initialize audio client for loopback with StreamMode
|
||||||
|
// For loopback mode: get Render device, initialize with Capture direction
|
||||||
|
// This triggers AUDCLNT_STREAMFLAGS_LOOPBACK in the wasapi crate
|
||||||
let stream_mode = StreamMode::EventsShared {
|
let stream_mode = StreamMode::EventsShared {
|
||||||
autoconvert: false,
|
autoconvert: true, // Enable automatic format conversion
|
||||||
buffer_duration_hns: 0, // 0 = use default buffer size
|
buffer_duration_hns: min_period, // Use minimum period
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = audio_client.initialize_client(&mix_format, &Direction::Capture, &stream_mode) {
|
if let Err(e) = audio_client.initialize_client(&mix_format, &Direction::Capture, &stream_mode) {
|
||||||
eprintln!("Failed to initialize audio client: {}", e);
|
let error_msg = format!("Failed to initialize audio client: {}", e);
|
||||||
|
eprintln!("{}", error_msg);
|
||||||
|
*error_arc.lock().unwrap() = Some(error_msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set up event handle for EventsShared mode
|
||||||
|
let h_event = match audio_client.set_get_eventhandle() {
|
||||||
|
Ok(event) => event,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to set event handle: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let capture_client = match audio_client.get_audiocaptureclient() {
|
let capture_client = match audio_client.get_audiocaptureclient() {
|
||||||
Ok(client) => client,
|
Ok(client) => client,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to get capture client: {}", e);
|
let error_msg = format!("Failed to get capture client: {}", e);
|
||||||
|
eprintln!("{}", error_msg);
|
||||||
|
*error_arc.lock().unwrap() = Some(error_msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = audio_client.start_stream() {
|
if let Err(e) = audio_client.start_stream() {
|
||||||
eprintln!("Failed to start stream: {}", e);
|
let error_msg = format!("Failed to start stream: {}", e);
|
||||||
|
eprintln!("{}", error_msg);
|
||||||
|
*error_arc.lock().unwrap() = Some(error_msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,8 +192,10 @@ pub async fn start_capture(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sleep briefly to avoid busy-waiting
|
// Wait for event signal (with timeout to allow checking stop flag)
|
||||||
thread::sleep(Duration::from_millis(10));
|
if h_event.wait_for_event(100).is_err() {
|
||||||
|
// Timeout is expected - just continue to check stop flag
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop the stream when done
|
// Stop the stream when done
|
||||||
@@ -176,13 +225,18 @@ pub async fn stop_capture(state: &AudioCaptureState) -> Result<String, String> {
|
|||||||
// Wait a bit for capture to stop
|
// Wait a bit for capture to stop
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||||
|
|
||||||
|
// Check if there was an error during capture
|
||||||
|
if let Some(error) = state.error.lock().unwrap().as_ref() {
|
||||||
|
return Err(error.clone());
|
||||||
|
}
|
||||||
|
|
||||||
// Get samples
|
// Get samples
|
||||||
let samples = state.samples.lock().unwrap().clone();
|
let samples = state.samples.lock().unwrap().clone();
|
||||||
let sample_rate = *state.sample_rate.lock().unwrap();
|
let sample_rate = *state.sample_rate.lock().unwrap();
|
||||||
let channels = *state.channels.lock().unwrap();
|
let channels = *state.channels.lock().unwrap();
|
||||||
|
|
||||||
if samples.is_empty() {
|
if samples.is_empty() {
|
||||||
return Err("No audio samples captured".to_string());
|
return Err("No audio samples captured. Make sure audio is playing on your system during recording.".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to WAV
|
// Convert to WAV
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
pub mod audio_capture;
|
||||||
@@ -400,7 +400,10 @@ pub fn run() {
|
|||||||
.manage(audio_output::AudioOutputState::new())
|
.manage(audio_output::AudioOutputState::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)]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Voicebox",
|
"productName": "Voicebox",
|
||||||
"version": "0.1.1",
|
"version": "0.1.3",
|
||||||
"identifier": "sh.voicebox.app",
|
"identifier": "sh.voicebox.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// NOTE: This test requires system audio to be playing during execution.
|
||||||
|
// To run this test successfully:
|
||||||
|
// 1. Start playing audio (music, video, etc.)
|
||||||
|
// 2. Run: cargo test --test audio_capture_test -- --nocapture
|
||||||
|
// 3. The test will capture audio for 5 seconds and verify the output
|
||||||
|
|
||||||
|
use voicebox::audio_capture::{AudioCaptureState, start_capture, stop_capture};
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_system_audio_capture() {
|
||||||
|
// Create AudioCaptureState
|
||||||
|
let state = AudioCaptureState::new();
|
||||||
|
|
||||||
|
println!("Starting system audio capture with 5 second max duration...");
|
||||||
|
|
||||||
|
// Start capture with 5 second max duration
|
||||||
|
let result = start_capture(&state, 5).await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
panic!("Failed to start capture: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Capture started, waiting 5 seconds...");
|
||||||
|
|
||||||
|
// Wait 5 seconds for capture to complete
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||||
|
|
||||||
|
println!("Stopping capture...");
|
||||||
|
|
||||||
|
// Stop capture and get the result
|
||||||
|
let audio_data = stop_capture(&state).await;
|
||||||
|
|
||||||
|
match audio_data {
|
||||||
|
Ok(base64_wav) => {
|
||||||
|
println!("Capture stopped successfully");
|
||||||
|
|
||||||
|
// Validate the returned base64 WAV data
|
||||||
|
println!("Validating base64 WAV data...");
|
||||||
|
|
||||||
|
// Decode base64 to bytes
|
||||||
|
let decoded_bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(&base64_wav)
|
||||||
|
.expect("Failed to decode base64 data");
|
||||||
|
|
||||||
|
// Verify bytes array is not empty
|
||||||
|
assert!(!decoded_bytes.is_empty(), "Decoded bytes array is empty");
|
||||||
|
|
||||||
|
// Confirm data has content (length > 0)
|
||||||
|
println!("WAV data length: {} bytes", decoded_bytes.len());
|
||||||
|
assert!(decoded_bytes.len() > 0, "WAV data has no content");
|
||||||
|
|
||||||
|
println!("✓ Test passed: Audio capture produced valid WAV data");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
panic!("Failed to stop capture or get audio data: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/web",
|
"name": "@voicebox/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.1",
|
"version": "0.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Reference in New Issue
Block a user