- Added support for TTS providers in the backend, including endpoints for listing, starting, stopping, and downloading providers. - Enhanced the release workflow to build and upload TTS provider binaries for both Windows and Linux platforms. - Updated the architecture documentation to reflect the new provider system and its benefits for modularity and user experience. - Introduced a new `ProviderSettings` component in the frontend for managing provider configurations.
28 KiB
TTS Provider Architecture
Status: Planned for v0.1.13 Created: 2025-01-31 Problem: GitHub 2GB release limit + poor UX for frequent updates requiring 2.4GB re-downloads
Overview
Split the monolithic backend into modular components:
- Main App:
- Windows/Linux (~150MB): Tauri + FastAPI backend + Whisper + UI/profiles/history
- macOS (~300MB): Same + MLX bundled for simplicity
- TTS Providers (Windows/Linux only): Downloadable executables for PyTorch CPU/CUDA inference
This architecture solves:
- ✅ GitHub 2GB release artifact limit
- ✅ Frequent app updates without re-downloading large python binaries (Windows/Linux)
- ✅ User choice of compute backend (CPU/GPU/Cloud) on Windows/Linux
- ✅ Simplified out-of-the-box experience on macOS
- ✅ External provider support (OpenAI, custom servers)
- ✅ Future extensibility
Architecture Diagram
Windows / Linux
┌─────────────────────────────────────────────────────────┐
│ Voicebox App (Tauri + Backend) ~150MB │
│ ├─ UI Layer (React) │
│ ├─ Backend (FastAPI) │
│ │ ├─ Voice Profiles │
│ │ ├─ Generation History │
│ │ ├─ Audio Editing / Stories │
│ │ └─ Provider Manager ◄──────────────┐ │
│ └─ Whisper (bundled, tiny ~50MB) │ │
└─────────────────────────────────────────┼────────────────┘
│
HTTP/IPC │
│
┌─────────────────────┴─────────────────┐
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ TTS Provider: │ │ TTS Provider: │
│ PyTorch CPU │ │ PyTorch CUDA │
│ │ │ │
│ ~300MB │ │ ~2.4GB │
│ │ │ │
│ Local inference │ │ GPU inference │
└─────────────────────┘ └─────────────────────┘
│ │
└───────────────┬───────────────────────┘
│
┌─────────────▼──────────────┐
│ Future Providers: │
│ • Remote Server │
│ • OpenAI API │
│ • ElevenLabs │
│ • Custom Docker Container │
└────────────────────────────┘
macOS
┌─────────────────────────────────────────────────────────┐
│ Voicebox App (Tauri + Backend) ~300MB │
│ ├─ UI Layer (React) │
│ ├─ Backend (FastAPI) │
│ │ ├─ Voice Profiles │
│ │ ├─ Generation History │
│ │ ├─ Audio Editing / Stories │
│ │ └─ MLX Backend (bundled) │
│ └─ Whisper (bundled, tiny ~50MB) │
│ │
│ No provider downloads needed - works out of the box │
└─────────────────────────────────────────────────────────┘
Problem Statement
Current Architecture Issues
Monolithic Binary:
- CPU version: ~295MB
- CUDA version: ~2.37GB
- GitHub releases: 2GB file size limit (BLOCKED)
- Updates require re-downloading entire binary
- Poor UX: update app → restart → download CUDA update → restart again
User Pain Points:
- Cannot release CUDA version on GitHub (over 2GB)
- Every app update forces 2.4GB re-download for GPU users
- No flexibility (can't use OpenAI, remote servers, etc.)
- Wastes bandwidth for small bug fixes
Solution: Pluggable TTS Providers
Component Breakdown
1. Main App (voicebox.exe / .app / .AppImage)
Windows/Linux Size: ~100-150MB macOS Size: ~300-350MB (includes MLX)
Includes:
- Tauri runtime + React UI
- FastAPI backend (pure Python, no PyTorch on Windows/Linux)
- Whisper model (tiny, ~50MB)
- SQLite database
- Profile/history/audio editing logic
- Provider management system (Windows/Linux only)
- MLX backend (macOS only, bundled)
Does NOT include (Windows/Linux only):
- PyTorch (CPU or CUDA)
- TTS models (Qwen3-TTS)
- Heavy ML dependencies
Updates frequently: UI fixes, feature additions, non-ML changes
2. TTS Provider: PyTorch CPU
Binary: tts-provider-pytorch-cpu.exe
Size: ~200MB
Includes:
- PyTorch CPU build
- Qwen3-TTS package
- Transformers
- No CUDA libraries
Download source: Cloudflare R2 Updates rarely: Only when model code changes
3. TTS Provider: PyTorch CUDA
Binary: tts-provider-pytorch-cuda.exe
Size: ~2.4GB
Includes:
- PyTorch CUDA build (cu121)
- Qwen3-TTS package
- CUDA runtime, cuDNN, cuBLAS
- Transformers
Download source: Cloudflare R2 Platform: Windows + Linux (NVIDIA GPU) Updates rarely: Only when model code or CUDA version changes
4. TTS Provider: Remote
Binary: None (built-in config) Size: 0MB
How it works:
- User provides URL to their own TTS server
- Backend proxies requests to that server
- Implements API spec from
EXTERNAL_PROVIDERS.md
Use cases:
- AMD GPU users running their own server
- Team deployments with shared GPU server
- Cloud hosting (Modal, RunPod, Replicate)
5. TTS Provider: OpenAI
Binary: None (API wrapper) Size: 0MB
How it works:
- User provides OpenAI API key
- Backend wraps OpenAI Audio API
- Voice profiles map to OpenAI voices
Benefits:
- Zero local compute
- Pay-per-use
- Instant setup
Communication Protocol
Provider API Specification
All TTS providers must implement these endpoints:
POST /tts/generate
Generate speech from text.
Request:
{
"text": "Hello world!",
"voice_prompt": {
/* voice prompt object */
},
"language": "en",
"seed": 12345,
"model_size": "1.7B"
}
Response:
{
"audio": "base64-encoded-audio",
"sample_rate": 24000,
"duration": 2.5
}
POST /tts/create_voice_prompt
Create voice prompt from reference audio.
Request: (multipart/form-data)
audio: Audio filereference_text: Transcript
Response:
{
"voice_prompt": {
/* serialized prompt */
}
}
GET /tts/health
Health check.
Response:
{
"status": "healthy",
"provider": "pytorch-cuda",
"version": "1.0.0",
"model": "Qwen3-TTS-12Hz-1.7B-Base",
"device": "cuda:0"
}
GET /tts/status
Model status.
Response:
{
"model_loaded": true,
"model_size": "1.7B",
"available_sizes": ["0.6B", "1.7B"],
"gpu_available": true,
"vram_used_mb": 1234
}
Backend Implementation
Provider Manager
File: backend/providers/__init__.py
class ProviderManager:
"""Manages TTS provider lifecycle (Windows/Linux only).
Note: macOS uses bundled MLX backend directly, no provider management needed.
"""
def __init__(self):
self.active_provider: Optional[Provider] = None
self.config = load_provider_config()
async def start_provider(self, provider_type: str) -> str:
"""Start a TTS provider process."""
if provider_type == "pytorch-cpu":
return await self._start_local_provider("tts-provider-pytorch-cpu.exe")
elif provider_type == "pytorch-cuda":
return await self._start_local_provider("tts-provider-pytorch-cuda.exe")
elif provider_type == "remote":
return self.config["remote_url"]
elif provider_type == "openai":
return None # No subprocess, API wrapper
async def _start_local_provider(self, binary_name: str) -> str:
"""Start local provider subprocess."""
provider_path = get_provider_binary_path(binary_name)
if not provider_path.exists():
raise ProviderNotInstalledException(binary_name)
# Start subprocess on random port
port = get_free_port()
process = subprocess.Popen([
str(provider_path),
"--port", str(port),
"--data-dir", str(config.get_data_dir())
])
# Wait for provider to be ready
await wait_for_provider_health(f"http://localhost:{port}")
self.active_provider = Provider(process, port)
return f"http://localhost:{port}"
async def stop_provider(self):
"""Stop active provider."""
if self.active_provider:
self.active_provider.process.terminate()
self.active_provider = None
Provider Abstraction
File: backend/providers/base.py
class TTSProvider(ABC):
"""Abstract base for TTS providers."""
@abstractmethod
async def generate(
self,
text: str,
voice_prompt: dict,
language: str,
seed: Optional[int]
) -> tuple[np.ndarray, int]:
"""Generate speech audio."""
pass
@abstractmethod
async def create_voice_prompt(
self,
audio_path: str,
reference_text: str
) -> dict:
"""Create voice prompt from reference audio."""
pass
File: backend/providers/local.py
class LocalProvider(TTSProvider):
"""Provider that communicates with local subprocess via HTTP."""
def __init__(self, base_url: str):
self.base_url = base_url
self.client = httpx.AsyncClient()
async def generate(self, text, voice_prompt, language, seed):
response = await self.client.post(
f"{self.base_url}/tts/generate",
json={
"text": text,
"voice_prompt": voice_prompt,
"language": language,
"seed": seed
}
)
data = response.json()
audio = np.frombuffer(base64.b64decode(data["audio"]), dtype=np.float32)
return audio, data["sample_rate"]
File: backend/providers/openai.py
class OpenAIProvider(TTSProvider):
"""Provider that wraps OpenAI Audio API."""
def __init__(self, api_key: str):
self.client = OpenAI(api_key=api_key)
async def generate(self, text, voice_prompt, language, seed):
# Map voice_prompt to OpenAI voice name
voice = map_profile_to_openai_voice(voice_prompt)
response = await self.client.audio.speech.create(
model="tts-1",
voice=voice,
input=text
)
# Convert to numpy array
audio_data = response.content
audio, sr = load_audio_from_bytes(audio_data)
return audio, sr
Provider Installation
Download Manager
File: backend/providers/installer.py
class ProviderInstaller:
"""Handles provider download and installation (Windows/Linux only)."""
async def download_provider(self, provider_type: str):
"""Download provider binary from R2."""
binary_name = {
"pytorch-cpu": "tts-provider-pytorch-cpu.exe",
"pytorch-cuda": "tts-provider-pytorch-cuda.exe"
}[provider_type]
download_url = f"https://downloads.voicebox.sh/providers/v{PROVIDER_VERSION}/{binary_name}"
# Download with progress tracking (reuse existing SSE system)
await download_with_progress(
url=download_url,
destination=get_provider_install_path(binary_name),
progress_key=f"provider-{provider_type}"
)
Provider Storage Location:
- Windows:
%APPDATA%/voicebox/providers/ - macOS:
~/Library/Application Support/voicebox/providers/ - Linux:
~/.local/share/voicebox/providers/
Frontend Implementation
Provider Settings UI
Component: app/src/components/ServerSettings/ProviderSettings.tsx
export function ProviderSettings() {
const [selectedProvider, setSelectedProvider] =
useState<ProviderType>("auto");
const {data: installedProviders} = useQuery({
queryKey: ["providers", "installed"],
queryFn: () => apiClient.getInstalledProviders(),
});
return (
<Card>
<CardHeader>
<CardTitle>TTS Provider</CardTitle>
<CardDescription>Choose how Voicebox generates speech</CardDescription>
</CardHeader>
<CardContent>
<RadioGroup
value={selectedProvider}
onValueChange={setSelectedProvider}
>
{/* Auto-detect */}
<div className="flex items-center space-x-2">
<RadioGroupItem value="auto" id="auto" />
<Label htmlFor="auto">
<div className="font-medium">Auto-detect (Recommended)</div>
<div className="text-sm text-muted-foreground">
Automatically choose the best available provider
</div>
</Label>
</div>
{/* PyTorch CUDA */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<RadioGroupItem
value="pytorch-cuda"
id="cuda"
disabled={!gpuAvailable}
/>
<Label htmlFor="cuda">
<div className="font-medium">PyTorch CUDA (NVIDIA GPU)</div>
<div className="text-sm text-muted-foreground">
4-5x faster inference on NVIDIA GPUs
</div>
</Label>
</div>
{!installedProviders?.includes("pytorch-cuda") && gpuAvailable && (
<Button
onClick={() => downloadProvider("pytorch-cuda")}
size="sm"
>
Download (2.4GB)
</Button>
)}
</div>
{/* PyTorch CPU (Windows/Linux only) */}
{!isMacOS && (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<RadioGroupItem value="pytorch-cpu" id="cpu" />
<Label htmlFor="cpu">
<div className="font-medium">PyTorch CPU</div>
<div className="text-sm text-muted-foreground">
Works on any system, slower inference
</div>
</Label>
</div>
{!installedProviders?.includes("pytorch-cpu") && (
<Button onClick={() => downloadProvider("pytorch-cpu")} size="sm">
Download (300MB)
</Button>
)}
</div>
)}
{/* MLX bundled (macOS only) */}
{isMacOS && (
<div className="p-3 bg-muted rounded-md">
<div className="text-sm">
<div className="font-medium">MLX (Apple Silicon)</div>
<div className="text-muted-foreground mt-1">
Bundled with the app - optimized for M1/M2/M3 chips
</div>
</div>
</div>
)}
{/* Remote */}
<div className="space-y-2">
<div className="flex items-center space-x-2">
<RadioGroupItem value="remote" id="remote" />
<Label htmlFor="remote">
<div className="font-medium">Remote Server</div>
<div className="text-sm text-muted-foreground">
Connect to your own TTS server
</div>
</Label>
</div>
{selectedProvider === "remote" && (
<Input placeholder="http://your-server:8000" className="ml-6" />
)}
</div>
{/* OpenAI */}
<div className="space-y-2">
<div className="flex items-center space-x-2">
<RadioGroupItem value="openai" id="openai" />
<Label htmlFor="openai">
<div className="font-medium">OpenAI API</div>
<div className="text-sm text-muted-foreground">
Use OpenAI's TTS API (requires API key)
</div>
</Label>
</div>
{selectedProvider === "openai" && (
<Input type="password" placeholder="sk-..." className="ml-6" />
)}
</div>
</RadioGroup>
</CardContent>
</Card>
);
}
File Structure
voicebox/
├── backend/
│ ├── main.py # Main FastAPI app (no TTS on Win/Linux)
│ ├── backends/
│ │ ├── __init__.py # Backend abstraction (existing)
│ │ ├── pytorch_backend.py # PyTorch backend (existing, for reference)
│ │ └── mlx_backend.py # MLX backend (bundled in macOS build only)
│ ├── providers/
│ │ ├── __init__.py # ProviderManager (Windows/Linux)
│ │ ├── base.py # TTSProvider Protocol
│ │ ├── local.py # LocalProvider (subprocess)
│ │ ├── remote.py # RemoteProvider (HTTP)
│ │ ├── openai.py # OpenAIProvider (API wrapper)
│ │ └── installer.py # Provider download logic (Windows/Linux)
│ ├── profiles.py # Voice profile management
│ ├── history.py # Generation history
│ ├── transcribe.py # Whisper (still bundled)
│ └── ... (other backend modules)
│
├── providers/
│ ├── pytorch-cpu/
│ │ ├── main.py # FastAPI server for TTS
│ │ ├── tts_backend.py # PyTorch TTS logic
│ │ ├── requirements.txt # torch (CPU), qwen-tts, transformers
│ │ └── build.spec # PyInstaller spec
│ │
│ └── pytorch-cuda/
│ ├── main.py # FastAPI server for TTS
│ ├── tts_backend.py # PyTorch TTS logic
│ ├── requirements.txt # torch+cu121, qwen-tts, transformers
│ └── build.spec # PyInstaller spec
│
├── app/ # Frontend (Tauri + React)
│ └── src/
│ └── components/
│ └── ServerSettings/
│ └── ProviderSettings.tsx # Only shown on Windows/Linux
│
└── tauri/
└── src-tauri/
└── tauri.conf.json # No externalBin for providers (Windows/Linux)
# MLX bundled in macOS build
Migration Path
Phase 1: Refactor Backend (No User Changes)
Goal: Abstract TTS behind provider interface
- Create
backend/providers/module structure - Implement
TTSProviderabstract base class - Create
LocalProviderwrapper for current PyTorch code - Modify
backend/tts.pyto use provider abstraction - Keep PyTorch bundled in main app
Result: Code is prepared, but user experience unchanged
Phase 2: Build Provider Binaries
Goal: Create standalone TTS provider executables (Windows/Linux only)
- Create separate PyInstaller specs for each provider
- Build provider executables:
tts-provider-pytorch-cpu.exe(~300MB)tts-provider-pytorch-cuda.exe(~2.4GB)
- Test subprocess communication
- Upload providers to Cloudflare R2
Result: Provider binaries exist but aren't used yet
Note: macOS keeps MLX bundled in main app - no separate provider needed
Phase 3: Remove PyTorch from Main App
Goal: Split main app from providers (Windows/Linux only)
- Exclude PyTorch/Qwen3-TTS from Windows/Linux main app PyInstaller spec
- Windows/Linux app now requires provider download
- Update GitHub CI to build multiple artifacts:
voicebox-{version}-windows.exe(~150MB, no TTS)voicebox-{version}-linux.AppImage(~150MB, no TTS)voicebox-{version}-macos.app(~300MB, MLX bundled)tts-provider-pytorch-cpu-{version}.exetts-provider-pytorch-cuda-{version}.exe
Result: Windows/Linux apps are small with downloadable providers, macOS app is self-contained
Phase 4: Add Provider UI
Goal: User-facing provider management
- Create Provider Settings page
- Implement provider download UI
- Add provider status indicators
- Show active provider in UI
Result: Users can choose and download providers
Phase 5: External Providers
Goal: Enable remote and cloud providers
- Implement
RemoteProvider(HTTP client) - Implement
OpenAIProvider(API wrapper) - Add provider configuration UI (URLs, API keys)
- Document external provider API spec
Result: Full provider ecosystem
Provider Versioning
Independent Versioning
Providers have their own version numbers, independent of the main app:
- App version:
v0.2.0(frequent updates) - Provider version:
v1.0.0(rare updates)
Compatibility Matrix
Example:
| App Version | Min Provider Version | Max Provider Version |
|---|---|---|
| v0.2.0 | v1.0.0 | v1.x.x |
| v0.3.0 | v1.0.0 | v1.x.x |
| v0.4.0 | v1.2.0 | v1.x.x |
| v1.0.0 | v2.0.0 | v2.x.x |
Backend checks compatibility:
async def check_provider_compatibility(provider_version: str) -> bool:
"""Check if provider version is compatible with current app."""
min_version = "1.0.0"
max_version = "1.999.999"
return min_version <= provider_version < max_version
UI shows warning if incompatible:
⚠️ Provider version 0.9.0 is outdated. Update to v1.0.0+
User Flows
First-Time Setup (Windows/Linux)
-
User downloads and installs Voicebox (~150MB)
-
App launches → detects no TTS provider installed
-
Shows setup wizard:
Choose your TTS provider: [ ] PyTorch CUDA (2.4GB) [Download] ✓ Fastest on NVIDIA GPUs ✗ Requires NVIDIA GPU [●] PyTorch CPU (300MB) [Download] ✓ Works on any system ✗ Slower inference [ ] Remote Server URL: ___________________ [ ] OpenAI API API Key: ________________ -
User selects provider → downloads with progress bar
-
Provider installs to AppData/Application Support
-
App starts provider → ready to use
First-Time Setup (macOS)
- User downloads and installs Voicebox (~300MB with MLX bundled)
- App launches → MLX backend is ready immediately
- No provider setup needed - works out of the box
App Update Flow (No Provider Change)
Scenario: Bug fix in UI, no backend changes
Windows/Linux:
- User gets update notification: "Voicebox v0.2.1 available"
- Downloads update (~150MB, not 2.4GB!)
- Installs and restarts
- Provider stays the same (no re-download needed)
- App starts using existing provider
macOS:
- User gets update notification: "Voicebox v0.2.1 available"
- Downloads update (~300MB with MLX bundled)
- Installs and restarts - ready to use
User experience: Fast updates, no multi-GB downloads (especially for CUDA users)
Provider Update Flow
Scenario: New Qwen3-TTS model version released
- User opens Settings → Provider tab
- Sees notification: "Provider update available (v1.1.0)"
- Clicks "Update Provider"
- Downloads new provider binary
- Old provider binary is replaced
- Restart app to use new provider
Frequency: Rare (only when TTS model/backend changes)
Switching Providers
Scenario: User upgrades to NVIDIA GPU
- User goes to Settings → Provider
- Selects "PyTorch CUDA"
- Clicks "Download" → downloads 2.4GB
- Download completes → restarts app
- App now uses CUDA provider
Benefits
| Benefit | Details |
|---|---|
| GitHub Releases Work | Main app ~150MB (Win/Linux), ~300MB (macOS) << 2GB limit |
| Fast Updates | UI/feature updates don't require re-downloading providers |
| User Choice | CPU, CUDA, OpenAI, remote server (Win/Linux) |
| macOS Simplicity | MLX bundled - works immediately, no provider setup needed |
| External Provider Support | Users can run their own TTS servers |
| Bandwidth Savings | Only download provider once, app updates are small |
| Future-Proof | Easy to add new providers (ElevenLabs, custom models) |
| Team Deployments | Multiple users share one remote provider |
| Cloud-Ready | Works with Modal, Replicate, RunPod, etc. |
Open Questions
1. Provider Versioning
Question: Should providers have independent versions or match app version?
Options:
- A. Independent (providers: v1.x, app: v0.2.x)
- B. Matched (both use v0.2.x)
Recommendation: Independent versioning with compatibility matrix
2. Auto-Update Providers
Question: Should providers auto-update separately from app?
Options:
- A. Manual updates only (user clicks "Update Provider")
- B. Optional auto-update (user can enable)
- C. Always auto-update
Recommendation: Optional auto-update (default off)
3. Provider Discovery
Question: How does app find installed providers?
Options:
- A. Check standard paths in AppData/Application Support
- B. Registry (Windows) / plist (macOS)
- C. Config file with provider locations
Recommendation: Standard paths + config fallback
4. Fallback Behavior
Question: What if no provider is installed?
Options:
- A. Show setup wizard on first launch
- B. Block app until provider installed
- C. Allow app to run in "demo mode" (transcription only)
Recommendation: Setup wizard on first launch
5. Provider Auto-Start
Question: Should provider start automatically with app?
Options:
- A. Always start selected provider on app launch
- B. Start on-demand (when user generates speech)
- C. User preference
Recommendation: Auto-start (configurable in settings)
Future Enhancements
- Provider Marketplace: Built-in directory of community providers
- Multi-Provider Support: Use different providers per voice/language
- Provider Health Monitoring: Automatic failover if provider crashes
- Cost Tracking: Monitor API usage for OpenAI/cloud providers
- Performance Metrics: Latency, throughput, VRAM usage dashboards
- Docker Providers: Run providers in Docker containers
- Provider Plugins: Load custom providers from user scripts
Related Documents
- EXTERNAL_PROVIDERS.md - External provider support plan
- OPENAI_SUPPORT.md - OpenAI API compatibility
- github-2gb-limit-issue.md - Original problem
- r2-setup.md - Cloudflare R2 configuration
Contributing
If you want to build a custom TTS provider:
- Implement the provider API spec (see above)
- Test with Voicebox locally
- Package as executable (PyInstaller, Docker, etc.)
- Share in GitHub Discussions
Questions?
- GitHub Issues: voicebox/issues
- Discord: Coming soon