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
This commit is contained in:
James Pine
2026-03-15 04:53:28 -07:00
parent 8377152d86
commit f80782a90a
16 changed files with 1756 additions and 462 deletions
+26 -128
View File
@@ -139,7 +139,11 @@ export function AudioPlayer() {
barRadius: 2, barRadius: 2,
height: 80, height: 80,
normalize: true, normalize: true,
backend: 'WebAudio', // Use MediaElement backend (default). Unlike the WebAudio backend,
// MediaElement uses a standard <audio> 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) interact: true, // Enable interaction (click to seek)
mediaControls: false, // Don't show native controls mediaControls: false, // Don't show native controls
}); });
@@ -189,15 +193,6 @@ export function AudioPlayer() {
const currentVolume = usePlayerStore.getState().volume; const currentVolume = usePlayerStore.getState().volume;
wavesurfer.setVolume(currentVolume); 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 // 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) // Get current values from the store and queries at runtime (not captured closure values)
const currentAudioUrl = usePlayerStore.getState().audioUrl; const currentAudioUrl = usePlayerStore.getState().audioUrl;
@@ -264,21 +259,8 @@ export function AudioPlayer() {
debug.log('Should use native playback:', shouldUseNative); debug.log('Should use native playback:', shouldUseNative);
if (!shouldUseNative) { if (!shouldUseNative) {
debug.log('No custom devices assigned, falling back to WaveSurfer'); debug.log('No custom devices assigned, using standard playback');
// Reset native playback flag and unmute WaveSurfer
isUsingNativePlaybackRef.current = false; 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 { } else {
const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids); const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids);
debug.log('Device IDs to play to:', deviceIds); debug.log('Device IDs to play to:', deviceIds);
@@ -299,19 +281,10 @@ export function AudioPlayer() {
// Mark that we're using native playback // Mark that we're using native playback
isUsingNativePlaybackRef.current = true; isUsingNativePlaybackRef.current = true;
// Mute WaveSurfer's audio element to prevent UI audio output // Mute WaveSurfer's audio output — native handles the actual sound
// Keep WaveSurfer running for visualization // Keep WaveSurfer running for waveform visualization
const mediaElement = wavesurfer.getMediaElement(); wavesurfer.setVolume(0);
if (mediaElement) { wavesurfer.setMuted(true);
mediaElement.volume = 0;
mediaElement.muted = true;
debug.log(
'WaveSurfer muted for native playback - volume:',
mediaElement.volume,
'muted:',
mediaElement.muted,
);
}
// Start WaveSurfer playback for visualization (muted) // Start WaveSurfer playback for visualization (muted)
wavesurfer.play().catch((error) => { wavesurfer.play().catch((error) => {
@@ -334,38 +307,15 @@ export function AudioPlayer() {
'Native playback failed during auto-play, falling back to WaveSurfer:', 'Native playback failed during auto-play, falling back to WaveSurfer:',
error, error,
); );
// Reset native playback flag and unmute WaveSurfer
isUsingNativePlaybackRef.current = false; 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 // 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) // Only auto-play if shouldAutoPlay flag is set (user explicitly clicked to play)
@@ -389,28 +339,6 @@ export function AudioPlayer() {
// Handle play/pause // Handle play/pause
wavesurfer.on('play', () => { wavesurfer.on('play', () => {
setIsPlaying(true); 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('pause', () => setIsPlaying(false));
wavesurfer.on('finish', () => { wavesurfer.on('finish', () => {
@@ -492,11 +420,6 @@ export function AudioPlayer() {
if (wavesurferRef.current) { if (wavesurferRef.current) {
debug.log('Destroying WaveSurfer instance'); debug.log('Destroying WaveSurfer instance');
try { try {
const mediaElement = wavesurferRef.current.getMediaElement();
if (mediaElement) {
mediaElement.pause();
mediaElement.src = '';
}
wavesurferRef.current.destroy(); wavesurferRef.current.destroy();
} catch (error) { } catch (error) {
debug.error('Error destroying WaveSurfer:', error); debug.error('Error destroying WaveSurfer:', error);
@@ -537,13 +460,10 @@ export function AudioPlayer() {
} }
// Reset native playback flag when loading new audio // 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) { if (isUsingNativePlaybackRef.current) {
const mediaElement = wavesurfer.getMediaElement(); wavesurfer.setMuted(false);
if (mediaElement) { wavesurfer.setVolume(usePlayerStore.getState().volume);
mediaElement.muted = false;
mediaElement.volume = usePlayerStore.getState().volume;
}
} }
isUsingNativePlaybackRef.current = false; isUsingNativePlaybackRef.current = false;
@@ -559,16 +479,7 @@ export function AudioPlayer() {
wavesurfer.pause(); wavesurfer.pause();
} }
// Stop the media element explicitly // Use empty() to completely destroy the waveform and reset media
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
debug.log('Calling wavesurfer.empty() to destroy audio'); debug.log('Calling wavesurfer.empty() to destroy audio');
wavesurfer.empty(); wavesurfer.empty();
} catch (error) { } catch (error) {
@@ -623,20 +534,13 @@ export function AudioPlayer() {
// Sync volume // Sync volume
useEffect(() => { useEffect(() => {
if (wavesurferRef.current) { 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 using native playback, keep WaveSurfer muted regardless of volume setting
if (isUsingNativePlaybackRef.current) { if (isUsingNativePlaybackRef.current) {
mediaElement.volume = 0; wavesurferRef.current.setVolume(0);
mediaElement.muted = true;
debug.log('Volume sync: Using native playback, keeping WaveSurfer muted'); debug.log('Volume sync: Using native playback, keeping WaveSurfer muted');
} else { } else {
mediaElement.volume = volume; wavesurferRef.current.setVolume(volume);
mediaElement.muted = volume === 0; debug.log('Volume synced:', volume);
debug.log('Volume synced:', volume, 'muted:', mediaElement.muted);
}
} }
} }
}, [volume]); }, [volume]);
@@ -757,11 +661,8 @@ export function AudioPlayer() {
isUsingNativePlaybackRef.current = true; isUsingNativePlaybackRef.current = true;
// Mute WaveSurfer and start it for visualization // Mute WaveSurfer and start it for visualization
const mediaElement = wavesurferRef.current.getMediaElement(); wavesurferRef.current.setVolume(0);
if (mediaElement) { wavesurferRef.current.setMuted(true);
mediaElement.volume = 0;
mediaElement.muted = true;
}
// Start WaveSurfer for visualization (muted) // Start WaveSurfer for visualization (muted)
wavesurferRef.current.play().catch((error) => { wavesurferRef.current.play().catch((error) => {
@@ -785,11 +686,8 @@ export function AudioPlayer() {
} else { } else {
// Ensure WaveSurfer is not muted if not using native playback // Ensure WaveSurfer is not muted if not using native playback
if (!isUsingNativePlaybackRef.current) { if (!isUsingNativePlaybackRef.current) {
const mediaElement = wavesurferRef.current.getMediaElement(); wavesurferRef.current.setMuted(false);
if (mediaElement) { wavesurferRef.current.setVolume(volume);
mediaElement.muted = false;
mediaElement.volume = volume;
}
} }
wavesurferRef.current.play().catch((error) => { wavesurferRef.current.play().catch((error) => {
@@ -5,6 +5,14 @@ import { useEffect, useRef, useState } from 'react';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor'; import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { GenerationPicker } from '@/components/Effects/GenerationPicker'; import { GenerationPicker } from '@/components/Effects/GenerationPicker';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
@@ -29,6 +37,11 @@ export function EffectsDetail() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = 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 // Preview state
const [previewGenId, setPreviewGenId] = useState<string | null>(null); const [previewGenId, setPreviewGenId] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false); const [previewLoading, setPreviewLoading] = useState(false);
@@ -165,8 +178,38 @@ export function EffectsDetail() {
} }
} }
async function handleSaveAsNew() { function handleSaveAsNew() {
await handleSaveNew(); // 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() { async function handleDelete() {
@@ -327,6 +370,53 @@ export function EffectsDetail() {
</p> </p>
</div> </div>
</div> </div>
{/* Save as Custom dialog */}
<Dialog open={saveAsDialogOpen} onOpenChange={setSaveAsDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Save as Custom Preset</DialogTitle>
<DialogDescription>
Create a new custom preset based on the current effects chain.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-1.5">
<Label className="text-xs">Name</Label>
<Input
value={saveAsName}
onChange={(e) => setSaveAsName(e.target.value)}
placeholder="My preset..."
className="h-9"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && saveAsName.trim()) {
handleSaveAsConfirm();
}
}}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Description</Label>
<Textarea
value={saveAsDescription}
onChange={(e) => setSaveAsDescription(e.target.value)}
placeholder="Describe what this preset does..."
className="min-h-[60px] resize-none"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSaveAsDialogOpen(false)} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSaveAsConfirm} disabled={saving || !saveAsName.trim()}>
<Save className="h-3.5 w-3.5 mr-1.5" />
{saving ? 'Saving...' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
+5 -2
View File
@@ -45,12 +45,15 @@ export function Sidebar({ isMacOS }: SidebarProps) {
{/* Navigation Buttons */} {/* Navigation Buttons */}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{tabs.map((tab) => { {tabs.map((tab, index) => {
const Icon = tab.icon; const Icon = tab.icon;
// For index route, use exact match; for others, use default matching // For index route, use exact match; for others, use default matching
const isActive = const isActive =
tab.path === '/' ? matchRoute({ to: '/', exact: true }) : matchRoute({ to: tab.path }); 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 ( return (
<Link <Link
key={tab.id} key={tab.id}
@@ -70,7 +73,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
style={{ style={{
maskImage: 'linear-gradient(to bottom, black, transparent 60%)', maskImage: 'linear-gradient(to bottom, black, transparent 60%)',
WebkitMaskImage: 'linear-gradient(to bottom, black, transparent 60%)', WebkitMaskImage: 'linear-gradient(to bottom, black, transparent 60%)',
border: '1px solid hsl(var(--accent) / 0.5)', border: `1px solid hsl(var(--accent) / ${accentOpacity})`,
}} }}
/> />
)} )}
+163
View File
@@ -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
+15
View File
@@ -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 });
}
}
+5 -4
View File
@@ -116,16 +116,17 @@
} }
/* Noise texture overlay for hero glow */ /* Noise texture overlay for hero glow */
.hero-glow::after { /* .hero-glow::after {
content: ""; content: "";
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 5; z-index: 5;
pointer-events: none; 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"); 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.3; opacity: 0.35;
mix-blend-mode: overlay; mix-blend-mode: overlay;
} will-change: transform;
} */
/* Scrollbar hiding */ /* Scrollbar hiding */
::-webkit-scrollbar { ::-webkit-scrollbar {
+10 -2
View File
@@ -4,11 +4,11 @@ import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Voicebox - Open Source Voice Cloning Desktop App', title: 'Voicebox - Open Source Voice Cloning Desktop App',
description: 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: [ keywords: [
'voice cloning', 'voice cloning',
'TTS', 'TTS',
'Qwen3', 'multi-engine',
'desktop app', 'desktop app',
'AI voice', 'AI voice',
'open source', 'open source',
@@ -32,6 +32,14 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en" suppressHydrationWarning className="dark"> <html lang="en" suppressHydrationWarning className="dark">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Caveat:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body> <body>
<div className="relative min-h-screen bg-background font-sans">{children}</div> <div className="relative min-h-screen bg-background font-sans">{children}</div>
</body> </body>
+130 -30
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { Github } from 'lucide-react'; import { Github, Globe, Languages, MessageSquare, Zap } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ControlUI } from '@/components/ControlUI'; import { ControlUI } from '@/components/ControlUI';
@@ -8,6 +8,7 @@ import { Features } from '@/components/Features';
import { Footer } from '@/components/Footer'; import { Footer } from '@/components/Footer';
import { Navbar } from '@/components/Navbar'; import { Navbar } from '@/components/Navbar';
import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons'; import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons';
import { VoiceCreator } from '@/components/VoiceCreator';
import { DOWNLOAD_LINKS, GITHUB_REPO } from '@/lib/constants'; import { DOWNLOAD_LINKS, GITHUB_REPO } from '@/lib/constants';
import type { DownloadLinks } from '@/lib/releases'; import type { DownloadLinks } from '@/lib/releases';
@@ -44,18 +45,18 @@ export default function Home() {
{/* Logo */} {/* Logo */}
<div <div
className="fade-in mx-auto mb-8 h-[120px] w-[120px] md:h-[160px] md:w-[160px]" className="fade-in mx-auto mb-8 h-[120px] w-[120px] md:h-[160px] md:w-[160px]"
style={{ animationDelay: '0ms' }} style={{
animationDelay: '0ms',
filter:
'drop-shadow(0 0 20px hsl(43 60% 50% / 0.4)) drop-shadow(0 0 60px hsl(43 60% 50% / 0.2))',
}}
> >
<Image <Image
src="/voicebox-logo-app.webp" src="/voicebox-logo-app.webp"
alt="Voicebox" alt="Voicebox"
width={160} width={160}
height={160} height={160}
className="h-full w-full object-contain" className="h-full w-full object-contain mix-blend-lighten"
style={{
filter:
'drop-shadow(0 0 20px hsl(43 60% 50% / 0.4)) drop-shadow(0 0 60px hsl(43 60% 50% / 0.2))',
}}
priority priority
/> />
</div> </div>
@@ -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" className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl"
style={{ animationDelay: '200ms' }} style={{ animationDelay: '200ms' }}
> >
Open source voice cloning studio powered by Qwen3-TTS. Clone any voice, generate natural Open source voice cloning studio with support for multiple TTS engines. Clone any voice,
speech, and compose multi-voice projects — all running locally. generate natural speech, and compose multi-voice projects — all running locally.
</p> </p>
{/* CTAs */} {/* CTAs */}
@@ -116,26 +117,124 @@ export default function Home() {
{/* ── Features ─────────────────────────────────────────────── */} {/* ── Features ─────────────────────────────────────────────── */}
<Features /> <Features />
{/* ── About / Manifesto ────────────────────────────────────── */} {/* ── Voice Creator ────────────────────────────────────────── */}
<VoiceCreator />
{/* ── Models ─────────────────────────────────────────────────── */}
<section id="about" className="border-t border-border py-24"> <section id="about" className="border-t border-border py-24">
<div className="mx-auto max-w-3xl px-6"> <div className="mx-auto max-w-5xl px-6">
<h2 className="mb-10 text-center text-3xl font-semibold tracking-tight text-foreground md:text-4xl"> <div className="text-center mb-14">
Why Voicebox exists <h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
Multi-Engine Architecture
</h2> </h2>
<div className="space-y-6 text-center"> <p className="text-muted-foreground max-w-2xl mx-auto">
<p className="text-base leading-relaxed text-muted-foreground"> Choose the right model for every job. All models run locally on your hardware —
Cloud voice cloning services lock your voice data behind subscriptions, rate limits, download once, use forever.
and terms of service that can change at any time. Your voice — and the voices you
clone — should belong to you.
</p> </p>
<p className="text-lg font-medium text-foreground"> </div>
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. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Qwen3-TTS */}
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-base font-semibold text-foreground">Qwen3-TTS</h3>
<span className="text-xs text-muted-foreground/60">by Alibaba</span>
</div>
<div className="flex gap-1.5">
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
1.7B
</span>
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
0.6B
</span>
</div>
</div>
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
High-quality multilingual voice cloning with natural prosody. The only engine with
delivery instructions — control tone, pace, and emotion with natural language.
</p> </p>
<p className="text-sm leading-relaxed text-muted-foreground"> <div className="flex flex-wrap gap-2">
Optimized with Metal acceleration on Mac and CUDA on Windows/Linux. No Python install <span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
required. No cloud. No subscriptions. Free and open-source, forever. <Globe className="h-3 w-3" />
10 languages
</span>
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
<MessageSquare className="h-3 w-3" />
Delivery instructions
</span>
</div>
</div>
{/* Chatterbox */}
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-base font-semibold text-foreground">Chatterbox</h3>
<span className="text-xs text-muted-foreground/60">by Resemble AI</span>
</div>
</div>
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
Production-grade voice cloning with the broadest language support. 23 languages with
zero-shot cloning and emotion exaggeration control.
</p> </p>
<div className="flex flex-wrap gap-2">
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
<Languages className="h-3 w-3" />
23 languages
</span>
</div>
</div>
{/* Chatterbox Turbo */}
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-base font-semibold text-foreground">Chatterbox Turbo</h3>
<span className="text-xs text-muted-foreground/60">by Resemble AI</span>
</div>
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
350M
</span>
</div>
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
Lightweight and fast. Supports paralinguistic tags — embed [laugh], [sigh], [gasp]
and more directly in your text for expressive, natural speech.
</p>
<div className="flex flex-wrap gap-2">
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
<Zap className="h-3 w-3" />
350M params
</span>
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
<MessageSquare className="h-3 w-3" />
[laugh] [sigh] tags
</span>
</div>
</div>
{/* LuxTTS */}
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-base font-semibold text-foreground">LuxTTS</h3>
<span className="text-xs text-muted-foreground/60">by ZipVoice</span>
</div>
</div>
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
Ultra-fast, CPU-friendly voice cloning at 48kHz. Exceeds 150x realtime on CPU with
~1GB VRAM. The fastest engine for quick iterations.
</p>
<div className="flex flex-wrap gap-2">
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
<Zap className="h-3 w-3" />
150x realtime
</span>
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
48kHz output
</span>
</div>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -193,16 +292,17 @@ export default function Home() {
</a> </a>
{/* Linux */} {/* Linux */}
<div <a
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 opacity-50 cursor-not-allowed" href={downloadLinks.linux}
title="Linux builds coming soon" download
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group"
> >
<LinuxIcon className="h-6 w-6 shrink-0 text-muted-foreground" /> <LinuxIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
<div className="ml-4"> <div className="ml-4">
<div className="text-sm font-medium">Linux</div> <div className="text-sm font-medium">Linux</div>
<div className="text-xs text-muted-foreground">Coming soon</div> <div className="text-xs text-muted-foreground">AppImage (x64)</div>
</div>
</div> </div>
</a>
</div> </div>
{/* GitHub link */} {/* GitHub link */}
+118 -30
View File
@@ -17,7 +17,7 @@ import {
Wand2, Wand2,
} from 'lucide-react'; } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { LandingAudioPlayer } from './LandingAudioPlayer'; import { LandingAudioPlayer, unlockAudioContext } from './LandingAudioPlayer';
// ─── Data ─────────────────────────────────────────────────────────────────── // ─── Data ───────────────────────────────────────────────────────────────────
// Edit this section to customise all the content shown in the ControlUI demo. // Edit this section to customise all the content shown in the ControlUI demo.
@@ -29,7 +29,7 @@ interface VoiceProfile {
hasEffects: boolean; 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[] = [ const PROFILES: VoiceProfile[] = [
{ {
name: 'Jarvis', name: 'Jarvis',
@@ -301,24 +301,27 @@ function LoadingBars({ mode }: { mode: 'idle' | 'generating' | 'playing' }) {
// ─── Profile Card ─────────────────────────────────────────────────────────── // ─── Profile Card ───────────────────────────────────────────────────────────
function ProfileCard({ const ProfileCard = ({
profile, profile,
selected, selected,
selecting, selecting,
cardRef,
}: { }: {
profile: VoiceProfile; profile: VoiceProfile;
selected: boolean; selected: boolean;
selecting: boolean; selecting: boolean;
}) { cardRef?: React.Ref<HTMLDivElement>;
}) => {
return ( return (
<motion.div <motion.div
className={`rounded-xl border-2 bg-card p-3.5 flex flex-col aspect-square transition-all duration-200 ${ ref={cardRef}
className={`rounded-xl border-2 bg-card p-3.5 flex flex-col h-[143px] transition-all duration-200 ${
selected ? 'border-accent shadow-md' : 'border-border/50 hover:shadow-sm' selected ? 'border-accent shadow-md' : 'border-border/50 hover:shadow-sm'
} ${selecting && !selected ? 'opacity-60' : ''}`} } ${selecting && !selected ? 'opacity-60' : ''}`}
animate={selecting && selected ? { scale: [1, 1.02, 1] } : {}} animate={selecting && selected ? { scale: [1, 1.02, 1] } : {}}
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
> >
<div className="text-[15px] font-bold leading-tight">{profile.name}</div> <div className="text-[15px] font-bold leading-tight line-clamp-2">{profile.name}</div>
<div className="text-[10px] text-muted-foreground line-clamp-2 leading-relaxed mt-1"> <div className="text-[10px] text-muted-foreground line-clamp-2 leading-relaxed mt-1">
{profile.description} {profile.description}
</div> </div>
@@ -335,7 +338,7 @@ function ProfileCard({
</div> </div>
</motion.div> </motion.div>
); );
} };
// ─── History Row ──────────────────────────────────────────────────────────── // ─── History Row ────────────────────────────────────────────────────────────
@@ -416,18 +419,19 @@ function FloatingGenerateBox({
phase, phase,
typingText, typingText,
selectedProfile, selectedProfile,
engine,
effect, effect,
}: { }: {
phase: Phase; phase: Phase;
typingText: string; typingText: string;
selectedProfile: VoiceProfile | null; selectedProfile: VoiceProfile | null;
engine: string;
effect?: string; effect?: string;
}) { }) {
const isFocused = phase === 'typing' || phase === 'generating'; const isFocused = phase === 'typing' || phase === 'generating';
const isGenerating = phase === 'generating'; const isGenerating = phase === 'generating';
return ( return (
<div className="absolute left-3 right-3 z-20" style={{ bottom: 117 }}>
<motion.div <motion.div
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[1.5rem] shadow-2xl p-2.5" className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[1.5rem] shadow-2xl p-2.5"
animate={{ animate={{
@@ -480,7 +484,7 @@ function FloatingGenerateBox({
English English
</span> </span>
<span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground"> <span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground">
Qwen3-TTS 1.7B {engine}
</span> </span>
<span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground flex items-center gap-1"> <span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground flex items-center gap-1">
<Sparkles className="h-2.5 w-2.5" /> <Sparkles className="h-2.5 w-2.5" />
@@ -488,7 +492,6 @@ function FloatingGenerateBox({
</span> </span>
</div> </div>
</motion.div> </motion.div>
</div>
); );
} }
@@ -505,11 +508,20 @@ export function ControlUI() {
const [pageHidden, setPageHidden] = useState(false); const [pageHidden, setPageHidden] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const phaseRef = useRef(phase); const phaseRef = useRef(phase);
const profileCardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
phaseRef.current = phase; phaseRef.current = phase;
const step = DEMO_SCRIPT[cycle % DEMO_SCRIPT.length]; const step = DEMO_SCRIPT[cycle % DEMO_SCRIPT.length];
const selectedProfile = PROFILES[selectedIndex]; 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 // Visibility detection
useEffect(() => { useEffect(() => {
const observer = new IntersectionObserver(([entry]) => setIsVisible(entry.isIntersecting), { const observer = new IntersectionObserver(([entry]) => setIsVisible(entry.isIntersecting), {
@@ -589,10 +601,62 @@ export function ControlUI() {
return ( return (
<div ref={containerRef} className="relative z-20 mx-auto w-full max-w-6xl px-6"> <div ref={containerRef} className="relative z-20 mx-auto w-full max-w-6xl px-6">
{/* Unmute button */} {/* Unmute button with handwritten hint */}
<div className="flex justify-end mb-3"> <div className="flex justify-end mb-3">
<div className="relative">
{/* Handwritten hint — absolutely positioned above the button */}
{isMuted && (
<motion.div
className="absolute select-none pointer-events-none"
style={{ top: -30, right: 100 }}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 2, duration: 0.6, ease: 'easeOut' }}
>
<span
className="text-xl text-accent/80 whitespace-nowrap"
style={{
fontFamily: "'Caveat', 'Segoe Script', 'Comic Sans MS', cursive",
letterSpacing: '0.02em',
}}
>
try me!
</span>
{/* Curved arrow from text down-right toward the button */}
<svg
width="22"
height="11"
viewBox="0 0 80 40"
fill="none"
className="text-accent/70 absolute"
style={{ top: 14, left: 60 }}
aria-hidden="true"
>
<title>Arrow</title>
<path
d="M4 4 C20 4, 40 8, 55 20 C62 26, 66 32, 70 36"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
fill="none"
/>
<path
d="M58 42 L70 36 L64 22"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
transform="rotate(35, 70, 36)"
fill="none"
/>
</svg>
</motion.div>
)}
<button <button
onClick={() => setIsMuted(!isMuted)} onClick={() => {
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" 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 ? ( {isMuted ? (
@@ -608,14 +672,12 @@ export function ControlUI() {
)} )}
</button> </button>
</div> </div>
</div>
<div <div className="overflow-hidden rounded-2xl border border-app-line bg-app-box shadow-[0_25px_60px_rgba(0,0,0,0.5),0_8px_20px_rgba(0,0,0,0.3)] md:h-[640px]">
className="overflow-hidden rounded-2xl border border-app-line bg-app-box shadow-[0_25px_60px_rgba(0,0,0,0.5),0_8px_20px_rgba(0,0,0,0.3)]" <div className="flex flex-col md:flex-row h-full">
style={{ height: 640 }} {/* ── Sidebar (hidden on mobile) ─────────────────────────── */}
> <div className="hidden md:flex w-16 shrink-0 border-r border-app-line bg-sidebar flex-col items-center py-4 gap-4">
<div className="flex h-full">
{/* ── Sidebar ───────────────────────────────────────────── */}
<div className="w-16 shrink-0 border-r border-app-line bg-sidebar flex flex-col items-center py-4 gap-4">
{/* Logo */} {/* Logo */}
<div className="mb-1"> <div className="mb-1">
<div <div
@@ -658,11 +720,11 @@ export function ControlUI() {
</div> </div>
{/* ── Main content ──────────────────────────────────────── */} {/* ── Main content ──────────────────────────────────────── */}
<div className="flex-1 flex min-w-0 relative"> <div className="flex-1 flex flex-col md:flex-row min-w-0 relative">
{/* Left: Profiles */} {/* Left: Profiles + Generate box */}
<div className="flex-1 flex flex-col min-w-0 relative"> <div className="flex flex-col min-w-0 relative md:flex-1">
{/* Header */} {/* Header */}
<div className="px-4 pt-6 pb-2 flex items-center justify-between relative z-10"> <div className="px-4 pt-4 md:pt-6 pb-2 flex items-center justify-between relative z-10">
<h2 className="text-base font-bold">Voicebox</h2> <h2 className="text-base font-bold">Voicebox</h2>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<button className="h-6 text-[10px] px-2.5 rounded-full border border-border bg-card text-muted-foreground flex items-center gap-1"> <button className="h-6 text-[10px] px-2.5 rounded-full border border-border bg-card text-muted-foreground flex items-center gap-1">
@@ -675,32 +737,58 @@ export function ControlUI() {
</div> </div>
</div> </div>
{/* Profile grid */} {/* Profile cards — horizontal scroll on mobile, 3-col grid on desktop */}
<div className="flex-1 overflow-hidden px-4 pb-24"> <div className="px-4">
<div className="grid grid-cols-3 gap-2 auto-rows-auto mt-1"> {/* Mobile: horizontal scroll strip */}
<div className="flex gap-2 overflow-x-auto pb-2 md:hidden">
{PROFILES.map((profile, i) => (
<div
key={profile.name}
className="shrink-0 w-[140px]"
ref={(el) => {
if (el) profileCardRefs.current.set(i, el);
}}
>
<ProfileCard
profile={profile}
selected={i === selectedIndex}
selecting={phase === 'selecting'}
/>
</div>
))}
</div>
{/* Desktop: 3-col grid */}
<div className="hidden md:grid grid-cols-3 gap-2 mt-1 pb-24">
{PROFILES.map((profile, i) => ( {PROFILES.map((profile, i) => (
<ProfileCard <ProfileCard
key={profile.name} key={profile.name}
profile={profile} profile={profile}
selected={i === selectedIndex} selected={i === selectedIndex}
selecting={phase === 'selecting'} selecting={phase === 'selecting'}
cardRef={(el: HTMLDivElement | null) => {
if (el) profileCardRefs.current.set(i, el);
}}
/> />
))} ))}
</div> </div>
</div> </div>
{/* Floating generate box */} {/* Floating generate box — desktop: absolute overlay, mobile: inline */}
<div className="px-3 pt-2 pb-3 md:pt-0 md:absolute md:left-3 md:right-3 md:bottom-[117px] md:z-20 md:pb-0">
<FloatingGenerateBox <FloatingGenerateBox
phase={phase} phase={phase}
typingText={step.text} typingText={step.text}
selectedProfile={selectedProfile} selectedProfile={selectedProfile}
engine={step.engine}
effect={step.effect} effect={step.effect}
/> />
</div> </div>
</div>
{/* Right: History */} {/* Right/Below: History */}
<div className="w-[48%] shrink-0 flex flex-col min-w-0"> <div className="md:w-[48%] shrink-0 flex flex-col min-w-0 border-t md:border-t-0 border-app-line">
<div className="flex-1 overflow-hidden px-3 pt-6 pb-3"> <div className="max-h-[360px] md:max-h-none flex-1 overflow-hidden px-3 pt-3 md:pt-6 pb-3">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{generations.map((gen) => { {generations.map((gen) => {
const isThisNew = gen.id === newGenId; const isThisNew = gen.id === newGenId;
+504 -127
View File
@@ -1,8 +1,8 @@
'use client'; 'use client';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { AudioLines, Cloud, Layers, MessageSquareText, Mic, Monitor } from 'lucide-react'; import { AudioLines, Cloud, MessageSquareText, Mic, Sparkles, TextCursorInput } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
// ─── Lazy load wrapper ────────────────────────────────────────────────────── // ─── Lazy load wrapper ──────────────────────────────────────────────────────
@@ -111,123 +111,436 @@ 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 WAVEFORM_BAR_COUNT = 60;
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%' }] },
];
useEffect(() => { function MiniWaveform({ seed, color }: { seed: number; color: string }) {
const interval = setInterval(() => { // Deterministic pseudo-random waveform that looks like real speech audio.
setActiveTrack((p) => (p + 1) % tracks.length); // Uses layered noise at different frequencies for natural envelope + detail.
}, 2200); const bars = useMemo(() => {
return () => clearInterval(interval); // Seeded pseudo-random number generator (deterministic per seed)
}, [tracks.length]); 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 ( return (
<div className="h-40 w-full flex flex-col justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4 gap-2"> <div className="flex items-center h-full overflow-hidden">
{tracks.map((track, i) => ( {bars.map((h, i) => (
<motion.div <div
key={track.name} key={`w-${seed}-${i}`}
className="flex items-center gap-2 h-8 rounded border px-2" className="shrink-0 rounded-full opacity-50"
animate={{
borderColor: i === activeTrack ? `${track.color}40` : 'rgba(255,255,255,0.06)',
backgroundColor: i === activeTrack ? `${track.color}08` : 'rgba(255,255,255,0.02)',
}}
transition={{ duration: 0.3 }}
>
<span
className="text-[8px] w-14 shrink-0 truncate"
style={{ color: i === activeTrack ? track.color : 'rgba(255,255,255,0.3)' }}
>
{track.name}
</span>
<div className="flex-1 relative h-4">
{track.clips.map((clip, j) => (
<motion.div
key={j}
className="absolute h-full rounded-sm"
style={{ style={{
left: clip.x, width: 2,
width: clip.w, marginRight: 1,
backgroundColor: height: `${h * 100}%`,
i === activeTrack ? `${track.color}30` : 'rgba(255,255,255,0.04)', backgroundColor: color,
borderLeft: `2px solid ${i === activeTrack ? track.color : 'rgba(255,255,255,0.1)'}`,
}} }}
transition={{ duration: 0.3 }}
/> />
))} ))}
</div> </div>
</motion.div>
))}
</div>
); );
} }
// ─── Animation: Multi-Sample ──────────────────────────────────────────────── // ─── Animation: Stories Editor ───────────────────────────────────────────────
function MultiSampleAnimation() { // Clip shape: id, profile, track, left (px out of 220), width (px), waveform seed
const [active, setActive] = useState(0); type DemoClip = { id: string; profile: string; track: number; x: number; w: number; seed: number };
const samples = [
{ label: 'interview_clip.wav', dur: '12.4s', quality: 0.92 }, const INITIAL_CLIPS: DemoClip[] = [
{ label: 'podcast_intro.wav', dur: '8.1s', quality: 0.88 }, { id: 'n1', profile: 'Morgan', track: 0, x: 4, w: 70, seed: 1 },
{ label: 'narration_take3.wav', dur: '15.7s', quality: 0.95 }, { id: 'n2', profile: 'Morgan', track: 0, x: 135, w: 35, seed: 2 },
{ label: 'casual_chat.wav', dur: '6.2s', quality: 0.85 }, { 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<DemoClip[]>(INITIAL_CLIPS);
const [actionIndex, setActionIndex] = useState(-1);
const [playheadX, setPlayheadX] = useState(0);
const [selectedId, setSelectedId] = useState<string | null>(null);
const playheadRef = useRef<ReturnType<typeof requestAnimationFrame>>(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 (
<div className="h-40 w-full flex flex-col overflow-hidden rounded-md bg-app-darkerBox/50">
{/* Toolbar */}
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-app-line bg-app-darkBox/60 shrink-0">
<div className="w-1.5 h-1.5 rounded-full bg-ink-faint/40" />
<div className="flex items-center gap-1">
<div className="w-4 h-4 rounded flex items-center justify-center bg-app-button">
<div className="border-l-[4px] border-l-ink-faint border-t-[3px] border-t-transparent border-b-[3px] border-b-transparent ml-0.5" />
</div>
<div className="w-4 h-4 rounded flex items-center justify-center bg-app-button">
<div className="w-2 h-2 rounded-sm bg-ink-faint/60" />
</div>
</div>
<span className="text-[8px] text-ink-faint font-mono ml-1 tabular-nums">0:03 / 0:10</span>
<div className="flex-1" />
{actionIndex >= 0 && actionIndex < ACTIONS.length - 1 && (
<motion.span
key={actionIndex}
className="text-[7px] font-medium px-1.5 py-0.5 rounded-full"
style={{
backgroundColor: `${accentColor.replace(')', ' / 0.15)')}`,
color: accentColor,
}}
initial={{ opacity: 0, y: 3 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
>
{ACTIONS[actionIndex].label}
</motion.span>
)}
<div className="flex items-center gap-0.5">
<span className="text-[7px] text-ink-faint">Zoom</span>
<div className="w-3 h-3 rounded flex items-center justify-center bg-app-button text-[8px] text-ink-faint">
-
</div>
<div className="w-3 h-3 rounded flex items-center justify-center bg-app-button text-[8px] text-ink-faint">
+
</div>
</div>
</div>
{/* Timeline */}
<div className="flex flex-1 min-h-0">
{/* Track labels sidebar */}
<div className="w-7 shrink-0 border-r border-app-line bg-app-darkBox/30 flex flex-col">
<div className="h-5 border-b border-app-line" />
{trackLabels.map((label) => (
<div
key={label}
className="flex-1 flex items-center justify-center border-b border-app-line"
>
<span className="text-[7px] text-ink-faint select-none">{label}</span>
</div>
))}
</div>
{/* Tracks area */}
<div className="flex-1 relative overflow-hidden flex flex-col">
{/* Time ruler */}
<div className="h-5 shrink-0 border-b border-app-line bg-app-darkBox/20 relative">
{timeMarkers.map((t) => (
<div
key={`tm-${t}`}
className="absolute top-0 h-full flex flex-col justify-end pb-0.5"
style={{ left: `${(t / 10) * 100}%` }}
>
<div className="h-1.5 w-px bg-app-line" />
<span className="text-[7px] text-ink-faint ml-0.5 select-none">{`0:0${t}`}</span>
</div>
))}
</div>
{/* Track rows + clips — same parent so percentages match */}
<div className="flex-1 relative min-h-0">
{/* Track rows background */}
{trackLabels.map((label, i) => (
<div
key={`bg-${label}`}
className="border-b border-app-line absolute left-0 right-0"
style={{
height: `${100 / 3}%`,
top: `${(i * 100) / 3}%`,
backgroundColor: i % 2 === 0 ? 'transparent' : 'rgba(255,255,255,0.01)',
}}
/>
))}
{/* 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 (
<motion.div
key={clip.id}
className="absolute rounded overflow-hidden"
initial={false}
style={{
height: clipHeight,
left: `${(clip.x / TL_W) * 100}%`,
width: `${(clip.w / TL_W) * 100}%`,
top: clipTop,
}}
animate={{
left: `${(clip.x / TL_W) * 100}%`,
width: `${(clip.w / TL_W) * 100}%`,
top: clipTop,
}}
transition={{ type: 'spring', stiffness: 200, damping: 25 }}
>
<div
className="w-full h-full rounded overflow-hidden flex flex-col"
style={{
backgroundColor: isSelected ? 'hsl(43 50% 45%)' : 'hsl(43 45% 40%)',
boxShadow: isSelected
? 'inset 0 0 0 1px hsl(43 50% 55%), 0 0 0 1px hsl(30 10% 94% / 0.4)'
: 'inset 0 0 0 1px hsl(30 10% 94% / 0.1)',
}}
>
{/* Profile label — scaled to bypass browser min font size */}
<div className="shrink-0 relative" style={{ height: 9 }}>
<span
className="text-[10px] font-medium leading-none absolute top-0 left-0.5 origin-top-left opacity-80 whitespace-nowrap"
style={{ color: accentFg, transform: 'scale(0.75)' }}
>
{clip.profile}
</span>
</div>
{/* Waveform — absolutely positioned so it never affects clip width */}
<div className="absolute left-0 right-0 bottom-0" style={{ top: 9 }}>
<MiniWaveform seed={clip.seed} color={accentFg} />
</div>
</div>
{/* Trim handles on selected */}
{isSelected && (
<>
<div
className="absolute left-0 top-0 bottom-0 w-1 rounded-l"
style={{ backgroundColor: 'hsl(30 10% 94% / 0.25)' }}
/>
<div
className="absolute right-0 top-0 bottom-0 w-1 rounded-r"
style={{ backgroundColor: 'hsl(30 10% 94% / 0.25)' }}
/>
</>
)}
</motion.div>
);
})}
{/* Playhead */}
<motion.div
className="absolute top-0 bottom-0 w-[2px] rounded-full z-20 pointer-events-none"
style={{ backgroundColor: accentColor }}
animate={{ left: `${(playheadX / TL_W) * 100}%` }}
transition={{ duration: 0.05, ease: 'linear' }}
>
<div
className="absolute -top-0.5 left-1/2 -translate-x-1/2 w-2 h-2 rounded-full"
style={{ backgroundColor: accentColor }}
/>
</motion.div>
</div>
</div>
</div>
</div>
);
}
// ─── 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(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
setActive((p) => (p + 1) % samples.length); setActiveEffect((p) => (p + 1) % effects.length);
}, 2000); }, 2200);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [samples.length]); }, [effects.length]);
return ( return (
<div className="h-40 w-full flex flex-col justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4 gap-1.5"> <div className="h-40 w-full flex flex-col items-center justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4 gap-3">
{samples.map((s, i) => ( {/* Effects chain */}
<div className="flex items-center gap-1">
{effects.map((fx, i) => (
<div key={fx.name} className="flex items-center gap-1">
<motion.div <motion.div
key={s.label} className="text-[8px] px-2 py-0.5 rounded-full border font-medium"
className="flex items-center gap-2 px-2 py-1.5 rounded border text-[9px]"
animate={{ animate={{
borderColor: i === active ? 'hsl(43 50% 45% / 0.4)' : 'rgba(255,255,255,0.06)', borderColor: i <= activeEffect ? `${fx.color}60` : 'rgba(255,255,255,0.06)',
backgroundColor: i === active ? 'hsl(43 50% 45% / 0.06)' : 'rgba(255,255,255,0.02)', 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 }} transition={{ duration: 0.3 }}
> >
<span {fx.name}
className={`flex-1 font-mono truncate ${i === active ? 'text-accent' : 'text-ink-faint'}`}
>
{s.label}
</span>
<span className="text-ink-faint shrink-0">{s.dur}</span>
<div className="w-10 h-1 rounded-full bg-app-line shrink-0 overflow-hidden">
<motion.div
className="h-full rounded-full bg-accent"
animate={{ width: i === active ? `${s.quality * 100}%` : '0%' }}
transition={{ duration: 0.5, delay: i === active ? 0.2 : 0 }}
/>
</div>
</motion.div> </motion.div>
{i < effects.length - 1 && (
<motion.span
className="text-[8px]"
animate={{
color: i < activeEffect ? 'rgba(255,255,255,0.3)' : 'rgba(255,255,255,0.08)',
}}
transition={{ duration: 0.3 }}
>
&rarr;
</motion.span>
)}
</div>
))} ))}
</div> </div>
{/* Waveform that morphs as effects are applied */}
<div className="flex items-center gap-[2px] h-10 w-full max-w-[200px] justify-center">
{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 (
<motion.div
key={`bar-${i}`}
className="w-[3px] rounded-full"
animate={{
height: `${finalH * 100}%`,
backgroundColor: effects[activeEffect].color,
}}
transition={{
height: { duration: 0.5, delay: i * 0.02, ease: 'easeInOut' },
backgroundColor: { duration: 0.4 },
}}
/>
);
})}
</div>
{/* Active effect detail */}
<motion.div
className="text-[9px] font-mono text-ink-faint"
key={activeEffect}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{effects[activeEffect].name}: {effects[activeEffect].param}
</motion.div>
</div>
); );
} }
@@ -332,51 +645,115 @@ function TranscriptionAnimation() {
); );
} }
// ─── Animation: Cross-Platform ────────────────────────────────────────────── // ─── Animation: Unlimited Length ─────────────────────────────────────────────
function CrossPlatformAnimation() { function UnlimitedLengthAnimation() {
const [active, setActive] = useState(0); const [phase, setPhase] = useState(0);
const platforms = [
{ name: 'macOS', icon: '🍎', detail: 'Metal acceleration' }, const chunks = [
{ name: 'Windows', icon: '🪟', detail: 'CUDA acceleration' }, 'The morning sun crept over the mountains, casting long shadows across the valley below.',
{ name: 'Linux', icon: '🐧', detail: 'CUDA acceleration' }, '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(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
setActive((p) => (p + 1) % platforms.length); setPhase((p) => (p + 1) % 4); // 0-2 = processing chunks, 3 = crossfade/done
}, 2000); }, 2000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [platforms.length]); }, []);
return ( return (
<div className="h-40 w-full flex items-center justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4"> <div className="h-40 w-full flex flex-col items-center justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4 gap-2.5">
<div className="flex gap-3"> {/* Chunk pills */}
{platforms.map((p, i) => ( <div className="flex flex-col gap-1 w-full max-w-[220px]">
{chunks.map((chunk, i) => (
<motion.div <motion.div
key={p.name} key={`chunk-${i}`}
className="flex flex-col items-center gap-2 px-3 py-2.5 rounded-lg border" className="flex items-center gap-1.5 px-2 py-1 rounded border text-[8px]"
animate={{ animate={{
borderColor: i === active ? 'hsl(43 50% 45% / 0.4)' : 'rgba(255,255,255,0.06)', borderColor:
backgroundColor: i === active ? 'hsl(43 50% 45% / 0.06)' : 'rgba(255,255,255,0.02)', phase === 3
scale: i === active ? 1.05 : 1, ? 'hsl(43 50% 45% / 0.3)'
: i === phase
? 'hsl(43 50% 45% / 0.5)'
: i < phase
? 'rgba(255,255,255,0.12)'
: 'rgba(255,255,255,0.06)',
backgroundColor:
phase === 3
? 'hsl(43 50% 45% / 0.04)'
: i === phase
? 'hsl(43 50% 45% / 0.08)'
: i < phase
? 'rgba(255,255,255,0.04)'
: 'rgba(255,255,255,0.02)',
}}
transition={{ duration: 0.4 }}
>
{/* Status indicator */}
<motion.div
className="w-1.5 h-1.5 rounded-full shrink-0"
animate={{
backgroundColor:
phase === 3
? 'hsl(43 50% 50%)'
: i === phase
? 'hsl(43 50% 50%)'
: i < phase
? 'rgba(255,255,255,0.3)'
: 'rgba(255,255,255,0.1)',
boxShadow:
i === phase && phase < 3 ? '0 0 6px hsl(43 50% 50%)' : '0 0 0px transparent',
}} }}
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
> />
<span className="text-lg">{p.icon}</span>
<span <span
className={`text-[9px] font-medium ${i === active ? 'text-accent' : 'text-ink-faint'}`} className={`truncate font-mono ${
phase === 3 || i <= phase ? 'text-ink-dull' : 'text-ink-faint/50'
}`}
> >
{p.name} {chunk}
</span>
<span
className={`text-[7px] font-mono ${i === active ? 'text-ink-dull' : 'text-ink-faint/50'}`}
>
{p.detail}
</span> </span>
</motion.div> </motion.div>
))} ))}
</div> </div>
{/* Crossfade / result bar */}
<div className="flex items-center gap-1 w-full max-w-[220px]">
{chunks.map((_, i) => (
<motion.div
key={`seg-${i}`}
className="h-1.5 flex-1 rounded-full"
animate={{
backgroundColor:
phase === 3
? 'hsl(43 50% 45%)'
: i < phase
? 'rgba(255,255,255,0.2)'
: i === phase
? 'hsl(43 50% 45% / 0.5)'
: 'rgba(255,255,255,0.06)',
}}
transition={{ duration: 0.4 }}
/>
))}
</div>
{/* Status text */}
<motion.div
className="text-[9px] font-mono"
key={phase}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<span className={phase === 3 ? 'text-accent' : 'text-ink-faint'}>
{phase < 3
? `generating chunk ${phase + 1} of ${chunks.length}...`
: 'crossfaded & ready'}
</span>
</motion.div>
</div> </div>
); );
} }
@@ -387,7 +764,7 @@ const FEATURES = [
{ {
title: 'Near-Perfect Voice Cloning', title: 'Near-Perfect Voice Cloning',
description: 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, icon: Mic,
animation: VoiceCloningAnimation, animation: VoiceCloningAnimation,
}, },
@@ -399,16 +776,16 @@ const FEATURES = [
animation: StoriesAnimation, animation: StoriesAnimation,
}, },
{ {
title: 'Multi-Sample Support', title: 'Audio Effects Pipeline',
description: description:
'Combine multiple voice samples for higher quality results. More samples means more natural-sounding speech synthesis.', 'Apply pitch shift, reverb, delay, compression, and more — then save as presets. Preview effects live and set defaults per voice profile.',
icon: Layers, icon: Sparkles,
animation: MultiSampleAnimation, animation: EffectsAnimation,
}, },
{ {
title: 'Local or Remote', title: 'Local or Remote',
description: 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, icon: Cloud,
animation: LocalRemoteAnimation, animation: LocalRemoteAnimation,
}, },
@@ -420,11 +797,11 @@ const FEATURES = [
animation: TranscriptionAnimation, animation: TranscriptionAnimation,
}, },
{ {
title: 'Cross-Platform', title: 'Unlimited Generation Length',
description: description:
'Available for macOS, Windows, and Linux. No Python installation required — everything is bundled.', 'Generate up to 50,000 characters in one go. Text is auto-split at sentence boundaries, generated per-chunk, and crossfaded seamlessly.',
icon: Monitor, icon: TextCursorInput,
animation: CrossPlatformAnimation, animation: UnlimitedLengthAnimation,
}, },
]; ];
+48 -44
View File
@@ -1,6 +1,6 @@
'use client'; '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 { useCallback, useEffect, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js'; import WaveSurfer from 'wavesurfer.js';
@@ -10,6 +10,26 @@ function formatDuration(seconds: number): string {
return `${m}:${s.toString().padStart(2, '0')}`; 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 { interface LandingAudioPlayerProps {
audioUrl: string; audioUrl: string;
title: string; title: string;
@@ -145,24 +165,23 @@ export function LandingAudioPlayer({
if (!ws || !isReady) return; if (!ws || !isReady) return;
if (playing) { 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() ws.play()
.then(() => { .then(() => {
console.log( console.log('[Player] play succeeded');
'[Player] play succeeded, isPlaying:',
ws.isPlaying(),
'currentTime:',
ws.getCurrentTime(),
);
setTimeout(() => {
console.log(
'[Player] 1s later, isPlaying:',
ws.isPlaying(),
'currentTime:',
ws.getCurrentTime(),
);
}, 1000);
}) })
.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 { } else {
ws.pause(); ws.pause();
} }
@@ -180,34 +199,27 @@ export function LandingAudioPlayer({
wavesurferRef.current.playPause(); wavesurferRef.current.playPause();
}, []); }, []);
const handleClose = useCallback(() => {
if (wavesurferRef.current) {
wavesurferRef.current.pause();
}
setIsPlaying(false);
onClose();
}, [onClose]);
return ( return (
<div className="absolute bottom-0 left-0 right-0 border-t border-border bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 z-30"> <div className="absolute bottom-0 left-0 right-0 border-t border-border bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 z-30">
<div className="px-4 py-3"> <div className="px-4 py-3 flex flex-col md:flex-row md:items-center gap-2 md:gap-4">
<div className="flex items-center gap-4"> {/* Waveform — full width row on mobile, inline on desktop */}
<div className="min-w-0 min-h-[60px] md:min-h-[80px] md:flex-1 md:order-2">
<div ref={waveformRef} className="w-full h-full min-h-[60px] md:min-h-[80px]" />
</div>
{/* Controls row */}
<div className="flex items-center gap-3 md:contents">
{/* Play/Pause */} {/* Play/Pause */}
<button <button
onClick={handlePlayPause} onClick={handlePlayPause}
disabled={!isReady} disabled={!isReady}
className="h-10 w-10 rounded-full flex items-center justify-center hover:bg-muted shrink-0 disabled:opacity-50" className="h-10 w-10 rounded-full flex items-center justify-center hover:bg-muted shrink-0 disabled:opacity-50 md:order-1"
> >
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5 ml-0.5" />} {isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5 ml-0.5" />}
</button> </button>
{/* Waveform */}
<div className="flex-1 min-w-0 flex flex-col gap-1">
<div ref={waveformRef} className="w-full min-h-[80px]" />
</div>
{/* Time */} {/* Time */}
<div className="flex items-center gap-1 text-sm text-muted-foreground shrink-0"> <div className="flex items-center gap-1 text-sm text-muted-foreground shrink-0 md:order-3">
<span className="font-mono text-xs">{formatDuration(currentTime)}</span> <span className="font-mono text-xs">{formatDuration(currentTime)}</span>
<span className="text-xs">/</span> <span className="text-xs">/</span>
<span className="font-mono text-xs">{formatDuration(duration)}</span> <span className="font-mono text-xs">{formatDuration(duration)}</span>
@@ -215,7 +227,7 @@ export function LandingAudioPlayer({
{/* Title */} {/* Title */}
{title && ( {title && (
<div className="text-sm font-medium truncate max-w-[200px] shrink-0 hidden lg:block"> <div className="text-sm font-medium truncate max-w-[200px] shrink-0 hidden lg:block md:order-4">
{title} {title}
</div> </div>
)} )}
@@ -223,7 +235,7 @@ export function LandingAudioPlayer({
{/* Loop */} {/* Loop */}
<button <button
onClick={() => setIsLooping(!isLooping)} onClick={() => 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' isLooping ? 'text-foreground' : 'text-muted-foreground'
}`} }`}
> >
@@ -231,7 +243,7 @@ export function LandingAudioPlayer({
</button> </button>
{/* Volume */} {/* Volume */}
<div className="flex items-center gap-2 shrink-0 w-[120px]"> <div className="flex items-center gap-2 shrink-0 w-[120px] md:order-6">
<button <button
onClick={() => setVolume(volume > 0 ? 0 : 0.75)} onClick={() => setVolume(volume > 0 ? 0 : 0.75)}
className="h-8 w-8 flex items-center justify-center hover:bg-muted rounded-sm" 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" 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"
/> />
</div> </div>
{/* Close */}
<button
onClick={handleClose}
className="h-8 w-8 flex items-center justify-center hover:bg-muted rounded-sm shrink-0"
>
<X className="h-5 w-5 text-muted-foreground" />
</button>
</div> </div>
</div> </div>
</div> </div>
+31 -2
View File
@@ -2,9 +2,34 @@
import { Github } from 'lucide-react'; import { Github } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import { useEffect, useState } from 'react';
import { GITHUB_REPO } from '@/lib/constants'; 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() { export function Navbar() {
const [starCount, setStarCount] = useState<number | null>(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 ( return (
<nav className="fixed inset-x-0 top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl"> <nav className="fixed inset-x-0 top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl">
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-3"> <div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-3">
@@ -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" 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"
> >
<Github className="h-4 w-4" /> <Github className="h-4 w-4" />
<span className="hidden sm:inline">Star on GitHub</span> <span className="text-[13px] font-medium">Star</span>
<span className="sm:hidden">GitHub</span> {starCount !== null && (
<span className="border-l border-border/60 pl-2 text-[13px] font-semibold text-foreground">
{formatStarCount(starCount)}
</span>
)}
</a> </a>
</div> </div>
</nav> </nav>
+479
View File
@@ -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 (
<div className="absolute inset-0 pointer-events-none flex items-end justify-center overflow-hidden">
<div className="flex items-end gap-[2px] w-full h-full px-4 pb-4">
{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 (
<motion.div
key={i}
className="flex-1 rounded-full bg-accent"
animate={{
opacity: active ? 0.35 : 0.1,
height: active ? [idleH, activeH, idleH * 1.5, activeH * 0.7, idleH] : idleH,
}}
transition={
active
? {
duration: 1.0 + (i % 5) * 0.12,
repeat: Infinity,
repeatType: 'mirror',
delay: (i % 7) * 0.04,
ease: 'easeInOut',
}
: { duration: 0.6 }
}
/>
);
})}
</div>
</div>
);
}
// ─── 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 (
<div
className={`relative flex flex-col items-center justify-center gap-3 p-6 border-2 rounded-lg min-h-[180px] transition-colors duration-300 ${
hasFile ? 'border-accent bg-accent/5' : 'border-dashed border-muted-foreground/25'
}`}
>
<AnimatePresence mode="wait">
{!hasFile ? (
<motion.div
key="idle"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col items-center gap-3"
>
<div className="h-10 px-5 rounded-md bg-accent text-accent-foreground flex items-center gap-2 text-sm font-medium">
<Upload className="h-4 w-4" />
Choose File
</div>
<p className="text-xs text-muted-foreground text-center">
Drag and drop an audio file, or click to browse.
<br />
Maximum duration: 30 seconds.
</p>
</motion.div>
) : (
<motion.div
key="file"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col items-center gap-3"
>
<div className="flex items-center gap-2">
<Upload className="h-4 w-4 text-accent" />
<span className="text-sm font-medium">sample-voice-clip.wav</span>
</div>
<div className="flex gap-2">
<div className="h-8 px-3 rounded-md border border-border flex items-center gap-1.5 text-xs text-muted-foreground">
<span>0:04</span>
</div>
<div className="h-8 px-3 rounded-md border border-border flex items-center gap-1.5 text-xs text-muted-foreground">
<Mic className="h-3 w-3" />
Transcribe
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
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 (
<div
className={`relative flex flex-col items-center justify-center gap-3 p-6 border-2 rounded-lg min-h-[180px] overflow-hidden transition-colors duration-300 ${
state === 'recording'
? 'border-accent bg-accent/5'
: state === 'done'
? 'border-accent bg-accent/5'
: 'border-dashed border-muted-foreground/25'
}`}
>
<WaveformBackground active={state === 'recording'} />
<AnimatePresence mode="wait">
{state === 'idle' && (
<motion.div
key="idle"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="relative z-10 flex flex-col items-center gap-3"
>
<div className="h-10 px-5 rounded-md bg-accent text-accent-foreground flex items-center gap-2 text-sm font-medium">
<Mic className="h-4 w-4" />
Start Recording
</div>
<p className="text-xs text-muted-foreground text-center">
Click to record from your microphone.
<br />
Maximum duration: 30 seconds.
</p>
</motion.div>
)}
{state === 'recording' && (
<motion.div
key="recording"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="relative z-10 flex flex-col items-center gap-3"
>
<div className="flex items-center gap-3">
<div className="h-3 w-3 rounded-full bg-accent animate-pulse" />
<span className="text-lg font-mono font-semibold">{formatTime(elapsed)}</span>
</div>
<div className="h-9 px-4 rounded-md bg-accent text-accent-foreground flex items-center gap-2 text-sm font-medium">
<div className="h-3 w-3 rounded-sm bg-accent-foreground" />
Stop Recording
</div>
<p className="text-xs text-muted-foreground">{formatTime(30 - elapsed)} remaining</p>
</motion.div>
)}
{state === 'done' && (
<motion.div
key="done"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
className="relative z-10 flex flex-col items-center gap-3"
>
<div className="flex items-center gap-2">
<Mic className="h-4 w-4 text-accent" />
<span className="text-sm font-medium">Recording complete</span>
</div>
<div className="flex gap-2">
<div className="h-8 px-3 rounded-md border border-border flex items-center gap-1.5 text-xs text-muted-foreground">
<span>0:04</span>
</div>
<div className="h-8 px-3 rounded-md border border-border flex items-center gap-1.5 text-xs text-muted-foreground">
<Mic className="h-3 w-3" />
Transcribe
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
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 (
<div
className={`relative flex flex-col items-center justify-center gap-3 p-6 border-2 rounded-lg min-h-[180px] overflow-hidden transition-colors duration-300 ${
state === 'capturing'
? 'border-accent bg-accent/5'
: state === 'done'
? 'border-accent bg-accent/5'
: 'border-dashed border-muted-foreground/25'
}`}
>
<WaveformBackground active={state === 'capturing'} />
<AnimatePresence mode="wait">
{state === 'idle' && (
<motion.div
key="idle"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="relative z-10 flex flex-col items-center gap-3"
>
<div className="h-10 px-5 rounded-md bg-accent text-accent-foreground flex items-center gap-2 text-sm font-medium">
<Monitor className="h-4 w-4" />
Start Capture
</div>
<p className="text-xs text-muted-foreground text-center">
Capture audio playing on your system.
<br />
Maximum duration: 30 seconds.
</p>
</motion.div>
)}
{state === 'capturing' && (
<motion.div
key="capturing"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="relative z-10 flex flex-col items-center gap-3"
>
<div className="flex items-center gap-3">
<div className="h-3 w-3 rounded-full bg-accent animate-pulse" />
<span className="text-lg font-mono font-semibold">{formatTime(elapsed)}</span>
</div>
<div className="h-9 px-4 rounded-md bg-accent text-accent-foreground flex items-center gap-2 text-sm font-medium">
<div className="h-3 w-3 rounded-sm bg-accent-foreground" />
Stop Capture
</div>
<p className="text-xs text-muted-foreground">{formatTime(30 - elapsed)} remaining</p>
</motion.div>
)}
{state === 'done' && (
<motion.div
key="done"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
className="relative z-10 flex flex-col items-center gap-3"
>
<div className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-accent" />
<span className="text-sm font-medium">Capture complete</span>
</div>
<div className="flex gap-2">
<div className="h-8 px-3 rounded-md border border-border flex items-center gap-1.5 text-xs text-muted-foreground">
<span>0:04</span>
</div>
<div className="h-8 px-3 rounded-md border border-border flex items-center gap-1.5 text-xs text-muted-foreground">
<Mic className="h-3 w-3" />
Transcribe
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// ─── 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<TabId>('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 (
<section className="border-t border-border py-24">
<div className="mx-auto max-w-5xl px-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-16 items-center">
{/* Left: Copy */}
<div>
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
Clone any voice in seconds
</h2>
<p className="text-muted-foreground mb-6">
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.
</p>
<div className="space-y-3">
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-0.5">
<Upload className="h-4 w-4 text-accent" />
</div>
<div>
<div className="text-sm font-medium">Upload a clip</div>
<div className="text-xs text-muted-foreground">
Drag and drop any audio file — WAV, MP3, FLAC, or WebM.
</div>
</div>
</div>
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-0.5">
<Mic className="h-4 w-4 text-accent" />
</div>
<div>
<div className="text-sm font-medium">Record from microphone</div>
<div className="text-xs text-muted-foreground">
Live waveform preview while you record. Up to 30 seconds.
</div>
</div>
</div>
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0 mt-0.5">
<Monitor className="h-4 w-4 text-accent" />
</div>
<div>
<div className="text-sm font-medium">System audio capture</div>
<div className="text-xs text-muted-foreground">
Clone a voice from a YouTube video, podcast, or any app playing audio.
</div>
</div>
</div>
</div>
</div>
{/* Right: Animated UI mock */}
<div className="rounded-xl border border-app-line bg-app-darkBox overflow-hidden">
<div className="p-5">
{/* Tab bar */}
<div className="flex rounded-lg border border-border bg-card/50 p-1 mb-4">
{TABS.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => {
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'
}`}
>
<Icon className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{tab.label}</span>
</button>
);
})}
</div>
{/* Panel */}
<AnimatePresence mode="wait">
<motion.div
key={`${activeTab}-${cycleKey}`}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.2 }}
>
{activeTab === 'upload' && <UploadPanel />}
{activeTab === 'record' && <RecordPanel />}
{activeTab === 'system' && <SystemPanel />}
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
</div>
</section>
);
}
+39
View File
@@ -19,6 +19,10 @@ let cachedReleaseInfo: ReleaseInfo | null = null;
let cacheTimestamp: number = 0; let cacheTimestamp: number = 0;
const CACHE_DURATION = 1000 * 60 * 10; // 10 minutes 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 * Fetches the latest release from GitHub and extracts download links
*/ */
@@ -92,3 +96,38 @@ export async function getLatestRelease(): Promise<ReleaseInfo> {
throw error; throw error;
} }
} }
/**
* Fetches the star count for the repo from GitHub
*/
export async function getStarCount(): Promise<number> {
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;
}
}
Binary file not shown.
Binary file not shown.