Merge pull request #274 from jamiepine/feat/landing-page-redesign

Landing page v0.2.0 redesign
This commit is contained in:
Jamie Pine
2026-03-15 06:22:04 -07:00
committed by GitHub
27 changed files with 3468 additions and 482 deletions
+31 -133
View File
@@ -139,7 +139,11 @@ export function AudioPlayer() {
barRadius: 2,
height: 80,
normalize: true,
backend: 'WebAudio',
// Use MediaElement backend (default). Unlike the WebAudio backend,
// MediaElement uses a standard <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)
mediaControls: false, // Don't show native controls
});
@@ -189,15 +193,6 @@ export function AudioPlayer() {
const currentVolume = usePlayerStore.getState().volume;
wavesurfer.setVolume(currentVolume);
// Get the underlying audio element and ensure it's not muted
// (unless we're using native playback, which will be set later)
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement && !isUsingNativePlaybackRef.current) {
mediaElement.volume = currentVolume;
mediaElement.muted = false;
debug.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
}
// Auto-play when ready - check if we should use native playback
// Get current values from the store and queries at runtime (not captured closure values)
const currentAudioUrl = usePlayerStore.getState().audioUrl;
@@ -264,21 +259,8 @@ export function AudioPlayer() {
debug.log('Should use native playback:', shouldUseNative);
if (!shouldUseNative) {
debug.log('No custom devices assigned, falling back to WaveSurfer');
// Reset native playback flag and unmute WaveSurfer
debug.log('No custom devices assigned, using standard playback');
isUsingNativePlaybackRef.current = false;
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
const currentVolume = usePlayerStore.getState().volume;
mediaElement.volume = currentVolume;
mediaElement.muted = false;
debug.log(
'WaveSurfer unmuted for normal playback - volume:',
mediaElement.volume,
'muted:',
mediaElement.muted,
);
}
} else {
const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids);
debug.log('Device IDs to play to:', deviceIds);
@@ -299,19 +281,10 @@ export function AudioPlayer() {
// Mark that we're using native playback
isUsingNativePlaybackRef.current = true;
// Mute WaveSurfer's audio element to prevent UI audio output
// Keep WaveSurfer running for visualization
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
mediaElement.volume = 0;
mediaElement.muted = true;
debug.log(
'WaveSurfer muted for native playback - volume:',
mediaElement.volume,
'muted:',
mediaElement.muted,
);
}
// Mute WaveSurfer's audio output — native handles the actual sound
// Keep WaveSurfer running for waveform visualization
wavesurfer.setVolume(0);
wavesurfer.setMuted(true);
// Start WaveSurfer playback for visualization (muted)
wavesurfer.play().catch((error) => {
@@ -334,38 +307,15 @@ export function AudioPlayer() {
'Native playback failed during auto-play, falling back to WaveSurfer:',
error,
);
// Reset native playback flag and unmute WaveSurfer
isUsingNativePlaybackRef.current = false;
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
const currentVolume = usePlayerStore.getState().volume;
mediaElement.volume = currentVolume;
mediaElement.muted = false;
debug.log(
'WaveSurfer unmuted after native playback failure - volume:',
mediaElement.volume,
'muted:',
mediaElement.muted,
);
}
// Fall through to WaveSurfer playback
}
} else {
debug.log('Not using native playback, using WaveSurfer');
// Reset native playback flag and unmute WaveSurfer
isUsingNativePlaybackRef.current = false;
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
const currentVolume = usePlayerStore.getState().volume;
mediaElement.volume = currentVolume;
mediaElement.muted = false;
debug.log(
'WaveSurfer unmuted for normal playback - volume:',
mediaElement.volume,
'muted:',
mediaElement.muted,
);
}
}
// Standard playback path — ensure WaveSurfer is unmuted
if (!isUsingNativePlaybackRef.current) {
wavesurfer.setMuted(false);
wavesurfer.setVolume(usePlayerStore.getState().volume);
}
// Only auto-play if shouldAutoPlay flag is set (user explicitly clicked to play)
@@ -389,28 +339,6 @@ export function AudioPlayer() {
// Handle play/pause
wavesurfer.on('play', () => {
setIsPlaying(true);
// Ensure audio element volume is set correctly
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
// Double-check: if using native playback, keep WaveSurfer muted
// Otherwise, ensure it's unmuted
if (isUsingNativePlaybackRef.current) {
mediaElement.volume = 0;
mediaElement.muted = true;
debug.log('Playing (native mode) - WaveSurfer muted for visualization only');
} else {
// Ensure WaveSurfer is unmuted for normal playback
const currentVolume = usePlayerStore.getState().volume;
mediaElement.volume = currentVolume;
mediaElement.muted = false;
debug.log(
'Playing (normal mode) - volume:',
mediaElement.volume,
'muted:',
mediaElement.muted,
);
}
}
});
wavesurfer.on('pause', () => setIsPlaying(false));
wavesurfer.on('finish', () => {
@@ -492,11 +420,6 @@ export function AudioPlayer() {
if (wavesurferRef.current) {
debug.log('Destroying WaveSurfer instance');
try {
const mediaElement = wavesurferRef.current.getMediaElement();
if (mediaElement) {
mediaElement.pause();
mediaElement.src = '';
}
wavesurferRef.current.destroy();
} catch (error) {
debug.error('Error destroying WaveSurfer:', error);
@@ -537,13 +460,10 @@ export function AudioPlayer() {
}
// Reset native playback flag when loading new audio
// Also unmute WaveSurfer if it was muted
// Unmute WaveSurfer if it was muted for native playback
if (isUsingNativePlaybackRef.current) {
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
mediaElement.muted = false;
mediaElement.volume = usePlayerStore.getState().volume;
}
wavesurfer.setMuted(false);
wavesurfer.setVolume(usePlayerStore.getState().volume);
}
isUsingNativePlaybackRef.current = false;
@@ -559,16 +479,7 @@ export function AudioPlayer() {
wavesurfer.pause();
}
// Stop the media element explicitly
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
debug.log('Stopping media element');
mediaElement.pause();
mediaElement.currentTime = 0;
mediaElement.src = '';
}
// Use empty() to completely destroy the waveform and media element
// Use empty() to completely destroy the waveform and reset media
debug.log('Calling wavesurfer.empty() to destroy audio');
wavesurfer.empty();
} catch (error) {
@@ -623,20 +534,13 @@ export function AudioPlayer() {
// Sync volume
useEffect(() => {
if (wavesurferRef.current) {
wavesurferRef.current.setVolume(volume);
// Also ensure the underlying audio element volume is set
const mediaElement = wavesurferRef.current.getMediaElement();
if (mediaElement) {
// If using native playback, keep WaveSurfer muted regardless of volume setting
if (isUsingNativePlaybackRef.current) {
mediaElement.volume = 0;
mediaElement.muted = true;
debug.log('Volume sync: Using native playback, keeping WaveSurfer muted');
} else {
mediaElement.volume = volume;
mediaElement.muted = volume === 0;
debug.log('Volume synced:', volume, 'muted:', mediaElement.muted);
}
// If using native playback, keep WaveSurfer muted regardless of volume setting
if (isUsingNativePlaybackRef.current) {
wavesurferRef.current.setVolume(0);
debug.log('Volume sync: Using native playback, keeping WaveSurfer muted');
} else {
wavesurferRef.current.setVolume(volume);
debug.log('Volume synced:', volume);
}
}
}, [volume]);
@@ -757,11 +661,8 @@ export function AudioPlayer() {
isUsingNativePlaybackRef.current = true;
// Mute WaveSurfer and start it for visualization
const mediaElement = wavesurferRef.current.getMediaElement();
if (mediaElement) {
mediaElement.volume = 0;
mediaElement.muted = true;
}
wavesurferRef.current.setVolume(0);
wavesurferRef.current.setMuted(true);
// Start WaveSurfer for visualization (muted)
wavesurferRef.current.play().catch((error) => {
@@ -785,11 +686,8 @@ export function AudioPlayer() {
} else {
// Ensure WaveSurfer is not muted if not using native playback
if (!isUsingNativePlaybackRef.current) {
const mediaElement = wavesurferRef.current.getMediaElement();
if (mediaElement) {
mediaElement.muted = false;
mediaElement.volume = volume;
}
wavesurferRef.current.setMuted(false);
wavesurferRef.current.setVolume(volume);
}
wavesurferRef.current.play().catch((error) => {
@@ -5,6 +5,14 @@ import { useEffect, useRef, useState } from 'react';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { GenerationPicker } from '@/components/Effects/GenerationPicker';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
@@ -29,6 +37,11 @@ export function EffectsDetail() {
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
// "Save as Custom" dialog state
const [saveAsDialogOpen, setSaveAsDialogOpen] = useState(false);
const [saveAsName, setSaveAsName] = useState('');
const [saveAsDescription, setSaveAsDescription] = useState('');
// Preview state
const [previewGenId, setPreviewGenId] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
@@ -165,8 +178,38 @@ export function EffectsDetail() {
}
}
async function handleSaveAsNew() {
await handleSaveNew();
function handleSaveAsNew() {
// Open the dialog with a suggested name based on the current preset
setSaveAsName(`${name} (Copy)`);
setSaveAsDescription(description);
setSaveAsDialogOpen(true);
}
async function handleSaveAsConfirm() {
if (!saveAsName.trim()) {
toast({ title: 'Name required', variant: 'destructive' });
return;
}
setSaving(true);
try {
const created = await apiClient.createEffectPreset({
name: saveAsName.trim(),
description: saveAsDescription.trim() || undefined,
effects_chain: workingChain,
});
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
setSaveAsDialogOpen(false);
setSelectedPresetId(created.id);
toast({ title: 'Preset saved', description: `"${created.name}" has been created.` });
} catch (error) {
toast({
title: 'Failed to save',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
setSaving(false);
}
}
async function handleDelete() {
@@ -327,6 +370,53 @@ export function EffectsDetail() {
</p>
</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>
);
}
+5 -2
View File
@@ -45,12 +45,15 @@ export function Sidebar({ isMacOS }: SidebarProps) {
{/* Navigation Buttons */}
<div className="flex flex-col gap-3">
{tabs.map((tab) => {
{tabs.map((tab, index) => {
const Icon = tab.icon;
// For index route, use exact match; for others, use default matching
const isActive =
tab.path === '/' ? matchRoute({ to: '/', exact: true }) : matchRoute({ to: tab.path });
// Accent fades as buttons get further from the logo
const accentOpacity = Math.max(0.08, 0.5 - index * 0.07);
return (
<Link
key={tab.id}
@@ -70,7 +73,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
style={{
maskImage: '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})`,
}}
/>
)}
+17 -4
View File
@@ -17,7 +17,7 @@
},
"app": {
"name": "@voicebox/app",
"version": "0.1.13",
"version": "0.2.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -72,13 +72,15 @@
},
"landing": {
"name": "@voicebox/landing",
"version": "0.1.13",
"version": "0.2.0",
"dependencies": {
"@fontsource/space-grotesk": "^5.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"autoprefixer": "^10.4.17",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.36.0",
"lucide-react": "^0.316.0",
"next": "^16.1.3",
"postcss": "^8.4.33",
@@ -87,6 +89,7 @@
"tailwind-merge": "^3.4.0",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"wavesurfer.js": "^7.12.2",
},
"devDependencies": {
"@types/node": "^20.11.5",
@@ -97,7 +100,7 @@
},
"tauri": {
"name": "@voicebox/tauri",
"version": "0.1.13",
"version": "0.2.0",
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
@@ -120,7 +123,7 @@
},
"web": {
"name": "@voicebox/web",
"version": "0.1.13",
"version": "0.2.0",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"react": "^18.3.0",
@@ -274,6 +277,8 @@
"@floating-ui/utils": ["@floating-ui/[email protected]", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@fontsource/space-grotesk": ["@fontsource/[email protected]", "", {}, "sha512-XNXEbT74OIITPqw2H6HXwPDp85fy43uxfBwFR5PU+9sLnjuLj12KlhVM9nZVN6q6dlKjkuN8JisW/OBxwxgUew=="],
"@hookform/resolvers": ["@hookform/[email protected]", "", { "peerDependencies": { "react-hook-form": "^7.0.0" } }, "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag=="],
"@humanwhocodes/config-array": ["@humanwhocodes/[email protected]", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="],
@@ -1152,12 +1157,16 @@
"@typescript-eslint/typescript-estree/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"@voicebox/landing/framer-motion": ["[email protected]", "", { "dependencies": { "motion-dom": "^12.36.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-4PqYHAT7gev0ke0wos+PyrcFxI0HScjm3asgU8nSYa8YzJFuwgIvdj3/s3ZaxLq0bUSboIn19A2WS/MHwLCvfw=="],
"@voicebox/landing/lucide-react": ["[email protected]", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, "sha512-dTmYX1H4IXsRfVcj/KUxworV6814ApTl7iXaS21AimK2RUEl4j4AfOmqD3VR8phe5V91m4vEJ8tCK4uT1jE5nA=="],
"@voicebox/landing/tailwind-merge": ["[email protected]", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"@voicebox/landing/tailwindcss": ["[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"@voicebox/landing/wavesurfer.js": ["[email protected]", "", {}, "sha512-akVYISAHCw2gNw/7n8Pk/zH1Zz91WJyL/2MaNQCLD1XV3A226gKlWoDHWp9UdWqQ3zXnWttDf9ewZQQ3cxbOmQ=="],
"chokidar/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -1169,5 +1178,9 @@
"tinyglobby/picomatch": ["[email protected]", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"@voicebox/landing/framer-motion/motion-dom": ["[email protected]", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-Ep1pq8P88rGJ75om8lTCA13zqd7ywPGwCqwuWwin6BKc0hMLkVfcS6qKlRqEo2+t0DwoUcgGJfXwaiFn4AOcQA=="],
"@voicebox/landing/framer-motion/motion-utils": ["[email protected]", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
}
}
+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
+4 -1
View File
@@ -9,11 +9,13 @@
"lint": "next lint"
},
"dependencies": {
"@fontsource/space-grotesk": "^5.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"autoprefixer": "^10.4.17",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.36.0",
"lucide-react": "^0.316.0",
"next": "^16.1.3",
"postcss": "^8.4.33",
@@ -21,7 +23,8 @@
"react-dom": "^18.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"wavesurfer.js": "^7.12.2"
},
"devDependencies": {
"@types/node": "^20.11.5",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

+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 });
}
}
+88 -23
View File
@@ -16,7 +16,7 @@
--secondary-foreground: 0 0% 0%;
--muted: 0 0% 96%;
--muted-foreground: 0 0% 45%;
--accent: 0 0% 96%;
--accent: 43 50% 50%;
--accent-foreground: 0 0% 0%;
--destructive: 0 0% 0%;
--destructive-foreground: 0 0% 100%;
@@ -27,26 +27,52 @@
}
.dark {
--background: 0 0% 3%;
--foreground: 0 0% 98%;
--card: 0 0% 8% / 0.6;
--card-foreground: 0 0% 98%;
--popover: 0 0% 8% / 0.8;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 8%;
--secondary: 0 0% 12% / 0.5;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 12% / 0.4;
--muted-foreground: 0 0% 65%;
--accent: 0 0% 15% / 0.5;
--accent-foreground: 0 0% 98%;
/* Surfaces -- slightly warm-tinted darks */
--background: 30 4% 4%;
--foreground: 30 10% 94%;
--card: 30 4% 7%;
--card-foreground: 30 10% 94%;
--popover: 30 4% 7%;
--popover-foreground: 30 10% 94%;
--primary: 30 10% 94%;
--primary-foreground: 30 4% 7%;
--secondary: 30 4% 10%;
--secondary-foreground: 30 10% 94%;
--muted: 30 3% 12%;
--muted-foreground: 30 5% 55%;
--accent: 43 50% 45%;
--accent-foreground: 30 10% 94%;
--destructive: 0 62% 50%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 15% / 0.5;
--input: 0 0% 15% / 0.5;
--ring: 0 0% 98% / 0.2;
--radius: 1rem;
--destructive-foreground: 30 10% 94%;
--border: 30 4% 13%;
--input: 30 4% 13%;
--ring: 30 10% 94% / 0.2;
--radius: 0.75rem;
/* App-specific surface tokens */
--app: 30 4% 4%;
--app-box: 30 4% 7%;
--app-dark-box: 30 4% 5%;
--app-darker-box: 30 4% 3%;
--app-light-box: 30 4% 14%;
--app-line: 30 4% 13%;
--app-button: 30 4% 11%;
--app-hover: 30 4% 15%;
--app-selected: 30 4% 17%;
/* Text hierarchy */
--ink: 30 10% 94%;
--ink-dull: 30 5% 55%;
--ink-faint: 30 3% 38%;
/* Accent shades */
--accent-faint: 43 45% 55%;
--accent-deep: 43 55% 35%;
--accent-glow: 43 60% 50%;
/* Sidebar */
--sidebar: 30 4% 3%;
--sidebar-line: 30 4% 10%;
}
}
@@ -60,9 +86,9 @@
body {
@apply bg-background text-foreground antialiased;
overflow-x: hidden;
background-image:
radial-gradient(at 0% 0%, rgba(255, 255, 255, 0.03) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(255, 255, 255, 0.02) 0px, transparent 50%);
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
}
}
@@ -71,3 +97,42 @@
text-wrap: balance;
}
}
/* Staggered fade-in animation for hero elements */
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
opacity: 0;
animation: fadeUp 0.6s ease-out forwards;
}
/* Noise texture overlay for hero glow */
/* .hero-glow::after {
content: "";
position: absolute;
inset: 0;
z-index: 5;
pointer-events: none;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2048' height='2048'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.5' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E") center / 100% 100% no-repeat;
opacity: 0.35;
mix-blend-mode: overlay;
will-change: transform;
} */
/* Scrollbar hiding */
::-webkit-scrollbar {
display: none;
}
* {
scrollbar-width: none;
}
+23 -20
View File
@@ -1,17 +1,19 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Banner } from '@/components/Banner';
import { Footer } from '@/components/Footer';
import { Header } from '@/components/Header';
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
export const metadata: Metadata = {
title: 'Voicebox - Open Source Voice Cloning Desktop App Powered by Qwen3-TTS',
title: 'Voicebox - Open Source Voice Cloning Desktop App',
description:
'Near-perfect voice cloning powered by Qwen3-TTS. Desktop app for Mac, Windows, and Linux. Multi-sample support, smart caching, local or remote inference.',
keywords: ['voice cloning', 'TTS', 'Qwen3', 'desktop app', 'AI voice'],
'Near-perfect voice cloning with multiple TTS engines. Desktop app for Mac, Windows, and Linux. Multi-sample support, smart caching, local or remote inference.',
keywords: [
'voice cloning',
'TTS',
'multi-engine',
'desktop app',
'AI voice',
'open source',
'text to speech',
],
icons: {
icon: [
{ url: '/favicon.png', type: 'image/png' },
@@ -20,8 +22,8 @@ export const metadata: Metadata = {
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
},
openGraph: {
title: 'voicebox',
description: 'Professional voice cloning with Qwen3-TTS',
title: 'Voicebox',
description: 'Open source voice cloning. Local-first. Free forever.',
type: 'website',
url: 'https://voicebox.sh',
},
@@ -30,15 +32,16 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning className="dark">
<body className={inter.variable}>
<div className="relative min-h-screen bg-background font-sans flex flex-col">
<Banner />
<Header />
<main className="container mx-auto px-4 sm:px-6 md:px-4 flex-1 py-4 sm:py-6 md:py-0">
{children}
</main>
<Footer />
</div>
<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>
<div className="relative min-h-screen bg-background font-sans">{children}</div>
</body>
</html>
);
+296 -273
View File
@@ -1,304 +1,327 @@
'use client';
import { Cloud, Code, Cpu, Github, Shield, Zap } from 'lucide-react';
import { Github, Globe, Languages, MessageSquare, Zap } from 'lucide-react';
import Image from 'next/image';
import { useEffect, useState } from 'react';
import { ControlUI } from '@/components/ControlUI';
import { Features } from '@/components/Features';
import { Footer } from '@/components/Footer';
import { Navbar } from '@/components/Navbar';
import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons';
import { Button } from '@/components/ui/button';
import { Section } from '@/components/ui/section';
import { VoiceCreator } from '@/components/VoiceCreator';
import { DOWNLOAD_LINKS, GITHUB_REPO } from '@/lib/constants';
import type { DownloadLinks } from '@/lib/releases';
import { FeatureCard } from '../components/ui/feature-card';
export default function Home() {
const [downloadLinks, setDownloadLinks] = useState<DownloadLinks>(DOWNLOAD_LINKS);
useEffect(() => {
// Fetch latest release info
fetch('/api/releases')
.then((res) => {
if (!res.ok) {
throw new Error('Failed to fetch releases');
}
if (!res.ok) throw new Error('Failed to fetch releases');
return res.json();
})
.then((data) => {
if (data.downloadLinks) {
setDownloadLinks(data.downloadLinks);
}
if (data.downloadLinks) setDownloadLinks(data.downloadLinks);
})
.catch((error) => {
console.error('Failed to fetch release info:', error);
// Keep fallback links (releases page) on error
});
}, []);
const features = [
{
title: 'Near-Perfect Voice Cloning',
description:
"Powered by Alibaba's Qwen3-TTS model for exceptional voice quality and accuracy.",
icon: <Zap className="h-6 w-6" />,
},
{
title: 'Stories Editor',
description:
'Create multi-voice narratives with a timeline-based editor. Arrange tracks, trim clips, and mix conversations.',
icon: <Code className="h-6 w-6" />,
},
{
title: 'Multi-Sample Support',
description:
'Combine multiple voice samples for higher quality and more natural-sounding results.',
icon: <Code className="h-6 w-6" />,
},
{
title: 'Local or Remote',
description:
'Run GPU inference locally or connect to a remote machine. One-click server setup.',
icon: <Cloud className="h-6 w-6" />,
},
{
title: 'Audio Transcription',
description:
'Powered by Whisper for accurate speech-to-text. Extract reference text from voice samples automatically.',
icon: <Shield className="h-6 w-6" />,
},
{
title: 'Cross-Platform',
description: 'Available for macOS, Windows, and Linux. No Python installation required.',
icon: <Cpu className="h-6 w-6" />,
},
];
return (
<div className="space-y-12 sm:space-y-16 md:space-y-20">
{/* Hero Section */}
<section className="relative py-12 sm:py-16 md:py-20 lg:py-24">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative max-w-7xl">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12 items-start">
{/* Left side - Content */}
<div className="space-y-6 lg:pr-8">
<div className="flex lg:justify-start justify-center mb-6">
<Image
src="/voicebox-logo-2.png"
alt="Voicebox Logo"
width={1024}
height={1024}
className="w-32 sm:w-40 md:w-48 h-auto"
priority
/>
</div>
<h1 className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl font-bold leading-tight text-center lg:text-left">
Voicebox
</h1>
<p className="text-lg sm:text-xl md:text-2xl text-foreground/70 max-w-xl text-center lg:text-left mx-auto lg:mx-0">
Open source voice cloning powered by Qwen3-TTS. Create natural-sounding speech from
text with near-perfect voice replication.
</p>
<>
<Navbar />
{/* Mobile: centered screenshot above download buttons */}
<div className="flex justify-center lg:hidden my-8">
<div className="w-full max-w-2xl">
<Image
src="/assets/app-screenshot-1.webp"
alt="Voicebox Application Screenshot"
width={1920}
height={1080}
className="w-full h-auto rounded-lg shadow-lg"
priority
/>
</div>
</div>
{/* Download buttons under left content */}
<div className="space-y-4 pt-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full max-w-2xl">
<Button asChild size="lg" className="w-full px-0">
<a
href={downloadLinks.macArm}
download
className="flex items-center w-full relative"
>
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
<AppleIcon className="h-5 w-5" />
<div className="h-5 w-px bg-border" />
</div>
<span className="flex-1 text-center px-4">macOS (ARM)</span>
</a>
</Button>
<Button asChild size="lg" className="w-full px-0">
<a
href={downloadLinks.macIntel}
download
className="flex items-center w-full relative"
>
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
<AppleIcon className="h-5 w-5" />
<div className="h-5 w-px bg-border" />
</div>
<span className="flex-1 text-center px-4">macOS (Intel)</span>
</a>
</Button>
<Button asChild size="lg" className="w-full px-0">
<a
href={downloadLinks.windows}
download
className="flex items-center w-full relative"
>
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
<WindowsIcon className="h-5 w-5" />
<div className="h-5 w-px bg-border" />
</div>
<span className="flex-1 text-center px-4">Windows</span>
</a>
</Button>
<Button asChild size="lg" className="w-full px-0" disabled>
<a
href={downloadLinks.linux}
onClick={(e) => e.preventDefault()}
className="flex items-center w-full relative opacity-50 cursor-not-allowed"
title="Linux builds coming soon — Currently blocked by GitHub runner disk space limitations."
aria-label="Linux builds coming soon — Currently blocked by GitHub runner disk space limitations."
>
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
<LinuxIcon className="h-5 w-5" />
<div className="h-5 w-px bg-border" />
</div>
<span className="flex-1 text-center px-4">Linux</span>
</a>
</Button>
</div>
<Button variant="outline" size="lg" asChild className="w-full max-w-2xl">
<a href={GITHUB_REPO} target="_blank" rel="noopener noreferrer">
<Github className="h-4 w-4 mr-2" />
View on GitHub
</a>
</Button>
</div>
</div>
{/* Desktop: Large screenshot positioned off-screen */}
<div className="hidden lg:block relative">
<div className="absolute right-0 top-0 -mt-10 w-[200%] -mr-[100%]">
<Image
src="/assets/app-screenshot-1.webp"
alt="Voicebox Application Screenshot"
width={1920}
height={1080}
className="w-full h-auto"
priority
/>
</div>
</div>
</div>
{/* ── Hero Section ─────────────────────────────────────────────── */}
<section className="relative pt-32 pb-16">
{/* Background glow */}
<div className="hero-glow pointer-events-none absolute inset-0 -top-32">
<div className="absolute left-1/2 top-0 -translate-x-1/2 w-[800px] h-[600px] rounded-full bg-accent/15 blur-[150px]" />
<div className="absolute left-1/2 top-12 -translate-x-1/2 w-[500px] h-[400px] rounded-full bg-accent/10 blur-[80px]" />
</div>
</section>
{/* Screenshots Section */}
<section className="py-12 sm:py-16 md:py-20">
<div className="w-full md:w-[150%] md:-ml-[25%]">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 px-8">
<div className="w-full">
<Image
src="/assets/app-screenshot-2.webp"
alt="Voicebox Screenshot 2"
width={1920}
height={1080}
className="w-full h-auto rounded-lg shadow-lg"
/>
</div>
<div className="w-full">
<Image
src="/assets/app-screenshot-1.webp"
alt="Voicebox Screenshot 1"
width={1920}
height={1080}
className="w-full h-auto rounded-lg shadow-lg"
/>
</div>
<div className="w-full">
<Image
src="/assets/app-screenshot-3.webp"
alt="Voicebox Screenshot 3"
width={1920}
height={1080}
className="w-full h-auto rounded-lg shadow-lg"
/>
</div>
</div>
</div>
</section>
{/* Description Section */}
<section className="py-12 sm:py-16 md:py-20">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl">
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold text-center mb-6 sm:mb-8">
What is Voicebox?
</h2>
<div className="space-y-6 text-lg text-foreground/80 text-center">
<p>
Voicebox is a <strong>local-first voice cloning studio</strong> with DAW-like features
for professional voice synthesis. Think of it as a{' '}
<strong>local, free and open-source alternative to ElevenLabs</strong> download
models, clone voices, and generate speech entirely on your machine.
</p>
<p>
Unlike cloud services that lock your voice data behind subscriptions, Voicebox gives
you complete privacy, professional tools, and native performance. Download a voice
model, clone any voice from a few seconds of audio, and compose multi-voice projects
with studio-grade editing tools.
</p>
<p>
Optimized for performance with <strong>Metal acceleration on Mac</strong> and{' '}
<strong>CUDA acceleration on Windows/Linux</strong> for fast, local inference.
</p>
<p className="text-foreground/60">No Python install required.</p>
</div>
</div>
</section>
{/* Demo Video Section */}
<section className="py-12 sm:py-16 md:py-20">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold text-center mb-8 sm:mb-12">
See it in action...
</h2>
<div className="flex justify-center">
<div className="w-full max-w-5xl">
{/** biome-ignore lint/a11y/useMediaCaption: not generating captions for this, ya damn linter */}
<video
className="w-full h-auto rounded-lg shadow-lg"
controls
playsInline
preload="metadata"
poster="/assets/app-screenshot-1.webp"
>
<source
src="/voicebox-demo.webm"
type="video/webm"
aria-label="Voicebox Demo Video"
/>
Your browser does not support the video tag.
</video>
</div>
</div>
</div>
</section>
{/* Features Section */}
<Section id="features">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{features.map((feature) => (
<FeatureCard
key={feature.title}
title={feature.title}
description={feature.description}
icon={feature.icon}
<div className="relative mx-auto max-w-7xl px-6 text-center">
{/* Logo */}
<div
className="fade-in mx-auto mb-8 h-[120px] w-[120px] md:h-[160px] md:w-[160px]"
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
src="/voicebox-logo-app.webp"
alt="Voicebox"
width={160}
height={160}
className="h-full w-full object-contain mix-blend-lighten"
priority
/>
))}
</div>
{/* Headline */}
<div className="fade-in relative" style={{ animationDelay: '100ms' }}>
<h1 className="text-5xl font-bold tracking-tighter leading-[0.9] text-foreground drop-shadow-[0_16px_50px_rgba(0,0,0,0.95)] md:text-7xl lg:text-8xl">
Your voice, your machine.
</h1>
</div>
{/* Subtitle */}
<p
className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl"
style={{ animationDelay: '200ms' }}
>
Open source voice cloning studio with support for multiple TTS engines. Clone any voice,
generate natural speech, and compose multi-voice projects all running locally.
</p>
{/* CTAs */}
<div
className="fade-in mt-10 flex flex-col sm:flex-row items-center justify-center gap-4"
style={{ animationDelay: '300ms' }}
>
<a
href="#download"
className="rounded-full bg-accent px-8 py-3.5 text-sm font-semibold uppercase tracking-wider text-white shadow-[0_4px_20px_hsl(43_60%_50%/0.3),inset_0_2px_0_rgba(255,255,255,0.2),inset_0_-2px_0_rgba(0,0,0,0.1)] transition-all hover:bg-accent-faint active:shadow-[0_2px_10px_hsl(43_60%_50%/0.3),inset_0_4px_8px_rgba(0,0,0,0.3)]"
>
Download
</a>
<a
href={GITHUB_REPO}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-full border border-border/60 bg-card/40 backdrop-blur-sm px-6 py-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:border-border"
>
<Github className="h-4 w-4" />
View on GitHub
</a>
</div>
{/* Version */}
<p
className="fade-in mt-4 text-xs text-muted-foreground/50"
style={{ animationDelay: '400ms' }}
>
Free and open source &middot; macOS, Windows, Linux
</p>
</div>
</Section>
</div>
{/* ── ControlUI mockup ─────────────────────────────────────── */}
<div className="mt-16">
<ControlUI />
</div>
</section>
{/* ── Features ─────────────────────────────────────────────── */}
<Features />
{/* ── Voice Creator ────────────────────────────────────────── */}
<VoiceCreator />
{/* ── Models ─────────────────────────────────────────────────── */}
<section id="about" className="border-t border-border py-24">
<div className="mx-auto max-w-5xl px-6">
<div className="text-center mb-14">
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
Multi-Engine Architecture
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Choose the right model for every job. All models run locally on your hardware
download once, use forever.
</p>
</div>
<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>
<div className="flex flex-wrap gap-2">
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
<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>
<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>
</section>
{/* ── Download Section ─────────────────────────────────────── */}
<section id="download" className="border-t border-border py-24">
<div className="mx-auto max-w-4xl px-6">
<div className="text-center mb-12">
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
Download Voicebox
</h2>
<p className="text-muted-foreground">
Available for macOS, Windows, and Linux. No dependencies required.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl mx-auto">
{/* macOS ARM */}
<a
href={downloadLinks.macArm}
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"
>
<AppleIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
<div className="ml-4">
<div className="text-sm font-medium">macOS</div>
<div className="text-xs text-muted-foreground">Apple Silicon (ARM)</div>
</div>
</a>
{/* macOS Intel */}
<a
href={downloadLinks.macIntel}
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"
>
<AppleIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
<div className="ml-4">
<div className="text-sm font-medium">macOS</div>
<div className="text-xs text-muted-foreground">Intel (x64)</div>
</div>
</a>
{/* Windows */}
<a
href={downloadLinks.windows}
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"
>
<WindowsIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
<div className="ml-4">
<div className="text-sm font-medium">Windows</div>
<div className="text-xs text-muted-foreground">64-bit (MSI)</div>
</div>
</a>
{/* Linux */}
<a
href={downloadLinks.linux}
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 group-hover:text-foreground transition-colors" />
<div className="ml-4">
<div className="text-sm font-medium">Linux</div>
<div className="text-xs text-muted-foreground">AppImage (x64)</div>
</div>
</a>
</div>
{/* GitHub link */}
<div className="mt-6 text-center">
<a
href={`${GITHUB_REPO}/releases`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Github className="h-4 w-4" />
View all releases on GitHub
</a>
</div>
</div>
</section>
{/* ── Footer ───────────────────────────────────────────────── */}
<Footer />
</>
);
}
+889
View File
@@ -0,0 +1,889 @@
'use client';
import { motion } from 'framer-motion';
import {
AudioLines,
Box,
Download,
Mic,
MoreHorizontal,
Pencil,
Server,
Sparkles,
Speaker,
Star,
Trash2,
Volume2,
Wand2,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { LandingAudioPlayer, unlockAudioContext } from './LandingAudioPlayer';
// ─── Data ───────────────────────────────────────────────────────────────────
// Edit this section to customise all the content shown in the ControlUI demo.
interface VoiceProfile {
name: string;
description: string;
language: string;
hasEffects: boolean;
}
/** Voice profiles shown in the grid / scroll strip. Index matters — DemoScript references profiles by index. */
const PROFILES: VoiceProfile[] = [
{
name: 'Jarvis',
description: 'Dry wit, composed British AI assistant',
language: 'en',
hasEffects: true,
},
{
name: 'Samuel L. Jackson',
description: 'Commanding intensity with sharp, punchy delivery',
language: 'en',
hasEffects: true,
},
{
name: 'Bob Ross',
description: 'Gentle, soothing voice full of quiet encouragement',
language: 'en',
hasEffects: false,
},
{
name: 'Sam Altman',
description: 'Measured, thoughtful Silicon Valley cadence',
language: 'en',
hasEffects: false,
},
{
name: 'Morgan Freeman',
description: 'Rich, warm baritone with gravitas and calm authority',
language: 'en',
hasEffects: false,
},
{
name: 'Linus Tech Tips',
description: 'Enthusiastic, fast-paced tech explainer energy',
language: 'en',
hasEffects: false,
},
{
name: 'Fireship',
description: 'Rapid-fire, deadpan tech humor with zero filler',
language: 'en',
hasEffects: false,
},
{
name: 'Scarlett Johansson',
description: 'Smooth, low alto with understated warmth',
language: 'en',
hasEffects: false,
},
{
name: 'Dario Amodei',
description: 'Calm, precise articulation with academic depth',
language: 'en',
hasEffects: false,
},
{
name: 'David Attenborough',
description: 'Warm, reverent narration with wonder and precision',
language: 'en',
hasEffects: false,
},
{
name: 'Zendaya',
description: 'Relaxed, modern delivery with effortless cool',
language: 'en',
hasEffects: false,
},
{
name: 'Barack Obama',
description: 'Measured cadence with rhythmic pauses and gravitas',
language: 'en',
hasEffects: false,
},
];
/** Each entry is one cycle of the demo animation: select a profile → type text → generate → play audio. */
interface DemoStep {
profileIndex: number;
text: string;
audioUrl: string;
engine: string;
duration: string;
effect?: string;
}
const DEMO_SCRIPT: DemoStep[] = [
{
profileIndex: 0,
text: 'Sir, I have completed the analysis. Your code has twelve critical vulnerabilities, your coffee is cold, and frankly your commit messages could use some work.',
audioUrl: '/audio/jarvis.webm',
engine: 'Qwen 1.7B',
duration: '0:10',
effect: 'Robot',
},
{
profileIndex: 4,
text: "I've narrated penguins, galaxies, and the entire history of mankind. But nothing prepared me for the moment a computer learned to do my job from a five second audio clip.",
audioUrl: '/audio/morganfreeman.webm',
engine: 'Qwen 1.7B',
duration: '0:11',
effect: 'Radio',
},
{
profileIndex: 3,
text: "Open source? [laugh] What's that?",
audioUrl: '/audio/samaltman.webm',
engine: 'Chatterbox',
duration: '0:03',
},
{
profileIndex: 1,
text: "So let me get this straight. You downloaded an app, pressed a button, and now there's two of me? The world was not ready for one",
audioUrl: '/audio/samjackson.webm',
engine: 'Qwen 1.7B',
duration: '0:10',
},
{
profileIndex: 5,
text: "So we got this voice cloning software and honestly it's kind of terrifying. Like, my wife could not tell the difference. Voicebox dot s h, link in the description!",
audioUrl: '/audio/linus.webm',
engine: 'Qwen 1.7B',
duration: '0:11',
},
{
profileIndex: 6,
text: 'This is Voicebox in one hundred seconds. It clones voices locally, it runs on your GPU, and no, OpenAI cannot hear you. Lets go.',
audioUrl: '/audio/fireship.webm',
engine: 'Qwen 0.6B',
duration: '0:09',
},
];
/** History rows pre-populated on first load. Oldest first visually (array index 0 = top row). */
interface Generation {
id: number;
profileName: string;
text: string;
language: string;
engine: string;
duration: string;
timeAgo: string;
favorited: boolean;
versions: number;
}
const INITIAL_GENERATIONS: Generation[] = [
{
id: 1,
profileName: 'Morgan Freeman',
text: 'The neural pathways of human speech contain more complexity than any language model can fully capture, yet we keep pushing the boundaries of what is possible.',
language: 'en',
engine: 'Qwen 1.7B',
duration: '0:08',
timeAgo: '2 minutes ago',
favorited: true,
versions: 3,
},
{
id: 2,
profileName: 'Samuel L. Jackson',
text: 'In a world increasingly shaped by artificial intelligence, the human voice remains our most powerful tool for connection and storytelling.',
language: 'en',
engine: 'Qwen 1.7B',
duration: '0:07',
timeAgo: '15 minutes ago',
favorited: false,
versions: 1,
},
{
id: 3,
profileName: 'Jarvis',
text: 'The architecture of modern text-to-speech systems reveals an elegant interplay between transformer models and acoustic feature prediction.',
language: 'en',
engine: 'Qwen 0.6B',
duration: '0:09',
timeAgo: '1 hour ago',
favorited: false,
versions: 2,
},
{
id: 4,
profileName: 'Bob Ross',
text: 'Welcome to the next chapter. Every great story begins with a single voice, and today that voice can be yours.',
language: 'en',
engine: 'Chatterbox',
duration: '0:06',
timeAgo: '3 hours ago',
favorited: true,
versions: 1,
},
{
id: 5,
profileName: 'Linus Tech Tips',
text: 'Local inference gives you complete control over your voice data. No cloud, no subscriptions, no compromises.',
language: 'en',
engine: 'Qwen 1.7B',
duration: '0:05',
timeAgo: '5 hours ago',
favorited: false,
versions: 1,
},
];
const SIDEBAR_ITEMS = [
{ icon: Volume2, label: 'Generate' },
{ icon: AudioLines, label: 'Stories' },
{ icon: Mic, label: 'Voices' },
{ icon: Wand2, label: 'Effects' },
{ icon: Speaker, label: 'Audio' },
{ icon: Box, label: 'Models' },
{ icon: Server, label: 'Server' },
];
// ─── Phase system ───────────────────────────────────────────────────────────
type Phase = 'idle' | 'selecting' | 'typing' | 'generating' | 'complete' | 'playing';
const PHASE_DURATIONS: Record<Phase, number> = {
idle: 2500,
selecting: 800,
typing: 6000,
generating: 2800,
complete: 1200,
playing: 4000,
};
// ─── Typewriter ─────────────────────────────────────────────────────────────
function TypewriterText({ text, speed }: { text: string; speed?: number }) {
// Default: fill the typing phase duration, leaving 500ms buffer at the end
const resolvedSpeed =
speed ?? Math.max(20, Math.floor((PHASE_DURATIONS.typing - 500) / text.length));
const [displayed, setDisplayed] = useState('');
const indexRef = useRef(0);
useEffect(() => {
indexRef.current = 0;
setDisplayed('');
const interval = setInterval(() => {
indexRef.current += 1;
if (indexRef.current <= text.length) {
setDisplayed(text.slice(0, indexRef.current));
} else {
clearInterval(interval);
}
}, resolvedSpeed);
return () => clearInterval(interval);
}, [text, resolvedSpeed]);
return (
<>
{displayed}
<span className="inline-block h-3.5 w-[2px] animate-pulse bg-foreground/70 ml-[1px] align-middle" />
</>
);
}
// ─── Loading bars (simplified react-loaders replacement) ────────────────────
function LoadingBars({ mode }: { mode: 'idle' | 'generating' | 'playing' }) {
const barColor = mode !== 'idle' ? 'bg-accent' : 'bg-muted-foreground/40';
return (
<div className="flex items-center gap-[2px] h-5">
{[0, 1, 2, 3, 4].map((i) => (
<motion.div
key={`${i}-${mode}`}
className={`w-[3px] rounded-full ${barColor}`}
animate={
mode === 'generating'
? { height: ['6px', '16px', '6px'] }
: mode === 'playing'
? { height: ['8px', '14px', '4px', '12px', '8px'] }
: { height: '8px' }
}
transition={
mode === 'generating'
? { duration: 0.6, repeat: Infinity, delay: i * 0.08, ease: 'easeInOut' }
: mode === 'playing'
? { duration: 1.2, repeat: Infinity, delay: i * 0.15, ease: 'easeInOut' }
: {}
}
/>
))}
</div>
);
}
// ─── Profile Card ───────────────────────────────────────────────────────────
const ProfileCard = ({
profile,
selected,
selecting,
cardRef,
}: {
profile: VoiceProfile;
selected: boolean;
selecting: boolean;
cardRef?: React.Ref<HTMLDivElement>;
}) => {
return (
<motion.div
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'
} ${selecting && !selected ? 'opacity-60' : ''}`}
animate={selecting && selected ? { scale: [1, 1.02, 1] } : {}}
transition={{ duration: 0.3 }}
>
<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">
{profile.description}
</div>
<div className="flex items-center gap-1.5 mt-2">
<span className="text-[10px] px-1.5 py-0.5 rounded-md border border-border text-muted-foreground">
{profile.language}
</span>
{profile.hasEffects && <Sparkles className="h-3 w-3 text-accent fill-accent" />}
</div>
<div className="flex items-center gap-1 mt-auto justify-end">
<Download className="h-3.5 w-3.5 text-muted-foreground/40" />
<Pencil className="h-3.5 w-3.5 text-muted-foreground/40" />
<Trash2 className="h-3.5 w-3.5 text-muted-foreground/40" />
</div>
</motion.div>
);
};
// ─── History Row ────────────────────────────────────────────────────────────
function HistoryRow({
gen,
mode,
isNew,
}: {
gen: Generation;
mode: 'idle' | 'generating' | 'playing';
isNew: boolean;
}) {
return (
<motion.div
className={`border rounded-md transition-colors text-left w-full ${
mode === 'playing' ? 'bg-muted/70' : 'bg-card'
}`}
initial={isNew ? { opacity: 0, y: -8 } : false}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
>
<div className="flex items-stretch gap-3 h-[80px] p-2.5">
{/* Status icon */}
<div className="w-8 flex items-center justify-center shrink-0">
<LoadingBars mode={mode} />
</div>
{/* Meta info */}
<div className="flex flex-col gap-1 w-36 shrink-0 justify-center">
<div className="text-[12px] font-medium truncate">{gen.profileName}</div>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span>{gen.language}</span>
<span>{gen.engine}</span>
{mode !== 'generating' && <span>{gen.duration}</span>}
</div>
<div className="text-[10px] text-muted-foreground">
{mode === 'generating' ? (
<span className="text-accent">Generating...</span>
) : (
gen.timeAgo
)}
</div>
</div>
{/* Transcript */}
<div className="flex-1 min-w-0 flex items-center">
<div className="text-[11px] text-muted-foreground line-clamp-3 leading-relaxed">
{gen.text}
</div>
</div>
{/* Action buttons */}
<div className="flex flex-col justify-center items-center gap-0.5 shrink-0">
<button className="h-5 w-5 flex items-center justify-center rounded-sm hover:bg-muted">
<Star
className={`h-2.5 w-2.5 ${
gen.favorited ? 'text-accent fill-accent' : 'text-muted-foreground/50'
}`}
/>
</button>
{gen.versions > 1 && (
<button className="h-5 w-5 flex items-center justify-center rounded-sm hover:bg-muted">
<AudioLines className="h-2.5 w-2.5 text-muted-foreground/50" />
</button>
)}
<button className="h-5 w-5 flex items-center justify-center rounded-sm hover:bg-muted">
<MoreHorizontal className="h-2.5 w-2.5 text-muted-foreground/50" />
</button>
</div>
</div>
</motion.div>
);
}
// ─── Floating Generate Box ──────────────────────────────────────────────────
function FloatingGenerateBox({
phase,
typingText,
selectedProfile,
engine,
effect,
}: {
phase: Phase;
typingText: string;
selectedProfile: VoiceProfile | null;
engine: string;
effect?: string;
}) {
const isFocused = phase === 'typing' || phase === 'generating';
const isGenerating = phase === 'generating';
return (
<motion.div
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[1.5rem] shadow-2xl p-2.5"
animate={{
borderColor: isGenerating
? 'hsl(43 50% 45% / 0.35)'
: isFocused
? 'hsl(43 50% 45% / 0.25)'
: 'hsl(43 50% 45% / 0.15)',
}}
transition={{ duration: 0.3 }}
>
{/* Text area + generate button */}
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<motion.div
className="overflow-hidden"
animate={{ height: isFocused ? 100 : 32 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
>
<div
className="text-[12.5px] text-muted-foreground/60 px-2 py-1 leading-relaxed"
style={{ minHeight: isFocused ? 100 : 32 }}
>
{phase === 'typing' ? (
<span className="text-foreground">
<TypewriterText text={typingText} />
</span>
) : phase === 'generating' ? (
<span className="text-muted-foreground/40">{typingText}</span>
) : (
<span>
{selectedProfile
? `Generate speech using ${selectedProfile.name}...`
: 'Select a voice profile above...'}
</span>
)}
</div>
</motion.div>
</div>
{/* Generate button */}
<button className="h-8 w-8 rounded-full bg-accent flex items-center justify-center shrink-0 shadow-lg">
<Sparkles className="h-3.5 w-3.5 text-white fill-white" />
</button>
</div>
{/* Bottom selectors */}
<div className="flex items-center gap-1.5 mt-2">
<span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground">
English
</span>
<span className="text-[10px] px-2 py-1 rounded-full border border-border bg-card text-muted-foreground">
{engine}
</span>
<span
className={`text-[10px] px-2 py-1 rounded-full border flex items-center gap-1 ${
effect
? 'border-accent/30 bg-accent/10 text-accent'
: 'border-border bg-card text-muted-foreground'
}`}
>
<Sparkles className={`h-2.5 w-2.5 ${effect ? 'fill-accent' : ''}`} />
{effect || 'Effect'}
</span>
</div>
</motion.div>
);
}
// ─── Main ControlUI ─────────────────────────────────────────────────────────
export function ControlUI() {
const [phase, setPhase] = useState<Phase>('idle');
const [selectedIndex, setSelectedIndex] = useState(DEMO_SCRIPT[0].profileIndex);
const [cycle, setCycle] = useState(0);
const [newGenId, setNewGenId] = useState<number | null>(null);
const [generations, setGenerations] = useState<Generation[]>([...INITIAL_GENERATIONS]);
const [isMuted, setIsMuted] = useState(true);
const [isVisible, setIsVisible] = useState(true);
const [pageHidden, setPageHidden] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const phaseRef = useRef(phase);
const mobileCardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const desktopCardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const profileGridRef = useRef<HTMLDivElement>(null);
const [scrollLeft, setScrollLeft] = useState(0);
phaseRef.current = phase;
const step = DEMO_SCRIPT[cycle % DEMO_SCRIPT.length];
const selectedProfile = PROFILES[selectedIndex];
// Scroll to selected profile card — accounts for generate box overlay on desktop
useEffect(() => {
const isMobile = window.innerWidth < 768;
if (isMobile) {
const el = mobileCardRefs.current.get(selectedIndex);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
return;
}
// Desktop
const el = desktopCardRefs.current.get(selectedIndex);
const scrollContainer = profileGridRef.current;
if (!el || !scrollContainer) return;
const containerTop = scrollContainer.getBoundingClientRect().top;
const elTop = el.getBoundingClientRect().top;
const elRelTop = elTop - containerTop + scrollContainer.scrollTop;
const rowHeight = 145;
const generateBoxHeight = 200;
const visibleTop = scrollContainer.scrollTop;
const visibleBottom = visibleTop + scrollContainer.clientHeight - generateBoxHeight;
const elRelBottom = elRelTop + el.offsetHeight;
if (elRelTop >= visibleTop && elRelBottom <= visibleBottom) {
return;
}
const target = elRelTop - rowHeight;
scrollContainer.scrollTo({ top: Math.max(0, target), behavior: 'smooth' });
}, [selectedIndex]);
// Visibility detection
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => setIsVisible(entry.isIntersecting), {
threshold: 0,
});
if (containerRef.current) observer.observe(containerRef.current);
const handleVisibility = () => setPageHidden(document.visibilityState !== 'visible');
document.addEventListener('visibilitychange', handleVisibility);
return () => {
observer.disconnect();
document.removeEventListener('visibilitychange', handleVisibility);
};
}, []);
const paused = !isVisible || pageHidden;
// Phase cycling — `playing` phase is driven by audio finish, not a timeout
useEffect(() => {
if (paused || phase === 'playing') return;
const duration = PHASE_DURATIONS[phase];
const timer = setTimeout(() => {
console.log(
'[ControlUI] phase transition',
phase,
'→ next, cycle:',
cycle,
'step profile:',
PROFILES[step.profileIndex].name,
);
switch (phase) {
case 'idle': {
setSelectedIndex(step.profileIndex);
setPhase('selecting');
break;
}
case 'selecting':
setPhase('typing');
break;
case 'typing': {
const profile = PROFILES[step.profileIndex];
const newGen: Generation = {
id: Date.now(),
profileName: profile.name,
text: step.text,
language: profile.language,
engine: step.engine,
duration: step.duration,
timeAgo: 'just now',
favorited: false,
versions: 1,
};
setGenerations((prev) => [newGen, ...prev.slice(0, 5)]);
setNewGenId(newGen.id);
setPhase('generating');
break;
}
case 'generating':
setPhase('playing');
break;
}
}, duration);
return () => clearTimeout(timer);
}, [phase, paused, step, cycle]);
const handleAudioFinish = useCallback(() => {
if (phaseRef.current !== 'playing') return;
setPhase('idle');
setCycle((c) => c + 1);
setNewGenId(null);
}, []);
const isGenerating = phase === 'generating';
return (
<div ref={containerRef} className="relative z-20 mx-auto w-full max-w-6xl px-6">
{/* Unmute button with handwritten hint */}
<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
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"
>
{isMuted ? (
<>
<Volume2 className="h-3.5 w-3.5" />
<span>Unmute</span>
</>
) : (
<>
<Volume2 className="h-3.5 w-3.5 text-accent" />
<span>Mute</span>
</>
)}
</button>
</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] pointer-events-none select-none">
<div className="flex flex-col md:flex-row h-full">
{/* ── 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">
{/* Logo */}
<div className="mb-1">
<div
className="w-9 h-9 rounded-lg overflow-hidden"
style={{
filter:
'drop-shadow(0 0 6px hsl(43 50% 45% / 0.5)) drop-shadow(0 0 14px hsl(43 50% 45% / 0.35))',
}}
>
<img
src="/voicebox-logo-app.webp"
alt=""
className="w-full h-full object-contain"
/>
</div>
</div>
{/* Nav items */}
<div className="flex flex-col gap-2">
{SIDEBAR_ITEMS.map((item, i) => {
const Icon = item.icon;
const active = i === 0;
return (
<div
key={item.label}
className={`w-9 h-9 rounded-full flex items-center justify-center transition-all duration-200 ${
active
? 'bg-white/[0.07] text-foreground shadow-lg backdrop-blur-sm border border-white/[0.08]'
: 'text-muted-foreground/60'
}`}
>
<Icon className="h-4 w-4" />
</div>
);
})}
</div>
{/* Version */}
<div className="mt-auto text-[8px] text-muted-foreground/40">v0.2.0</div>
</div>
{/* ── Main content ──────────────────────────────────────── */}
<div className="flex-1 flex flex-col md:flex-row min-w-0 relative">
{/* Left: Profiles + Generate box */}
<div className="flex flex-col min-w-0 relative md:flex-1 md:overflow-hidden">
{/* Gradient fade overlay — sits between header and scroll content */}
<div className="hidden md:block absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-app-box to-transparent z-[1] pointer-events-none" />
{/* Header — floats above everything */}
<div className="absolute top-0 left-0 right-0 z-10 px-4 pt-4 md:pt-6 pb-2 flex items-center justify-between">
<h2 className="text-base font-bold">Voicebox</h2>
<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">
Import Voice
</button>
<button className="h-6 text-[10px] px-2.5 rounded-full bg-accent text-accent-foreground flex items-center">
Create Voice
</button>
</div>
</div>
{/* Scrollable profile cards — scrolls behind header + gradient */}
<div
ref={profileGridRef}
className="flex-1 min-h-0 md:overflow-y-auto md:pt-14 pt-12"
>
<div className="px-4">
{/* Mobile: horizontal scroll strip with edge fade */}
<div className="relative md:hidden">
{scrollLeft > 0 && (
<div className="absolute left-0 top-0 bottom-0 w-6 bg-gradient-to-r from-app-box to-transparent z-10" />
)}
<div className="absolute right-0 top-0 bottom-0 w-6 bg-gradient-to-l from-app-box to-transparent z-10" />
<div
className="flex gap-2 overflow-x-auto pb-2"
onScroll={(e) => setScrollLeft(e.currentTarget.scrollLeft)}
>
{PROFILES.map((profile, i) => (
<div
key={profile.name}
className="shrink-0 w-[140px]"
ref={(el) => {
if (el) mobileCardRefs.current.set(i, el);
}}
>
<ProfileCard
profile={profile}
selected={i === selectedIndex}
selecting={phase === 'selecting'}
/>
</div>
))}
</div>
</div>
{/* Desktop: 3-col grid */}
<div className="hidden md:grid grid-cols-3 gap-2 mt-1 pb-44">
{PROFILES.map((profile, i) => (
<ProfileCard
key={profile.name}
profile={profile}
selected={i === selectedIndex}
selecting={phase === 'selecting'}
cardRef={(el: HTMLDivElement | null) => {
if (el) desktopCardRefs.current.set(i, el);
}}
/>
))}
</div>
</div>
</div>
{/* Floating generate box — desktop: absolute overlay, mobile: inline */}
<div className="px-3 pt-2 pb-3 md:pt-0 md:absolute md:left-4 md:right-4 md:bottom-[117px] md:z-20 md:pb-0 md:px-0">
<FloatingGenerateBox
phase={phase}
typingText={step.text}
selectedProfile={selectedProfile}
engine={step.engine}
effect={step.effect}
/>
</div>
</div>
{/* Right/Below: History */}
<div className="md:w-[48%] shrink-0 flex flex-col min-w-0 border-t md:border-t-0 border-app-line">
<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">
{generations.map((gen) => {
const isThisNew = gen.id === newGenId;
const rowMode: 'idle' | 'generating' | 'playing' =
isThisNew && isGenerating
? 'generating'
: isThisNew && phase === 'playing'
? 'playing'
: 'idle';
return <HistoryRow key={gen.id} gen={gen} mode={rowMode} isNew={isThisNew} />;
})}
</div>
</div>
</div>
{/* Audio player */}
<LandingAudioPlayer
audioUrl={step.audioUrl}
title={selectedProfile.name}
playing={phase === 'playing'}
muted={isMuted}
onFinish={handleAudioFinish}
onClose={() => {}}
/>
</div>
</div>
</div>
</div>
);
}
+855
View File
@@ -0,0 +1,855 @@
'use client';
import { motion } from 'framer-motion';
import { AudioLines, Cloud, MessageSquareText, Mic, Sparkles, TextCursorInput } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
// ─── Lazy load wrapper ──────────────────────────────────────────────────────
function LazyLoad({
children,
className,
rootMargin = '200px',
}: {
children: React.ReactNode;
className?: string;
rootMargin?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ rootMargin },
);
observer.observe(el);
return () => observer.disconnect();
}, [rootMargin]);
return (
<div ref={ref} className={className}>
{visible ? children : null}
</div>
);
}
// ─── Animation: Voice Cloning ───────────────────────────────────────────────
function VoiceCloningAnimation() {
const [phase, setPhase] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setPhase((p) => (p + 1) % 3);
}, 2400);
return () => clearInterval(interval);
}, []);
const samples = ['Sample 1', 'Sample 2', 'Sample 3'];
const bars = [0.4, 0.7, 0.5, 0.9, 0.3, 0.6, 0.8, 0.4, 0.7, 0.5, 0.3, 0.6];
return (
<div className="h-40 w-full flex items-center justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4">
<div className="flex flex-col items-center gap-3 w-full max-w-[200px]">
{/* Sample pills */}
<div className="flex gap-1.5">
{samples.map((s, i) => (
<motion.div
key={s}
className="text-[9px] px-2 py-1 rounded-full border font-medium"
animate={{
borderColor: i === phase ? 'hsl(43 50% 45% / 0.5)' : 'rgba(255,255,255,0.06)',
backgroundColor: i === phase ? 'hsl(43 50% 45% / 0.08)' : 'rgba(255,255,255,0.02)',
color: i === phase ? 'hsl(43 50% 45%)' : 'rgba(255,255,255,0.4)',
}}
transition={{ duration: 0.3 }}
>
{s}
</motion.div>
))}
</div>
{/* Waveform visualization */}
<div className="flex items-center gap-[2px] h-10 w-full justify-center">
{bars.map((h, i) => (
<motion.div
key={i}
className="w-[4px] rounded-full"
animate={{
height: `${h * 100}%`,
backgroundColor: phase === 2 ? 'hsl(43 50% 45%)' : 'rgba(255,255,255,0.15)',
}}
transition={{
height: { duration: 0.6, delay: i * 0.04, ease: 'easeInOut' },
backgroundColor: { duration: 0.3 },
}}
/>
))}
</div>
{/* Result label */}
<motion.div
className="text-[9px] font-mono"
animate={{
opacity: phase === 2 ? 1 : 0.3,
color: phase === 2 ? 'hsl(43 50% 45%)' : 'rgba(255,255,255,0.3)',
}}
transition={{ duration: 0.3 }}
>
voice profile ready
</motion.div>
</div>
</div>
);
}
// ─── 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.
const WAVEFORM_BAR_COUNT = 60;
function MiniWaveform({ seed, color }: { seed: number; color: string }) {
// Deterministic pseudo-random waveform that looks like real speech audio.
// Uses layered noise at different frequencies for natural envelope + detail.
const bars = useMemo(() => {
// Seeded pseudo-random number generator (deterministic per seed)
let s = seed * 9301 + 49297;
const rand = () => {
s = (s * 16807 + 0) % 2147483647;
return s / 2147483647;
};
// Pre-generate random values
const r = Array.from({ length: WAVEFORM_BAR_COUNT }, () => rand());
return Array.from({ length: WAVEFORM_BAR_COUNT }, (_, i) => {
const t = i / WAVEFORM_BAR_COUNT;
// Slow envelope — broad amplitude shape (words / phrases)
const envelope =
0.3 +
0.35 *
Math.sin(t * Math.PI * (2 + (seed % 3))) *
Math.sin(t * Math.PI * (1.3 + seed * 0.7)) +
0.2 * Math.sin(t * Math.PI * (4.7 + seed * 1.3));
// Medium variation — syllable-level bumps
const mid = 0.15 * Math.sin(i * 0.8 + seed * 3.1) * Math.cos(i * 1.3 + seed);
// High-frequency noise — individual sample jitter
const noise = (r[i] - 0.5) * 0.25;
// Combine and clamp
const raw = envelope + mid + noise;
return Math.max(0.06, Math.min(1, raw));
});
}, [seed]);
return (
<div className="flex items-center h-full overflow-hidden">
{bars.map((h, i) => (
<div
key={`w-${seed}-${i}`}
className="shrink-0 rounded-full opacity-50"
style={{
width: 2,
marginRight: 1,
height: `${h * 100}%`,
backgroundColor: color,
}}
/>
))}
</div>
);
}
// ─── Animation: Stories Editor ───────────────────────────────────────────────
// Clip shape: id, profile, track, left (px out of 220), width (px), waveform seed
type DemoClip = { id: string; profile: string; track: number; x: number; w: number; seed: number };
const INITIAL_CLIPS: DemoClip[] = [
{ id: 'n1', profile: 'Morgan', track: 0, x: 4, w: 70, seed: 1 },
{ id: 'n2', profile: 'Morgan', track: 0, x: 135, w: 35, seed: 2 },
{ id: 'a1', profile: 'Scarlett', track: 1, x: 25, w: 40, seed: 3 },
{ id: 'a2', profile: 'Scarlett', track: 1, x: 120, w: 35, seed: 4 },
{ id: 'b1', profile: 'Jarvis', track: 2, x: 70, w: 45, seed: 5 },
];
// Timeline width the clips live inside
const TL_W = 220;
// Each action returns a new clips array (or modifies in place)
type Action = { label: string; apply: (clips: DemoClip[]) => DemoClip[] };
const ACTIONS: Action[] = [
// 0 — move Jarvis clip earlier
{ label: 'Move clip', apply: (c) => c.map((cl) => (cl.id === 'b1' ? { ...cl, x: 55 } : cl)) },
// 1 — split Morgan's first clip into two with visible gap
{
label: 'Split clip',
apply: (c) => {
// Idempotent: if n1b already exists, the split already happened
if (c.some((cl) => cl.id === 'n1b')) return c;
const clip = c.find((cl) => cl.id === 'n1');
if (!clip) return c;
const leftW = 25;
const gap = 8;
const rightW = clip.w - leftW - gap;
return [
...c.filter((cl) => cl.id !== 'n1'),
{ ...clip, w: leftW, id: 'n1' },
{
id: 'n1b',
profile: clip.profile,
track: clip.track,
x: clip.x + leftW + gap,
w: rightW,
seed: 6,
},
];
},
},
// 2 — trim Scarlett's second clip shorter
{ label: 'Trim clip', apply: (c) => c.map((cl) => (cl.id === 'a2' ? { ...cl, w: 25 } : cl)) },
// 3 — duplicate Jarvis to track 0
{
label: 'Duplicate',
apply: (c) => {
// Idempotent: if b1d already exists, the duplicate already happened
if (c.some((cl) => cl.id === 'b1d')) return c;
const clip = c.find((cl) => cl.id === 'b1');
if (!clip) return c;
return [...c, { ...clip, id: 'b1d', track: 0, x: 180, w: 35, seed: 7 }];
},
},
// 4 — reset
{ label: '', apply: () => INITIAL_CLIPS },
];
function StoriesAnimation() {
const [clips, setClips] = useState<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(() => {
const interval = setInterval(() => {
setActiveEffect((p) => (p + 1) % effects.length);
}, 2200);
return () => clearInterval(interval);
}, [effects.length]);
return (
<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">
{/* 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
className="text-[8px] px-2 py-0.5 rounded-full border font-medium"
animate={{
borderColor: i <= activeEffect ? `${fx.color}60` : 'rgba(255,255,255,0.06)',
backgroundColor: i <= activeEffect ? `${fx.color}15` : 'rgba(255,255,255,0.02)',
color: i <= activeEffect ? fx.color : 'rgba(255,255,255,0.3)',
}}
transition={{ duration: 0.3 }}
>
{fx.name}
</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>
{/* 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>
);
}
// ─── Animation: Local or Remote ─────────────────────────────────────────────
function LocalRemoteAnimation() {
const [mode, setMode] = useState(0);
const modes = ['Local GPU', 'Remote Server'];
useEffect(() => {
const interval = setInterval(() => {
setMode((p) => (p + 1) % 2);
}, 2800);
return () => clearInterval(interval);
}, []);
return (
<div className="h-40 w-full flex items-center justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4">
<div className="flex flex-col items-center gap-4 w-full max-w-[180px]">
{/* Toggle */}
<div className="flex gap-1 p-0.5 rounded-full border border-app-line bg-app-darkerBox">
{modes.map((m, i) => (
<motion.div
key={m}
className="text-[9px] px-3 py-1 rounded-full font-medium"
animate={{
backgroundColor: i === mode ? 'hsl(43 50% 45%)' : 'transparent',
color: i === mode ? 'hsl(30 10% 94%)' : 'rgba(255,255,255,0.35)',
}}
transition={{ duration: 0.25 }}
>
{m}
</motion.div>
))}
</div>
{/* Status */}
<div className="flex flex-col items-center gap-2">
<motion.div
className="w-2 h-2 rounded-full"
animate={{
backgroundColor: mode === 0 ? '#4ade80' : '#3b82f6',
boxShadow: mode === 0 ? '0 0 8px #4ade80' : '0 0 8px #3b82f6',
}}
transition={{ duration: 0.3 }}
/>
<span className="text-[9px] text-ink-faint font-mono">
{mode === 0 ? 'Metal acceleration active' : 'Connected to 192.168.1.50'}
</span>
<span className="text-[8px] text-ink-faint/60 font-mono">
{mode === 0 ? 'VRAM: 8.2 / 16.0 GB' : 'Latency: 12ms | CUDA'}
</span>
</div>
</div>
</div>
);
}
// ─── Animation: Transcription ───────────────────────────────────────────────
function TranscriptionAnimation() {
const [charIndex, setCharIndex] = useState(0);
const text = 'The quick brown fox jumps over the lazy dog near the riverbank.';
useEffect(() => {
const interval = setInterval(() => {
setCharIndex((p) => {
if (p >= text.length) return 0;
return p + 1;
});
}, 80);
return () => clearInterval(interval);
}, [text.length]);
return (
<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">
{/* Fake waveform */}
<div className="flex items-center gap-[1px] h-6 w-full max-w-[180px] justify-center">
{Array.from({ length: 30 }, (_, i) => {
const h = 0.2 + 0.8 * Math.abs(Math.sin(i * 0.5 + charIndex * 0.1));
const active = i < (charIndex / text.length) * 30;
return (
<div
key={i}
className={`w-[3px] rounded-full transition-colors duration-100 ${
active ? 'bg-accent' : 'bg-app-line'
}`}
style={{ height: `${h * 100}%` }}
/>
);
})}
</div>
{/* Transcribed text */}
<div className="text-[10px] text-ink-dull font-mono max-w-[200px] text-center leading-relaxed min-h-[32px]">
{text.slice(0, charIndex)}
{charIndex < text.length && (
<span className="inline-block w-[2px] h-3 bg-accent animate-pulse ml-[1px] align-middle" />
)}
</div>
</div>
);
}
// ─── Animation: Unlimited Length ─────────────────────────────────────────────
function UnlimitedLengthAnimation() {
const [phase, setPhase] = useState(0);
const chunks = [
'The morning sun crept over the mountains, casting long shadows across the valley below.',
'Birds stirred in the canopy, their songs weaving through the cool air like threads of gold.',
'Far below, a river wound its way through ancient stones, carrying whispers of the night.',
];
useEffect(() => {
const interval = setInterval(() => {
setPhase((p) => (p + 1) % 4); // 0-2 = processing chunks, 3 = crossfade/done
}, 2000);
return () => clearInterval(interval);
}, []);
return (
<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">
{/* Chunk pills */}
<div className="flex flex-col gap-1 w-full max-w-[220px]">
{chunks.map((chunk, i) => (
<motion.div
key={`chunk-${i}`}
className="flex items-center gap-1.5 px-2 py-1 rounded border text-[8px]"
animate={{
borderColor:
phase === 3
? '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 }}
/>
<span
className={`truncate font-mono ${
phase === 3 || i <= phase ? 'text-ink-dull' : 'text-ink-faint/50'
}`}
>
{chunk}
</span>
</motion.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>
);
}
// ─── Feature data ───────────────────────────────────────────────────────────
const FEATURES = [
{
title: 'Near-Perfect Voice Cloning',
description:
'Multiple TTS engines for exceptional voice quality. Clone any voice from a few seconds of audio with natural intonation and emotion.',
icon: Mic,
animation: VoiceCloningAnimation,
},
{
title: 'Stories Editor',
description:
'Create multi-voice narratives with a timeline-based editor. Arrange tracks, trim clips, and mix conversations between characters.',
icon: AudioLines,
animation: StoriesAnimation,
},
{
title: 'Audio Effects Pipeline',
description:
'Apply pitch shift, reverb, delay, compression, and more — then save as presets. Preview effects live and set defaults per voice profile.',
icon: Sparkles,
animation: EffectsAnimation,
},
{
title: 'Local or Remote',
description:
'Run GPU inference locally with Metal, CUDA, ROCm, Intel Arc, or DirectML — or connect to a remote machine. One-click server setup with automatic discovery.',
icon: Cloud,
animation: LocalRemoteAnimation,
},
{
title: 'Audio Transcription',
description:
'Powered by Whisper for accurate speech-to-text. Automatically extract reference text from voice samples.',
icon: MessageSquareText,
animation: TranscriptionAnimation,
},
{
title: 'Unlimited Generation Length',
description:
'Generate up to 50,000 characters in one go. Text is auto-split at sentence boundaries, generated per-chunk, and crossfaded seamlessly.',
icon: TextCursorInput,
animation: UnlimitedLengthAnimation,
},
];
// ─── Feature Card ───────────────────────────────────────────────────────────
function FeatureCard({ feature }: { feature: (typeof FEATURES)[number] }) {
const Icon = feature.icon;
const Animation = feature.animation;
return (
<div className="rounded-lg border border-app-line bg-app-darkBox overflow-hidden">
<LazyLoad>
<div className="pointer-events-none select-none">
<Animation />
</div>
</LazyLoad>
<div className="p-5">
<div className="flex items-center gap-2 mb-2">
<Icon className="h-4 w-4 text-accent" />
<h3 className="text-[15px] font-medium text-foreground">{feature.title}</h3>
</div>
<p className="text-sm leading-relaxed text-muted-foreground">{feature.description}</p>
</div>
</div>
);
}
// ─── Features Section ───────────────────────────────────────────────────────
export function Features() {
return (
<section id="features" className="border-t border-border py-24">
<div className="mx-auto max-w-7xl px-6">
<div className="mb-16 text-center">
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
Professional voice tools, zero compromise
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Everything you need to clone voices, generate speech, and produce multi-voice content
running entirely on your machine.
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{FEATURES.map((feature) => (
<FeatureCard key={feature.title} feature={feature} />
))}
</div>
</div>
</section>
);
}
+61 -24
View File
@@ -1,22 +1,33 @@
import Image from 'next/image';
import Link from 'next/link';
import { Separator } from '@/components/ui/separator';
import { GITHUB_REPO } from '@/lib/constants';
export function Footer() {
return (
<footer className="border-t border-border pt-8 sm:pt-12 pb-6 sm:pb-8 mt-12 sm:mt-16 md:mt-20">
<div className="container mx-auto px-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 sm:gap-8 mb-6 sm:mb-8">
<div>
<h3 className="font-bold text-lg mb-4">voicebox</h3>
<p className="text-muted-foreground text-sm">
Professional voice cloning powered by Qwen3-TTS. Desktop app for Mac, Windows, and
Linux.
<footer className="border-t border-border py-12">
<div className="mx-auto max-w-7xl px-6">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 mb-10">
{/* Brand */}
<div className="md:col-span-1">
<div className="flex items-center gap-2.5 mb-4">
<Image
src="/voicebox-logo-app.webp"
alt="Voicebox"
width={24}
height={24}
className="h-6 w-6"
/>
<span className="text-sm font-semibold">Voicebox</span>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
Open source voice cloning studio. Local-first, free forever.
</p>
</div>
{/* Product */}
<div>
<h4 className="font-semibold mb-3">Product</h4>
<ul className="space-y-2 text-muted-foreground text-sm">
<h4 className="text-sm font-semibold mb-3">Product</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
<a href="#features" className="hover:text-foreground transition-colors">
Features
@@ -28,20 +39,17 @@ export function Footer() {
</a>
</li>
<li>
<Link
href={GITHUB_REPO}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
GitHub
</Link>
<a href="#about" className="hover:text-foreground transition-colors">
About
</a>
</li>
</ul>
</div>
{/* Resources */}
<div>
<h4 className="font-semibold mb-3">Resources</h4>
<ul className="space-y-2 text-muted-foreground text-sm">
<h4 className="text-sm font-semibold mb-3">Resources</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
<Link
href={GITHUB_REPO}
@@ -74,10 +82,39 @@ export function Footer() {
</li>
</ul>
</div>
{/* Also by */}
<div>
<h4 className="text-sm font-semibold mb-3">Also By</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
<a
href="https://spacebot.sh"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Spacebot
</a>
</li>
<li>
<a
href="https://spacedrive.com"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Spacedrive
</a>
</li>
</ul>
</div>
</div>
<Separator className="my-8" />
<div className="text-center text-muted-foreground text-sm space-y-2">
<p>© 2026 voicebox. All rights reserved.</p>
<div className="border-t border-border pt-6">
<p className="text-center text-sm text-muted-foreground">
&copy; {new Date().getFullYear()} Voicebox. Open source under MIT license.
</p>
</div>
</div>
</footer>
@@ -0,0 +1,296 @@
'use client';
import { Pause, Play, Repeat, Volume2, VolumeX } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
// Shared ref so the unmute button can unlock WaveSurfer's audio on iOS Safari
// Must call .play() on WaveSurfer's actual media element during a user gesture
let sharedWaveSurfer: WaveSurfer | null = null;
let audioUnlocked = false;
export function unlockAudioContext() {
if (audioUnlocked) return;
audioUnlocked = true;
// Unlock WaveSurfer's internal audio element
if (sharedWaveSurfer) {
const media = sharedWaveSurfer.getMediaElement();
if (media) {
media.muted = true;
media
.play()
.then(() => {
media.pause();
media.muted = false;
media.currentTime = 0;
})
.catch(() => {});
}
}
// Also unlock a standalone AudioContext as fallback
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);
} catch {
// Silently fail
}
}
interface LandingAudioPlayerProps {
audioUrl: string;
title: string;
playing: boolean;
muted: boolean;
onFinish: () => void;
onClose: () => void;
}
export function LandingAudioPlayer({
audioUrl,
title,
playing,
muted,
onFinish,
onClose,
}: LandingAudioPlayerProps) {
const waveformRef = useRef<HTMLDivElement>(null);
const wavesurferRef = useRef<WaveSurfer | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(0.75);
const [isLooping, setIsLooping] = useState(false);
const [isReady, setIsReady] = useState(false);
const onFinishRef = useRef(onFinish);
onFinishRef.current = onFinish;
const playingRef = useRef(playing);
playingRef.current = playing;
const mutedRef = useRef(muted);
mutedRef.current = muted;
// Initialize WaveSurfer
useEffect(() => {
const initWaveSurfer = () => {
const container = waveformRef.current;
if (!container) {
setTimeout(initWaveSurfer, 50);
return;
}
const rect = container.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
setTimeout(initWaveSurfer, 50);
return;
}
// Clean up existing instance
if (wavesurferRef.current) {
wavesurferRef.current.destroy();
wavesurferRef.current = null;
}
const root = document.documentElement;
const getCSSVar = (varName: string) => {
const value = getComputedStyle(root).getPropertyValue(varName).trim();
return value ? `hsl(${value})` : '';
};
const ws = WaveSurfer.create({
container,
waveColor: getCSSVar('--muted'),
progressColor: getCSSVar('--accent'),
cursorColor: getCSSVar('--accent'),
barWidth: 2,
barRadius: 2,
height: 80,
normalize: true,
interact: true,
mediaControls: false,
});
ws.on('ready', () => {
setDuration(ws.getDuration());
ws.setVolume(mutedRef.current ? 0 : volume);
setIsReady(true);
});
ws.on('play', () => {
console.log('[Player] play event');
setIsPlaying(true);
});
ws.on('pause', () => {
console.log('[Player] pause event');
setIsPlaying(false);
});
ws.on('timeupdate', (time: number) => {
setCurrentTime(Math.min(time, ws.getDuration()));
});
let didFinish = false;
ws.on('finish', () => {
if (didFinish) return;
didFinish = true;
console.log(
'[Player] finish event, currentTime:',
ws.getCurrentTime(),
'duration:',
ws.getDuration(),
);
setIsPlaying(false);
onFinishRef.current();
});
ws.load(audioUrl);
wavesurferRef.current = ws;
sharedWaveSurfer = ws;
};
setIsReady(false);
setCurrentTime(0);
setDuration(0);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setTimeout(initWaveSurfer, 10);
});
});
return () => {
if (wavesurferRef.current) {
wavesurferRef.current.destroy();
wavesurferRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [audioUrl]);
// Respond to external play/stop signals
useEffect(() => {
const ws = wavesurferRef.current;
console.log('[Player] effect', { playing, isReady, hasWs: !!ws });
if (!ws || !isReady) return;
if (playing) {
// Resume the AudioContext first (required for iOS Safari after unlock)
const backend = ws.getMediaElement();
if (backend && 'context' in backend) {
const ctx = (backend as unknown as { context: AudioContext }).context;
if (ctx?.state === 'suspended') ctx.resume();
}
ws.play()
.then(() => {
console.log('[Player] play succeeded');
})
.catch((e: Error) => {
if (e.name === 'NotAllowedError') {
console.warn('[Player] Autoplay blocked by browser — waiting for user gesture');
} else {
console.error('[Player] play failed', e);
}
});
} else {
ws.pause();
}
}, [playing, isReady]);
// Sync volume and muted state
useEffect(() => {
if (wavesurferRef.current) {
wavesurferRef.current.setVolume(muted ? 0 : volume);
}
}, [volume, muted]);
const handlePlayPause = useCallback(() => {
if (!wavesurferRef.current) return;
wavesurferRef.current.playPause();
}, []);
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="px-4 py-3 flex flex-col md:flex-row md:items-center gap-2 md: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 */}
<button
onClick={handlePlayPause}
disabled={!isReady}
className="h-10 w-10 rounded-full bg-accent flex items-center justify-center shrink-0 disabled:opacity-50 md:order-1 shadow-lg"
>
{isPlaying ? (
<Pause className="h-5 w-5 text-accent-foreground fill-accent-foreground" />
) : (
<Play className="h-5 w-5 ml-0.5 text-accent-foreground fill-accent-foreground" />
)}
</button>
{/* Time */}
<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="text-xs">/</span>
<span className="font-mono text-xs">{formatDuration(duration)}</span>
</div>
{/* Title */}
{title && (
<div className="text-sm font-medium truncate max-w-[200px] shrink-0 hidden lg:block md:order-4">
{title}
</div>
)}
{/* Loop */}
<button
onClick={() => setIsLooping(!isLooping)}
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'
}`}
>
<Repeat className="h-4 w-4" />
</button>
{/* Volume */}
<div className="flex items-center gap-2 shrink-0 w-[140px] md:order-6 mr-3">
<button
onClick={() => setVolume(volume > 0 ? 0 : 0.75)}
className="h-8 w-8 flex items-center justify-center hover:bg-muted rounded-sm"
>
{volume > 0 ? (
<Volume2 className="h-4 w-4 text-muted-foreground" />
) : (
<VolumeX className="h-4 w-4 text-muted-foreground" />
)}
</button>
<input
type="range"
min={0}
max={100}
value={volume * 100}
onChange={(e) => setVolume(Number(e.target.value) / 100)}
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>
</div>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
'use client';
import { Github } from 'lucide-react';
import Image from 'next/image';
import { useEffect, useState } from 'react';
import { GITHUB_REPO } from '@/lib/constants';
function formatStarCount(count: number): string {
if (count >= 1000) {
const k = count / 1000;
return k % 1 === 0 ? `${k}k` : `${k.toFixed(1)}k`;
}
return count.toString();
}
export function Navbar() {
const [starCount, setStarCount] = useState<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 (
<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">
{/* Logo + wordmark */}
<a href="/" className="flex items-center gap-2.5">
<Image
src="/voicebox-logo-app.webp"
alt="Voicebox"
width={28}
height={28}
className="h-7 w-7"
/>
<span className="text-[15px] font-semibold text-foreground">Voicebox</span>
</a>
{/* Nav links */}
<div className="hidden sm:flex items-center gap-1">
<a
href="#features"
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
Features
</a>
<a
href="#about"
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
About
</a>
<a
href="#download"
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
Download
</a>
</div>
{/* GitHub star button */}
<a
href={GITHUB_REPO}
target="_blank"
rel="noopener noreferrer"
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" />
<span className="text-[13px] font-medium">Star</span>
{starCount !== null && (
<span className="border-l border-border/60 pl-2 text-[13px] font-semibold text-foreground">
{formatStarCount(starCount)}
</span>
)}
</a>
</div>
</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 pointer-events-none select-none">
<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;
const CACHE_DURATION = 1000 * 60 * 10; // 10 minutes
// Cache for star count
let cachedStarCount: number | null = null;
let starCacheTimestamp: number = 0;
/**
* Fetches the latest release from GitHub and extracts download links
*/
@@ -92,3 +96,38 @@ export async function getLatestRelease(): Promise<ReleaseInfo> {
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;
}
}
+27
View File
@@ -8,6 +8,9 @@ module.exports = {
],
theme: {
extend: {
fontFamily: {
sans: ['var(--font-sans)', 'system-ui', 'sans-serif'],
},
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
@@ -33,6 +36,9 @@ module.exports = {
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
faint: 'hsl(var(--accent-faint))',
deep: 'hsl(var(--accent-deep))',
glow: 'hsl(var(--accent-glow))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
@@ -42,6 +48,27 @@ module.exports = {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
// App surface tokens
app: {
DEFAULT: 'hsl(var(--app))',
box: 'hsl(var(--app-box))',
darkBox: 'hsl(var(--app-dark-box))',
darkerBox: 'hsl(var(--app-darker-box))',
lightBox: 'hsl(var(--app-light-box))',
line: 'hsl(var(--app-line))',
button: 'hsl(var(--app-button))',
hover: 'hsl(var(--app-hover))',
selected: 'hsl(var(--app-selected))',
},
ink: {
DEFAULT: 'hsl(var(--ink))',
dull: 'hsl(var(--ink-dull))',
faint: 'hsl(var(--ink-faint))',
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar))',
line: 'hsl(var(--sidebar-line))',
},
},
borderRadius: {
lg: 'var(--radius)',
Binary file not shown.
Binary file not shown.