From f80782a90a45421c63d07b670eafe3af1dd8e7ac Mon Sep 17 00:00:00 2001
From: James Pine
Date: Sun, 15 Mar 2026 04:53:28 -0700
Subject: [PATCH] Landing page v0.2.0 updates: multi-engine copy, star count,
model cards, voice creator section, responsive ControlUI, iOS audio fix
- Replace Qwen-specific copy with multi-engine messaging across hero, meta, and features
- Add GitHub star count fetched server-side via /api/stars with Spacedrive-style navbar badge
- Replace 'Why Voicebox exists' section with model cards for all 4 TTS engines
- Enable Linux download card (was 'Coming soon')
- Update GPU support copy to include ROCm, Intel Arc, DirectML
- Add Voice Creator section with animated 3-tab UI (upload, mic, system audio) and waveform background
- Make ControlUI responsive: horizontal scroll cards on mobile, stacked layout, scroll-to-active profile
- Fix iOS Safari audio autoplay (unlock AudioContext on user gesture)
- Fix hero logo square background with mix-blend-lighten
- Remove generation length green coloring, use gray with accent highlights
- Comment out grain overlay (visible tile seams)
- Remove player close button, stack waveform above controls on mobile
- Fixed-height profile cards (143px) with space between badges and buttons
---
.../components/AudioPlayer/AudioPlayer.tsx | 164 +----
.../components/EffectsTab/EffectsDetail.tsx | 94 ++-
app/src/components/Sidebar.tsx | 7 +-
docs/RELEASE_v0.2.0.md | 163 +++++
landing/src/app/api/stars/route.ts | 15 +
landing/src/app/globals.css | 9 +-
landing/src/app/layout.tsx | 12 +-
landing/src/app/page.tsx | 166 ++++-
landing/src/components/ControlUI.tsx | 300 +++++---
landing/src/components/Features.tsx | 645 ++++++++++++++----
landing/src/components/LandingAudioPlayer.tsx | 92 +--
landing/src/components/Navbar.tsx | 33 +-
landing/src/components/VoiceCreator.tsx | 479 +++++++++++++
landing/src/lib/releases.ts | 39 ++
tauri/src-tauri/gen/Assets.car | Bin 3847048 -> 2899688 bytes
tauri/src-tauri/gen/voicebox.icns | Bin 82957 -> 87844 bytes
16 files changed, 1756 insertions(+), 462 deletions(-)
create mode 100644 docs/RELEASE_v0.2.0.md
create mode 100644 landing/src/app/api/stars/route.ts
create mode 100644 landing/src/components/VoiceCreator.tsx
diff --git a/app/src/components/AudioPlayer/AudioPlayer.tsx b/app/src/components/AudioPlayer/AudioPlayer.tsx
index 75e1b4e5..667404f3 100644
--- a/app/src/components/AudioPlayer/AudioPlayer.tsx
+++ b/app/src/components/AudioPlayer/AudioPlayer.tsx
@@ -139,7 +139,11 @@ export function AudioPlayer() {
barRadius: 2,
height: 80,
normalize: true,
- backend: 'WebAudio',
+ // Use MediaElement backend (default). Unlike the WebAudio backend,
+ // MediaElement uses a standard element for playback which
+ // benefits from the browser/webview's built-in audio session recovery.
+ // This prevents audio loss when another app steals audio output or
+ // the system audio session is interrupted.
interact: true, // Enable interaction (click to seek)
mediaControls: false, // Don't show native controls
});
@@ -189,15 +193,6 @@ export function AudioPlayer() {
const currentVolume = usePlayerStore.getState().volume;
wavesurfer.setVolume(currentVolume);
- // Get the underlying audio element and ensure it's not muted
- // (unless we're using native playback, which will be set later)
- const mediaElement = wavesurfer.getMediaElement();
- if (mediaElement && !isUsingNativePlaybackRef.current) {
- mediaElement.volume = currentVolume;
- mediaElement.muted = false;
- debug.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
- }
-
// Auto-play when ready - check if we should use native playback
// Get current values from the store and queries at runtime (not captured closure values)
const currentAudioUrl = usePlayerStore.getState().audioUrl;
@@ -264,21 +259,8 @@ export function AudioPlayer() {
debug.log('Should use native playback:', shouldUseNative);
if (!shouldUseNative) {
- debug.log('No custom devices assigned, falling back to WaveSurfer');
- // Reset native playback flag and unmute WaveSurfer
+ debug.log('No custom devices assigned, using standard playback');
isUsingNativePlaybackRef.current = false;
- const mediaElement = wavesurfer.getMediaElement();
- if (mediaElement) {
- const currentVolume = usePlayerStore.getState().volume;
- mediaElement.volume = currentVolume;
- mediaElement.muted = false;
- debug.log(
- 'WaveSurfer unmuted for normal playback - volume:',
- mediaElement.volume,
- 'muted:',
- mediaElement.muted,
- );
- }
} else {
const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids);
debug.log('Device IDs to play to:', deviceIds);
@@ -299,19 +281,10 @@ export function AudioPlayer() {
// Mark that we're using native playback
isUsingNativePlaybackRef.current = true;
- // Mute WaveSurfer's audio element to prevent UI audio output
- // Keep WaveSurfer running for visualization
- const mediaElement = wavesurfer.getMediaElement();
- if (mediaElement) {
- mediaElement.volume = 0;
- mediaElement.muted = true;
- debug.log(
- 'WaveSurfer muted for native playback - volume:',
- mediaElement.volume,
- 'muted:',
- mediaElement.muted,
- );
- }
+ // Mute WaveSurfer's audio output — native handles the actual sound
+ // Keep WaveSurfer running for waveform visualization
+ wavesurfer.setVolume(0);
+ wavesurfer.setMuted(true);
// Start WaveSurfer playback for visualization (muted)
wavesurfer.play().catch((error) => {
@@ -334,38 +307,15 @@ export function AudioPlayer() {
'Native playback failed during auto-play, falling back to WaveSurfer:',
error,
);
- // Reset native playback flag and unmute WaveSurfer
isUsingNativePlaybackRef.current = false;
- const mediaElement = wavesurfer.getMediaElement();
- if (mediaElement) {
- const currentVolume = usePlayerStore.getState().volume;
- mediaElement.volume = currentVolume;
- mediaElement.muted = false;
- debug.log(
- 'WaveSurfer unmuted after native playback failure - volume:',
- mediaElement.volume,
- 'muted:',
- mediaElement.muted,
- );
- }
// Fall through to WaveSurfer playback
}
- } else {
- debug.log('Not using native playback, using WaveSurfer');
- // Reset native playback flag and unmute WaveSurfer
- isUsingNativePlaybackRef.current = false;
- const mediaElement = wavesurfer.getMediaElement();
- if (mediaElement) {
- const currentVolume = usePlayerStore.getState().volume;
- mediaElement.volume = currentVolume;
- mediaElement.muted = false;
- debug.log(
- 'WaveSurfer unmuted for normal playback - volume:',
- mediaElement.volume,
- 'muted:',
- mediaElement.muted,
- );
- }
+ }
+
+ // Standard playback path — ensure WaveSurfer is unmuted
+ if (!isUsingNativePlaybackRef.current) {
+ wavesurfer.setMuted(false);
+ wavesurfer.setVolume(usePlayerStore.getState().volume);
}
// Only auto-play if shouldAutoPlay flag is set (user explicitly clicked to play)
@@ -389,28 +339,6 @@ export function AudioPlayer() {
// Handle play/pause
wavesurfer.on('play', () => {
setIsPlaying(true);
- // Ensure audio element volume is set correctly
- const mediaElement = wavesurfer.getMediaElement();
- if (mediaElement) {
- // Double-check: if using native playback, keep WaveSurfer muted
- // Otherwise, ensure it's unmuted
- if (isUsingNativePlaybackRef.current) {
- mediaElement.volume = 0;
- mediaElement.muted = true;
- debug.log('Playing (native mode) - WaveSurfer muted for visualization only');
- } else {
- // Ensure WaveSurfer is unmuted for normal playback
- const currentVolume = usePlayerStore.getState().volume;
- mediaElement.volume = currentVolume;
- mediaElement.muted = false;
- debug.log(
- 'Playing (normal mode) - volume:',
- mediaElement.volume,
- 'muted:',
- mediaElement.muted,
- );
- }
- }
});
wavesurfer.on('pause', () => setIsPlaying(false));
wavesurfer.on('finish', () => {
@@ -492,11 +420,6 @@ export function AudioPlayer() {
if (wavesurferRef.current) {
debug.log('Destroying WaveSurfer instance');
try {
- const mediaElement = wavesurferRef.current.getMediaElement();
- if (mediaElement) {
- mediaElement.pause();
- mediaElement.src = '';
- }
wavesurferRef.current.destroy();
} catch (error) {
debug.error('Error destroying WaveSurfer:', error);
@@ -537,13 +460,10 @@ export function AudioPlayer() {
}
// Reset native playback flag when loading new audio
- // Also unmute WaveSurfer if it was muted
+ // Unmute WaveSurfer if it was muted for native playback
if (isUsingNativePlaybackRef.current) {
- const mediaElement = wavesurfer.getMediaElement();
- if (mediaElement) {
- mediaElement.muted = false;
- mediaElement.volume = usePlayerStore.getState().volume;
- }
+ wavesurfer.setMuted(false);
+ wavesurfer.setVolume(usePlayerStore.getState().volume);
}
isUsingNativePlaybackRef.current = false;
@@ -559,16 +479,7 @@ export function AudioPlayer() {
wavesurfer.pause();
}
- // Stop the media element explicitly
- const mediaElement = wavesurfer.getMediaElement();
- if (mediaElement) {
- debug.log('Stopping media element');
- mediaElement.pause();
- mediaElement.currentTime = 0;
- mediaElement.src = '';
- }
-
- // Use empty() to completely destroy the waveform and media element
+ // Use empty() to completely destroy the waveform and reset media
debug.log('Calling wavesurfer.empty() to destroy audio');
wavesurfer.empty();
} catch (error) {
@@ -623,20 +534,13 @@ export function AudioPlayer() {
// Sync volume
useEffect(() => {
if (wavesurferRef.current) {
- wavesurferRef.current.setVolume(volume);
- // Also ensure the underlying audio element volume is set
- const mediaElement = wavesurferRef.current.getMediaElement();
- if (mediaElement) {
- // If using native playback, keep WaveSurfer muted regardless of volume setting
- if (isUsingNativePlaybackRef.current) {
- mediaElement.volume = 0;
- mediaElement.muted = true;
- debug.log('Volume sync: Using native playback, keeping WaveSurfer muted');
- } else {
- mediaElement.volume = volume;
- mediaElement.muted = volume === 0;
- debug.log('Volume synced:', volume, 'muted:', mediaElement.muted);
- }
+ // If using native playback, keep WaveSurfer muted regardless of volume setting
+ if (isUsingNativePlaybackRef.current) {
+ wavesurferRef.current.setVolume(0);
+ debug.log('Volume sync: Using native playback, keeping WaveSurfer muted');
+ } else {
+ wavesurferRef.current.setVolume(volume);
+ debug.log('Volume synced:', volume);
}
}
}, [volume]);
@@ -757,11 +661,8 @@ export function AudioPlayer() {
isUsingNativePlaybackRef.current = true;
// Mute WaveSurfer and start it for visualization
- const mediaElement = wavesurferRef.current.getMediaElement();
- if (mediaElement) {
- mediaElement.volume = 0;
- mediaElement.muted = true;
- }
+ wavesurferRef.current.setVolume(0);
+ wavesurferRef.current.setMuted(true);
// Start WaveSurfer for visualization (muted)
wavesurferRef.current.play().catch((error) => {
@@ -785,11 +686,8 @@ export function AudioPlayer() {
} else {
// Ensure WaveSurfer is not muted if not using native playback
if (!isUsingNativePlaybackRef.current) {
- const mediaElement = wavesurferRef.current.getMediaElement();
- if (mediaElement) {
- mediaElement.muted = false;
- mediaElement.volume = volume;
- }
+ wavesurferRef.current.setMuted(false);
+ wavesurferRef.current.setVolume(volume);
}
wavesurferRef.current.play().catch((error) => {
diff --git a/app/src/components/EffectsTab/EffectsDetail.tsx b/app/src/components/EffectsTab/EffectsDetail.tsx
index f877c914..60c2bf7f 100644
--- a/app/src/components/EffectsTab/EffectsDetail.tsx
+++ b/app/src/components/EffectsTab/EffectsDetail.tsx
@@ -5,6 +5,14 @@ import { useEffect, useRef, useState } from 'react';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { GenerationPicker } from '@/components/Effects/GenerationPicker';
import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
@@ -29,6 +37,11 @@ export function EffectsDetail() {
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
+ // "Save as Custom" dialog state
+ const [saveAsDialogOpen, setSaveAsDialogOpen] = useState(false);
+ const [saveAsName, setSaveAsName] = useState('');
+ const [saveAsDescription, setSaveAsDescription] = useState('');
+
// Preview state
const [previewGenId, setPreviewGenId] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false);
@@ -165,8 +178,38 @@ export function EffectsDetail() {
}
}
- async function handleSaveAsNew() {
- await handleSaveNew();
+ function handleSaveAsNew() {
+ // Open the dialog with a suggested name based on the current preset
+ setSaveAsName(`${name} (Copy)`);
+ setSaveAsDescription(description);
+ setSaveAsDialogOpen(true);
+ }
+
+ async function handleSaveAsConfirm() {
+ if (!saveAsName.trim()) {
+ toast({ title: 'Name required', variant: 'destructive' });
+ return;
+ }
+ setSaving(true);
+ try {
+ const created = await apiClient.createEffectPreset({
+ name: saveAsName.trim(),
+ description: saveAsDescription.trim() || undefined,
+ effects_chain: workingChain,
+ });
+ queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
+ setSaveAsDialogOpen(false);
+ setSelectedPresetId(created.id);
+ toast({ title: 'Preset saved', description: `"${created.name}" has been created.` });
+ } catch (error) {
+ toast({
+ title: 'Failed to save',
+ description: error instanceof Error ? error.message : 'Unknown error',
+ variant: 'destructive',
+ });
+ } finally {
+ setSaving(false);
+ }
}
async function handleDelete() {
@@ -327,6 +370,53 @@ export function EffectsDetail() {
+
+ {/* Save as Custom dialog */}
+
+
+
+ Save as Custom Preset
+
+ Create a new custom preset based on the current effects chain.
+
+
+
+
+ Name
+ setSaveAsName(e.target.value)}
+ placeholder="My preset..."
+ className="h-9"
+ autoFocus
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && saveAsName.trim()) {
+ handleSaveAsConfirm();
+ }
+ }}
+ />
+
+
+ Description
+
+
+
+ setSaveAsDialogOpen(false)} disabled={saving}>
+ Cancel
+
+
+
+ {saving ? 'Saving...' : 'Save'}
+
+
+
+
);
}
diff --git a/app/src/components/Sidebar.tsx b/app/src/components/Sidebar.tsx
index ac4e9f17..88659399 100644
--- a/app/src/components/Sidebar.tsx
+++ b/app/src/components/Sidebar.tsx
@@ -45,12 +45,15 @@ export function Sidebar({ isMacOS }: SidebarProps) {
{/* Navigation Buttons */}
- {tabs.map((tab) => {
+ {tabs.map((tab, index) => {
const Icon = tab.icon;
// For index route, use exact match; for others, use default matching
const isActive =
tab.path === '/' ? matchRoute({ to: '/', exact: true }) : matchRoute({ to: tab.path });
+ // Accent fades as buttons get further from the logo
+ const accentOpacity = Math.max(0.08, 0.5 - index * 0.07);
+
return (
)}
diff --git a/docs/RELEASE_v0.2.0.md b/docs/RELEASE_v0.2.0.md
new file mode 100644
index 00000000..9d2d8e7b
--- /dev/null
+++ b/docs/RELEASE_v0.2.0.md
@@ -0,0 +1,163 @@
+# Voicebox v0.2.0 -- Release Notes
+
+## The story
+
+Voicebox v0.1.x shipped as a single-engine voice cloning app built around Qwen3-TTS. It worked, but it was limited: one model family, 10 languages, English-centric emotion, a synchronous generation pipeline that locked the UI, and a hard ceiling on how much text you could generate at once.
+
+v0.2.0 is a ground-up rethink. Voicebox is now a **multi-engine voice cloning platform**. Four TTS engines. 23 languages. Expressive paralinguistic controls. A full post-processing effects pipeline. Unlimited generation length. Asynchronous everything. And it runs on every major GPU vendor -- NVIDIA, AMD, Intel Arc, Apple Silicon -- plus Docker for headless deployment.
+
+This is the release where Voicebox stops being a proof of concept and starts being a real tool.
+
+---
+
+## Major New Features
+
+### Multi-Engine Architecture
+Voicebox now supports **four TTS engines**, each with different strengths. Switch between them per-generation from a single unified interface:
+
+| Engine | Languages | Strengths |
+|--------|-----------|-----------|
+| **Qwen3-TTS** (0.6B / 1.7B) | 10 | High-quality multilingual cloning, delivery instructions ("speak slowly", "whisper") |
+| **LuxTTS** | English | Lightweight (~1GB VRAM), 48kHz output, 150x realtime on CPU |
+| **Chatterbox Multilingual** | 23 | Broadest language coverage -- Arabic, Danish, Finnish, Greek, Hebrew, Hindi, Malay, Norwegian, Polish, Swahili, Swedish, Turkish and more |
+| **Chatterbox Turbo** | English | Fast 350M model with paralinguistic emotion/sound tags |
+
+### Emotions and Paralinguistic Tags (Chatterbox Turbo)
+Type `/` in the text input to open an autocomplete for **9 expressive tags** that the model synthesizes inline with speech:
+
+`[laugh]` `[chuckle]` `[gasp]` `[cough]` `[sigh]` `[groan]` `[sniff]` `[shush]` `[clear throat]`
+
+Tags render as inline badges in a rich text editor and serialize cleanly to the API. This makes generated speech sound natural and expressive in a way that plain TTS can't.
+
+### 23 Languages via Chatterbox Multilingual
+The Chatterbox Multilingual engine brings zero-shot voice cloning to **23 languages**: Arabic, Chinese, Danish, Dutch, English, Finnish, French, German, Greek, Hebrew, Hindi, Italian, Japanese, Korean, Malay, Norwegian, Polish, Portuguese, Russian, Spanish, Swahili, Swedish, and Turkish. The language dropdown dynamically filters to show only languages supported by the selected engine.
+
+### Unlimited Generation Length (Auto-Chunking)
+Previously, long text would hit model context limits and degrade. Now, text is **automatically split at sentence boundaries** and each chunk is generated independently, then crossfaded back together. This is fully engine-agnostic and works with all four engines.
+
+- **Auto-chunking limit slider** (100-5,000 chars, default 800) -- controls when text gets split
+- **Crossfade slider** (0-200ms, default 50ms) -- blends chunk boundaries smoothly, or set to 0 for a hard cut
+- **Max text length raised to 50,000 characters** -- generate entire scripts, chapters, or articles in one go
+- Smart splitting respects abbreviations (Dr., e.g., a.m.), CJK punctuation, and never breaks inside paralinguistic `[tags]`
+
+### Asynchronous Generation Queue
+Generation is now fully **non-blocking**. Submit a generation and immediately start typing the next one -- no more frozen UI waiting for inference to complete.
+
+- Serial execution queue prevents GPU contention across all backends
+- Real-time SSE status streaming (`generating` -> `completed` / `failed`)
+- Failed generations can be retried without re-entering text
+- Stale generations from crashes are auto-recovered on startup
+- Generating status pill shown inline in the story editor
+
+### Post-Processing Effects Pipeline
+A full audio effects system powered by Spotify's `pedalboard` library. Apply effects after generation, preview them in real time, and build reusable presets -- all without leaving the app.
+
+**8 effects available:**
+
+| Effect | What it does |
+|--------|-------------|
+| **Pitch Shift** | Shift pitch up or down by up to 12 semitones |
+| **Reverb** | Room reverb with configurable size, damping, and wet/dry mix |
+| **Delay** | Echo with adjustable delay time, feedback, and mix |
+| **Chorus / Flanger** | Modulated delay -- short for metallic flanger, longer for lush chorus |
+| **Compressor** | Dynamic range compression with threshold, ratio, attack, and release |
+| **Gain** | Volume adjustment from -40 to +40 dB |
+| **High-Pass Filter** | Remove low frequencies below a configurable cutoff |
+| **Low-Pass Filter** | Remove high frequencies above a configurable cutoff |
+
+**Effects presets** -- Four built-in presets ship out of the box (Robotic, Radio, Echo Chamber, Deep Voice), and you can create unlimited custom presets. Presets are drag-and-drop chains of effects with per-parameter sliders.
+
+**Per-profile default effects** -- Assign an effects chain to a voice profile and it applies automatically to every generation with that voice. Override per-generation from the generate box.
+
+**Live preview** -- Audition any effects chain against an existing generation before committing. The preview streams processed audio without saving anything.
+
+### Generation Versions
+Every generation now supports **multiple versions** with full provenance tracking:
+
+- **Original** -- the clean, unprocessed TTS output (always preserved)
+- **Effects versions** -- apply different effects chains to create new versions from any source version
+- **Takes** -- regenerate with the same text and voice but a new seed for variation
+- **Source tracking** -- each version records which version it was derived from
+- **Version pinning in stories** -- pin a specific version to a track clip in the story editor, independent of the generation's default
+- **Favorites** -- star generations to mark them for quick access
+
+---
+
+## New Platform Support
+
+### Linux (Native)
+Full Linux support with `.deb` and `.rpm` packages. Includes PulseAudio/PipeWire audio capture for voice sample recording.
+
+### AMD ROCm GPU Acceleration
+AMD GPU users now get hardware-accelerated inference via ROCm, with automatic `HSA_OVERRIDE_GFX_VERSION` configuration for GPUs not officially in the ROCm compatibility list (e.g., RX 6600).
+
+### NVIDIA CUDA Backend Swap
+The CPU-only release can download and swap in a CUDA-accelerated backend binary from within the app -- no reinstall required. Handles GitHub's 2GB asset limit by downloading split parts and verifying SHA-256 checksums.
+
+### Intel Arc (XPU) and DirectML
+PyTorch backend also supports Intel Arc GPUs via IPEX/XPU and Windows any-GPU via DirectML.
+
+### Docker + Web Deployment
+Run Voicebox headless as a Docker container with the full web UI:
+```bash
+docker compose up
+```
+3-stage build, non-root runtime, health checks, persistent model cache across rebuilds. Binds to localhost only by default.
+
+---
+
+## Model Management
+- **Per-model unload** -- free GPU memory without deleting downloaded models
+- **Custom models directory** -- set `VOICEBOX_MODELS_DIR` to store models anywhere
+- **Model folder migration** -- move all models to a new location with progress tracking
+- **Whisper Turbo** -- added `openai/whisper-large-v3-turbo` as a transcription model option
+- **Download cancel/clear UI** -- cancel in-progress downloads, VS Code-style problems panel for errors
+
+---
+
+## Security
+- **CORS hardening** -- replaced wildcard `*` with an explicit allowlist of local origins; extensible via `VOICEBOX_CORS_ORIGINS` env var
+- **Network access toggle** -- fully disable outbound network requests for air-gapped deployments
+
+## Accessibility
+- Comprehensive screen reader support (tested with NVDA/Narrator) across all major UI surfaces
+- Keyboard navigation for voice cards, history rows, model management, and story editor
+- State-aware `aria-label` attributes on all interactive controls
+
+## Reliability
+- **Atomic audio saves** -- two-phase write prevents corrupted files on crash/interrupt
+- **Filesystem health endpoint** -- proactive disk space and directory writability checks
+- **Errno-specific error messages** -- clear feedback for permission denied, disk full, missing directory
+
+## UX Polish
+- Responsive layout with horizontal-scroll voice cards on mobile
+- App version shown in sidebar
+- Voice card heights normalized
+- Audio player title hidden at narrow widths to prevent overflow
+
+---
+
+## Installation
+
+| Platform | Download |
+|----------|----------|
+| **macOS (Apple Silicon)** | `Voicebox_0.2.0_aarch64.dmg` |
+| **macOS (Intel)** | `Voicebox_0.2.0_x64.dmg` |
+| **Windows** | `Voicebox_0.2.0_x64_en-US.msi` or `x64-setup.exe` |
+| **Linux** | `.deb` / `.rpm` packages |
+| **Docker** | `docker compose up` |
+
+The app includes automatic updates -- future patches will be installed automatically.
+
+---
+
+## Video Script Beats
+
+For the marketing video, focus on these six beats:
+
+1. **"Four engines, one app"** -- show the engine dropdown switching between Qwen, LuxTTS, Chatterbox, and Turbo
+2. **"23 languages"** -- generate the same voice clone in Arabic, Japanese, Hindi, etc.
+3. **"Make it expressive"** -- type `/laugh` and `/sigh` with Chatterbox Turbo, play back the result
+4. **"Shape your sound"** -- apply the Robotic or Deep Voice preset, preview it live, then build a custom effects chain with drag-and-drop
+5. **"No limits"** -- paste a long script, show it auto-chunk and generate seamlessly
+6. **"Queue and go"** -- fire off multiple generations back-to-back without waiting
diff --git a/landing/src/app/api/stars/route.ts b/landing/src/app/api/stars/route.ts
new file mode 100644
index 00000000..bc8f73c5
--- /dev/null
+++ b/landing/src/app/api/stars/route.ts
@@ -0,0 +1,15 @@
+import { NextResponse } from 'next/server';
+import { getStarCount } from '@/lib/releases';
+
+export const dynamic = 'force-dynamic';
+export const revalidate = 600;
+
+export async function GET() {
+ try {
+ const count = await getStarCount();
+ return NextResponse.json({ count });
+ } catch (error) {
+ console.error('Error fetching star count:', error);
+ return NextResponse.json({ error: 'Failed to fetch star count' }, { status: 500 });
+ }
+}
diff --git a/landing/src/app/globals.css b/landing/src/app/globals.css
index 709d74b7..143203e7 100644
--- a/landing/src/app/globals.css
+++ b/landing/src/app/globals.css
@@ -116,16 +116,17 @@
}
/* Noise texture overlay for hero glow */
-.hero-glow::after {
+/* .hero-glow::after {
content: "";
position: absolute;
inset: 0;
z-index: 5;
pointer-events: none;
- background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.8' numOctaves='5' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
- opacity: 0.3;
+ background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2048' height='2048'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.5' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E") center / 100% 100% no-repeat;
+ opacity: 0.35;
mix-blend-mode: overlay;
-}
+ will-change: transform;
+} */
/* Scrollbar hiding */
::-webkit-scrollbar {
diff --git a/landing/src/app/layout.tsx b/landing/src/app/layout.tsx
index a5ba9383..164b7f71 100644
--- a/landing/src/app/layout.tsx
+++ b/landing/src/app/layout.tsx
@@ -4,11 +4,11 @@ import './globals.css';
export const metadata: Metadata = {
title: 'Voicebox - Open Source Voice Cloning Desktop App',
description:
- 'Near-perfect voice cloning powered by Qwen3-TTS. Desktop app for Mac, Windows, and Linux. Multi-sample support, smart caching, local or remote inference.',
+ 'Near-perfect voice cloning with multiple TTS engines. Desktop app for Mac, Windows, and Linux. Multi-sample support, smart caching, local or remote inference.',
keywords: [
'voice cloning',
'TTS',
- 'Qwen3',
+ 'multi-engine',
'desktop app',
'AI voice',
'open source',
@@ -32,6 +32,14 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
+
+
+
+
+
{children}
diff --git a/landing/src/app/page.tsx b/landing/src/app/page.tsx
index cc5728e3..e78d1ca3 100644
--- a/landing/src/app/page.tsx
+++ b/landing/src/app/page.tsx
@@ -1,6 +1,6 @@
'use client';
-import { Github } from 'lucide-react';
+import { Github, Globe, Languages, MessageSquare, Zap } from 'lucide-react';
import Image from 'next/image';
import { useEffect, useState } from 'react';
import { ControlUI } from '@/components/ControlUI';
@@ -8,6 +8,7 @@ import { Features } from '@/components/Features';
import { Footer } from '@/components/Footer';
import { Navbar } from '@/components/Navbar';
import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons';
+import { VoiceCreator } from '@/components/VoiceCreator';
import { DOWNLOAD_LINKS, GITHUB_REPO } from '@/lib/constants';
import type { DownloadLinks } from '@/lib/releases';
@@ -44,18 +45,18 @@ export default function Home() {
{/* Logo */}
@@ -72,8 +73,8 @@ export default function Home() {
className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl"
style={{ animationDelay: '200ms' }}
>
- Open source voice cloning studio powered by Qwen3-TTS. Clone any voice, generate natural
- speech, and compose multi-voice projects — all running locally.
+ Open source voice cloning studio with support for multiple TTS engines. Clone any voice,
+ generate natural speech, and compose multi-voice projects — all running locally.
{/* CTAs */}
@@ -116,27 +117,125 @@ export default function Home() {
{/* ── Features ─────────────────────────────────────────────── */}
- {/* ── About / Manifesto ────────────────────────────────────── */}
+ {/* ── Voice Creator ────────────────────────────────────────── */}
+
+
+ {/* ── Models ─────────────────────────────────────────────────── */}
-
-
- Why Voicebox exists
-
-
-
- Cloud voice cloning services lock your voice data behind subscriptions, rate limits,
- and terms of service that can change at any time. Your voice — and the voices you
- clone — should belong to you.
-
-
- Voicebox is a local-first voice cloning studio. Download a model, clone any voice from
- a few seconds of audio, and generate speech entirely on your machine.
-
-
- Optimized with Metal acceleration on Mac and CUDA on Windows/Linux. No Python install
- required. No cloud. No subscriptions. Free and open-source, forever.
+
+
+
+ Multi-Engine Architecture
+
+
+ Choose the right model for every job. All models run locally on your hardware —
+ download once, use forever.
+
+
+ {/* Qwen3-TTS */}
+
+
+
+
Qwen3-TTS
+ by Alibaba
+
+
+
+ 1.7B
+
+
+ 0.6B
+
+
+
+
+ High-quality multilingual voice cloning with natural prosody. The only engine with
+ delivery instructions — control tone, pace, and emotion with natural language.
+
+
+
+
+ 10 languages
+
+
+
+ Delivery instructions
+
+
+
+
+ {/* Chatterbox */}
+
+
+
+
Chatterbox
+ by Resemble AI
+
+
+
+ Production-grade voice cloning with the broadest language support. 23 languages with
+ zero-shot cloning and emotion exaggeration control.
+
+
+
+
+ 23 languages
+
+
+
+
+ {/* Chatterbox Turbo */}
+
+
+
+
Chatterbox Turbo
+ by Resemble AI
+
+
+ 350M
+
+
+
+ Lightweight and fast. Supports paralinguistic tags — embed [laugh], [sigh], [gasp]
+ and more directly in your text for expressive, natural speech.
+
+
+
+
+ 350M params
+
+
+
+ [laugh] [sigh] tags
+
+
+
+
+ {/* LuxTTS */}
+
+
+
+
LuxTTS
+ by ZipVoice
+
+
+
+ Ultra-fast, CPU-friendly voice cloning at 48kHz. Exceeds 150x realtime on CPU with
+ ~1GB VRAM. The fastest engine for quick iterations.
+
+
+
+
+ 150x realtime
+
+
+ 48kHz output
+
+
+
+
@@ -193,16 +292,17 @@ export default function Home() {
{/* Linux */}
-
-
+
Linux
-
Coming soon
+
AppImage (x64)
-
+
{/* GitHub link */}
diff --git a/landing/src/components/ControlUI.tsx b/landing/src/components/ControlUI.tsx
index 886f1374..b7babe96 100644
--- a/landing/src/components/ControlUI.tsx
+++ b/landing/src/components/ControlUI.tsx
@@ -17,7 +17,7 @@ import {
Wand2,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
-import { LandingAudioPlayer } from './LandingAudioPlayer';
+import { LandingAudioPlayer, unlockAudioContext } from './LandingAudioPlayer';
// ─── Data ───────────────────────────────────────────────────────────────────
// Edit this section to customise all the content shown in the ControlUI demo.
@@ -29,7 +29,7 @@ interface VoiceProfile {
hasEffects: boolean;
}
-/** Voice profiles shown in the 3×3 grid. Index matters — DemoScript references profiles by index. */
+/** Voice profiles shown in the grid / scroll strip. Index matters — DemoScript references profiles by index. */
const PROFILES: VoiceProfile[] = [
{
name: 'Jarvis',
@@ -301,24 +301,27 @@ function LoadingBars({ mode }: { mode: 'idle' | 'generating' | 'playing' }) {
// ─── Profile Card ───────────────────────────────────────────────────────────
-function ProfileCard({
+const ProfileCard = ({
profile,
selected,
selecting,
+ cardRef,
}: {
profile: VoiceProfile;
selected: boolean;
selecting: boolean;
-}) {
+ cardRef?: React.Ref
;
+}) => {
return (
- {profile.name}
+ {profile.name}
{profile.description}
@@ -335,7 +338,7 @@ function ProfileCard({
);
-}
+};
// ─── History Row ────────────────────────────────────────────────────────────
@@ -416,79 +419,79 @@ function FloatingGenerateBox({
phase,
typingText,
selectedProfile,
+ engine,
effect,
}: {
phase: Phase;
typingText: string;
selectedProfile: VoiceProfile | null;
+ engine: string;
effect?: string;
}) {
const isFocused = phase === 'typing' || phase === 'generating';
const isGenerating = phase === 'generating';
return (
-
-
- {/* Text area + generate button */}
-
-
-
+ {/* Text area + generate button */}
+
+
+
+
-
- {phase === 'typing' ? (
-
-
-
- ) : phase === 'generating' ? (
- {typingText}
- ) : (
-
- {selectedProfile
- ? `Generate speech using ${selectedProfile.name}...`
- : 'Select a voice profile above...'}
-
- )}
-
-
-
-
- {/* Generate button */}
-
-
-
+ {phase === 'typing' ? (
+
+
+
+ ) : phase === 'generating' ? (
+ {typingText}
+ ) : (
+
+ {selectedProfile
+ ? `Generate speech using ${selectedProfile.name}...`
+ : 'Select a voice profile above...'}
+
+ )}
+
+
- {/* Bottom selectors */}
-
-
- English
-
-
- Qwen3-TTS 1.7B
-
-
-
- {effect || 'Effect'}
-
-
-
-
+ {/* Generate button */}
+
+
+
+
+
+ {/* Bottom selectors */}
+
+
+ English
+
+
+ {engine}
+
+
+
+ {effect || 'Effect'}
+
+
+
);
}
@@ -505,11 +508,20 @@ export function ControlUI() {
const [pageHidden, setPageHidden] = useState(false);
const containerRef = useRef
(null);
const phaseRef = useRef(phase);
+ const profileCardRefs = useRef>(new Map());
phaseRef.current = phase;
const step = DEMO_SCRIPT[cycle % DEMO_SCRIPT.length];
const selectedProfile = PROFILES[selectedIndex];
+ // Scroll to selected profile card
+ useEffect(() => {
+ const el = profileCardRefs.current.get(selectedIndex);
+ if (el) {
+ el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
+ }
+ }, [selectedIndex]);
+
// Visibility detection
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => setIsVisible(entry.isIntersecting), {
@@ -589,33 +601,83 @@ export function ControlUI() {
return (
- {/* Unmute button */}
+ {/* Unmute button with handwritten hint */}
-
setIsMuted(!isMuted)}
- className="flex items-center gap-2 px-3 py-1.5 rounded-full border border-border bg-card/50 backdrop-blur text-xs text-muted-foreground hover:text-foreground transition-colors"
- >
- {isMuted ? (
- <>
-
- Unmute
- >
- ) : (
- <>
-
- Mute
- >
+
+ {/* Handwritten hint — absolutely positioned above the button */}
+ {isMuted && (
+
+
+ try me!
+
+ {/* Curved arrow from text down-right toward the button */}
+
+ Arrow
+
+
+
+
)}
-
+
{
+ unlockAudioContext();
+ setIsMuted(!isMuted);
+ }}
+ className="flex items-center gap-2 px-3 py-1.5 rounded-full border border-border bg-card/50 backdrop-blur text-xs text-muted-foreground hover:text-foreground transition-colors"
+ >
+ {isMuted ? (
+ <>
+
+ Unmute
+ >
+ ) : (
+ <>
+
+ Mute
+ >
+ )}
+
+
-
-
- {/* ── Sidebar ───────────────────────────────────────────── */}
-
+
+
+ {/* ── Sidebar (hidden on mobile) ─────────────────────────── */}
+
{/* Logo */}
{/* ── Main content ──────────────────────────────────────── */}
-
- {/* Left: Profiles */}
-
+
+ {/* Left: Profiles + Generate box */}
+
{/* Header */}
-
+
Voicebox
@@ -675,32 +737,58 @@ export function ControlUI() {
- {/* Profile grid */}
-
-
+ {/* Profile cards — horizontal scroll on mobile, 3-col grid on desktop */}
+
+ {/* Mobile: horizontal scroll strip */}
+
+ {PROFILES.map((profile, i) => (
+
{
+ if (el) profileCardRefs.current.set(i, el);
+ }}
+ >
+
+
+ ))}
+
+
+ {/* Desktop: 3-col grid */}
+
{PROFILES.map((profile, i) => (
{
+ if (el) profileCardRefs.current.set(i, el);
+ }}
/>
))}
- {/* Floating generate box */}
-
+ {/* Floating generate box — desktop: absolute overlay, mobile: inline */}
+
+
+
- {/* Right: History */}
-
-
+ {/* Right/Below: History */}
+
+
{generations.map((gen) => {
const isThisNew = gen.id === newGenId;
diff --git a/landing/src/components/Features.tsx b/landing/src/components/Features.tsx
index 498c742d..9d1c0e8b 100644
--- a/landing/src/components/Features.tsx
+++ b/landing/src/components/Features.tsx
@@ -1,8 +1,8 @@
'use client';
import { motion } from 'framer-motion';
-import { AudioLines, Cloud, Layers, MessageSquareText, Mic, Monitor } from 'lucide-react';
-import { useEffect, useRef, useState } from 'react';
+import { AudioLines, Cloud, MessageSquareText, Mic, Sparkles, TextCursorInput } from 'lucide-react';
+import { useEffect, useMemo, useRef, useState } from 'react';
// ─── Lazy load wrapper ──────────────────────────────────────────────────────
@@ -111,122 +111,435 @@ function VoiceCloningAnimation() {
);
}
-// ─── Animation: Stories Editor ───────────────────────────────────────────────
+// ─── Mini waveform for clips ────────────────────────────────────────────────
+// Fixed-width dense waveform that overflows — the clip container clips it.
+// This way resizing a clip just reveals/hides bars instead of re-rendering.
-function StoriesAnimation() {
- const [activeTrack, setActiveTrack] = useState(0);
- const tracks = [
- {
- name: 'Narrator',
- color: '#ac8a2b',
- clips: [
- { w: '45%', x: '5%' },
- { w: '30%', x: '60%' },
- ],
- },
- {
- name: 'Character A',
- color: '#3b82f6',
- clips: [
- { w: '25%', x: '15%' },
- { w: '35%', x: '50%' },
- ],
- },
- { name: 'Character B', color: '#8b5cf6', clips: [{ w: '20%', x: '30%' }] },
- ];
+const WAVEFORM_BAR_COUNT = 60;
- useEffect(() => {
- const interval = setInterval(() => {
- setActiveTrack((p) => (p + 1) % tracks.length);
- }, 2200);
- return () => clearInterval(interval);
- }, [tracks.length]);
+function MiniWaveform({ seed, color }: { seed: number; color: string }) {
+ // Deterministic pseudo-random waveform that looks like real speech audio.
+ // Uses layered noise at different frequencies for natural envelope + detail.
+ const bars = useMemo(() => {
+ // Seeded pseudo-random number generator (deterministic per seed)
+ let s = seed * 9301 + 49297;
+ const rand = () => {
+ s = (s * 16807 + 0) % 2147483647;
+ return s / 2147483647;
+ };
+
+ // Pre-generate random values
+ const r = Array.from({ length: WAVEFORM_BAR_COUNT }, () => rand());
+
+ return Array.from({ length: WAVEFORM_BAR_COUNT }, (_, i) => {
+ const t = i / WAVEFORM_BAR_COUNT;
+
+ // Slow envelope — broad amplitude shape (words / phrases)
+ const envelope =
+ 0.3 +
+ 0.35 *
+ Math.sin(t * Math.PI * (2 + (seed % 3))) *
+ Math.sin(t * Math.PI * (1.3 + seed * 0.7)) +
+ 0.2 * Math.sin(t * Math.PI * (4.7 + seed * 1.3));
+
+ // Medium variation — syllable-level bumps
+ const mid = 0.15 * Math.sin(i * 0.8 + seed * 3.1) * Math.cos(i * 1.3 + seed);
+
+ // High-frequency noise — individual sample jitter
+ const noise = (r[i] - 0.5) * 0.25;
+
+ // Combine and clamp
+ const raw = envelope + mid + noise;
+ return Math.max(0.06, Math.min(1, raw));
+ });
+ }, [seed]);
return (
-
- {tracks.map((track, i) => (
-
+ {bars.map((h, i) => (
+
-
- {track.name}
-
-
- {track.clips.map((clip, j) => (
-
- ))}
-
-
+ />
))}
);
}
-// ─── Animation: Multi-Sample ────────────────────────────────────────────────
+// ─── Animation: Stories Editor ───────────────────────────────────────────────
-function MultiSampleAnimation() {
- const [active, setActive] = useState(0);
- const samples = [
- { label: 'interview_clip.wav', dur: '12.4s', quality: 0.92 },
- { label: 'podcast_intro.wav', dur: '8.1s', quality: 0.88 },
- { label: 'narration_take3.wav', dur: '15.7s', quality: 0.95 },
- { label: 'casual_chat.wav', dur: '6.2s', quality: 0.85 },
+// Clip shape: id, profile, track, left (px out of 220), width (px), waveform seed
+type DemoClip = { id: string; profile: string; track: number; x: number; w: number; seed: number };
+
+const INITIAL_CLIPS: DemoClip[] = [
+ { id: 'n1', profile: 'Morgan', track: 0, x: 4, w: 70, seed: 1 },
+ { id: 'n2', profile: 'Morgan', track: 0, x: 135, w: 35, seed: 2 },
+ { id: 'a1', profile: 'Scarlett', track: 1, x: 25, w: 40, seed: 3 },
+ { id: 'a2', profile: 'Scarlett', track: 1, x: 120, w: 35, seed: 4 },
+ { id: 'b1', profile: 'Jarvis', track: 2, x: 70, w: 45, seed: 5 },
+];
+
+// Timeline width the clips live inside
+const TL_W = 220;
+// Each action returns a new clips array (or modifies in place)
+type Action = { label: string; apply: (clips: DemoClip[]) => DemoClip[] };
+
+const ACTIONS: Action[] = [
+ // 0 — move Jarvis clip earlier
+ { label: 'Move clip', apply: (c) => c.map((cl) => (cl.id === 'b1' ? { ...cl, x: 55 } : cl)) },
+ // 1 — split Morgan's first clip into two with visible gap
+ {
+ label: 'Split clip',
+ apply: (c) => {
+ // Idempotent: if n1b already exists, the split already happened
+ if (c.some((cl) => cl.id === 'n1b')) return c;
+ const clip = c.find((cl) => cl.id === 'n1');
+ if (!clip) return c;
+ const leftW = 25;
+ const gap = 8;
+ const rightW = clip.w - leftW - gap;
+ return [
+ ...c.filter((cl) => cl.id !== 'n1'),
+ { ...clip, w: leftW, id: 'n1' },
+ {
+ id: 'n1b',
+ profile: clip.profile,
+ track: clip.track,
+ x: clip.x + leftW + gap,
+ w: rightW,
+ seed: 6,
+ },
+ ];
+ },
+ },
+ // 2 — trim Scarlett's second clip shorter
+ { label: 'Trim clip', apply: (c) => c.map((cl) => (cl.id === 'a2' ? { ...cl, w: 25 } : cl)) },
+ // 3 — duplicate Jarvis to track 0
+ {
+ label: 'Duplicate',
+ apply: (c) => {
+ // Idempotent: if b1d already exists, the duplicate already happened
+ if (c.some((cl) => cl.id === 'b1d')) return c;
+ const clip = c.find((cl) => cl.id === 'b1');
+ if (!clip) return c;
+ return [...c, { ...clip, id: 'b1d', track: 0, x: 180, w: 35, seed: 7 }];
+ },
+ },
+ // 4 — reset
+ { label: '', apply: () => INITIAL_CLIPS },
+];
+
+function StoriesAnimation() {
+ const [clips, setClips] = useState(INITIAL_CLIPS);
+ const [actionIndex, setActionIndex] = useState(-1);
+ const [playheadX, setPlayheadX] = useState(0);
+ const [selectedId, setSelectedId] = useState(null);
+ const playheadRef = useRef>(0);
+
+ // Animate the playhead continuously
+ useEffect(() => {
+ let start: number | null = null;
+ const speed = 12; // px per second
+ const animate = (ts: number) => {
+ if (start === null) start = ts;
+ const elapsed = (ts - start) / 1000;
+ setPlayheadX((elapsed * speed) % TL_W);
+ playheadRef.current = requestAnimationFrame(animate);
+ };
+ playheadRef.current = requestAnimationFrame(animate);
+ return () => cancelAnimationFrame(playheadRef.current);
+ }, []);
+
+ // Step through actions
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setActionIndex((prev) => {
+ const next = (prev + 1) % ACTIONS.length;
+ setClips((current) => ACTIONS[next].apply(current));
+ // Highlight the clip being acted on
+ if (next === 0) setSelectedId('b1');
+ else if (next === 1) setSelectedId('n1');
+ else if (next === 2) setSelectedId('a2');
+ else if (next === 3) setSelectedId('b1');
+ else setSelectedId(null);
+ return next;
+ });
+ }, 2600);
+ return () => clearInterval(interval);
+ }, []);
+
+ const trackLabels = ['1', '0', '-1'];
+ const timeMarkers = [0, 2, 4, 6, 8];
+ const accentColor = 'hsl(43 50% 45%)';
+ const accentFg = 'hsl(30 10% 94%)';
+
+ return (
+
+ {/* Toolbar */}
+
+
+
+
0:03 / 0:10
+
+ {actionIndex >= 0 && actionIndex < ACTIONS.length - 1 && (
+
+ {ACTIONS[actionIndex].label}
+
+ )}
+
+
+
+ {/* Timeline */}
+
+ {/* Track labels sidebar */}
+
+
+ {trackLabels.map((label) => (
+
+ {label}
+
+ ))}
+
+
+ {/* Tracks area */}
+
+ {/* Time ruler */}
+
+ {timeMarkers.map((t) => (
+
+ ))}
+
+
+ {/* Track rows + clips — same parent so percentages match */}
+
+ {/* Track rows background */}
+ {trackLabels.map((label, i) => (
+
+ ))}
+
+ {/* Clips */}
+ {clips.map((clip) => {
+ const trackIdx = clip.track;
+ const isSelected = clip.id === selectedId;
+ const clipTop = `calc(${(trackIdx * 100) / 3}% + 2px)`;
+ const clipHeight = `calc(${100 / 3}% - 4px)`;
+ return (
+
+
+ {/* Profile label — scaled to bypass browser min font size */}
+
+
+ {clip.profile}
+
+
+ {/* Waveform — absolutely positioned so it never affects clip width */}
+
+
+
+
+ {/* Trim handles on selected */}
+ {isSelected && (
+ <>
+
+
+ >
+ )}
+
+ );
+ })}
+
+ {/* Playhead */}
+
+
+
+
+
+
+
+ );
+}
+
+// ─── Animation: Effects Pipeline ────────────────────────────────────────────
+
+function EffectsAnimation() {
+ const [activeEffect, setActiveEffect] = useState(0);
+ const effects = [
+ { name: 'Pitch Shift', param: '-3 semitones', color: '#3b82f6' },
+ { name: 'Reverb', param: 'Room 0.7', color: '#8b5cf6' },
+ { name: 'Compressor', param: '-15 dB', color: '#ec4899' },
+ { name: 'Low-Pass', param: '6000 Hz', color: '#14b8a6' },
];
+ // Waveform bars — original shape
+ const rawBars = [0.3, 0.6, 0.8, 0.5, 0.9, 0.4, 0.7, 0.3, 0.6, 0.5, 0.8, 0.4, 0.7, 0.9, 0.3];
+
useEffect(() => {
const interval = setInterval(() => {
- setActive((p) => (p + 1) % samples.length);
- }, 2000);
+ setActiveEffect((p) => (p + 1) % effects.length);
+ }, 2200);
return () => clearInterval(interval);
- }, [samples.length]);
+ }, [effects.length]);
return (
-
- {samples.map((s, i) => (
-
-
- {s.label}
-
- {s.dur}
-
+
+ {/* Effects chain */}
+
+ {effects.map((fx, i) => (
+
+ className="text-[8px] px-2 py-0.5 rounded-full border font-medium"
+ animate={{
+ borderColor: i <= activeEffect ? `${fx.color}60` : 'rgba(255,255,255,0.06)',
+ backgroundColor: i <= activeEffect ? `${fx.color}15` : 'rgba(255,255,255,0.02)',
+ color: i <= activeEffect ? fx.color : 'rgba(255,255,255,0.3)',
+ }}
+ transition={{ duration: 0.3 }}
+ >
+ {fx.name}
+
+ {i < effects.length - 1 && (
+
+ →
+
+ )}
-
- ))}
+ ))}
+
+
+ {/* Waveform that morphs as effects are applied */}
+
+ {rawBars.map((h, i) => {
+ // Each effect stage progressively transforms the shape
+ const shifted = activeEffect >= 0 ? h * (0.7 + 0.3 * Math.sin(i * 0.8)) : h;
+ const dampened = activeEffect >= 1 ? shifted * (0.6 + 0.4 * Math.cos(i * 0.3)) : shifted;
+ const compressed = activeEffect >= 2 ? 0.3 + dampened * 0.5 : dampened;
+ const filtered = activeEffect >= 3 ? compressed * (1 - i * 0.03) : compressed;
+ const finalH = Math.max(0.08, Math.min(1, filtered));
+
+ return (
+
+ );
+ })}
+
+
+ {/* Active effect detail */}
+
+ {effects[activeEffect].name}: {effects[activeEffect].param}
+
);
}
@@ -332,51 +645,115 @@ function TranscriptionAnimation() {
);
}
-// ─── Animation: Cross-Platform ──────────────────────────────────────────────
+// ─── Animation: Unlimited Length ─────────────────────────────────────────────
-function CrossPlatformAnimation() {
- const [active, setActive] = useState(0);
- const platforms = [
- { name: 'macOS', icon: '🍎', detail: 'Metal acceleration' },
- { name: 'Windows', icon: '🪟', detail: 'CUDA acceleration' },
- { name: 'Linux', icon: '🐧', detail: 'CUDA acceleration' },
+function UnlimitedLengthAnimation() {
+ const [phase, setPhase] = useState(0);
+
+ const chunks = [
+ 'The morning sun crept over the mountains, casting long shadows across the valley below.',
+ 'Birds stirred in the canopy, their songs weaving through the cool air like threads of gold.',
+ 'Far below, a river wound its way through ancient stones, carrying whispers of the night.',
];
useEffect(() => {
const interval = setInterval(() => {
- setActive((p) => (p + 1) % platforms.length);
+ setPhase((p) => (p + 1) % 4); // 0-2 = processing chunks, 3 = crossfade/done
}, 2000);
return () => clearInterval(interval);
- }, [platforms.length]);
+ }, []);
return (
-
-
- {platforms.map((p, i) => (
+
+ {/* Chunk pills */}
+
+ {chunks.map((chunk, i) => (
- {p.icon}
+ {/* Status indicator */}
+
- {p.name}
-
-
- {p.detail}
+ {chunk}
))}
+
+ {/* Crossfade / result bar */}
+
+ {chunks.map((_, i) => (
+
+ ))}
+
+
+ {/* Status text */}
+
+
+ {phase < 3
+ ? `generating chunk ${phase + 1} of ${chunks.length}...`
+ : 'crossfaded & ready'}
+
+
);
}
@@ -387,7 +764,7 @@ const FEATURES = [
{
title: 'Near-Perfect Voice Cloning',
description:
- 'Powered by Qwen3-TTS for exceptional voice quality. Clone any voice from a few seconds of audio with natural intonation and emotion.',
+ 'Multiple TTS engines for exceptional voice quality. Clone any voice from a few seconds of audio with natural intonation and emotion.',
icon: Mic,
animation: VoiceCloningAnimation,
},
@@ -399,16 +776,16 @@ const FEATURES = [
animation: StoriesAnimation,
},
{
- title: 'Multi-Sample Support',
+ title: 'Audio Effects Pipeline',
description:
- 'Combine multiple voice samples for higher quality results. More samples means more natural-sounding speech synthesis.',
- icon: Layers,
- animation: MultiSampleAnimation,
+ 'Apply pitch shift, reverb, delay, compression, and more — then save as presets. Preview effects live and set defaults per voice profile.',
+ icon: Sparkles,
+ animation: EffectsAnimation,
},
{
title: 'Local or Remote',
description:
- 'Run GPU inference locally with Metal or CUDA, or connect to a remote machine. One-click server setup with automatic discovery.',
+ 'Run GPU inference locally with Metal, CUDA, ROCm, Intel Arc, or DirectML — or connect to a remote machine. One-click server setup with automatic discovery.',
icon: Cloud,
animation: LocalRemoteAnimation,
},
@@ -420,11 +797,11 @@ const FEATURES = [
animation: TranscriptionAnimation,
},
{
- title: 'Cross-Platform',
+ title: 'Unlimited Generation Length',
description:
- 'Available for macOS, Windows, and Linux. No Python installation required — everything is bundled.',
- icon: Monitor,
- animation: CrossPlatformAnimation,
+ 'Generate up to 50,000 characters in one go. Text is auto-split at sentence boundaries, generated per-chunk, and crossfaded seamlessly.',
+ icon: TextCursorInput,
+ animation: UnlimitedLengthAnimation,
},
];
diff --git a/landing/src/components/LandingAudioPlayer.tsx b/landing/src/components/LandingAudioPlayer.tsx
index a3b105ce..61c76e2c 100644
--- a/landing/src/components/LandingAudioPlayer.tsx
+++ b/landing/src/components/LandingAudioPlayer.tsx
@@ -1,6 +1,6 @@
'use client';
-import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react';
+import { Pause, Play, Repeat, Volume2, VolumeX } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
@@ -10,6 +10,26 @@ function formatDuration(seconds: number): string {
return `${m}:${s.toString().padStart(2, '0')}`;
}
+// Unlock Web Audio on iOS Safari — must be called from a user gesture (click/tap)
+let audioContextUnlocked = false;
+export function unlockAudioContext() {
+ if (audioContextUnlocked) return;
+ try {
+ const ctx = new (
+ window.AudioContext ||
+ (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
+ )();
+ const buffer = ctx.createBuffer(1, 1, 22050);
+ const source = ctx.createBufferSource();
+ source.buffer = buffer;
+ source.connect(ctx.destination);
+ source.start(0);
+ audioContextUnlocked = true;
+ } catch {
+ // Silently fail — non-Safari browsers don't need this
+ }
+}
+
interface LandingAudioPlayerProps {
audioUrl: string;
title: string;
@@ -145,24 +165,23 @@ export function LandingAudioPlayer({
if (!ws || !isReady) return;
if (playing) {
+ // Resume the AudioContext first (required for iOS Safari after unlock)
+ const backend = ws.getMediaElement();
+ if (backend && 'context' in backend) {
+ const ctx = (backend as unknown as { context: AudioContext }).context;
+ if (ctx?.state === 'suspended') ctx.resume();
+ }
ws.play()
.then(() => {
- console.log(
- '[Player] play succeeded, isPlaying:',
- ws.isPlaying(),
- 'currentTime:',
- ws.getCurrentTime(),
- );
- setTimeout(() => {
- console.log(
- '[Player] 1s later, isPlaying:',
- ws.isPlaying(),
- 'currentTime:',
- ws.getCurrentTime(),
- );
- }, 1000);
+ console.log('[Player] play succeeded');
})
- .catch((e) => console.error('[Player] play failed', e));
+ .catch((e: Error) => {
+ if (e.name === 'NotAllowedError') {
+ console.warn('[Player] Autoplay blocked by browser — waiting for user gesture');
+ } else {
+ console.error('[Player] play failed', e);
+ }
+ });
} else {
ws.pause();
}
@@ -180,34 +199,27 @@ export function LandingAudioPlayer({
wavesurferRef.current.playPause();
}, []);
- const handleClose = useCallback(() => {
- if (wavesurferRef.current) {
- wavesurferRef.current.pause();
- }
- setIsPlaying(false);
- onClose();
- }, [onClose]);
-
return (
-
-
+
+ {/* Waveform — full width row on mobile, inline on desktop */}
+
+
+ {/* Controls row */}
+
{/* Play/Pause */}
{isPlaying ? : }
- {/* Waveform */}
-
-
{/* Time */}
-
+
{formatDuration(currentTime)}
/
{formatDuration(duration)}
@@ -215,7 +227,7 @@ export function LandingAudioPlayer({
{/* Title */}
{title && (
-
+
{title}
)}
@@ -223,7 +235,7 @@ export function LandingAudioPlayer({
{/* Loop */}
setIsLooping(!isLooping)}
- className={`h-8 w-8 flex items-center justify-center rounded-sm shrink-0 hover:bg-muted ${
+ className={`h-8 w-8 flex items-center justify-center rounded-sm shrink-0 hover:bg-muted md:order-5 ${
isLooping ? 'text-foreground' : 'text-muted-foreground'
}`}
>
@@ -231,7 +243,7 @@ export function LandingAudioPlayer({
{/* Volume */}
-
+
setVolume(volume > 0 ? 0 : 0.75)}
className="h-8 w-8 flex items-center justify-center hover:bg-muted rounded-sm"
@@ -251,14 +263,6 @@ export function LandingAudioPlayer({
className="flex-1 h-1 appearance-none bg-muted rounded-full accent-foreground cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-foreground"
/>
-
- {/* Close */}
-
-
-
diff --git a/landing/src/components/Navbar.tsx b/landing/src/components/Navbar.tsx
index 41eb6c76..c0b0b186 100644
--- a/landing/src/components/Navbar.tsx
+++ b/landing/src/components/Navbar.tsx
@@ -2,9 +2,34 @@
import { Github } from 'lucide-react';
import Image from 'next/image';
+import { useEffect, useState } from 'react';
import { GITHUB_REPO } from '@/lib/constants';
+function formatStarCount(count: number): string {
+ if (count >= 1000) {
+ const k = count / 1000;
+ return k % 1 === 0 ? `${k}k` : `${k.toFixed(1)}k`;
+ }
+ return count.toString();
+}
+
export function Navbar() {
+ const [starCount, setStarCount] = useState
(null);
+
+ useEffect(() => {
+ fetch('/api/stars')
+ .then((res) => {
+ if (!res.ok) throw new Error('Failed to fetch stars');
+ return res.json();
+ })
+ .then((data) => {
+ if (typeof data.count === 'number') setStarCount(data.count);
+ })
+ .catch((error) => {
+ console.error('Failed to fetch star count:', error);
+ });
+ }, []);
+
return (
@@ -50,8 +75,12 @@ export function Navbar() {
className="flex items-center gap-2 rounded-lg border border-border/60 bg-card/60 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground hover:border-border"
>
- Star on GitHub
- GitHub
+ Star
+ {starCount !== null && (
+
+ {formatStarCount(starCount)}
+
+ )}
diff --git a/landing/src/components/VoiceCreator.tsx b/landing/src/components/VoiceCreator.tsx
new file mode 100644
index 00000000..7c1523b8
--- /dev/null
+++ b/landing/src/components/VoiceCreator.tsx
@@ -0,0 +1,479 @@
+'use client';
+
+import { AnimatePresence, motion } from 'framer-motion';
+import { Mic, Monitor, Upload } from 'lucide-react';
+import { useEffect, useMemo, useState } from 'react';
+
+// ─── Waveform bars generator ────────────────────────────────────────────────
+
+function generateWaveformBars(count: number, seed: number): number[] {
+ const bars: number[] = [];
+ for (let i = 0; i < count; i++) {
+ const x = i / count;
+ // Speech-like envelope: ramp up, sustain, taper
+ const envelope = Math.sin(x * Math.PI) * 0.8 + 0.2;
+ // Layered pseudo-random noise
+ const n1 = Math.sin(seed * 127.1 + i * 43.7) * 0.5 + 0.5;
+ const n2 = Math.sin(seed * 269.5 + i * 17.3) * 0.3 + 0.5;
+ const n3 = Math.sin(seed * 53.9 + i * 97.1) * 0.2 + 0.5;
+ const noise = (n1 + n2 + n3) / 3;
+ bars.push(envelope * noise);
+ }
+ return bars;
+}
+
+// ─── Animated waveform background ───────────────────────────────────────────
+
+function WaveformBackground({ active }: { active: boolean }) {
+ const bars = useMemo(() => generateWaveformBars(60, 42), []);
+
+ return (
+
+
+ {bars.map((h, i) => {
+ const maxH = 120; // max bar height in px
+ const baseH = 4;
+ const activeH = baseH + h * maxH;
+ const idleH = baseH + h * maxH * 0.25;
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+// ─── Tab content panels ─────────────────────────────────────────────────────
+
+function UploadPanel() {
+ const [hasFile, setHasFile] = useState(false);
+
+ useEffect(() => {
+ // Simulate file drop after 2s
+ const t1 = setTimeout(() => setHasFile(true), 2000);
+ const t2 = setTimeout(() => setHasFile(false), 5000);
+ return () => {
+ clearTimeout(t1);
+ clearTimeout(t2);
+ };
+ }, []);
+
+ return (
+
+
+ {!hasFile ? (
+
+
+
+ Choose File
+
+
+ Drag and drop an audio file, or click to browse.
+
+ Maximum duration: 30 seconds.
+
+
+ ) : (
+
+
+
+ sample-voice-clip.wav
+
+
+
+ 0:04
+
+
+
+ Transcribe
+
+
+
+ )}
+
+
+ );
+}
+
+function RecordPanel() {
+ const [state, setState] = useState<'idle' | 'recording' | 'done'>('idle');
+ const [elapsed, setElapsed] = useState(0);
+
+ useEffect(() => {
+ const t1 = setTimeout(() => setState('recording'), 1500);
+ const t2 = setTimeout(() => setState('done'), 5500);
+ const t3 = setTimeout(() => {
+ setState('idle');
+ setElapsed(0);
+ }, 8000);
+ return () => {
+ clearTimeout(t1);
+ clearTimeout(t2);
+ clearTimeout(t3);
+ };
+ }, []);
+
+ // Timer
+ useEffect(() => {
+ if (state !== 'recording') return;
+ setElapsed(0);
+ const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
+ return () => clearInterval(interval);
+ }, [state]);
+
+ const formatTime = (s: number) => `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
+
+ return (
+
+
+
+
+ {state === 'idle' && (
+
+
+
+ Start Recording
+
+
+ Click to record from your microphone.
+
+ Maximum duration: 30 seconds.
+
+
+ )}
+
+ {state === 'recording' && (
+
+
+
+
{formatTime(elapsed)}
+
+
+ {formatTime(30 - elapsed)} remaining
+
+ )}
+
+ {state === 'done' && (
+
+
+
+ Recording complete
+
+
+
+ 0:04
+
+
+
+ Transcribe
+
+
+
+ )}
+
+
+ );
+}
+
+function SystemPanel() {
+ const [state, setState] = useState<'idle' | 'capturing' | 'done'>('idle');
+ const [elapsed, setElapsed] = useState(0);
+
+ useEffect(() => {
+ const t1 = setTimeout(() => setState('capturing'), 1500);
+ const t2 = setTimeout(() => setState('done'), 5500);
+ const t3 = setTimeout(() => {
+ setState('idle');
+ setElapsed(0);
+ }, 8000);
+ return () => {
+ clearTimeout(t1);
+ clearTimeout(t2);
+ clearTimeout(t3);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (state !== 'capturing') return;
+ setElapsed(0);
+ const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
+ return () => clearInterval(interval);
+ }, [state]);
+
+ const formatTime = (s: number) => `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
+
+ return (
+
+
+
+
+ {state === 'idle' && (
+
+
+
+ Start Capture
+
+
+ Capture audio playing on your system.
+
+ Maximum duration: 30 seconds.
+
+
+ )}
+
+ {state === 'capturing' && (
+
+
+
+
{formatTime(elapsed)}
+
+
+ {formatTime(30 - elapsed)} remaining
+
+ )}
+
+ {state === 'done' && (
+
+
+
+ Capture complete
+
+
+
+ 0:04
+
+
+
+ Transcribe
+
+
+
+ )}
+
+
+ );
+}
+
+// ─── Tab selector ───────────────────────────────────────────────────────────
+
+const TABS = [
+ { id: 'upload' as const, label: 'Upload', icon: Upload },
+ { id: 'record' as const, label: 'Microphone', icon: Mic },
+ { id: 'system' as const, label: 'System Audio', icon: Monitor },
+];
+
+type TabId = (typeof TABS)[number]['id'];
+
+// ─── Main section ───────────────────────────────────────────────────────────
+
+export function VoiceCreator() {
+ const [activeTab, setActiveTab] = useState('record');
+ const [cycleKey, setCycleKey] = useState(0);
+
+ // Auto-cycle tabs
+ useEffect(() => {
+ const tabOrder: TabId[] = ['record', 'upload', 'system'];
+ let idx = tabOrder.indexOf(activeTab);
+
+ const interval = setInterval(() => {
+ idx = (idx + 1) % tabOrder.length;
+ setActiveTab(tabOrder[idx]);
+ setCycleKey((k) => k + 1);
+ }, 9000);
+
+ return () => clearInterval(interval);
+ }, [activeTab]);
+
+ return (
+
+
+
+ {/* Left: Copy */}
+
+
+ Clone any voice in seconds
+
+
+ Three ways to capture a voice sample. Upload a clip, record from your microphone, or
+ capture audio playing on your system. Voicebox clones the voice from as little as 3
+ seconds of audio.
+
+
+
+
+
+
+
+
Upload a clip
+
+ Drag and drop any audio file — WAV, MP3, FLAC, or WebM.
+
+
+
+
+
+
+
+
+
Record from microphone
+
+ Live waveform preview while you record. Up to 30 seconds.
+
+
+
+
+
+
+
+
+
System audio capture
+
+ Clone a voice from a YouTube video, podcast, or any app playing audio.
+
+
+
+
+
+
+ {/* Right: Animated UI mock */}
+
+
+ {/* Tab bar */}
+
+ {TABS.map((tab) => {
+ const Icon = tab.icon;
+ const isActive = activeTab === tab.id;
+ return (
+ {
+ setActiveTab(tab.id);
+ setCycleKey((k) => k + 1);
+ }}
+ className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
+ isActive
+ ? 'bg-background text-foreground shadow-sm'
+ : 'text-muted-foreground hover:text-foreground'
+ }`}
+ >
+
+ {tab.label}
+
+ );
+ })}
+
+
+ {/* Panel */}
+
+
+ {activeTab === 'upload' && }
+ {activeTab === 'record' && }
+ {activeTab === 'system' && }
+
+
+
+
+
+
+
+ );
+}
diff --git a/landing/src/lib/releases.ts b/landing/src/lib/releases.ts
index ad19c548..3f985d31 100644
--- a/landing/src/lib/releases.ts
+++ b/landing/src/lib/releases.ts
@@ -19,6 +19,10 @@ let cachedReleaseInfo: ReleaseInfo | null = null;
let cacheTimestamp: number = 0;
const CACHE_DURATION = 1000 * 60 * 10; // 10 minutes
+// Cache for star count
+let cachedStarCount: number | null = null;
+let starCacheTimestamp: number = 0;
+
/**
* Fetches the latest release from GitHub and extracts download links
*/
@@ -92,3 +96,38 @@ export async function getLatestRelease(): Promise {
throw error;
}
}
+
+/**
+ * Fetches the star count for the repo from GitHub
+ */
+export async function getStarCount(): Promise {
+ const now = Date.now();
+ if (cachedStarCount !== null && now - starCacheTimestamp < CACHE_DURATION) {
+ return cachedStarCount;
+ }
+
+ try {
+ const response = await fetch(`${GITHUB_API_BASE}/repos/${GITHUB_REPO}`, {
+ next: { revalidate: 600 },
+ headers: {
+ Accept: 'application/vnd.github.v3+json',
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`GitHub API error: ${response.status}`);
+ }
+
+ const repo = await response.json();
+ const count = repo.stargazers_count ?? 0;
+
+ cachedStarCount = count;
+ starCacheTimestamp = now;
+
+ return count;
+ } catch (error) {
+ console.error('Failed to fetch star count:', error);
+ if (cachedStarCount !== null) return cachedStarCount;
+ throw error;
+ }
+}
diff --git a/tauri/src-tauri/gen/Assets.car b/tauri/src-tauri/gen/Assets.car
index 6f92edd00285e831913e5d98e59e454051abedb9..8f321854c7670a0036d9e56e4499b3ceae924d95 100644
GIT binary patch
literal 2899688
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