+ {/* Text field - hidden when in instruct mode */}
+
+ (
+
+
+
+
+
+
+
+ )}
+ />
+
+ {/* Instruct field - hidden when in text mode */}
+
+ (
+
+
+ setIsExpanded(true)}
- onFocus={() => setIsExpanded(true)}
- />
-
-
-
-
- )}
- />
+ transition={{ duration: 0.15, ease: 'easeOut' }}
+ style={{ overflow: 'hidden' }}
+ >
+
@@ -278,9 +321,12 @@ export function FloatingGenerateBox({
variant="ghost"
size="icon"
onClick={() => setIsInstructMode(!isInstructMode)}
- className={`h-10 w-10 rounded-full bg-card border border-border hover:bg-background/50 transition-all duration-200 ${
- isInstructMode ? 'text-accent' : ''
- }`}
+ className={cn(
+ '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',
+ )}
>
diff --git a/app/src/components/ServerSettings/ModelProgress.tsx b/app/src/components/ServerSettings/ModelProgress.tsx
index ae882c1a..f222ed10 100644
--- a/app/src/components/ServerSettings/ModelProgress.tsx
+++ b/app/src/components/ServerSettings/ModelProgress.tsx
@@ -12,11 +12,10 @@ interface ModelProgressProps {
export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
const [progress, setProgress] = useState
(null);
- const [isSubscribed, setIsSubscribed] = useState(false);
const serverUrl = useServerStore((state) => state.serverUrl);
useEffect(() => {
- if (!serverUrl || isSubscribed) return;
+ if (!serverUrl) return;
// Subscribe to progress updates via Server-Sent Events
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
@@ -29,7 +28,6 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
// Close connection if complete or error
if (data.status === 'complete' || data.status === 'error') {
eventSource.close();
- setIsSubscribed(false);
}
} catch (error) {
console.error('Error parsing progress event:', error);
@@ -39,16 +37,12 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
eventSource.onerror = (error) => {
console.error('SSE error:', error);
eventSource.close();
- setIsSubscribed(false);
};
- setIsSubscribed(true);
-
return () => {
eventSource.close();
- setIsSubscribed(false);
};
- }, [serverUrl, modelName, isSubscribed]);
+ }, [serverUrl, modelName]);
// Don't render if no progress or if complete/error and some time has passed
if (
diff --git a/app/src/components/StoriesTab/StoryTrackEditor.tsx b/app/src/components/StoriesTab/StoryTrackEditor.tsx
index f2f4dbff..fb7004ce 100644
--- a/app/src/components/StoriesTab/StoryTrackEditor.tsx
+++ b/app/src/components/StoriesTab/StoryTrackEditor.tsx
@@ -539,7 +539,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
return;
}
- if (e.key === 'Escape') {
+ if (e.key === ' ') {
+ e.preventDefault();
+ handlePlayPause();
+ } else if (e.key === 'Escape') {
setSelectedClipId(null);
} else if (e.key === 's' || e.key === 'S') {
if (selectedClipId) {
@@ -561,7 +564,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
- }, [selectedClipId, handleSplit, handleDuplicate, handleDelete, setSelectedClipId]);
+ }, [selectedClipId, handleSplit, handleDuplicate, handleDelete, setSelectedClipId, handlePlayPause]);
// Add global mouse listeners for trimming
useEffect(() => {
@@ -701,7 +704,13 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
{/* Play controls - left side */}
-
+
{isCurrentlyPlaying ? : }
(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 (
+
+
+
+ {isPlaying ? : }
+
+
+
+
0 ? [(currentTime / duration) * 100] : [0]}
+ onValueChange={handleSeek}
+ max={100}
+ step={0.1}
+ className="flex-1"
+ />
+
+ {formatAudioDuration(currentTime)}
+ /
+ {formatAudioDuration(duration)}
+
+
+
+
+
+
+
+
+ );
+}
+
interface SampleListProps {
profileId: string;
}
@@ -13,10 +134,11 @@ interface SampleListProps {
export function SampleList({ profileId }: SampleListProps) {
const { data: samples, isLoading } = useProfileSamples(profileId);
const deleteSample = useDeleteSample();
+ const updateSample = useUpdateSample();
+ const { toast } = useToast();
const [uploadOpen, setUploadOpen] = useState(false);
- const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
- const currentAudioId = usePlayerStore((state) => state.audioId);
- const isPlaying = usePlayerStore((state) => state.isPlaying);
+ const [editingSampleId, setEditingSampleId] = useState(null);
+ const [editedText, setEditedText] = useState('');
const handleDelete = (sampleId: string) => {
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 audioUrl = apiClient.getSampleUrl(sampleId);
- setAudioWithAutoPlay(audioUrl, sampleId, null, referenceText.substring(0, 50));
+ const handleStartEdit = (sampleId: string, currentText: string) => {
+ setEditingSampleId(sampleId);
+ 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) {
@@ -44,43 +198,109 @@ export function SampleList({ profileId }: SampleListProps) {
{samples && samples.length === 0 ? (
-
- No samples yet. Add your first audio sample.
+
+
+
No samples yet
+
Add your first audio sample to get started
) : (
- {samples?.map((sample) => (
-
-
-
{sample.reference_text}
-
{sample.audio_path}
+ {samples?.map((sample, index) => {
+ const isEditing = editingSampleId === sample.id;
+
+ return (
+
+ {isEditing ? (
+ /* Edit Mode */
+
+
+
+
Editing transcription
+
+
+ ) : (
+ <>
+ {/* View Mode */}
+
+ {/* Text Content */}
+
+
+ {sample.reference_text}
+
+
+
+ {/* Action Buttons */}
+
+
handleStartEdit(sample.id, sample.reference_text)}
+ >
+
+
+
handleDelete(sample.id)}
+ disabled={deleteSample.isPending}
+ >
+
+
+
+
+ {/* Sample Number Badge */}
+
+ #{index + 1}
+
+
+
+ {/* Mini Player - Always visible */}
+
+ >
+ )}
-
-
handlePlay(sample.reference_text, sample.id)}
- className={currentAudioId === sample.id && isPlaying ? 'text-primary' : ''}
- >
-
- Play
-
-
handleDelete(sample.id)}
- disabled={deleteSample.isPending}
- >
-
-
-
-
- ))}
+ );
+ })}
)}
diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts
index c8262e09..3e27c6cf 100644
--- a/app/src/lib/api/client.ts
+++ b/app/src/lib/api/client.ts
@@ -120,6 +120,16 @@ class ApiClient {
});
}
+ async updateProfileSample(
+ sampleId: string,
+ referenceText: string,
+ ): Promise
{
+ return this.request(`/profiles/samples/${sampleId}`, {
+ method: 'PUT',
+ body: JSON.stringify({ reference_text: referenceText }),
+ });
+ }
+
async exportProfile(profileId: string): Promise {
const url = `${this.getBaseUrl()}/profiles/${profileId}/export`;
const response = await fetch(url);
diff --git a/app/src/lib/hooks/useModelDownloadToast.tsx b/app/src/lib/hooks/useModelDownloadToast.tsx
index 18dcfcc3..d1865bb4 100644
--- a/app/src/lib/hooks/useModelDownloadToast.tsx
+++ b/app/src/lib/hooks/useModelDownloadToast.tsx
@@ -140,8 +140,8 @@ export function useModelDownloadToast({
}
};
- eventSource.onerror = () => {
- console.error('SSE error');
+ eventSource.onerror = (error) => {
+ console.error('SSE error:', error);
eventSource.close();
eventSourceRef.current = null;
diff --git a/app/src/lib/hooks/useProfiles.ts b/app/src/lib/hooks/useProfiles.ts
index 52379345..f0415d70 100644
--- a/app/src/lib/hooks/useProfiles.ts
+++ b/app/src/lib/hooks/useProfiles.ts
@@ -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() {
return useMutation({
mutationFn: async (profileId: string) => {
diff --git a/backend/build_binary.py b/backend/build_binary.py
index 088f6e13..e9b23733 100644
--- a/backend/build_binary.py
+++ b/backend/build_binary.py
@@ -11,9 +11,6 @@ def build_server():
"""Build Python server as standalone binary."""
backend_dir = Path(__file__).parent
- # Check for local editable qwen_tts install
- local_qwen_path = Path.home() / 'Projects' / 'voice' / 'Qwen3-TTS'
-
# PyInstaller arguments
args = [
'server.py', # Use server.py as entry point instead of main.py
@@ -21,10 +18,11 @@ def build_server():
'--name', 'voicebox-server',
]
- # Add local qwen_tts path if it exists (for editable installs)
- if local_qwen_path.exists():
- args.extend(['--paths', str(local_qwen_path)])
- print(f"Using local qwen_tts source from: {local_qwen_path}")
+ # Add local qwen_tts path if specified (for editable installs)
+ qwen_tts_path = os.getenv('QWEN_TTS_PATH')
+ if qwen_tts_path and Path(qwen_tts_path).exists():
+ args.extend(['--paths', str(qwen_tts_path)])
+ print(f"Using local qwen_tts source from: {qwen_tts_path}")
# Add hidden imports
args.extend([
diff --git a/backend/main.py b/backend/main.py
index e3f6909b..ed1942ae 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -283,6 +283,19 @@ async def delete_profile_sample(
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")
async def export_profile(
profile_id: str,
diff --git a/backend/models.py b/backend/models.py
index b0f4452d..5009c2a3 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -32,6 +32,11 @@ class ProfileSampleCreate(BaseModel):
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):
"""Response model for profile sample."""
id: str
diff --git a/backend/profiles.py b/backend/profiles.py
index 49fcd521..894611aa 100644
--- a/backend/profiles.py
+++ b/backend/profiles.py
@@ -273,6 +273,33 @@ async def delete_profile_sample(
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(
profile_id: str,
db: Session,
diff --git a/backend/transcribe.py b/backend/transcribe.py
index 6f21966b..6d4ac060 100644
--- a/backend/transcribe.py
+++ b/backend/transcribe.py
@@ -162,9 +162,10 @@ class WhisperModel:
# Set language if provided
forced_decoder_ids = None
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(
- language=lang_code,
+ language=language,
task="transcribe",
)
@@ -221,9 +222,10 @@ class WhisperModel:
# Set language if provided
forced_decoder_ids = None
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(
- language=lang_code,
+ language=language,
task="transcribe",
)
diff --git a/backend/utils/progress.py b/backend/utils/progress.py
index 879ba6a5..bd51d984 100644
--- a/backend/utils/progress.py
+++ b/backend/utils/progress.py
@@ -26,7 +26,7 @@ class ProgressManager:
):
"""
Update progress for a model download.
-
+
Args:
model_name: Name of the model (e.g., "qwen-tts-1.7B", "whisper-base")
current: Current bytes downloaded
@@ -34,8 +34,11 @@ class ProgressManager:
filename: Current file being downloaded
status: Status string (downloading, extracting, complete, error)
"""
+ import logging
+ logger = logging.getLogger(__name__)
+
progress_pct = (current / total * 100) if total > 0 else 0
-
+
self._progress[model_name] = {
"model_name": model_name,
"current": current,
@@ -45,14 +48,18 @@ class ProgressManager:
"status": status,
"timestamp": datetime.now().isoformat(),
}
-
+
# 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]:
try:
queue.put_nowait(self._progress[model_name].copy())
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]:
"""Get current progress for a model."""
@@ -98,30 +105,40 @@ class ProgressManager:
async def subscribe(self, model_name: str):
"""
Subscribe to progress updates for a model.
-
+
Yields progress updates as Server-Sent Events.
"""
+ import logging
+ logger = logging.getLogger(__name__)
+
queue = asyncio.Queue(maxsize=10)
-
+
# Add to listeners
if model_name not in self._listeners:
self._listeners[model_name] = []
self._listeners[model_name].append(queue)
-
+
+ logger.info(f"SSE client subscribed to {model_name}, total listeners: {len(self._listeners[model_name])}")
+
try:
# Send initial progress if available
if model_name in self._progress:
+ logger.info(f"Sending initial progress for {model_name}: {self._progress[model_name].get('status')}")
yield f"data: {json.dumps(self._progress[model_name])}\n\n"
-
+ else:
+ logger.info(f"No initial progress available for {model_name}")
+
# Stream updates
while True:
try:
# Wait for update with timeout
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"
-
+
# Stop if complete or error
if progress.get("status") in ("complete", "error"):
+ logger.info(f"Download {progress.get('status')} for {model_name}, closing SSE connection")
break
except asyncio.TimeoutError:
# Send heartbeat
@@ -133,32 +150,41 @@ class ProgressManager:
self._listeners[model_name].remove(queue)
if not 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):
"""Mark a model download as complete."""
+ import logging
+ logger = logging.getLogger(__name__)
+
if model_name in self._progress:
self._progress[model_name]["status"] = "complete"
self._progress[model_name]["progress"] = 100.0
+ logger.info(f"Marked {model_name} as complete")
# Notify listeners
if model_name in self._listeners:
for queue in self._listeners[model_name]:
try:
queue.put_nowait(self._progress[model_name].copy())
except asyncio.QueueFull:
- pass
+ logger.warning(f"Queue full when marking {model_name} complete")
def mark_error(self, model_name: str, error: str):
"""Mark a model download as failed."""
+ import logging
+ logger = logging.getLogger(__name__)
+
if model_name in self._progress:
self._progress[model_name]["status"] = "error"
self._progress[model_name]["error"] = error
+ logger.error(f"Marked {model_name} as error: {error}")
# Notify listeners
if model_name in self._listeners:
for queue in self._listeners[model_name]:
try:
queue.put_nowait(self._progress[model_name].copy())
except asyncio.QueueFull:
- pass
+ logger.warning(f"Queue full when marking {model_name} error")
# Global progress manager instance
diff --git a/backend/voicebox-server.spec b/backend/voicebox-server.spec
deleted file mode 100644
index 71d87865..00000000
--- a/backend/voicebox-server.spec
+++ /dev/null
@@ -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,
-)
diff --git a/docs/plans/DOCKER_DEPLOYMENT.md b/docs/plans/DOCKER_DEPLOYMENT.md
new file mode 100644
index 00000000..6af65ed7
--- /dev/null
+++ b/docs/plans/DOCKER_DEPLOYMENT.md
@@ -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@
+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/)
diff --git a/docs/plans/EXTERNAL_PROVIDERS.md b/docs/plans/EXTERNAL_PROVIDERS.md
new file mode 100644
index 00000000..3b1e7e21
--- /dev/null
+++ b/docs/plans/EXTERNAL_PROVIDERS.md
@@ -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)
diff --git a/tauri/src-tauri/gen/Assets.car b/tauri/src-tauri/gen/Assets.car
index c01e5cca..9a0cfb6b 100644
Binary files a/tauri/src-tauri/gen/Assets.car and b/tauri/src-tauri/gen/Assets.car differ