mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-28 22:55:15 -07:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
236e464525 | ||
|
|
cf3cf3f002 | ||
|
|
3be8980f48 | ||
|
|
341d71470c | ||
|
|
bb6cea24ba | ||
|
|
fa7ac88abc | ||
|
|
c68ddc45b1 | ||
|
|
3c89b068f3 |
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 0.1.8
|
current_version = 0.1.9
|
||||||
commit = True
|
commit = True
|
||||||
tag = True
|
tag = True
|
||||||
tag_name = v{new_version}
|
tag_name = v{new_version}
|
||||||
|
|||||||
@@ -115,6 +115,17 @@ First-time usage will be slower due to model downloads, but subsequent runs will
|
|||||||
```
|
```
|
||||||
Creates platform-specific binary in `tauri/src-tauri/binaries/`
|
Creates platform-specific binary in `tauri/src-tauri/binaries/`
|
||||||
|
|
||||||
|
**Building with local Qwen3-TTS development version:**
|
||||||
|
|
||||||
|
If you're actively developing or modifying the Qwen3-TTS library, set the `QWEN_TTS_PATH` environment variable to point to your local clone:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export QWEN_TTS_PATH=~/path/to/your/Qwen3-TTS
|
||||||
|
./scripts/build-server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This makes PyInstaller use your local qwen-tts version instead of the pip-installed package. Useful when testing changes to the TTS library before they're published to PyPI or when using an editable install (`pip install -e`).
|
||||||
|
|
||||||
**Build Tauri desktop app:**
|
**Build Tauri desktop app:**
|
||||||
```bash
|
```bash
|
||||||
cd tauri
|
cd tauri
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/app",
|
"name": "@voicebox/app",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -112,8 +112,6 @@ export function FloatingGenerateBox({
|
|||||||
}
|
}
|
||||||
}, [selectedProfileId, profiles, setSelectedProfileId]);
|
}, [selectedProfileId, profiles, setSelectedProfileId]);
|
||||||
|
|
||||||
// Get current form value to trigger resize when it changes
|
|
||||||
const formValue = form.watch(isInstructMode ? 'instruct' : 'text');
|
|
||||||
|
|
||||||
// Auto-resize textarea based on content (only when expanded)
|
// Auto-resize textarea based on content (only when expanded)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -196,59 +194,104 @@ export function FloatingGenerateBox({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<motion.div className="flex-1" transition={{ duration: 0.3, ease: 'easeOut' }}>
|
<motion.div
|
||||||
{isInstructMode && (
|
className={cn('flex-1', isExpanded && 'mr-12')}
|
||||||
<span className="text-xs text-accent font-medium mb-1 block">
|
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||||
Delivery instructions:
|
>
|
||||||
</span>
|
{/* Text field - hidden when in instruct mode */}
|
||||||
)}
|
<div style={{ display: isInstructMode ? 'none' : 'block' }}>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={isInstructMode ? 'instruct' : 'text'}
|
name="text"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{
|
animate={{
|
||||||
height: isExpanded ? 'auto' : '32px',
|
height: isExpanded ? 'auto' : '32px',
|
||||||
}}
|
|
||||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
|
||||||
style={{ overflow: 'hidden' }}
|
|
||||||
>
|
|
||||||
<Textarea
|
|
||||||
{...field}
|
|
||||||
ref={(node: HTMLTextAreaElement | null) => {
|
|
||||||
// Store ref for auto-resize
|
|
||||||
textareaRef.current = node;
|
|
||||||
// Forward ref to react-hook-form
|
|
||||||
if (typeof field.ref === 'function') {
|
|
||||||
field.ref(node);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
placeholder={
|
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||||
isInstructMode
|
style={{ overflow: 'hidden' }}
|
||||||
? 'Add delivery instructions...'
|
>
|
||||||
: isStoriesRoute && currentStory
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
ref={(node: HTMLTextAreaElement | null) => {
|
||||||
|
// Store ref for auto-resize (only for active field)
|
||||||
|
if (!isInstructMode) {
|
||||||
|
textareaRef.current = node;
|
||||||
|
}
|
||||||
|
// Forward ref to react-hook-form
|
||||||
|
if (typeof field.ref === 'function') {
|
||||||
|
field.ref(node);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
isStoriesRoute && currentStory
|
||||||
? `Generate speech for "${currentStory.name}"...`
|
? `Generate speech for "${currentStory.name}"...`
|
||||||
: selectedProfile
|
: selectedProfile
|
||||||
? `Generate speech using ${selectedProfile.name}...`
|
? `Generate speech using ${selectedProfile.name}...`
|
||||||
: 'Select a voice profile above...'
|
: '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 w-full"
|
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 w-full"
|
||||||
style={{
|
style={{
|
||||||
minHeight: isExpanded ? '100px' : '32px',
|
minHeight: isExpanded ? '100px' : '32px',
|
||||||
maxHeight: '300px',
|
maxHeight: '300px',
|
||||||
|
}}
|
||||||
|
disabled={!selectedProfileId}
|
||||||
|
onClick={() => setIsExpanded(true)}
|
||||||
|
onFocus={() => setIsExpanded(true)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage className="text-xs" />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Instruct field - hidden when in text mode */}
|
||||||
|
<div style={{ display: isInstructMode ? 'block' : 'none' }}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="instruct"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
height: isExpanded ? 'auto' : '32px',
|
||||||
}}
|
}}
|
||||||
disabled={!selectedProfileId}
|
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||||
onClick={() => setIsExpanded(true)}
|
style={{ overflow: 'hidden' }}
|
||||||
onFocus={() => setIsExpanded(true)}
|
>
|
||||||
/>
|
<Textarea
|
||||||
</motion.div>
|
{...field}
|
||||||
</FormControl>
|
ref={(node: HTMLTextAreaElement | null) => {
|
||||||
<FormMessage className="text-xs" />
|
// Store ref for auto-resize (only for active field)
|
||||||
</FormItem>
|
if (isInstructMode) {
|
||||||
)}
|
textareaRef.current = node;
|
||||||
/>
|
}
|
||||||
|
// Forward ref to react-hook-form
|
||||||
|
if (typeof field.ref === 'function') {
|
||||||
|
field.ref(node);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Add delivery instructions..."
|
||||||
|
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 w-full"
|
||||||
|
style={{
|
||||||
|
minHeight: isExpanded ? '100px' : '32px',
|
||||||
|
maxHeight: '300px',
|
||||||
|
}}
|
||||||
|
disabled={!selectedProfileId}
|
||||||
|
onClick={() => setIsExpanded(true)}
|
||||||
|
onFocus={() => setIsExpanded(true)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage className="text-xs" />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
@@ -278,9 +321,12 @@ export function FloatingGenerateBox({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setIsInstructMode(!isInstructMode)}
|
onClick={() => setIsInstructMode(!isInstructMode)}
|
||||||
className={`h-10 w-10 rounded-full bg-card border border-border hover:bg-background/50 transition-all duration-200 ${
|
className={cn(
|
||||||
isInstructMode ? 'text-accent' : ''
|
'h-10 w-10 rounded-full transition-all duration-200',
|
||||||
}`}
|
isInstructMode
|
||||||
|
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
|
||||||
|
: 'bg-card border border-border hover:bg-background/50',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<MessageSquare className="h-4 w-4" />
|
<MessageSquare className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -12,11 +12,10 @@ interface ModelProgressProps {
|
|||||||
|
|
||||||
export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||||
const [progress, setProgress] = useState<ModelProgressType | null>(null);
|
const [progress, setProgress] = useState<ModelProgressType | null>(null);
|
||||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverUrl || isSubscribed) return;
|
if (!serverUrl) return;
|
||||||
|
|
||||||
// Subscribe to progress updates via Server-Sent Events
|
// Subscribe to progress updates via Server-Sent Events
|
||||||
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
||||||
@@ -29,7 +28,6 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
|||||||
// Close connection if complete or error
|
// Close connection if complete or error
|
||||||
if (data.status === 'complete' || data.status === 'error') {
|
if (data.status === 'complete' || data.status === 'error') {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
setIsSubscribed(false);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing progress event:', error);
|
console.error('Error parsing progress event:', error);
|
||||||
@@ -39,16 +37,12 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
|||||||
eventSource.onerror = (error) => {
|
eventSource.onerror = (error) => {
|
||||||
console.error('SSE error:', error);
|
console.error('SSE error:', error);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
setIsSubscribed(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setIsSubscribed(true);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
setIsSubscribed(false);
|
|
||||||
};
|
};
|
||||||
}, [serverUrl, modelName, isSubscribed]);
|
}, [serverUrl, modelName]);
|
||||||
|
|
||||||
// Don't render if no progress or if complete/error and some time has passed
|
// Don't render if no progress or if complete/error and some time has passed
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ interface StoryTrackEditorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TRACK_HEIGHT = 48;
|
const TRACK_HEIGHT = 48;
|
||||||
|
const TIME_RULER_HEIGHT = 24; // h-6 = 1.5rem = 24px
|
||||||
const MIN_PIXELS_PER_SECOND = 10;
|
const MIN_PIXELS_PER_SECOND = 10;
|
||||||
const MAX_PIXELS_PER_SECOND = 200;
|
const MAX_PIXELS_PER_SECOND = 200;
|
||||||
const DEFAULT_PIXELS_PER_SECOND = 50;
|
const DEFAULT_PIXELS_PER_SECOND = 50;
|
||||||
@@ -539,7 +540,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handlePlayPause();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
setSelectedClipId(null);
|
setSelectedClipId(null);
|
||||||
} else if (e.key === 's' || e.key === 'S') {
|
} else if (e.key === 's' || e.key === 'S') {
|
||||||
if (selectedClipId) {
|
if (selectedClipId) {
|
||||||
@@ -561,7 +565,14 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [selectedClipId, handleSplit, handleDuplicate, handleDelete, setSelectedClipId]);
|
}, [
|
||||||
|
selectedClipId,
|
||||||
|
handleSplit,
|
||||||
|
handleDuplicate,
|
||||||
|
handleDelete,
|
||||||
|
setSelectedClipId,
|
||||||
|
handlePlayPause,
|
||||||
|
]);
|
||||||
|
|
||||||
// Add global mouse listeners for trimming
|
// Add global mouse listeners for trimming
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -586,7 +597,8 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
});
|
});
|
||||||
setDragPosition({
|
setDragPosition({
|
||||||
x: rect.left - tracksRef.current.getBoundingClientRect().left + tracksRef.current.scrollLeft,
|
x: rect.left - tracksRef.current.getBoundingClientRect().left + tracksRef.current.scrollLeft,
|
||||||
y: rect.top - tracksRef.current.getBoundingClientRect().top,
|
// Subtract ruler height since clips are positioned relative to tracks area, not the scrollable container
|
||||||
|
y: rect.top - tracksRef.current.getBoundingClientRect().top - TIME_RULER_HEIGHT,
|
||||||
});
|
});
|
||||||
setDraggingItem(item.id);
|
setDraggingItem(item.id);
|
||||||
};
|
};
|
||||||
@@ -597,7 +609,8 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
|
|
||||||
const rect = tracksRef.current.getBoundingClientRect();
|
const rect = tracksRef.current.getBoundingClientRect();
|
||||||
const x = e.clientX - rect.left + tracksRef.current.scrollLeft - dragOffset.x;
|
const x = e.clientX - rect.left + tracksRef.current.scrollLeft - dragOffset.x;
|
||||||
const y = e.clientY - rect.top - dragOffset.y;
|
// Subtract ruler height since clips are positioned relative to tracks area
|
||||||
|
const y = e.clientY - rect.top - dragOffset.y - TIME_RULER_HEIGHT;
|
||||||
|
|
||||||
setDragPosition({ x: Math.max(0, x), y });
|
setDragPosition({ x: Math.max(0, x), y });
|
||||||
},
|
},
|
||||||
@@ -673,6 +686,22 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
// Playhead position
|
// Playhead position
|
||||||
const playheadLeft = msToPixels(currentTimeMs);
|
const playheadLeft = msToPixels(currentTimeMs);
|
||||||
|
|
||||||
|
// Auto-scroll timeline to follow playhead during playback
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCurrentlyPlaying || !tracksRef.current) return;
|
||||||
|
|
||||||
|
const container = tracksRef.current;
|
||||||
|
const containerWidth = container.clientWidth;
|
||||||
|
const scrollLeft = container.scrollLeft;
|
||||||
|
const halfwayPoint = scrollLeft + containerWidth / 2;
|
||||||
|
|
||||||
|
// If playhead is past the halfway point, scroll to keep it centered
|
||||||
|
if (playheadLeft > halfwayPoint) {
|
||||||
|
const targetScroll = playheadLeft - containerWidth / 2;
|
||||||
|
container.scrollLeft = targetScroll;
|
||||||
|
}
|
||||||
|
}, [isCurrentlyPlaying, playheadLeft]);
|
||||||
|
|
||||||
// Calculate tracks area height
|
// Calculate tracks area height
|
||||||
const tracksAreaHeight = tracks.length * TRACK_HEIGHT;
|
const tracksAreaHeight = tracks.length * TRACK_HEIGHT;
|
||||||
const timelineContainerHeight = editorHeight - 40; // Subtract toolbar height
|
const timelineContainerHeight = editorHeight - 40; // Subtract toolbar height
|
||||||
@@ -701,7 +730,13 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30 mt-2">
|
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30 mt-2">
|
||||||
{/* Play controls - left side */}
|
{/* Play controls - left side */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handlePlayPause}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
title="Play/Pause (Space)"
|
||||||
|
>
|
||||||
{isCurrentlyPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
{isCurrentlyPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Mic, Monitor, Upload } from 'lucide-react';
|
import { Mic, Monitor, Upload, X } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
@@ -43,7 +43,7 @@ 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 { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
|
import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore, type ProfileFormDraft } 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';
|
||||||
@@ -75,11 +75,35 @@ const profileSchema = baseProfileSchema.refine(
|
|||||||
|
|
||||||
type ProfileFormValues = z.infer<typeof profileSchema>;
|
type ProfileFormValues = z.infer<typeof profileSchema>;
|
||||||
|
|
||||||
|
// Helper to convert File to base64
|
||||||
|
async function fileToBase64(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to convert base64 to File
|
||||||
|
function base64ToFile(base64: string, fileName: string, fileType: string): File {
|
||||||
|
const arr = base64.split(',');
|
||||||
|
const bstr = atob(arr[1]);
|
||||||
|
let n = bstr.length;
|
||||||
|
const u8arr = new Uint8Array(n);
|
||||||
|
while (n--) {
|
||||||
|
u8arr[n] = bstr.charCodeAt(n);
|
||||||
|
}
|
||||||
|
return new File([u8arr], fileName, { type: fileType });
|
||||||
|
}
|
||||||
|
|
||||||
export function ProfileForm() {
|
export function ProfileForm() {
|
||||||
const open = useUIStore((state) => state.profileDialogOpen);
|
const open = useUIStore((state) => state.profileDialogOpen);
|
||||||
const setOpen = useUIStore((state) => state.setProfileDialogOpen);
|
const setOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||||
const editingProfileId = useUIStore((state) => state.editingProfileId);
|
const editingProfileId = useUIStore((state) => state.editingProfileId);
|
||||||
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
||||||
|
const profileFormDraft = useUIStore((state) => state.profileFormDraft);
|
||||||
|
const setProfileFormDraft = useUIStore((state) => state.setProfileFormDraft);
|
||||||
const { data: editingProfile } = useProfile(editingProfileId || '');
|
const { data: editingProfile } = useProfile(editingProfileId || '');
|
||||||
const createProfile = useCreateProfile();
|
const createProfile = useCreateProfile();
|
||||||
const updateProfile = useUpdateProfile();
|
const updateProfile = useUpdateProfile();
|
||||||
@@ -220,6 +244,7 @@ export function ProfileForm() {
|
|||||||
}
|
}
|
||||||
}, [systemRecordingError, toast]);
|
}, [systemRecordingError, toast]);
|
||||||
|
|
||||||
|
// Restore form state from draft or editing profile
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingProfile) {
|
if (editingProfile) {
|
||||||
form.reset({
|
form.reset({
|
||||||
@@ -229,7 +254,27 @@ export function ProfileForm() {
|
|||||||
sampleFile: undefined,
|
sampleFile: undefined,
|
||||||
referenceText: undefined,
|
referenceText: undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else if (profileFormDraft && open) {
|
||||||
|
// Restore from draft when opening in create mode
|
||||||
|
form.reset({
|
||||||
|
name: profileFormDraft.name,
|
||||||
|
description: profileFormDraft.description,
|
||||||
|
language: profileFormDraft.language as LanguageCode,
|
||||||
|
referenceText: profileFormDraft.referenceText,
|
||||||
|
sampleFile: undefined,
|
||||||
|
});
|
||||||
|
setSampleMode(profileFormDraft.sampleMode);
|
||||||
|
// Restore the file if we have it saved
|
||||||
|
if (profileFormDraft.sampleFileData && profileFormDraft.sampleFileName && profileFormDraft.sampleFileType) {
|
||||||
|
const file = base64ToFile(
|
||||||
|
profileFormDraft.sampleFileData,
|
||||||
|
profileFormDraft.sampleFileName,
|
||||||
|
profileFormDraft.sampleFileType
|
||||||
|
);
|
||||||
|
form.setValue('sampleFile', file);
|
||||||
|
}
|
||||||
|
} else if (!open) {
|
||||||
|
// Only reset to defaults when modal is closed and no draft
|
||||||
form.reset({
|
form.reset({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -239,7 +284,7 @@ export function ProfileForm() {
|
|||||||
});
|
});
|
||||||
setSampleMode('upload');
|
setSampleMode('upload');
|
||||||
}
|
}
|
||||||
}, [editingProfile, form]);
|
}, [editingProfile, profileFormDraft, open, form]);
|
||||||
|
|
||||||
async function handleTranscribe() {
|
async function handleTranscribe() {
|
||||||
const file = form.getValues('sampleFile');
|
const file = form.getValues('sampleFile');
|
||||||
@@ -383,6 +428,8 @@ export function ProfileForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear draft and reset form on success
|
||||||
|
setProfileFormDraft(null);
|
||||||
form.reset();
|
form.reset();
|
||||||
setEditingProfileId(null);
|
setEditingProfileId(null);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -395,12 +442,40 @@ export function ProfileForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOpenChange(open: boolean) {
|
async function handleOpenChange(newOpen: boolean) {
|
||||||
setOpen(open);
|
if (!newOpen && isCreating) {
|
||||||
if (!open) {
|
// Save draft when closing the create modal
|
||||||
|
const values = form.getValues();
|
||||||
|
const hasContent = values.name || values.description || values.referenceText || values.sampleFile;
|
||||||
|
|
||||||
|
if (hasContent) {
|
||||||
|
const draft: ProfileFormDraft = {
|
||||||
|
name: values.name || '',
|
||||||
|
description: values.description || '',
|
||||||
|
language: values.language || 'en',
|
||||||
|
referenceText: values.referenceText || '',
|
||||||
|
sampleMode,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save file as base64 if present
|
||||||
|
if (values.sampleFile) {
|
||||||
|
try {
|
||||||
|
draft.sampleFileName = values.sampleFile.name;
|
||||||
|
draft.sampleFileType = values.sampleFile.type;
|
||||||
|
draft.sampleFileData = await fileToBase64(values.sampleFile);
|
||||||
|
} catch {
|
||||||
|
// If file conversion fails, just don't save the file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setProfileFormDraft(draft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(newOpen);
|
||||||
|
if (!newOpen) {
|
||||||
setEditingProfileId(null);
|
setEditingProfileId(null);
|
||||||
form.reset();
|
// Don't reset form here - let the effect handle it based on draft state
|
||||||
setSampleMode('upload');
|
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
cancelRecording();
|
cancelRecording();
|
||||||
}
|
}
|
||||||
@@ -421,6 +496,31 @@ export function ProfileForm() {
|
|||||||
? 'Update your voice profile details and manage samples.'
|
? '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>
|
||||||
|
{isCreating && profileFormDraft && (
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
<span className="text-xs text-muted-foreground">Draft restored</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs text-muted-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
setProfileFormDraft(null);
|
||||||
|
form.reset({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
language: 'en',
|
||||||
|
sampleFile: undefined,
|
||||||
|
referenceText: '',
|
||||||
|
});
|
||||||
|
setSampleMode('upload');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 mr-1" />
|
||||||
|
Discard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
|
|||||||
@@ -1,11 +1,132 @@
|
|||||||
import { Plus, Trash2, Play } from 'lucide-react';
|
import { Plus, Trash2, Play, Pencil, Check, X, Volume2, Pause } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useDeleteSample, useProfileSamples } from '@/lib/hooks/useProfiles';
|
import { useDeleteSample, useProfileSamples, useUpdateSample } from '@/lib/hooks/useProfiles';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { SampleUpload } from './SampleUpload';
|
import { SampleUpload } from './SampleUpload';
|
||||||
|
|
||||||
|
interface MiniSamplePlayerProps {
|
||||||
|
audioUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = new Audio(audioUrl);
|
||||||
|
audioRef.current = audio;
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
setDuration(audio.duration);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
setCurrentTime(audio.currentTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnded = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlay = () => setIsPlaying(true);
|
||||||
|
const handlePause = () => setIsPlaying(false);
|
||||||
|
|
||||||
|
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
audio.addEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
audio.addEventListener('ended', handleEnded);
|
||||||
|
audio.addEventListener('play', handlePlay);
|
||||||
|
audio.addEventListener('pause', handlePause);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.pause();
|
||||||
|
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
audio.removeEventListener('ended', handleEnded);
|
||||||
|
audio.removeEventListener('play', handlePlay);
|
||||||
|
audio.removeEventListener('pause', handlePause);
|
||||||
|
audio.src = '';
|
||||||
|
};
|
||||||
|
}, [audioUrl]);
|
||||||
|
|
||||||
|
const handlePlayPause = () => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
} else {
|
||||||
|
audioRef.current.play();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeek = (value: number[]) => {
|
||||||
|
if (!audioRef.current || duration === 0) return;
|
||||||
|
const progress = value[0] / 100;
|
||||||
|
audioRef.current.currentTime = progress * duration;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
}
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t bg-muted/30 px-3 py-2 mt-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 ml-0.5" />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||||
|
<Slider
|
||||||
|
value={duration > 0 ? [(currentTime / duration) * 100] : [0]}
|
||||||
|
onValueChange={handleSeek}
|
||||||
|
max={100}
|
||||||
|
step={0.1}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 min-w-[70px]">
|
||||||
|
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="font-mono">{formatAudioDuration(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={handleStop}
|
||||||
|
title="Stop"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface SampleListProps {
|
interface SampleListProps {
|
||||||
profileId: string;
|
profileId: string;
|
||||||
}
|
}
|
||||||
@@ -13,10 +134,11 @@ interface SampleListProps {
|
|||||||
export function SampleList({ profileId }: SampleListProps) {
|
export function SampleList({ profileId }: SampleListProps) {
|
||||||
const { data: samples, isLoading } = useProfileSamples(profileId);
|
const { data: samples, isLoading } = useProfileSamples(profileId);
|
||||||
const deleteSample = useDeleteSample();
|
const deleteSample = useDeleteSample();
|
||||||
|
const updateSample = useUpdateSample();
|
||||||
|
const { toast } = useToast();
|
||||||
const [uploadOpen, setUploadOpen] = useState(false);
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
const [editingSampleId, setEditingSampleId] = useState<string | null>(null);
|
||||||
const currentAudioId = usePlayerStore((state) => state.audioId);
|
const [editedText, setEditedText] = useState<string>('');
|
||||||
const isPlaying = usePlayerStore((state) => state.isPlaying);
|
|
||||||
|
|
||||||
const handleDelete = (sampleId: string) => {
|
const handleDelete = (sampleId: string) => {
|
||||||
if (confirm('Are you sure you want to delete this sample?')) {
|
if (confirm('Are you sure you want to delete this sample?')) {
|
||||||
@@ -24,9 +146,41 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePlay = (referenceText: string, sampleId: string) => {
|
const handleStartEdit = (sampleId: string, currentText: string) => {
|
||||||
const audioUrl = apiClient.getSampleUrl(sampleId);
|
setEditingSampleId(sampleId);
|
||||||
setAudioWithAutoPlay(audioUrl, sampleId, null, referenceText.substring(0, 50));
|
setEditedText(currentText);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditingSampleId(null);
|
||||||
|
setEditedText('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async (sampleId: string) => {
|
||||||
|
if (!editedText.trim()) {
|
||||||
|
toast({
|
||||||
|
title: 'Invalid text',
|
||||||
|
description: 'Reference text cannot be empty.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateSample.mutateAsync({ sampleId, referenceText: editedText.trim() });
|
||||||
|
toast({
|
||||||
|
title: 'Sample updated',
|
||||||
|
description: 'Reference text has been updated successfully.',
|
||||||
|
});
|
||||||
|
setEditingSampleId(null);
|
||||||
|
setEditedText('');
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Update failed',
|
||||||
|
description: error instanceof Error ? error.message : 'Failed to update sample',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -44,43 +198,109 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{samples && samples.length === 0 ? (
|
{samples && samples.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground py-4">
|
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
|
||||||
No samples yet. Add your first audio sample.
|
<Volume2 className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">No samples yet</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-1">Add your first audio sample to get started</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{samples?.map((sample) => (
|
{samples?.map((sample, index) => {
|
||||||
<div
|
const isEditing = editingSampleId === sample.id;
|
||||||
key={sample.id}
|
|
||||||
className="flex items-center justify-between p-3 border rounded-lg"
|
return (
|
||||||
>
|
<div
|
||||||
<div className="flex-1">
|
key={sample.id}
|
||||||
<p className="text-sm font-medium">{sample.reference_text}</p>
|
className={cn(
|
||||||
<p className="text-xs text-muted-foreground mt-1">{sample.audio_path}</p>
|
'group relative rounded-lg border bg-card transition-all duration-200',
|
||||||
|
isEditing ? 'ring-2 ring-primary/20' : 'hover:border-primary/30'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isEditing ? (
|
||||||
|
/* Edit Mode */
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
<span>Editing transcription</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={editedText}
|
||||||
|
onChange={(e) => setEditedText(e.target.value)}
|
||||||
|
className="min-h-[100px] text-sm resize-none"
|
||||||
|
placeholder="Enter reference text..."
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
disabled={updateSample.isPending}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 mr-1" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSaveEdit(sample.id)}
|
||||||
|
disabled={updateSample.isPending}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4 mr-1" />
|
||||||
|
{updateSample.isPending ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* View Mode */}
|
||||||
|
<div className="flex items-center gap-3 p-3 h-[72px]">
|
||||||
|
{/* Text Content */}
|
||||||
|
<div className="flex-1 min-w-0 py-0.5">
|
||||||
|
<p className="text-sm font-medium line-clamp-2 leading-snug">
|
||||||
|
{sample.reference_text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
title="Edit transcription"
|
||||||
|
onClick={() => handleStartEdit(sample.id, sample.reference_text)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
|
title="Delete sample"
|
||||||
|
onClick={() => handleDelete(sample.id)}
|
||||||
|
disabled={deleteSample.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sample Number Badge */}
|
||||||
|
<div className="absolute top-1 right-2 text-[10px] text-muted-foreground/50 font-medium">
|
||||||
|
#{index + 1}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mini Player - Always visible */}
|
||||||
|
<MiniSamplePlayer audioUrl={apiClient.getSampleUrl(sample.id)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
);
|
||||||
<Button
|
})}
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handlePlay(sample.reference_text, sample.id)}
|
|
||||||
className={currentAudioId === sample.id && isPlaying ? 'text-primary' : ''}
|
|
||||||
>
|
|
||||||
<Play className="h-4 w-4 mr-1" />
|
|
||||||
Play
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDelete(sample.id)}
|
|
||||||
disabled={deleteSample.isPending}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
import type { LanguageCode } from '@/lib/constants/languages';
|
||||||
import type {
|
import type {
|
||||||
VoiceProfileCreate,
|
VoiceProfileCreate,
|
||||||
VoiceProfileResponse,
|
VoiceProfileResponse,
|
||||||
@@ -120,6 +121,16 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateProfileSample(
|
||||||
|
sampleId: string,
|
||||||
|
referenceText: string,
|
||||||
|
): Promise<ProfileSampleResponse> {
|
||||||
|
return this.request<ProfileSampleResponse>(`/profiles/samples/${sampleId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ reference_text: referenceText }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async exportProfile(profileId: string): Promise<Blob> {
|
async exportProfile(profileId: string): Promise<Blob> {
|
||||||
const url = `${this.getBaseUrl()}/profiles/${profileId}/export`;
|
const url = `${this.getBaseUrl()}/profiles/${profileId}/export`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
@@ -244,7 +255,7 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Transcription
|
// Transcription
|
||||||
async transcribeAudio(file: File, language?: 'en' | 'zh'): Promise<TranscriptionResponse> {
|
async transcribeAudio(file: File, language?: LanguageCode): Promise<TranscriptionResponse> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
if (language) {
|
if (language) {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// API Types matching backend Pydantic models
|
// API Types matching backend Pydantic models
|
||||||
|
import type { LanguageCode } from '@/lib/constants/languages';
|
||||||
|
|
||||||
export interface VoiceProfileCreate {
|
export interface VoiceProfileCreate {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
language: 'en' | 'zh';
|
language: LanguageCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VoiceProfileResponse {
|
export interface VoiceProfileResponse {
|
||||||
@@ -29,7 +30,7 @@ export interface ProfileSampleResponse {
|
|||||||
export interface GenerationRequest {
|
export interface GenerationRequest {
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
text: string;
|
text: string;
|
||||||
language: 'en' | 'zh';
|
language: LanguageCode;
|
||||||
seed?: number;
|
seed?: number;
|
||||||
model_size?: '1.7B' | '0.6B';
|
model_size?: '1.7B' | '0.6B';
|
||||||
}
|
}
|
||||||
@@ -62,7 +63,7 @@ export interface HistoryListResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TranscriptionRequest {
|
export interface TranscriptionRequest {
|
||||||
language?: 'en' | 'zh';
|
language?: LanguageCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TranscriptionResponse {
|
export interface TranscriptionResponse {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
const modelName = `qwen-tts-${data.modelSize}`;
|
const modelName = `qwen-tts-${data.modelSize}`;
|
||||||
const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B';
|
const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B';
|
||||||
|
|
||||||
|
let isDownloading = false;
|
||||||
try {
|
try {
|
||||||
const modelStatus = await apiClient.getModelStatus();
|
const modelStatus = await apiClient.getModelStatus();
|
||||||
const model = modelStatus.models.find((m) => m.model_name === modelName);
|
const model = modelStatus.models.find((m) => m.model_name === modelName);
|
||||||
@@ -77,6 +78,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
if (model && !model.downloaded) {
|
if (model && !model.downloaded) {
|
||||||
setDownloadingModelName(modelName);
|
setDownloadingModelName(modelName);
|
||||||
setDownloadingDisplayName(displayName);
|
setDownloadingDisplayName(displayName);
|
||||||
|
isDownloading = true;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to check model status:', error);
|
console.error('Failed to check model status:', error);
|
||||||
@@ -101,16 +103,21 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
|
|
||||||
form.reset();
|
form.reset();
|
||||||
options.onSuccess?.(result.id);
|
options.onSuccess?.(result.id);
|
||||||
|
|
||||||
|
if (isDownloading) {
|
||||||
|
setDownloadingModelName(null);
|
||||||
|
setDownloadingDisplayName(null);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Generation failed',
|
title: 'Generation failed',
|
||||||
description: error instanceof Error ? error.message : 'Failed to generate audio',
|
description: error instanceof Error ? error.message : 'Failed to generate audio',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
setIsGenerating(false);
|
|
||||||
setDownloadingModelName(null);
|
setDownloadingModelName(null);
|
||||||
setDownloadingDisplayName(null);
|
setDownloadingDisplayName(null);
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,8 +140,8 @@ export function useModelDownloadToast({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onerror = () => {
|
eventSource.onerror = (error) => {
|
||||||
console.error('SSE error');
|
console.error('SSE error:', error);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,24 @@ export function useDeleteSample() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUpdateSample() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ sampleId, referenceText }: { sampleId: string; referenceText: string }) =>
|
||||||
|
apiClient.updateProfileSample(sampleId, referenceText),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['profiles', data.profile_id, 'samples'],
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['profiles', data.profile_id],
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useExportProfile() {
|
export function useExportProfile() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (profileId: string) => {
|
mutationFn: async (profileId: string) => {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { LanguageCode } from '@/lib/constants/languages';
|
||||||
|
|
||||||
export function useTranscription() {
|
export function useTranscription() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ file, language }: { file: File; language?: 'en' | 'zh' }) =>
|
mutationFn: ({ file, language }: { file: File; language?: LanguageCode }) =>
|
||||||
apiClient.transcribeAudio(file, language),
|
apiClient.transcribeAudio(file, language),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ export const useStoryStore = create<StoryPlaybackState>((set, get) => ({
|
|||||||
playbackItems: items,
|
playbackItems: items,
|
||||||
totalDurationMs: maxEndTimeMs,
|
totalDurationMs: maxEndTimeMs,
|
||||||
currentTimeMs: startTimeMs,
|
currentTimeMs: startTimeMs,
|
||||||
|
// Reset timing anchors - will be set fresh by the playback hook
|
||||||
|
playbackStartContextTime: null,
|
||||||
|
playbackStartStoryTime: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
// Draft state for the create voice profile form
|
||||||
|
export interface ProfileFormDraft {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
language: string;
|
||||||
|
referenceText: string;
|
||||||
|
sampleMode: 'upload' | 'record' | 'system';
|
||||||
|
// Note: File objects can't be persisted, so we store metadata
|
||||||
|
sampleFileName?: string;
|
||||||
|
sampleFileType?: string;
|
||||||
|
sampleFileData?: string; // Base64 encoded
|
||||||
|
}
|
||||||
|
|
||||||
interface UIStore {
|
interface UIStore {
|
||||||
// Sidebar
|
// Sidebar
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
@@ -18,6 +31,10 @@ interface UIStore {
|
|||||||
selectedProfileId: string | null;
|
selectedProfileId: string | null;
|
||||||
setSelectedProfileId: (id: string | null) => void;
|
setSelectedProfileId: (id: string | null) => void;
|
||||||
|
|
||||||
|
// Profile form draft (for persisting create voice modal state)
|
||||||
|
profileFormDraft: ProfileFormDraft | null;
|
||||||
|
setProfileFormDraft: (draft: ProfileFormDraft | null) => void;
|
||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
theme: 'light' | 'dark';
|
theme: 'light' | 'dark';
|
||||||
setTheme: (theme: 'light' | 'dark') => void;
|
setTheme: (theme: 'light' | 'dark') => void;
|
||||||
@@ -38,6 +55,9 @@ export const useUIStore = create<UIStore>((set) => ({
|
|||||||
selectedProfileId: null,
|
selectedProfileId: null,
|
||||||
setSelectedProfileId: (id) => set({ selectedProfileId: id }),
|
setSelectedProfileId: (id) => set({ selectedProfileId: id }),
|
||||||
|
|
||||||
|
profileFormDraft: null,
|
||||||
|
setProfileFormDraft: (draft) => set({ profileFormDraft: draft }),
|
||||||
|
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
set({ theme });
|
set({ theme });
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
# Backend package
|
# Backend package
|
||||||
|
|
||||||
__version__ = "0.1.8"
|
__version__ = "0.1.9"
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ def build_server():
|
|||||||
"""Build Python server as standalone binary."""
|
"""Build Python server as standalone binary."""
|
||||||
backend_dir = Path(__file__).parent
|
backend_dir = Path(__file__).parent
|
||||||
|
|
||||||
# Check for local editable qwen_tts install
|
|
||||||
local_qwen_path = Path.home() / 'Projects' / 'voice' / 'Qwen3-TTS'
|
|
||||||
|
|
||||||
# PyInstaller arguments
|
# PyInstaller arguments
|
||||||
args = [
|
args = [
|
||||||
'server.py', # Use server.py as entry point instead of main.py
|
'server.py', # Use server.py as entry point instead of main.py
|
||||||
@@ -21,10 +18,11 @@ def build_server():
|
|||||||
'--name', 'voicebox-server',
|
'--name', 'voicebox-server',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add local qwen_tts path if it exists (for editable installs)
|
# Add local qwen_tts path if specified (for editable installs)
|
||||||
if local_qwen_path.exists():
|
qwen_tts_path = os.getenv('QWEN_TTS_PATH')
|
||||||
args.extend(['--paths', str(local_qwen_path)])
|
if qwen_tts_path and Path(qwen_tts_path).exists():
|
||||||
print(f"Using local qwen_tts source from: {local_qwen_path}")
|
args.extend(['--paths', str(qwen_tts_path)])
|
||||||
|
print(f"Using local qwen_tts source from: {qwen_tts_path}")
|
||||||
|
|
||||||
# Add hidden imports
|
# Add hidden imports
|
||||||
args.extend([
|
args.extend([
|
||||||
|
|||||||
@@ -283,6 +283,19 @@ async def delete_profile_sample(
|
|||||||
return {"message": "Sample deleted successfully"}
|
return {"message": "Sample deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/profiles/samples/{sample_id}", response_model=models.ProfileSampleResponse)
|
||||||
|
async def update_profile_sample(
|
||||||
|
sample_id: str,
|
||||||
|
data: models.ProfileSampleUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update a profile sample's reference text."""
|
||||||
|
sample = await profiles.update_profile_sample(sample_id, data.reference_text, db)
|
||||||
|
if not sample:
|
||||||
|
raise HTTPException(status_code=404, detail="Sample not found")
|
||||||
|
return sample
|
||||||
|
|
||||||
|
|
||||||
@app.get("/profiles/{profile_id}/export")
|
@app.get("/profiles/{profile_id}/export")
|
||||||
async def export_profile(
|
async def export_profile(
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ class ProfileSampleCreate(BaseModel):
|
|||||||
reference_text: str = Field(..., min_length=1, max_length=1000)
|
reference_text: str = Field(..., min_length=1, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileSampleUpdate(BaseModel):
|
||||||
|
"""Request model for updating a profile sample."""
|
||||||
|
reference_text: str = Field(..., min_length=1, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
class ProfileSampleResponse(BaseModel):
|
class ProfileSampleResponse(BaseModel):
|
||||||
"""Response model for profile sample."""
|
"""Response model for profile sample."""
|
||||||
id: str
|
id: str
|
||||||
|
|||||||
@@ -273,6 +273,33 @@ async def delete_profile_sample(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def update_profile_sample(
|
||||||
|
sample_id: str,
|
||||||
|
reference_text: str,
|
||||||
|
db: Session,
|
||||||
|
) -> Optional[ProfileSampleResponse]:
|
||||||
|
"""
|
||||||
|
Update a profile sample's reference text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sample_id: Sample ID
|
||||||
|
reference_text: Updated reference text
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated sample or None if not found
|
||||||
|
"""
|
||||||
|
sample = db.query(DBProfileSample).filter_by(id=sample_id).first()
|
||||||
|
if not sample:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sample.reference_text = reference_text
|
||||||
|
db.commit()
|
||||||
|
db.refresh(sample)
|
||||||
|
|
||||||
|
return ProfileSampleResponse.model_validate(sample)
|
||||||
|
|
||||||
|
|
||||||
async def create_voice_prompt_for_profile(
|
async def create_voice_prompt_for_profile(
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
db: Session,
|
db: Session,
|
||||||
|
|||||||
@@ -162,9 +162,10 @@ class WhisperModel:
|
|||||||
# Set language if provided
|
# Set language if provided
|
||||||
forced_decoder_ids = None
|
forced_decoder_ids = None
|
||||||
if language:
|
if language:
|
||||||
lang_code = "en" if language == "en" else "zh"
|
# Support all languages from frontend: en, zh, ja, ko, de, fr, ru, pt, es, it
|
||||||
|
# Whisper supports these and many more
|
||||||
forced_decoder_ids = self.processor.get_decoder_prompt_ids(
|
forced_decoder_ids = self.processor.get_decoder_prompt_ids(
|
||||||
language=lang_code,
|
language=language,
|
||||||
task="transcribe",
|
task="transcribe",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -221,9 +222,10 @@ class WhisperModel:
|
|||||||
# Set language if provided
|
# Set language if provided
|
||||||
forced_decoder_ids = None
|
forced_decoder_ids = None
|
||||||
if language:
|
if language:
|
||||||
lang_code = "en" if language == "en" else "zh"
|
# Support all languages from frontend: en, zh, ja, ko, de, fr, ru, pt, es, it
|
||||||
|
# Whisper supports these and many more
|
||||||
forced_decoder_ids = self.processor.get_decoder_prompt_ids(
|
forced_decoder_ids = self.processor.get_decoder_prompt_ids(
|
||||||
language=lang_code,
|
language=language,
|
||||||
task="transcribe",
|
task="transcribe",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ class ProgressManager:
|
|||||||
filename: Current file being downloaded
|
filename: Current file being downloaded
|
||||||
status: Status string (downloading, extracting, complete, error)
|
status: Status string (downloading, extracting, complete, error)
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
progress_pct = (current / total * 100) if total > 0 else 0
|
progress_pct = (current / total * 100) if total > 0 else 0
|
||||||
|
|
||||||
self._progress[model_name] = {
|
self._progress[model_name] = {
|
||||||
@@ -47,12 +50,16 @@ class ProgressManager:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Notify all listeners
|
# Notify all listeners
|
||||||
if model_name in self._listeners:
|
listener_count = len(self._listeners.get(model_name, []))
|
||||||
|
if listener_count > 0:
|
||||||
|
logger.debug(f"Notifying {listener_count} listeners for {model_name}: {progress_pct:.1f}% ({filename})")
|
||||||
for queue in self._listeners[model_name]:
|
for queue in self._listeners[model_name]:
|
||||||
try:
|
try:
|
||||||
queue.put_nowait(self._progress[model_name].copy())
|
queue.put_nowait(self._progress[model_name].copy())
|
||||||
except asyncio.QueueFull:
|
except asyncio.QueueFull:
|
||||||
pass
|
logger.warning(f"Queue full for {model_name}, dropping update")
|
||||||
|
else:
|
||||||
|
logger.debug(f"No listeners for {model_name}, progress update stored: {progress_pct:.1f}%")
|
||||||
|
|
||||||
def get_progress(self, model_name: str) -> Optional[Dict]:
|
def get_progress(self, model_name: str) -> Optional[Dict]:
|
||||||
"""Get current progress for a model."""
|
"""Get current progress for a model."""
|
||||||
@@ -101,6 +108,9 @@ class ProgressManager:
|
|||||||
|
|
||||||
Yields progress updates as Server-Sent Events.
|
Yields progress updates as Server-Sent Events.
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
queue = asyncio.Queue(maxsize=10)
|
queue = asyncio.Queue(maxsize=10)
|
||||||
|
|
||||||
# Add to listeners
|
# Add to listeners
|
||||||
@@ -108,20 +118,33 @@ class ProgressManager:
|
|||||||
self._listeners[model_name] = []
|
self._listeners[model_name] = []
|
||||||
self._listeners[model_name].append(queue)
|
self._listeners[model_name].append(queue)
|
||||||
|
|
||||||
|
logger.info(f"SSE client subscribed to {model_name}, total listeners: {len(self._listeners[model_name])}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Send initial progress if available
|
# Send initial progress if available and still in progress
|
||||||
if model_name in self._progress:
|
if model_name in self._progress:
|
||||||
yield f"data: {json.dumps(self._progress[model_name])}\n\n"
|
status = self._progress[model_name].get('status')
|
||||||
|
# Only send initial progress if download is actually in progress
|
||||||
|
# Don't send old 'complete' or 'error' status from previous downloads
|
||||||
|
if status in ('downloading', 'extracting'):
|
||||||
|
logger.info(f"Sending initial progress for {model_name}: {status}")
|
||||||
|
yield f"data: {json.dumps(self._progress[model_name])}\n\n"
|
||||||
|
else:
|
||||||
|
logger.info(f"Skipping initial progress for {model_name} (status: {status})")
|
||||||
|
else:
|
||||||
|
logger.info(f"No initial progress available for {model_name}")
|
||||||
|
|
||||||
# Stream updates
|
# Stream updates
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# Wait for update with timeout
|
# Wait for update with timeout
|
||||||
progress = await asyncio.wait_for(queue.get(), timeout=1.0)
|
progress = await asyncio.wait_for(queue.get(), timeout=1.0)
|
||||||
|
logger.debug(f"Sending progress update for {model_name}: {progress.get('status')} - {progress.get('progress', 0):.1f}%")
|
||||||
yield f"data: {json.dumps(progress)}\n\n"
|
yield f"data: {json.dumps(progress)}\n\n"
|
||||||
|
|
||||||
# Stop if complete or error
|
# Stop if complete or error
|
||||||
if progress.get("status") in ("complete", "error"):
|
if progress.get("status") in ("complete", "error"):
|
||||||
|
logger.info(f"Download {progress.get('status')} for {model_name}, closing SSE connection")
|
||||||
break
|
break
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
# Send heartbeat
|
# Send heartbeat
|
||||||
@@ -133,32 +156,41 @@ class ProgressManager:
|
|||||||
self._listeners[model_name].remove(queue)
|
self._listeners[model_name].remove(queue)
|
||||||
if not self._listeners[model_name]:
|
if not self._listeners[model_name]:
|
||||||
del self._listeners[model_name]
|
del self._listeners[model_name]
|
||||||
|
logger.info(f"SSE client unsubscribed from {model_name}, remaining listeners: {len(self._listeners.get(model_name, []))}")
|
||||||
|
|
||||||
def mark_complete(self, model_name: str):
|
def mark_complete(self, model_name: str):
|
||||||
"""Mark a model download as complete."""
|
"""Mark a model download as complete."""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
if model_name in self._progress:
|
if model_name in self._progress:
|
||||||
self._progress[model_name]["status"] = "complete"
|
self._progress[model_name]["status"] = "complete"
|
||||||
self._progress[model_name]["progress"] = 100.0
|
self._progress[model_name]["progress"] = 100.0
|
||||||
|
logger.info(f"Marked {model_name} as complete")
|
||||||
# Notify listeners
|
# Notify listeners
|
||||||
if model_name in self._listeners:
|
if model_name in self._listeners:
|
||||||
for queue in self._listeners[model_name]:
|
for queue in self._listeners[model_name]:
|
||||||
try:
|
try:
|
||||||
queue.put_nowait(self._progress[model_name].copy())
|
queue.put_nowait(self._progress[model_name].copy())
|
||||||
except asyncio.QueueFull:
|
except asyncio.QueueFull:
|
||||||
pass
|
logger.warning(f"Queue full when marking {model_name} complete")
|
||||||
|
|
||||||
def mark_error(self, model_name: str, error: str):
|
def mark_error(self, model_name: str, error: str):
|
||||||
"""Mark a model download as failed."""
|
"""Mark a model download as failed."""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
if model_name in self._progress:
|
if model_name in self._progress:
|
||||||
self._progress[model_name]["status"] = "error"
|
self._progress[model_name]["status"] = "error"
|
||||||
self._progress[model_name]["error"] = error
|
self._progress[model_name]["error"] = error
|
||||||
|
logger.error(f"Marked {model_name} as error: {error}")
|
||||||
# Notify listeners
|
# Notify listeners
|
||||||
if model_name in self._listeners:
|
if model_name in self._listeners:
|
||||||
for queue in self._listeners[model_name]:
|
for queue in self._listeners[model_name]:
|
||||||
try:
|
try:
|
||||||
queue.put_nowait(self._progress[model_name].copy())
|
queue.put_nowait(self._progress[model_name].copy())
|
||||||
except asyncio.QueueFull:
|
except asyncio.QueueFull:
|
||||||
pass
|
logger.warning(f"Queue full when marking {model_name} error")
|
||||||
|
|
||||||
|
|
||||||
# Global progress manager instance
|
# Global progress manager instance
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
|
||||||
from PyInstaller.utils.hooks import collect_data_files
|
|
||||||
from PyInstaller.utils.hooks import collect_submodules
|
|
||||||
from PyInstaller.utils.hooks import copy_metadata
|
|
||||||
|
|
||||||
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', 'pkg_resources.extern']
|
|
||||||
datas += collect_data_files('qwen_tts')
|
|
||||||
datas += copy_metadata('qwen-tts')
|
|
||||||
hiddenimports += collect_submodules('qwen_tts')
|
|
||||||
hiddenimports += collect_submodules('jaraco')
|
|
||||||
|
|
||||||
|
|
||||||
a = Analysis(
|
|
||||||
['server.py'],
|
|
||||||
pathex=['C:\\Users\\ijame\\Projects\\voice\\Qwen3-TTS'],
|
|
||||||
binaries=[],
|
|
||||||
datas=datas,
|
|
||||||
hiddenimports=hiddenimports,
|
|
||||||
hookspath=[],
|
|
||||||
hooksconfig={},
|
|
||||||
runtime_hooks=[],
|
|
||||||
excludes=[],
|
|
||||||
noarchive=False,
|
|
||||||
optimize=0,
|
|
||||||
)
|
|
||||||
pyz = PYZ(a.pure)
|
|
||||||
|
|
||||||
exe = EXE(
|
|
||||||
pyz,
|
|
||||||
a.scripts,
|
|
||||||
a.binaries,
|
|
||||||
a.datas,
|
|
||||||
[],
|
|
||||||
name='voicebox-server',
|
|
||||||
debug=False,
|
|
||||||
bootloader_ignore_signals=False,
|
|
||||||
strip=False,
|
|
||||||
upx=True,
|
|
||||||
upx_exclude=[],
|
|
||||||
runtime_tmpdir=None,
|
|
||||||
console=True,
|
|
||||||
disable_windowed_traceback=False,
|
|
||||||
argv_emulation=False,
|
|
||||||
target_arch=None,
|
|
||||||
codesign_identity=None,
|
|
||||||
entitlements_file=None,
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,758 @@
|
|||||||
|
# Docker Deployment Guide
|
||||||
|
|
||||||
|
**Status:** In Development for v0.2.0
|
||||||
|
**Requested By:** Reddit community ([thread](https://reddit.com/r/LocalLLaMA/...))
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Docker support makes Voicebox easier to deploy, especially for:
|
||||||
|
|
||||||
|
- **Consistent Environments**: Same setup across dev/staging/prod
|
||||||
|
- **GPU Passthrough**: Easy NVIDIA/AMD GPU access
|
||||||
|
- **Server Deployments**: Run on headless Linux servers
|
||||||
|
- **Multi-User Setups**: Isolate instances per user/team
|
||||||
|
- **Cloud Platforms**: Deploy to AWS, GCP, Azure, DigitalOcean
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Using Pre-Built Images (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CPU-only version
|
||||||
|
docker run -p 8000:8000 -v voicebox-data:/app/data \
|
||||||
|
ghcr.io/jamiepine/voicebox:latest
|
||||||
|
|
||||||
|
# NVIDIA GPU version
|
||||||
|
docker run --gpus all -p 8000:8000 -v voicebox-data:/app/data \
|
||||||
|
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||||
|
|
||||||
|
# AMD GPU version (experimental)
|
||||||
|
docker run --device=/dev/kfd --device=/dev/dri -p 8000:8000 \
|
||||||
|
-v voicebox-data:/app/data \
|
||||||
|
ghcr.io/jamiepine/voicebox:latest-rocm
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open: `http://localhost:8000`
|
||||||
|
|
||||||
|
### Using Docker Compose (Easiest)
|
||||||
|
|
||||||
|
Create `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
voicebox:
|
||||||
|
image: ghcr.io/jamiepine/voicebox:latest-cuda
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- voicebox-data:/app/data
|
||||||
|
- huggingface-cache:/root/.cache/huggingface
|
||||||
|
environment:
|
||||||
|
- GPU_MEMORY_FRACTION=0.8 # Use 80% of GPU memory
|
||||||
|
- TTS_MODE=local
|
||||||
|
- WHISPER_MODE=local
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
voicebox-data:
|
||||||
|
huggingface-cache:
|
||||||
|
```
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building From Source
|
||||||
|
|
||||||
|
### Basic Dockerfile
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Dockerfile
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
git \
|
||||||
|
build-essential \
|
||||||
|
ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy application
|
||||||
|
COPY backend/ /app/backend/
|
||||||
|
COPY requirements.txt /app/
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
RUN pip install --no-cache-dir git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Run server
|
||||||
|
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Build and run:
|
||||||
|
```bash
|
||||||
|
docker build -t voicebox .
|
||||||
|
docker run -p 8000:8000 -v $(pwd)/data:/app/data voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Stage Build (Optimized)
|
||||||
|
|
||||||
|
Smaller image size by separating build and runtime:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Dockerfile.optimized
|
||||||
|
# Stage 1: Build dependencies
|
||||||
|
FROM python:3.11-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
git build-essential && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir --target=/build/packages \
|
||||||
|
-r requirements.txt
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir --target=/build/packages \
|
||||||
|
git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||||
|
|
||||||
|
# Stage 2: Runtime
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install only runtime dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy installed packages from builder
|
||||||
|
COPY --from=builder /build/packages /usr/local/lib/python3.11/site-packages/
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY backend/ /app/backend/
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Build:
|
||||||
|
```bash
|
||||||
|
docker build -f Dockerfile.optimized -t voicebox:slim .
|
||||||
|
```
|
||||||
|
|
||||||
|
## GPU Support
|
||||||
|
|
||||||
|
### NVIDIA GPUs (CUDA)
|
||||||
|
|
||||||
|
**Dockerfile:**
|
||||||
|
```dockerfile
|
||||||
|
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04
|
||||||
|
|
||||||
|
# Install Python
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
python3.11 python3-pip git ffmpeg && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install PyTorch with CUDA support
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
|
||||||
|
|
||||||
|
# Install other dependencies
|
||||||
|
RUN pip3 install -r requirements.txt
|
||||||
|
RUN pip3 install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||||
|
|
||||||
|
COPY backend/ /app/backend/
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run with GPU:**
|
||||||
|
```bash
|
||||||
|
docker run --gpus all -p 8000:8000 \
|
||||||
|
-v voicebox-data:/app/data \
|
||||||
|
voicebox:cuda
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker Compose with GPU:**
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
voicebox:
|
||||||
|
image: voicebox:cuda
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: all
|
||||||
|
capabilities: [gpu]
|
||||||
|
```
|
||||||
|
|
||||||
|
### AMD GPUs (ROCm) - Experimental
|
||||||
|
|
||||||
|
**Dockerfile:**
|
||||||
|
```dockerfile
|
||||||
|
FROM rocm/dev-ubuntu-22.04:6.0
|
||||||
|
|
||||||
|
# Install Python
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
python3.11 python3-pip git ffmpeg && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install PyTorch with ROCm support
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.0
|
||||||
|
|
||||||
|
# Install other dependencies
|
||||||
|
RUN pip3 install -r requirements.txt
|
||||||
|
RUN pip3 install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||||
|
|
||||||
|
# Set ROCm environment variables
|
||||||
|
ENV HSA_OVERRIDE_GFX_VERSION=10.3.0
|
||||||
|
ENV ROCM_PATH=/opt/rocm
|
||||||
|
|
||||||
|
COPY backend/ /app/backend/
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run with AMD GPU:**
|
||||||
|
```bash
|
||||||
|
docker run --device=/dev/kfd --device=/dev/dri \
|
||||||
|
--group-add video --ipc=host --cap-add=SYS_PTRACE \
|
||||||
|
--security-opt seccomp=unconfined \
|
||||||
|
-p 8000:8000 -v voicebox-data:/app/data \
|
||||||
|
voicebox:rocm
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** ROCm support varies by GPU model. Works best on Linux. See [AMD ROCm docs](https://rocm.docs.amd.com) for compatibility.
|
||||||
|
|
||||||
|
## Volume Mounts
|
||||||
|
|
||||||
|
### Essential Volumes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -v voicebox-data:/app/data \ # Profiles, generations, history
|
||||||
|
-v huggingface-cache:/root/.cache/huggingface \ # Downloaded models
|
||||||
|
-p 8000:8000 voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Volume Mounts
|
||||||
|
|
||||||
|
For development with hot-reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -v $(pwd)/backend:/app/backend \ # Live code changes
|
||||||
|
-v voicebox-data:/app/data \
|
||||||
|
-e RELOAD=true \
|
||||||
|
-p 8000:8000 voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Model Storage
|
||||||
|
|
||||||
|
Use external model directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -v /path/to/models:/models \
|
||||||
|
-e MODELS_DIR=/models \
|
||||||
|
-v voicebox-data:/app/data \
|
||||||
|
-p 8000:8000 voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Configure Voicebox via environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -e TTS_MODE=local \
|
||||||
|
-e WHISPER_MODE=openai-api \
|
||||||
|
-e OPENAI_API_KEY=sk-... \
|
||||||
|
-e GPU_MEMORY_FRACTION=0.8 \
|
||||||
|
-e LOG_LEVEL=info \
|
||||||
|
-p 8000:8000 voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `TTS_MODE` | `local` | TTS provider: `local`, `remote` |
|
||||||
|
| `TTS_REMOTE_URL` | - | URL for remote TTS server |
|
||||||
|
| `WHISPER_MODE` | `local` | Whisper provider: `local`, `openai-api`, `remote` |
|
||||||
|
| `WHISPER_REMOTE_URL` | - | URL for remote Whisper server |
|
||||||
|
| `OPENAI_API_KEY` | - | OpenAI API key (if using OpenAI Whisper) |
|
||||||
|
| `GPU_MEMORY_FRACTION` | `0.9` | Fraction of GPU memory to use (0.0-1.0) |
|
||||||
|
| `DATA_DIR` | `/app/data` | Directory for profiles/generations |
|
||||||
|
| `MODELS_DIR` | `/app/models` | Directory for local models |
|
||||||
|
| `LOG_LEVEL` | `info` | Logging level: `debug`, `info`, `warning`, `error` |
|
||||||
|
| `RELOAD` | `false` | Enable hot-reload for development |
|
||||||
|
|
||||||
|
## Complete Docker Compose Examples
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.prod.yml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
voicebox:
|
||||||
|
image: ghcr.io/jamiepine/voicebox:latest-cuda
|
||||||
|
container_name: voicebox
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- voicebox-data:/app/data
|
||||||
|
- huggingface-cache:/root/.cache/huggingface
|
||||||
|
environment:
|
||||||
|
- TTS_MODE=local
|
||||||
|
- WHISPER_MODE=local
|
||||||
|
- GPU_MEMORY_FRACTION=0.8
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
voicebox-data:
|
||||||
|
driver: local
|
||||||
|
huggingface-cache:
|
||||||
|
driver: local
|
||||||
|
```
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.dev.yml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
voicebox:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app/backend:ro
|
||||||
|
- voicebox-data:/app/data
|
||||||
|
- huggingface-cache:/root/.cache/huggingface
|
||||||
|
environment:
|
||||||
|
- RELOAD=true
|
||||||
|
- LOG_LEVEL=debug
|
||||||
|
- TTS_MODE=local
|
||||||
|
command: uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
voicebox-data:
|
||||||
|
huggingface-cache:
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Service Stack
|
||||||
|
|
||||||
|
Full stack with reverse proxy and monitoring:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.stack.yml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Main Voicebox app
|
||||||
|
voicebox:
|
||||||
|
image: ghcr.io/jamiepine/voicebox:latest-cuda
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- voicebox-data:/app/data
|
||||||
|
- huggingface-cache:/root/.cache/huggingface
|
||||||
|
environment:
|
||||||
|
- TTS_MODE=local
|
||||||
|
- WHISPER_MODE=local
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
|
|
||||||
|
# Nginx reverse proxy
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./ssl:/etc/nginx/ssl:ro
|
||||||
|
depends_on:
|
||||||
|
- voicebox
|
||||||
|
|
||||||
|
# Prometheus monitoring (optional)
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
volumes:
|
||||||
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
|
- prometheus-data:/prometheus
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
voicebox-data:
|
||||||
|
huggingface-cache:
|
||||||
|
prometheus-data:
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cloud Deployment
|
||||||
|
|
||||||
|
### AWS EC2
|
||||||
|
|
||||||
|
1. **Launch GPU Instance** (g4dn.xlarge or p3.2xlarge)
|
||||||
|
2. **Install Docker + nvidia-docker:**
|
||||||
|
```bash
|
||||||
|
# Amazon Linux 2
|
||||||
|
sudo yum install -y docker
|
||||||
|
sudo systemctl start docker
|
||||||
|
distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
|
||||||
|
curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add -
|
||||||
|
curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | \
|
||||||
|
sudo tee /etc/apt/sources.list.d/nvidia-docker.list
|
||||||
|
sudo apt-get update && sudo apt-get install -y nvidia-docker2
|
||||||
|
sudo systemctl restart docker
|
||||||
|
```
|
||||||
|
3. **Deploy:**
|
||||||
|
```bash
|
||||||
|
docker run --gpus all -d -p 80:8000 \
|
||||||
|
-v voicebox-data:/app/data \
|
||||||
|
--restart unless-stopped \
|
||||||
|
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||||
|
```
|
||||||
|
|
||||||
|
### DigitalOcean
|
||||||
|
|
||||||
|
Use GPU Droplet + Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create droplet via CLI
|
||||||
|
doctl compute droplet create voicebox \
|
||||||
|
--size gpu-h100x1-80gb \
|
||||||
|
--image ubuntu-22-04-x64 \
|
||||||
|
--region nyc3
|
||||||
|
|
||||||
|
# SSH and deploy
|
||||||
|
ssh root@<droplet-ip>
|
||||||
|
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||||
|
sh get-docker.sh
|
||||||
|
docker run --gpus all -d -p 80:8000 voicebox:cuda
|
||||||
|
```
|
||||||
|
|
||||||
|
### Google Cloud Run (CPU-only)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and push
|
||||||
|
docker build -t gcr.io/your-project/voicebox .
|
||||||
|
docker push gcr.io/your-project/voicebox
|
||||||
|
|
||||||
|
# Deploy to Cloud Run
|
||||||
|
gcloud run deploy voicebox \
|
||||||
|
--image gcr.io/your-project/voicebox \
|
||||||
|
--platform managed \
|
||||||
|
--region us-central1 \
|
||||||
|
--memory 4Gi \
|
||||||
|
--cpu 2 \
|
||||||
|
--port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fly.io
|
||||||
|
|
||||||
|
Create `fly.toml`:
|
||||||
|
```toml
|
||||||
|
app = "voicebox"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
image = "ghcr.io/jamiepine/voicebox:latest"
|
||||||
|
|
||||||
|
[[services]]
|
||||||
|
http_checks = []
|
||||||
|
internal_port = 8000
|
||||||
|
protocol = "tcp"
|
||||||
|
|
||||||
|
[[services.ports]]
|
||||||
|
port = 80
|
||||||
|
handlers = ["http"]
|
||||||
|
|
||||||
|
[[services.ports]]
|
||||||
|
port = 443
|
||||||
|
handlers = ["tls", "http"]
|
||||||
|
|
||||||
|
[mounts]
|
||||||
|
source = "voicebox_data"
|
||||||
|
destination = "/app/data"
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy:
|
||||||
|
```bash
|
||||||
|
fly launch
|
||||||
|
fly deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### GPU Not Detected
|
||||||
|
|
||||||
|
**Check NVIDIA Docker:**
|
||||||
|
```bash
|
||||||
|
docker run --rm --gpus all nvidia/cuda:12.1.0-base-ubuntu22.04 nvidia-smi
|
||||||
|
```
|
||||||
|
|
||||||
|
If this fails, reinstall nvidia-docker2.
|
||||||
|
|
||||||
|
**Check AMD ROCm:**
|
||||||
|
```bash
|
||||||
|
docker run --rm --device=/dev/kfd --device=/dev/dri rocm/dev-ubuntu-22.04:6.0 rocminfo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission Errors
|
||||||
|
|
||||||
|
Container can't write to volumes:
|
||||||
|
```bash
|
||||||
|
# Fix permissions
|
||||||
|
docker run --user $(id -u):$(id -g) -v $(pwd)/data:/app/data voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### Out of Memory
|
||||||
|
|
||||||
|
Reduce GPU memory usage:
|
||||||
|
```bash
|
||||||
|
docker run -e GPU_MEMORY_FRACTION=0.5 voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use CPU-only:
|
||||||
|
```bash
|
||||||
|
docker run -e DEVICE=cpu voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model Download Fails
|
||||||
|
|
||||||
|
Ensure HuggingFace cache is writable:
|
||||||
|
```bash
|
||||||
|
docker run -v huggingface-cache:/root/.cache/huggingface voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use host cache:
|
||||||
|
```bash
|
||||||
|
docker run -v ~/.cache/huggingface:/root/.cache/huggingface voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port Already in Use
|
||||||
|
|
||||||
|
Change host port:
|
||||||
|
```bash
|
||||||
|
docker run -p 8080:8000 voicebox # Use port 8080 instead
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
### 1. Don't Run as Root
|
||||||
|
|
||||||
|
Create non-root user in Dockerfile:
|
||||||
|
```dockerfile
|
||||||
|
RUN useradd -m -u 1000 voicebox
|
||||||
|
USER voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Use Secrets for API Keys
|
||||||
|
|
||||||
|
Don't put API keys in docker-compose.yml:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use Docker secrets
|
||||||
|
echo "sk-your-key" | docker secret create openai_key -
|
||||||
|
|
||||||
|
docker service create \
|
||||||
|
--secret openai_key \
|
||||||
|
-e OPENAI_API_KEY_FILE=/run/secrets/openai_key \
|
||||||
|
voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Network Isolation
|
||||||
|
|
||||||
|
Use internal networks for multi-container setups:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
voicebox:
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
nginx:
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
- external
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
internal: true
|
||||||
|
external:
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Resource Limits
|
||||||
|
|
||||||
|
Prevent resource exhaustion:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
voicebox:
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '4'
|
||||||
|
memory: 8G
|
||||||
|
reservations:
|
||||||
|
cpus: '2'
|
||||||
|
memory: 4G
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Tuning
|
||||||
|
|
||||||
|
### GPU Memory Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use 80% of GPU (default 90%)
|
||||||
|
docker run -e GPU_MEMORY_FRACTION=0.8 voicebox
|
||||||
|
|
||||||
|
# Allow GPU memory growth (prevents OOM)
|
||||||
|
docker run -e TF_FORCE_GPU_ALLOW_GROWTH=true voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model Caching
|
||||||
|
|
||||||
|
Pre-download models to volume:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download models first
|
||||||
|
docker run --rm -v huggingface-cache:/root/.cache/huggingface \
|
||||||
|
voicebox python -c "
|
||||||
|
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
||||||
|
WhisperProcessor.from_pretrained('openai/whisper-base')
|
||||||
|
WhisperForConditionalGeneration.from_pretrained('openai/whisper-base')
|
||||||
|
"
|
||||||
|
|
||||||
|
# Then run normally
|
||||||
|
docker run -v huggingface-cache:/root/.cache/huggingface voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Worker Setup
|
||||||
|
|
||||||
|
Use uvicorn workers for better throughput:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
Built-in health endpoint:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker health check:
|
||||||
|
```yaml
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prometheus Metrics
|
||||||
|
|
||||||
|
Add metrics exporter:
|
||||||
|
```python
|
||||||
|
# backend/main.py
|
||||||
|
from prometheus_fastapi_instrumentator import Instrumentator
|
||||||
|
|
||||||
|
Instrumentator().instrument(app).expose(app)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then scrape `/metrics` with Prometheus.
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
View container logs:
|
||||||
|
```bash
|
||||||
|
docker logs -f voicebox
|
||||||
|
|
||||||
|
# Or with compose
|
||||||
|
docker compose logs -f voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- [ ] Publish official images to GitHub Container Registry
|
||||||
|
- [ ] Add Kubernetes Helm charts
|
||||||
|
- [ ] Create Docker Desktop extension
|
||||||
|
- [ ] Add automated vulnerability scanning
|
||||||
|
- [ ] Support ARM64 builds for Raspberry Pi / Apple Silicon
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Help improve Docker support:
|
||||||
|
1. Test on different platforms (AMD GPU, ARM64, etc.)
|
||||||
|
2. Submit Dockerfile optimizations
|
||||||
|
3. Share deployment configurations
|
||||||
|
4. Report issues: [GitHub Issues](https://github.com/jamiepine/voicebox/issues)
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Docker Documentation](https://docs.docker.com)
|
||||||
|
- [NVIDIA Container Toolkit](https://github.com/NVIDIA/nvidia-docker)
|
||||||
|
- [AMD ROCm Docker](https://rocm.docs.amd.com/projects/install-on-linux/en/latest/how-to/docker.html)
|
||||||
|
- [Docker Compose Reference](https://docs.docker.com/compose/compose-file/)
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
# External Provider Support
|
||||||
|
|
||||||
|
**Status:** Planned for v0.2.0
|
||||||
|
**Discussion:** [Reddit Thread](https://reddit.com/r/LocalLLaMA/...)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
External provider support allows you to connect Voicebox to remotely-hosted TTS and Whisper services instead of running models locally. This is useful for:
|
||||||
|
|
||||||
|
- **Existing GPU Infrastructure**: You already have Qwen3-TTS running on a GPU server
|
||||||
|
- **AMD GPU Users**: Run models on your AMD hardware, use Voicebox as the UI
|
||||||
|
- **Cloud Deployments**: Host models on Modal, Replicate, RunPod, etc.
|
||||||
|
- **Team Sharing**: Multiple users share one GPU server running models
|
||||||
|
- **Mixed Deployments**: Local Whisper + remote TTS, or vice versa
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ HTTP/API ┌──────────────────┐
|
||||||
|
│ Voicebox UI │ ───────────────────────> │ Your TTS Server │
|
||||||
|
│ + Backend │ │ (Qwen3-TTS on │
|
||||||
|
│ │ <─────────────────────── │ AMD/NVIDIA GPU)│
|
||||||
|
│ - Profiles │ Audio + Metadata └──────────────────┘
|
||||||
|
│ - History │
|
||||||
|
│ - Audio Edit │ HTTP/API ┌──────────────────┐
|
||||||
|
│ - UI │ ───────────────────────> │ Whisper Service │
|
||||||
|
└─────────────────┘ │ (OpenAI API or │
|
||||||
|
│ self-hosted) │
|
||||||
|
└──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**What Voicebox Still Handles:**
|
||||||
|
- Voice profile management
|
||||||
|
- Generation history
|
||||||
|
- Audio trimming/editing
|
||||||
|
- Multi-track story editor
|
||||||
|
- UI/UX layer
|
||||||
|
|
||||||
|
**What External Providers Handle:**
|
||||||
|
- Model inference (TTS generation, transcription)
|
||||||
|
- GPU allocation
|
||||||
|
- Model loading/caching
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# TTS Provider
|
||||||
|
TTS_MODE=remote # local | remote
|
||||||
|
TTS_REMOTE_URL=http://192.168.1.100:8000 # Your TTS server URL
|
||||||
|
TTS_API_KEY=your-api-key # Optional authentication
|
||||||
|
|
||||||
|
# Whisper Provider
|
||||||
|
WHISPER_MODE=openai-api # local | openai-api | remote
|
||||||
|
WHISPER_REMOTE_URL=http://localhost:9000 # For self-hosted Whisper
|
||||||
|
OPENAI_API_KEY=sk-... # For OpenAI Whisper API
|
||||||
|
```
|
||||||
|
|
||||||
|
### Voicebox Config UI (Planned)
|
||||||
|
|
||||||
|
Settings page will include:
|
||||||
|
- Provider selection dropdowns
|
||||||
|
- URL/API key inputs
|
||||||
|
- Connection test button
|
||||||
|
- Latency/status indicators
|
||||||
|
|
||||||
|
## Hosting External Services
|
||||||
|
|
||||||
|
### Option 1: Simple FastAPI Server (Recommended)
|
||||||
|
|
||||||
|
Create a lightweight server to expose your local Qwen3-TTS model:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# tts_server.py
|
||||||
|
from fastapi import FastAPI, UploadFile, File
|
||||||
|
from qwen_tts import Qwen3TTSModel
|
||||||
|
import numpy as np
|
||||||
|
import base64
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
model = Qwen3TTSModel.from_pretrained(
|
||||||
|
"Qwen/Qwen3-TTS-12Hz-1.7B-Base",
|
||||||
|
device_map="cuda" # or "cpu" for AMD ROCm: use torch+rocm
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/v1/generate")
|
||||||
|
async def generate(
|
||||||
|
text: str,
|
||||||
|
voice_prompt: dict,
|
||||||
|
language: str = "en",
|
||||||
|
seed: int = None
|
||||||
|
):
|
||||||
|
"""Generate speech from text using voice prompt."""
|
||||||
|
audio, sample_rate = model.generate_voice_clone(
|
||||||
|
text=text,
|
||||||
|
voice_clone_prompt=voice_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return as base64 for transport
|
||||||
|
audio_bytes = audio.tobytes()
|
||||||
|
return {
|
||||||
|
"audio": base64.b64encode(audio_bytes).decode(),
|
||||||
|
"sample_rate": sample_rate,
|
||||||
|
"dtype": str(audio.dtype)
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.post("/v1/create_voice_prompt")
|
||||||
|
async def create_voice_prompt(
|
||||||
|
audio: UploadFile = File(...),
|
||||||
|
reference_text: str = ""
|
||||||
|
):
|
||||||
|
"""Create voice prompt from reference audio."""
|
||||||
|
# Save uploaded audio temporarily
|
||||||
|
audio_path = f"/tmp/{audio.filename}"
|
||||||
|
with open(audio_path, "wb") as f:
|
||||||
|
f.write(await audio.read())
|
||||||
|
|
||||||
|
# Create voice prompt
|
||||||
|
voice_prompt = model.create_voice_clone_prompt(
|
||||||
|
ref_audio=audio_path,
|
||||||
|
ref_text=reference_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"voice_prompt": voice_prompt}
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"model": "Qwen3-TTS-12Hz-1.7B-Base",
|
||||||
|
"device": str(model.device)
|
||||||
|
}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run it:**
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pip install fastapi uvicorn qwen-tts torch
|
||||||
|
|
||||||
|
# For AMD GPUs, use ROCm PyTorch:
|
||||||
|
pip install torch --index-url https://download.pytorch.org/whl/rocm6.4
|
||||||
|
|
||||||
|
# Start server
|
||||||
|
python tts_server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: vLLM (If Supported)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-Base \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port 8000 \
|
||||||
|
--gpu-memory-utilization 0.9
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Cloud Platforms
|
||||||
|
|
||||||
|
**Modal.com Example:**
|
||||||
|
```python
|
||||||
|
import modal
|
||||||
|
|
||||||
|
app = modal.App("qwen-tts")
|
||||||
|
image = modal.Image.debian_slim().pip_install("qwen-tts", "torch")
|
||||||
|
|
||||||
|
@app.function(gpu="A10G", image=image)
|
||||||
|
@modal.web_endpoint(method="POST")
|
||||||
|
def generate(text: str, voice_prompt: dict):
|
||||||
|
from qwen_tts import Qwen3TTSModel
|
||||||
|
model = Qwen3TTSModel.from_pretrained("Qwen/Qwen3-TTS-12Hz-1.7B-Base")
|
||||||
|
audio, sr = model.generate_voice_clone(text, voice_prompt)
|
||||||
|
return {"audio": audio.tolist(), "sample_rate": sr}
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy: `modal deploy tts_server.py`
|
||||||
|
Get URL: `https://yourapp--generate.modal.run`
|
||||||
|
|
||||||
|
## API Specification
|
||||||
|
|
||||||
|
External TTS providers must implement these endpoints:
|
||||||
|
|
||||||
|
### `POST /v1/generate`
|
||||||
|
|
||||||
|
Generate speech from text.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "Hello, this is a test.",
|
||||||
|
"voice_prompt": { /* voice prompt object */ },
|
||||||
|
"language": "en",
|
||||||
|
"seed": 12345
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"audio": "base64-encoded-audio-bytes",
|
||||||
|
"sample_rate": 24000,
|
||||||
|
"dtype": "float32"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /v1/create_voice_prompt`
|
||||||
|
|
||||||
|
Create a voice prompt from reference audio.
|
||||||
|
|
||||||
|
**Request:** (multipart/form-data)
|
||||||
|
- `audio`: Audio file upload
|
||||||
|
- `reference_text`: Transcript of the audio
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"voice_prompt": { /* voice prompt object */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /health`
|
||||||
|
|
||||||
|
Health check endpoint.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"model": "Qwen3-TTS-12Hz-1.7B-Base",
|
||||||
|
"device": "cuda:0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Whisper External Providers
|
||||||
|
|
||||||
|
### OpenAI Whisper API
|
||||||
|
|
||||||
|
Simply set:
|
||||||
|
```bash
|
||||||
|
WHISPER_MODE=openai-api
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
```
|
||||||
|
|
||||||
|
Voicebox will use OpenAI's Whisper API automatically.
|
||||||
|
|
||||||
|
### Self-Hosted Whisper
|
||||||
|
|
||||||
|
Run your own Whisper server:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# whisper_server.py
|
||||||
|
from fastapi import FastAPI, UploadFile, File
|
||||||
|
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
||||||
|
import librosa
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
processor = WhisperProcessor.from_pretrained("openai/whisper-base")
|
||||||
|
model = WhisperForConditionalGeneration.from_pretrained("openai/whisper-base")
|
||||||
|
|
||||||
|
@app.post("/v1/transcribe")
|
||||||
|
async def transcribe(audio: UploadFile = File(...), language: str = None):
|
||||||
|
# Load audio
|
||||||
|
audio_path = f"/tmp/{audio.filename}"
|
||||||
|
with open(audio_path, "wb") as f:
|
||||||
|
f.write(await audio.read())
|
||||||
|
|
||||||
|
audio_data, sr = librosa.load(audio_path, sr=16000)
|
||||||
|
|
||||||
|
# Process
|
||||||
|
inputs = processor(audio_data, sampling_rate=16000, return_tensors="pt")
|
||||||
|
predicted_ids = model.generate(inputs["input_features"])
|
||||||
|
transcription = processor.batch_decode(predicted_ids, skip_special_tokens=True)[0]
|
||||||
|
|
||||||
|
return {"text": transcription}
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure Voicebox:
|
||||||
|
```bash
|
||||||
|
WHISPER_MODE=remote
|
||||||
|
WHISPER_REMOTE_URL=http://localhost:9000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
### 1. AMD GPU User with Existing Setup
|
||||||
|
|
||||||
|
**Scenario:** You have a Radeon 7900 XTX running Qwen3-TTS on Linux.
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
1. Run `tts_server.py` on your AMD box (ROCm PyTorch)
|
||||||
|
2. Configure Voicebox: `TTS_MODE=remote`, `TTS_REMOTE_URL=http://amd-box:8000`
|
||||||
|
3. Use Voicebox UI for profiles, generation, editing
|
||||||
|
4. TTS happens on your AMD GPU
|
||||||
|
|
||||||
|
### 2. Team Deployment
|
||||||
|
|
||||||
|
**Scenario:** 5 team members, 1 GPU server.
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
1. Deploy TTS server on shared GPU box
|
||||||
|
2. Each person runs Voicebox desktop app locally
|
||||||
|
3. All point to same `TTS_REMOTE_URL`
|
||||||
|
4. Profiles and history stay local per user
|
||||||
|
5. GPU usage is shared
|
||||||
|
|
||||||
|
### 3. Hybrid Local/Remote
|
||||||
|
|
||||||
|
**Scenario:** Fast local Whisper, heavy TTS on cloud.
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
```bash
|
||||||
|
TTS_MODE=remote
|
||||||
|
TTS_REMOTE_URL=https://your-modal-app.modal.run
|
||||||
|
|
||||||
|
WHISPER_MODE=local # Fast transcription on your CPU
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. OpenAI Whisper + Self-Hosted TTS
|
||||||
|
|
||||||
|
**Scenario:** Use OpenAI's API for transcription, run TTS locally.
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
```bash
|
||||||
|
TTS_MODE=local
|
||||||
|
|
||||||
|
WHISPER_MODE=openai-api
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
Add API key authentication to your external server:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import Header, HTTPException
|
||||||
|
|
||||||
|
API_KEY = "your-secret-key"
|
||||||
|
|
||||||
|
async def verify_api_key(x_api_key: str = Header(...)):
|
||||||
|
if x_api_key != API_KEY:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||||
|
|
||||||
|
@app.post("/v1/generate", dependencies=[Depends(verify_api_key)])
|
||||||
|
async def generate(...):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure Voicebox:
|
||||||
|
```bash
|
||||||
|
TTS_API_KEY=your-secret-key
|
||||||
|
```
|
||||||
|
|
||||||
|
### Network Security
|
||||||
|
|
||||||
|
- **VPN/Tailscale**: Use private network for remote servers
|
||||||
|
- **HTTPS**: Use reverse proxy (nginx/Caddy) with SSL certificates
|
||||||
|
- **Firewall**: Restrict access to known IPs
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
Protect your external server:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
app.state.limiter = limiter
|
||||||
|
|
||||||
|
@app.post("/v1/generate")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def generate(...):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Latency
|
||||||
|
|
||||||
|
External providers add network latency:
|
||||||
|
- **Local network**: ~10-50ms overhead (negligible)
|
||||||
|
- **Same datacenter**: ~1-5ms overhead
|
||||||
|
- **Cross-region cloud**: 50-200ms+ overhead
|
||||||
|
|
||||||
|
For real-time applications, keep TTS server on local network or same cloud region.
|
||||||
|
|
||||||
|
### Caching
|
||||||
|
|
||||||
|
Implement response caching on external server:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1000)
|
||||||
|
def get_cached_generation(text, voice_prompt_hash, language, seed):
|
||||||
|
return model.generate_voice_clone(text, voice_prompt)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Load Balancing
|
||||||
|
|
||||||
|
For high-traffic deployments, run multiple TTS servers behind a load balancer:
|
||||||
|
|
||||||
|
```
|
||||||
|
Voicebox ──> Load Balancer ──> TTS Server 1 (GPU 1)
|
||||||
|
├──> TTS Server 2 (GPU 2)
|
||||||
|
└──> TTS Server 3 (GPU 3)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] **Provider Marketplace**: Built-in directory of compatible providers
|
||||||
|
- [ ] **Automatic Fallback**: If remote fails, fallback to local
|
||||||
|
- [ ] **Cost Tracking**: Monitor API usage and costs
|
||||||
|
- [ ] **Performance Metrics**: Latency, throughput dashboards
|
||||||
|
- [ ] **Multi-Provider**: Use different providers for different voices/languages
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
If you build an external provider, please share:
|
||||||
|
1. Server implementation
|
||||||
|
2. Performance benchmarks
|
||||||
|
3. Deployment guide
|
||||||
|
|
||||||
|
Submit to: [GitHub Discussions](https://github.com/jamiepine/voicebox/discussions)
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
- **Discord**: [Join the community](https://discord.gg/...)
|
||||||
|
- **GitHub**: [Open an issue](https://github.com/jamiepine/voicebox/issues)
|
||||||
|
- **Docs**: [Full documentation](https://voicebox.sh/docs)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/landing",
|
"name": "@voicebox/landing",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"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",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "voicebox",
|
"name": "voicebox",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"app",
|
"app",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/tauri",
|
"name": "@voicebox/tauri",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "voicebox"
|
name = "voicebox"
|
||||||
version = "0.1.8"
|
version = "0.1.9"
|
||||||
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 = ""
|
||||||
|
|||||||
Binary file not shown.
@@ -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.8",
|
"version": "0.1.9",
|
||||||
"identifier": "sh.voicebox.app",
|
"identifier": "sh.voicebox.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/web",
|
"name": "@voicebox/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Reference in New Issue
Block a user