Initial commit

This commit is contained in:
2026-04-24 19:18:15 +08:00
commit fbcbe08696
555 changed files with 96692 additions and 0 deletions

35
landing/.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

100
landing/README.md Normal file
View File

@@ -0,0 +1,100 @@
# Voicebox Landing Page
Landing page for voicebox.sh - a modern Next.js 16 application.
## Tech Stack
- **Next.js 16** with App Router
- **Bun** for package management
- **Tailwind CSS** with shadcn/ui components
- **TypeScript** with strict mode
- **Railway** deployment ready
## Getting Started
### Prerequisites
- Bun installed ([bun.sh](https://bun.sh))
### Installation
```bash
cd landing
bun install
```
### Development
```bash
bun run dev
```
Open [http://localhost:3000](http://localhost:3000) to view the landing page.
### Build
```bash
bun run build
```
### Production
```bash
bun run start
```
## Configuration
### Update Download Links
Edit `src/lib/constants.ts` to update:
- `LATEST_VERSION` - Current release version
- `DOWNLOAD_LINKS` - GitHub release download URLs
- `GITHUB_REPO` - Repository URL
### Update GitHub Username
Replace `USERNAME` in `src/lib/constants.ts` with your actual GitHub username.
## Deployment to Railway
1. Connect your GitHub repository to Railway
2. Railway will auto-detect `nixpacks.toml`
3. Set root directory to `landing/`
4. Railway will automatically:
- Install dependencies with `bun install`
- Build with `bun run build`
- Start with `bun run start`
5. Configure custom domain `voicebox.sh` in Railway settings
## Project Structure
```
landing/
├── src/
│ ├── app/
│ │ ├── layout.tsx # Root layout with metadata
│ │ ├── page.tsx # Landing page
│ │ └── globals.css # Global styles
│ ├── components/
│ │ ├── Header.tsx # Top navigation
│ │ ├── Footer.tsx # Footer
│ │ ├── DownloadSection.tsx # Download buttons
│ │ └── ui/ # shadcn/ui components
│ └── lib/
│ ├── utils.ts # Utility functions
│ └── constants.ts # App constants
├── public/
│ └── voicebox-logo.png # Logo asset
└── nixpacks.toml # Railway deployment config
```
## Features
- Responsive design (mobile-first)
- Dark mode by default
- SEO optimized metadata
- Download links for Mac, Windows, Linux
- Feature showcase
- Platform highlights
- GitHub integration

18
landing/components.json Normal file
View File

@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui"
}
}

12
landing/next.config.js Normal file
View File

@@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: 'standalone',
images: {
unoptimized: false,
formats: ['image/avif', 'image/webp'],
},
turbopack: {},
};
module.exports = nextConfig;

11
landing/nixpacks.toml Normal file
View File

@@ -0,0 +1,11 @@
[phases.setup]
nixPkgs = ["nodejs_20", "bun"]
[phases.install]
cmds = ["bun install"]
[phases.build]
cmds = ["bun run build"]
[start]
cmd = "bun run start"

36
landing/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "@voicebox/landing",
"version": "0.4.5",
"description": "Landing page for voicebox.sh",
"scripts": {
"dev": "next dev --turbo",
"build": "bun --bun next build",
"start": "bun --bun next start",
"lint": "next lint"
},
"dependencies": {
"@fontsource/space-grotesk": "^5.2.10",
"@icons-pack/react-simple-icons": "^13.13.0",
"@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",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"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",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"typescript": "^5.3.3"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

BIN
landing/public/App.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
landing/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
landing/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
landing/public/og.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { getLatestRelease } from '@/lib/releases';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
const releaseInfo = await getLatestRelease();
return NextResponse.json(releaseInfo);
} catch (error) {
console.error('Error fetching release info:', error);
return NextResponse.json({ error: 'Failed to fetch release information' }, { status: 500 });
}
}

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 });
}
}

View File

@@ -0,0 +1,46 @@
import { type NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
// Pretty URLs from README / docs (e.g. /download/mac-arm) are kept for
// compatibility, but we now always route through the /download page so users
// see context + a donate prompt + resources while the download kicks off.
// The page handles the actual file trigger itself — no more silent redirects
// to GitHub or direct asset URLs.
const PLATFORM_ALIAS: Record<string, string> = {
'mac-arm': 'macArm',
macArm: 'macArm',
'mac-intel': 'macIntel',
macIntel: 'macIntel',
windows: 'windows',
};
function getPublicOrigin(request: NextRequest): string {
const forwardedHost = request.headers.get('x-forwarded-host');
const forwardedProto = request.headers.get('x-forwarded-proto');
if (forwardedHost && forwardedProto) {
// Behind reverse proxies/CDNs, request.url can be an internal origin
// (for example localhost:8080). Prefer forwarded headers so redirects
// keep users on the public domain.
return `${forwardedProto}://${forwardedHost}`;
}
return new URL(request.url).origin;
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ platform: string }> },
) {
const origin = getPublicOrigin(request);
const { platform } = await params;
// No prebuilt Linux binary yet — send straight to the build-from-source page.
if (platform === 'linux') {
return NextResponse.redirect(new URL('/linux-install', origin), 307);
}
const normalized = PLATFORM_ALIAS[platform];
const target = new URL('/download', origin);
if (normalized) target.searchParams.set('platform', normalized);
return NextResponse.redirect(target, 307);
}

View File

@@ -0,0 +1,313 @@
'use client';
import {
ArrowLeft,
Bot,
Coffee,
Download as DownloadIcon,
FileText,
Github,
} from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons';
import { Button } from '@/components/ui/button';
import { DONATE_URL, GITHUB_RELEASES_PAGE, GITHUB_REPO } from '@/lib/constants';
import type { DownloadLinks } from '@/lib/releases';
type Platform = keyof DownloadLinks;
type PlatformMeta = {
key: Platform;
label: string;
description: string;
icon: React.ComponentType<{ className?: string }>;
};
const PLATFORMS: PlatformMeta[] = [
{ key: 'macArm', label: 'macOS', description: 'Apple Silicon', icon: AppleIcon },
{ key: 'macIntel', label: 'macOS', description: 'Intel (x64)', icon: AppleIcon },
{ key: 'windows', label: 'Windows', description: '64-bit (MSI)', icon: WindowsIcon },
{ key: 'linux', label: 'Linux', description: 'Build from source', icon: LinuxIcon },
];
function detectPlatform(): Platform | null {
if (typeof navigator === 'undefined') return null;
const ua = navigator.userAgent;
if (/Windows/i.test(ua)) return 'windows';
if (/Linux/i.test(ua) && !/Android/i.test(ua)) return 'linux';
if (/Mac/i.test(ua)) {
// Apple Silicon Safari reports "Intel" for compat; default to ARM since
// M-series is the majority. Users can click the Intel button if needed.
return 'macArm';
}
return null;
}
function parseQueryPlatform(search: string): Platform | null {
const params = new URLSearchParams(search);
const raw = params.get('platform');
if (!raw) return null;
// Accept both camelCase and hyphenated forms (/download/mac-arm → ?platform=mac-arm).
const normalized = raw
.toLowerCase()
.replace(/[-_\s]/g, '')
.replace('macarm', 'macArm')
.replace('macintel', 'macIntel');
const valid: Platform[] = ['macArm', 'macIntel', 'windows', 'linux'];
return (valid as string[]).includes(normalized) ? (normalized as Platform) : null;
}
export default function DownloadPage() {
const [links, setLinks] = useState<DownloadLinks | null>(null);
const [linksError, setLinksError] = useState(false);
const [platform, setPlatform] = useState<Platform | null>(null);
const [triggered, setTriggered] = useState(false);
useEffect(() => {
const fromQuery = parseQueryPlatform(window.location.search);
const resolved = fromQuery ?? detectPlatform();
// No prebuilt Linux binary yet — send Linux users to the build-from-source
// instructions instead of sitting on /download trying to trigger a
// download that doesn't exist.
if (resolved === 'linux') {
window.location.replace('/linux-install');
return;
}
setPlatform(resolved);
}, []);
useEffect(() => {
let cancelled = false;
fetch('/api/releases')
.then((r) => {
if (!r.ok) throw new Error(`releases ${r.status}`);
return r.json();
})
.then((data) => {
if (cancelled) return;
if (data.downloadLinks) setLinks(data.downloadLinks as DownloadLinks);
})
.catch(() => {
if (!cancelled) setLinksError(true);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (triggered || !links || !platform) return;
const url = links[platform];
if (!url) return;
const a = document.createElement('a');
a.href = url;
a.rel = 'noopener';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTriggered(true);
}, [triggered, links, platform]);
const activeMeta = useMemo(
() => PLATFORMS.find((p) => p.key === platform) ?? null,
[platform],
);
return (
<div className="min-h-screen bg-background">
{/* Minimal branded header */}
<header className="border-b border-border/50">
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
<Link 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>
</Link>
<Link
href="/"
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-3.5 w-3.5" />
Back to voicebox.sh
</Link>
</div>
</header>
<main className="mx-auto max-w-5xl px-6 py-16 md:py-24">
{/* Hero */}
<div className="flex flex-col md:flex-row md:items-center gap-10 md:gap-14">
<Image
src="/voicebox-logo-app.webp"
alt="Voicebox"
width={200}
height={200}
priority
className="h-32 w-32 md:h-44 md:w-44 shrink-0 drop-shadow-2xl"
/>
<div className="flex-1 min-w-0 text-center md:text-left">
{triggered ? (
<>
<h1 className="text-4xl md:text-5xl font-semibold tracking-tight text-foreground mb-4">
Your download has started.
</h1>
<p className="text-lg text-muted-foreground">
{activeMeta
? `Downloading Voicebox for ${activeMeta.label} (${activeMeta.description}). Check your downloads folder.`
: 'Check your downloads folder for Voicebox.'}
</p>
</>
) : (
<>
<h1 className="text-4xl md:text-5xl font-semibold tracking-tight text-foreground mb-4">
{linksError ? "We couldn't load the latest release." : 'Download Voicebox'}
</h1>
<p className="text-lg text-muted-foreground">
{linksError
? 'Our release server is temporarily unreachable. Please try again in a moment.'
: 'Pick your platform to get started.'}
</p>
</>
)}
</div>
</div>
{/* Platform buttons — always visible as a fallback */}
{linksError ? (
<div className="mt-12 rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 text-center">
<p className="text-sm text-muted-foreground mb-4">
If this keeps happening, you can{' '}
<a
href={`${GITHUB_RELEASES_PAGE}/latest`}
target="_blank"
rel="noopener noreferrer"
className="text-accent underline underline-offset-2 hover:text-accent/80"
>
browse releases on GitHub
</a>
{' '}and grab the build for your platform manually.
</p>
</div>
) : (
<div className="mt-12 rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6">
<h2 className="text-sm font-medium text-foreground mb-4">
{triggered ? 'Download not working?' : 'Choose your platform'}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{PLATFORMS.map((meta) => {
const isLinux = meta.key === 'linux';
const url = isLinux ? '/linux-install' : links?.[meta.key];
const isActive = meta.key === platform;
const disabled = !isLinux && !url;
return (
<a
key={meta.key}
href={url ?? '#'}
{...(isLinux ? {} : { download: true })}
aria-disabled={disabled}
onClick={(e) => {
if (disabled) e.preventDefault();
}}
className={`flex items-center rounded-xl border px-5 py-4 transition-all group ${
isActive
? 'border-accent/40 bg-accent/5 hover:border-accent/60'
: 'border-border bg-card/40 hover:border-accent/30 hover:bg-card'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<meta.icon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
<div className="ml-4 flex-1">
<div className="text-sm font-medium text-foreground">{meta.label}</div>
<div className="text-xs text-muted-foreground">{meta.description}</div>
</div>
<DownloadIcon className="h-4 w-4 text-muted-foreground/60 group-hover:text-accent transition-colors" />
</a>
);
})}
</div>
</div>
)}
{/* Donate — prominent, heartfelt, post-click context */}
<div className="mt-16 rounded-2xl border border-border bg-gradient-to-br from-card via-card/80 to-background backdrop-blur-sm p-8 md:p-10 overflow-hidden relative">
<div className="absolute top-0 right-0 w-64 h-64 bg-[#FFDD00]/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<div className="relative">
<div className="inline-flex items-center gap-2 rounded-full border border-[#FFDD00]/30 bg-[#FFDD00]/10 px-3 py-1 mb-4">
<Coffee className="h-3 w-3 text-[#FFDD00]" />
<span className="text-[11px] font-medium uppercase tracking-wider text-[#FFDD00]">
Hi from the maintainer
</span>
</div>
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground mb-4">
Jamie here Voicebox is a side project.
</h2>
<p className="text-muted-foreground leading-relaxed mb-6 max-w-2xl">
I build and maintain Voicebox in my spare time. It's completely
free, open source, runs entirely on your machine no accounts, no
cloud, no subscriptions, no upsells. If it saves you an ElevenLabs
bill or just made your day, a coffee genuinely helps me keep
shipping updates, adding new models, and fixing bugs. Every little
bit keeps the lights on.
</p>
<Button asChild size="lg" className="bg-[#FFDD00]/10 border-[#FFDD00]/30 text-[#FFDD00] hover:bg-[#FFDD00]/20 hover:border-[#FFDD00]/50">
<a href={DONATE_URL} target="_blank" rel="noopener noreferrer">
<Coffee className="h-4 w-4 mr-2" />
Buy me a coffee
</a>
</Button>
</div>
</div>
{/* Resources */}
<div className="mt-10">
<h2 className="text-sm font-medium text-foreground mb-4">While you wait</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<a
href="https://docs.voicebox.sh"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5 hover:border-accent/30 hover:bg-card transition-all group"
>
<FileText className="h-5 w-5 text-accent mb-3" />
<h3 className="text-sm font-medium text-foreground mb-1">Read the docs</h3>
<p className="text-xs text-muted-foreground leading-relaxed">
Get familiar with Voicebox setup, voice cloning, the REST API.
</p>
</a>
<a
href="https://deepwiki.com/jamiepine/voicebox"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5 hover:border-accent/30 hover:bg-card transition-all group"
>
<Bot className="h-5 w-5 text-accent mb-3" />
<h3 className="text-sm font-medium text-foreground mb-1">Got questions? Ask AI.</h3>
<p className="text-xs text-muted-foreground leading-relaxed">
DeepWiki is an AI that knows Voicebox inside-out. Ask anything.
</p>
</a>
<a
href={GITHUB_REPO}
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5 hover:border-accent/30 hover:bg-card transition-all group"
>
<Github className="h-5 w-5 text-accent mb-3" />
<h3 className="text-sm font-medium text-foreground mb-1">Source on GitHub</h3>
<p className="text-xs text-muted-foreground leading-relaxed">
Star the repo, file issues, or contribute a PR.
</p>
</a>
</div>
</div>
</main>
</div>
);
}

152
landing/src/app/globals.css Normal file
View File

@@ -0,0 +1,152 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 0%;
--card: 0 0% 100%;
--card-foreground: 0 0% 0%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 0%;
--primary: 0 0% 0%;
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 96%;
--secondary-foreground: 0 0% 0%;
--muted: 0 0% 96%;
--muted-foreground: 0 0% 45%;
--accent: 43 50% 50%;
--accent-foreground: 0 0% 0%;
--destructive: 0 0% 0%;
--destructive-foreground: 0 0% 100%;
--border: 0 0% 90%;
--input: 0 0% 90%;
--ring: 0 0% 0%;
--radius: 0.5rem;
}
.dark {
/* 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: 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%;
}
}
@layer base {
* {
@apply border-border;
}
html {
overflow-x: hidden;
}
body {
@apply bg-background text-foreground antialiased;
overflow-x: hidden;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
}
}
@layer utilities {
.text-balance {
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;
}
.hero-glow-fade {
opacity: 0;
animation: fadeIn 2s ease-out 0.3s forwards;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 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;
}

View File

@@ -0,0 +1,56 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
metadataBase: new URL('https://voicebox.sh'),
title: 'Voicebox - Open Source Voice Cloning Desktop App',
description:
'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' },
{ url: '/favicon.ico', sizes: 'any' },
],
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
},
openGraph: {
title: 'Voicebox',
description: 'Open source voice cloning. Local-first. Free forever.',
type: 'website',
url: 'https://voicebox.sh',
images: [{ url: '/og.webp', width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title: 'Voicebox',
description: 'Open source voice cloning. Local-first. Free forever.',
images: ['/og.webp'],
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning className="dark">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Caveat:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div className="relative min-h-screen bg-background font-sans">{children}</div>
</body>
</html>
);
}

View File

@@ -0,0 +1,169 @@
import type { Metadata } from 'next';
import { Footer } from '@/components/Footer';
import { Navbar } from '@/components/Navbar';
import { GITHUB_REPO } from '@/lib/constants';
export const metadata: Metadata = {
title: 'Linux Install - Voicebox',
description: 'Build Voicebox from source on Linux. Clone, setup, and build in three commands.',
};
export default function LinuxInstall() {
return (
<>
<Navbar />
<section className="relative pt-32 pb-24">
<div className="mx-auto max-w-2xl px-6">
<h1 className="text-3xl font-bold tracking-tight text-foreground">Install on Linux</h1>
<p className="mt-4 text-muted-foreground">
We&apos;re currently working through CI issues that prevent us from shipping a reliable
pre-built binary for Linux. In the meantime, building from source is straightforward and
takes just a few minutes.
</p>
<div className="mt-10 space-y-6">
{/* Prerequisites */}
<div>
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-3">
Prerequisites
</h2>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
<li>
<a
href="https://git-scm.com"
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:underline"
>
Git
</a>
</li>
<li>
<a
href="https://www.rust-lang.org/tools/install"
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:underline"
>
Rust
</a>
</li>
<li>
<a
href="https://github.com/casey/just#installation"
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:underline"
>
just
</a>{' '}
install via{' '}
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">cargo install just</code>
</li>
<li>
<a
href="https://bun.sh"
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:underline"
>
Bun
</a>
</li>
<li>
Tauri system deps {' '}
<a
href="https://v2.tauri.app/start/prerequisites/#linux"
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:underline"
>
see Tauri docs
</a>
</li>
</ul>
</div>
{/* Steps */}
<div>
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-3">
Build from source
</h2>
<div className="space-y-3">
<div className="rounded-lg border border-border bg-card/60 p-4 font-mono text-sm">
<div className="text-muted-foreground select-none"># Clone the repo</div>
<div>git clone https://github.com/jamiepine/voicebox.git</div>
<div>cd voicebox</div>
</div>
<div className="rounded-lg border border-border bg-card/60 p-4 font-mono text-sm">
<div className="text-muted-foreground select-none">
# Install all dependencies (Python venv, JS deps, etc.)
</div>
<div>just setup</div>
</div>
<div className="rounded-lg border border-border bg-card/60 p-4 font-mono text-sm">
<div className="text-muted-foreground select-none"># Build the app</div>
<div>just build</div>
</div>
</div>
<p className="mt-4 text-sm text-muted-foreground">
The built app will be in{' '}
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
tauri/src-tauri/target/release/bundle/
</code>
</p>
</div>
{/* Dev mode */}
<div>
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-3">
Or run in dev mode
</h2>
<div className="rounded-lg border border-border bg-card/60 p-4 font-mono text-sm">
<div className="text-muted-foreground select-none">
# Start the dev server with hot reload
</div>
<div>just dev</div>
</div>
</div>
</div>
{/* Links */}
<div className="mt-12 pt-8 border-t border-border flex flex-wrap gap-4 text-sm">
<a
href={GITHUB_REPO}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
GitHub Repo
</a>
<a
href={`${GITHUB_REPO}/issues`}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Report an issue
</a>
<a
href={`${GITHUB_REPO}/blob/main/CONTRIBUTING.md`}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Contributing guide
</a>
</div>
</div>
</section>
<Footer />
</>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
export default function OgPreview() {
return (
<div className="flex min-h-screen items-center justify-center bg-[#0a0a09] p-10">
{/* The card — standard OG image dimensions */}
<div
id="og"
className="relative flex items-center overflow-hidden"
style={{
width: 1200,
height: 630,
background:
'radial-gradient(ellipse 80% 70% at 50% 45%, hsla(43,60%,50%,0.12) 0%, hsla(43,60%,50%,0.04) 40%, transparent 70%), linear-gradient(180deg, hsl(30,4%,6%) 0%, hsl(30,4%,4%) 100%)',
}}
>
{/* Logo + text — left-justified, horizontal */}
<div className="relative z-10 flex items-center" style={{ paddingLeft: 20 }}>
{/* Glow behind logo */}
<div
className="pointer-events-none absolute rounded-full blur-[100px]"
style={{
width: 300,
height: 300,
top: '50%',
left: 0,
transform: 'translateY(-50%)',
background: 'hsla(43, 60%, 50%, 0.15)',
}}
/>
<img
src="/voicebox-logo-app.webp"
alt=""
className="relative shrink-0 object-contain"
style={{ width: 260, height: 260 }}
draggable={false}
/>
<div className="flex flex-col" style={{ marginLeft: -8 }}>
<h1
className="font-bold tracking-tight"
style={{
fontSize: 72,
lineHeight: 1,
color: 'hsl(30, 10%, 94%)',
}}
>
Voicebox
</h1>
<p
style={{
fontSize: 24,
lineHeight: 1.4,
marginTop: 16,
color: 'hsl(30, 5%, 55%)',
}}
>
Open source voice cloning.
<br />
Local-first. Free forever.
</p>
</div>
</div>
{/* App screenshot — right, overflowing */}
<img
src="/assets/app-screenshot-1.webp"
alt=""
className="pointer-events-none absolute top-1/2 -translate-y-1/2 z-10"
style={{
right: -300,
width: 900,
}}
draggable={false}
/>
{/* Border overlay */}
<div className="pointer-events-none absolute inset-0 ring-1 ring-inset ring-white/[0.06]" />
</div>
{/* Helper text */}
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 text-xs text-white/30">
1200 &times; 630 &mdash; Right-click the card or screenshot at 1:1 zoom
</div>
</div>
);
}

474
landing/src/app/page.tsx Normal file
View File

@@ -0,0 +1,474 @@
"use client";
import {
Github,
Globe,
Languages,
MessageSquare,
SlidersHorizontal,
Zap,
} from "lucide-react";
import {useEffect, useState} from "react";
import {ApiSection} from "@/components/ApiSection";
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 {TutorialsSection} from "@/components/TutorialsSection";
import {VoiceCreator} from "@/components/VoiceCreator";
import {GITHUB_REPO} from "@/lib/constants";
export default function Home() {
const [version, setVersion] = useState<string | null>(null);
const [totalDownloads, setTotalDownloads] = useState<number | null>(null);
useEffect(() => {
fetch("/api/releases")
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch releases");
return res.json();
})
.then((data) => {
if (data.version) setVersion(data.version);
if (data.totalDownloads != null) setTotalDownloads(data.totalDownloads);
})
.catch((error) => {
console.error("Failed to fetch release info:", error);
});
}, []);
return (
<>
<Navbar />
{/* ── Hero Section ─────────────────────────────────────────────── */}
<section className="relative pt-32 pb-16">
{/* Background glow */}
<div className="hero-glow hero-glow-fade 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>
<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"}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/voicebox-logo-app.webp"
alt="Voicebox"
className="h-full w-full object-contain"
/>
</div>
{/* Headline */}
<div className="fade-in relative" style={{animationDelay: "100ms"}}>
<h1 className="text-5xl font-bold tracking-tighter leading-[0.9] text-foreground md:text-7xl lg:text-8xl">
Clone any voice, in seconds.
</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{" "}
<b className="text-white">locally on your machine.</b>
</p>
{/* CTAs */}
<div
className="fade-in mt-10 flex flex-row items-center justify-center gap-3 sm: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 + downloads */}
<p
className="fade-in mt-4 text-xs text-muted-foreground/50"
style={{animationDelay: "400ms"}}
>
{version ?? ""}
{version && totalDownloads != null ? " \u00b7 " : ""}
{totalDownloads != null
? `${totalDownloads.toLocaleString()} downloads`
: ""}
{version || totalDownloads != null ? " \u00b7 " : ""}
macOS, Windows, Linux
</p>
</div>
{/* ── ControlUI mockup ─────────────────────────────────────── */}
<div className="mt-16">
<ControlUI />
</div>
</section>
{/* ── Features ─────────────────────────────────────────────── */}
<Features />
{/* ── Voice Creator ────────────────────────────────────────── */}
<VoiceCreator />
{/* ── Tutorials ────────────────────────────────────────────── */}
<TutorialsSection />
{/* ── API Section ──────────────────────────────────────────── */}
<ApiSection />
{/* ── 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>
{/* Qwen CustomVoice */}
<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">
Qwen CustomVoice
</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">
Nine premium preset speakers with natural-language style
control. Tell the model how to deliver "speak slowly with
warmth", "authoritative and clear" and it adapts tone,
emotion, and pace.
</p>
<div className="flex flex-wrap gap-2">
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
<SlidersHorizontal className="h-3 w-3" />
Instruct control
</span>
<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">
9 preset voices
</span>
</div>
</div>
{/* HumeAI TADA */}
<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">
TADA
</h3>
<span className="text-xs text-muted-foreground/60">
by Hume AI
</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">
3B
</span>
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
1B
</span>
</div>
</div>
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
Speech-language model with text-acoustic dual alignment. Built
for long-form generation produces 700s+ of coherent audio
without drift. Multilingual at 3B, English-focused at 1B.
</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">
Long-form coherent
</span>
</div>
</div>
{/* Kokoro 82M */}
<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">
Kokoro
</h3>
<span className="text-xs text-muted-foreground/60">
by hexgrad · Apache 2.0
</span>
</div>
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
82M
</span>
</div>
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
Tiny 82M-parameter TTS that runs at CPU realtime with negligible
VRAM. Pre-built voice styles instead of cloning pick a voice,
type, generate. Smallest footprint of any engine.
</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" />
CPU realtime
</span>
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
Preset voices
</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="/download?platform=macArm"
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="/download?platform=macIntel"
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="/download?platform=windows"
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="/linux-install"
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">
Build from source
</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 />
</>
);
}

View File

@@ -0,0 +1,200 @@
"use client";
import {AppWindow, Code2, Gamepad2, Terminal, Wrench} from "lucide-react";
type Endpoint = {
method: "POST" | "GET" | "DELETE" | "PATCH";
path: string;
label: string;
};
const ENDPOINTS: Endpoint[] = [
{method: "POST", path: "/generate", label: "Generate speech"},
{method: "POST", path: "/generate/{id}/cancel", label: "Cancel a generation"},
{method: "GET", path: "/profiles", label: "List voice profiles"},
{method: "POST", path: "/profiles", label: "Create a new profile"},
{method: "GET", path: "/models/status", label: "Model catalog & state"},
{method: "GET", path: "/history", label: "Past generations"},
{method: "GET", path: "/health", label: "Server health"},
];
const METHOD_STYLES: Record<Endpoint["method"], string> = {
POST: "bg-accent/10 text-accent border-accent/20",
GET: "bg-muted text-muted-foreground border-border",
DELETE: "bg-red-500/10 text-red-400 border-red-500/20",
PATCH: "bg-blue-500/10 text-blue-400 border-blue-500/20",
};
const CURL_SNIPPET = `curl -X POST http://127.0.0.1:17493/generate \\
-H "Content-Type: application/json" \\
-d '{
"text": "Welcome to the game, player one.",
"profile_id": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"engine": "qwen_custom_voice",
"instruct": "warm, slow, cinematic"
}' \\
--output line.wav`;
const USE_CASES = [
{
icon: Gamepad2,
title: "Games",
description:
"Generate NPC dialogue on the fly, localize characters into new languages, or ship expressive voice lines without a studio.",
},
{
icon: AppWindow,
title: "Apps & agents",
description:
"Give your app or AI agent a voice. Real-time narration, accessibility readouts, voice replies — all running on the user's machine.",
},
{
icon: Wrench,
title: "Scripts & tools",
description:
"Batch-generate audiobook chapters, automate podcast intros, or wire Voicebox into your Stream Deck. It's just a localhost URL.",
},
];
export function ApiSection() {
return (
<section id="api" className="border-t border-border py-24">
<div className="mx-auto max-w-6xl px-6">
{/* Header */}
<div className="text-center mb-14">
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/40 backdrop-blur-sm px-3 py-1 mb-4">
<Code2 className="h-3 w-3 text-accent" />
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Built-in REST API
</span>
</div>
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
Your local voice API
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Every engine you download becomes a REST endpoint on your machine.
Build apps, games, and voice tools with full programmatic control
no API keys, no rate limits, no per-character fees.
</p>
</div>
{/* Main panel: endpoints + code snippet */}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-5 mb-14">
{/* Endpoint reference */}
<div className="lg:col-span-3 rounded-xl border border-border bg-card/60 backdrop-blur-sm overflow-hidden">
<div className="flex items-center justify-between px-5 py-3 border-b border-border/60 bg-card/40">
<div className="flex items-center gap-2">
<div className="flex gap-1">
<div className="h-2 w-2 rounded-full bg-muted-foreground/30" />
<div className="h-2 w-2 rounded-full bg-muted-foreground/30" />
<div className="h-2 w-2 rounded-full bg-muted-foreground/30" />
</div>
<span className="text-xs font-medium text-foreground ml-2">
API Reference
</span>
</div>
<code className="text-[10px] bg-background border border-border px-1.5 py-0.5 rounded font-mono text-muted-foreground">
http://127.0.0.1:17493
</code>
</div>
<div className="px-5 py-4 space-y-1">
{ENDPOINTS.map((ep) => (
<div
key={`${ep.method}-${ep.path}`}
className="flex items-center gap-3 py-1.5 group"
>
<span
className={`text-[10px] font-mono font-semibold w-12 text-center rounded px-1 py-0.5 border ${METHOD_STYLES[ep.method]}`}
>
{ep.method}
</span>
<code className="text-xs font-mono text-foreground/90">
{ep.path}
</code>
<span className="text-xs text-muted-foreground/60 ml-auto">
{ep.label}
</span>
</div>
))}
</div>
<div className="border-t border-border/60 px-5 py-3 bg-card/40">
<a
href="http://127.0.0.1:17493/docs"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-accent hover:underline"
>
See the full OpenAPI reference at{" "}
<code className="font-mono">/docs</code> when Voicebox is running
</a>
</div>
</div>
{/* Code snippet */}
<div className="lg:col-span-2 rounded-xl border border-border bg-card/60 backdrop-blur-sm overflow-hidden flex flex-col">
<div className="flex items-center gap-2 px-4 py-3 border-b border-border/60 bg-card/40">
<Terminal className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-foreground">
Generate a line
</span>
<span className="ml-auto text-[10px] text-muted-foreground/50 font-mono">
curl
</span>
</div>
<pre className="flex-1 p-4 text-[11px] font-mono text-muted-foreground/90 leading-relaxed overflow-x-auto whitespace-pre">
<code>{CURL_SNIPPET}</code>
</pre>
</div>
</div>
{/* Use cases */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{USE_CASES.map((uc) => {
const Icon = uc.icon;
return (
<div
key={uc.title}
className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5 transition-colors hover:border-accent/30"
>
<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">
{uc.title}
</h3>
</div>
<p className="text-sm leading-relaxed text-muted-foreground">
{uc.description}
</p>
</div>
);
})}
</div>
{/* Bottom bar: key selling points */}
<div className="mt-10 flex flex-wrap items-center justify-center gap-x-8 gap-y-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-accent" />
No API keys
</span>
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-accent" />
No rate limits
</span>
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-accent" />
No per-character fees
</span>
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-accent" />
Works offline
</span>
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-accent" />
Your audio, your machine
</span>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,25 @@
import { ArrowRight } from 'lucide-react';
export function Banner() {
return (
<div className="bg-primary/[0.06] border-b border-border backdrop-blur-sm">
<div className="container mx-auto px-4">
<div className="flex items-center justify-center h-10 text-sm">
<a
href="https://spacebot.sh"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors group"
>
<span>
Also by the creator of Voicebox:{' '}
<strong className="text-foreground/90">Spacebot</strong>, an AI agent OS for teams.
Connect Discord, Slack, or Telegram in one click.
</span>
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
</a>
</div>
</div>
</div>
);
}

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>
);
}

View File

@@ -0,0 +1,71 @@
'use client';
import { Download, Laptop, Monitor, Terminal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { DOWNLOAD_LINKS, LATEST_VERSION } from '@/lib/constants';
export function DownloadSection() {
const downloads = [
{
platform: 'Mac',
icon: Laptop,
link: DOWNLOAD_LINKS.macArm,
description: 'macOS (Intel + Apple Silicon)',
disabled: false,
},
{
platform: 'Windows',
icon: Monitor,
link: DOWNLOAD_LINKS.windows,
description: 'Windows x64',
disabled: false,
},
{
platform: 'Linux',
icon: Terminal,
link: DOWNLOAD_LINKS.linux,
description: 'Linux AppImage',
disabled: true,
},
];
return (
<div className="space-y-6">
<div className="text-center">
<p className="text-sm text-muted-foreground mb-2">Latest Version</p>
<p className="text-2xl font-bold">{LATEST_VERSION}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
{downloads.map(({ platform, icon: Icon, link, description, disabled }) => (
<Card
key={platform}
className={`transition-all duration-200 ${
disabled
? 'opacity-50'
: 'hover:border-primary/20 hover:shadow-lg hover:shadow-primary/3 hover:-translate-y-0.5'
}`}
>
<CardContent className="p-6">
<div className="flex flex-col items-center text-center space-y-4">
<div className="p-3 rounded-xl bg-muted/50 backdrop-blur-sm border border-border">
<Icon className="h-8 w-8" />
</div>
<div>
<h3 className="text-lg font-semibold mb-1">{platform}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
<Button asChild size="lg" className="w-full" disabled={disabled}>
<a href={link} download className={disabled ? 'pointer-events-none' : ''}>
<Download className="h-4 w-4 mr-2" />
Download
</a>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

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>
);
}

View File

@@ -0,0 +1,143 @@
import { Coffee } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { DONATE_URL, GITHUB_REPO } from '@/lib/constants';
export function Footer() {
return (
<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 mb-4">
Open source voice cloning studio. Local-first, free forever.
</p>
<a
href={DONATE_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-border/60 bg-card/60 px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground hover:border-[#FFDD00]/40"
aria-label="Donate via Buy Me a Coffee"
>
<Coffee className="h-4 w-4 text-[#FFDD00]" />
<span className="text-[13px] font-medium">Donate</span>
</a>
</div>
{/* Product */}
<div>
<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
</a>
</li>
<li>
<a href="/download" className="hover:text-foreground transition-colors">
Download
</a>
</li>
<li>
<a href="#about" className="hover:text-foreground transition-colors">
About
</a>
</li>
</ul>
</div>
{/* Resources */}
<div>
<h4 className="text-sm font-semibold mb-3">Resources</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
<Link
href="https://docs.voicebox.sh"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Documentation
</Link>
</li>
<li>
<Link
href={GITHUB_REPO}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Source Code
</Link>
</li>
<li>
<Link
href={`${GITHUB_REPO}/releases`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Releases
</Link>
</li>
<li>
<Link
href={`${GITHUB_REPO}/issues`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Issues
</Link>
</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>
<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>
);
}

View File

@@ -0,0 +1,39 @@
'use client';
import { Github } from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { GITHUB_REPO } from '@/lib/constants';
export function Header() {
return (
<header className="border-b border-border bg-background/60 backdrop-blur-2xl sticky top-0 z-50 supports-[backdrop-filter]:bg-background/40">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link
href="/"
className="flex items-center gap-2 font-bold text-xl sm:text-2xl hover:opacity-80 transition-opacity tracking-tight"
>
Voicebox
</Link>
{/* Actions */}
<div className="flex items-center gap-2 sm:gap-4">
<Button variant="outline" size="sm" asChild className="hidden sm:flex">
<a href={GITHUB_REPO} target="_blank" rel="noopener noreferrer">
<Github className="h-4 w-4 mr-2" />
GitHub
</a>
</Button>
<Button variant="ghost" size="icon" asChild className="sm:hidden">
<a href={GITHUB_REPO} target="_blank" rel="noopener noreferrer" aria-label="GitHub">
<Github className="h-5 w-5" />
</a>
</Button>
</div>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,298 @@
'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
// Skip if already playing — the context is already unlocked and the
// play/pause/reset dance would destroy the active playback.
if (sharedWaveSurfer && !sharedWaveSurfer.isPlaying()) {
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>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import { Coffee, Github } from 'lucide-react';
import Image from 'next/image';
import { useEffect, useState } from 'react';
import { DONATE_URL, 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 sm:grid sm:grid-cols-3">
{/* Logo + wordmark */}
<a href="/" className="flex items-center gap-2.5 justify-self-start">
<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 - centered */}
<div className="hidden sm:flex items-center gap-1 justify-self-center">
<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"
>
Models
</a>
<a
href="#api"
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
API
</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>
<a
href="https://docs.voicebox.sh"
target="_blank"
rel="noopener noreferrer"
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
Docs
</a>
</div>
{/* Donate + GitHub star buttons */}
<div className="flex items-center gap-2 justify-self-end">
<a
href={DONATE_URL}
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-[#FFDD00]/40"
aria-label="Donate via Buy Me a Coffee"
>
<Coffee className="h-4 w-4 text-[#FFDD00]" />
<span className="text-[13px] font-medium">Donate</span>
</a>
<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>
</div>
</nav>
);
}

View File

@@ -0,0 +1,29 @@
import { SiApple, SiLinux } from '@icons-pack/react-simple-icons';
// Official brand icons via Simple Icons (apple/linux). Simple Icons drops
// Microsoft's mark due to trademark policy, so the Windows 11 flag is
// inlined from Microsoft's public brand guidance.
export function AppleIcon({ className }: { className?: string }) {
return <SiApple className={className} color="currentColor" />;
}
export function LinuxIcon({ className }: { className?: string }) {
return <SiLinux className={className} color="currentColor" />;
}
export function WindowsIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Windows"
>
<title>Windows</title>
<path d="M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4l-13.051.149M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-12.9-1.801" />
</svg>
);
}

View File

@@ -0,0 +1,145 @@
"use client";
import {Play, Youtube} from "lucide-react";
type Tutorial = {
id: string;
title: string;
author: string;
thumbnail: string;
};
const TUTORIALS: (Tutorial | null)[] = [
{
id: "sisnzgc73zc",
title: "Free AI Voice Generator on Your PC (Clones Any Voice)",
author: "Kevin Stratvert",
thumbnail: "/tutorials/sisnzgc73zc.jpg",
},
{
id: "woQe90k7g3c",
title: "NEW Voicebox DESTROYS ElevenLabs?",
author: "Julian Goldie SEO",
thumbnail: "/tutorials/woQe90k7g3c.jpg",
},
{
id: "kqxqjRsdD5E",
title: "This Open-Source TTS App Sounds Scary Good (And It's Free)",
author: "Dave Swift",
thumbnail: "/tutorials/kqxqjRsdD5E.jpg",
},
{
id: "05YBqrWTLQ0",
title: "2026年最好的声音克隆工具Voicebox完整测评从下载到API调用附速度对比",
author: "Tech指南",
thumbnail: "/tutorials/05YBqrWTLQ0.jpg",
},
{
id: "RRRBxNXgeKQ",
title: "Get Started with Voicebox: Open-Source Alternative to ElevenLabs Tutorial",
author: "StinkyScrublet",
thumbnail: "/tutorials/RRRBxNXgeKQ.jpg",
},
{
id: "PyMx4L9mky4",
title: "Free AI Voice Generator (Clones Any Voice)",
author: "mikbes",
thumbnail: "/tutorials/PyMx4L9mky4.jpg",
},
];
function TutorialCard({tutorial}: {tutorial: Tutorial}) {
return (
<a
href={`https://www.youtube.com/watch?v=${tutorial.id}`}
target="_blank"
rel="noopener noreferrer"
className="group rounded-xl border border-border bg-card/60 backdrop-blur-sm overflow-hidden transition-all hover:border-accent/30 hover:bg-card"
>
<div className="relative aspect-video overflow-hidden bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={tutorial.thumbnail}
alt={tutorial.title}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0" />
{/* Play button overlay */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-black/50 backdrop-blur-md border border-white/20 transition-all group-hover:scale-110 group-hover:bg-accent/90 group-hover:border-accent">
<Play className="h-5 w-5 text-white fill-white ml-0.5" />
</div>
</div>
{/* YouTube badge */}
<div className="absolute top-3 right-3 flex items-center gap-1 rounded bg-black/60 backdrop-blur-sm px-2 py-1">
<Youtube className="h-3 w-3 text-white" />
<span className="text-[10px] font-medium text-white uppercase tracking-wider">
YouTube
</span>
</div>
</div>
<div className="p-4">
<h3 className="text-sm font-medium text-foreground line-clamp-2 leading-snug mb-1.5 group-hover:text-accent transition-colors">
{tutorial.title}
</h3>
<p className="text-xs text-muted-foreground">{tutorial.author}</p>
</div>
</a>
);
}
function TutorialPlaceholder() {
return (
<div className="rounded-xl border border-dashed border-border/60 bg-card/30 backdrop-blur-sm overflow-hidden">
<div className="relative aspect-video overflow-hidden bg-gradient-to-br from-card via-muted/20 to-card">
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full border border-border/40 bg-card/40">
<Play className="h-5 w-5 text-muted-foreground/40 fill-muted-foreground/40 ml-0.5" />
</div>
</div>
</div>
<div className="p-4">
<div className="h-3 w-3/4 rounded bg-muted-foreground/10 mb-2" />
<div className="h-2.5 w-1/3 rounded bg-muted-foreground/10" />
<p className="text-[11px] text-muted-foreground/50 mt-3 uppercase tracking-wider">
Coming soon
</p>
</div>
</div>
);
}
export function TutorialsSection() {
return (
<section id="tutorials" className="border-t border-border py-24">
<div className="mx-auto max-w-6xl px-6">
<div className="text-center mb-14">
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/40 backdrop-blur-sm px-3 py-1 mb-4">
<Youtube className="h-3 w-3 text-accent" />
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Video tutorials
</span>
</div>
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
Learn by watching
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Walkthroughs from the community covering setup, voice cloning, and
production workflows.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{TUTORIALS.map((tutorial, i) =>
tutorial ? (
<TutorialCard key={tutorial.id} tutorial={tutorial} />
) : (
<TutorialPlaceholder key={`placeholder-${i}`} />
),
)}
</div>
</div>
</section>
);
}

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>
);
}

View File

@@ -0,0 +1,53 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-xl text-sm font-medium ring-offset-background transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default:
'bg-primary/10 backdrop-blur-sm border border-border text-primary hover:bg-primary/11 hover:border-primary/15 shadow-lg shadow-black/20 active:scale-[0.99]',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-lg shadow-destructive/20 active:scale-[0.98]',
outline:
'border border-border bg-background/50 backdrop-blur-sm hover:bg-foreground/5 transition-all active:scale-[0.99]',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/70 backdrop-blur-sm active:scale-[0.99]',
ghost:
'hover:bg-accent/30 hover:text-accent-foreground backdrop-blur-sm active:scale-[0.99]',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-lg px-3',
lg: 'h-12 rounded-xl text-base px-8 font-semibold',
icon: 'h-10 w-10 rounded-xl',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@@ -0,0 +1,58 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-2xl border border-border bg-card backdrop-blur-xl text-card-foreground shadow-lg shadow-black/20',
className,
)}
{...props}
/>
),
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6 pb-4', className)} {...props} />
),
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
),
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-4', className)} {...props} />
),
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,35 @@
import { ReactNode } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './card';
import { cn } from '@/lib/utils';
interface FeatureCardProps {
title: string;
description: string;
icon?: ReactNode;
className?: string;
}
export function FeatureCard({ title, description, icon, className }: FeatureCardProps) {
return (
<Card
className={cn(
'text-center hover:border-primary/20 hover:shadow-lg hover:shadow-primary/3 transition-all duration-200 hover:-translate-y-0.5',
className,
)}
>
<CardHeader>
{icon && (
<div className="flex justify-center mb-3">
<div className="p-3 rounded-xl bg-muted/50 backdrop-blur-sm border border-border">
{icon}
</div>
</div>
)}
<CardTitle className="text-xl sm:text-2xl">{title}</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-sm sm:text-base">{description}</CardDescription>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,46 @@
'use client';
import Image from 'next/image';
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface HeroProps {
title: ReactNode;
description: string;
actions?: ReactNode;
className?: string;
showLogo?: boolean;
}
export function Hero({ title, description, actions, className, showLogo = true }: HeroProps) {
return (
<section className={cn('relative py-12 sm:py-16 md:py-20 lg:py-24', className)}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12 items-center">
{/* Left side - Content */}
<div className="space-y-6 lg:pr-8">
{showLogo && (
<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-left">
{title}
</h1>
<p className="text-lg sm:text-xl md:text-2xl text-foreground/70 max-w-xl text-left">
{description}
</p>
</div>
{/* Right side - Actions */}
<div className="flex flex-col items-start lg:items-end gap-4">{actions}</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,29 @@
import { cn } from '@/lib/utils';
import { ReactNode } from 'react';
interface SectionProps {
children: ReactNode;
className?: string;
id?: string;
}
export function Section({ children, className, id }: SectionProps) {
return (
<section id={id} className={cn('space-y-6 sm:space-y-8', className)}>
{children}
</section>
);
}
export function SectionTitle({ children, className }: { children: ReactNode; className?: string }) {
return (
<h2
className={cn(
'text-2xl sm:text-3xl md:text-4xl font-bold text-center md:text-left',
className,
)}
>
{children}
</h2>
);
}

View File

@@ -0,0 +1,23 @@
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className,
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@@ -0,0 +1,17 @@
// Download links for voicebox releases
// These are fallback values - link to releases page if API fails
export const LATEST_VERSION = 'v0.1.0';
export const GITHUB_REPO = 'https://github.com/jamiepine/voicebox';
export const GITHUB_RELEASES_PAGE = `${GITHUB_REPO}/releases`;
export const DONATE_URL = 'https://buymeacoffee.com/jamiepine';
export const DOWNLOAD_LINKS = {
macArm: GITHUB_RELEASES_PAGE,
macIntel: GITHUB_RELEASES_PAGE,
windows: GITHUB_RELEASES_PAGE,
linux: GITHUB_RELEASES_PAGE,
} as const;
// Export function to get dynamic download links
export { getLatestRelease } from './releases';

191
landing/src/lib/releases.ts Normal file
View File

@@ -0,0 +1,191 @@
// Fetch latest release information from GitHub
export interface DownloadLinks {
macArm: string;
macIntel: string;
windows: string;
linux: string;
}
export interface ReleaseInfo {
version: string;
downloadLinks: DownloadLinks;
totalDownloads: number;
}
const GITHUB_REPO = 'jamiepine/voicebox';
const GITHUB_API_BASE = 'https://api.github.com';
// Cache for release info (in-memory cache, resets on server restart)
let cachedReleaseInfo: ReleaseInfo | null = null;
let cacheTimestamp: number = 0;
const CACHE_DURATION = 1000 * 60 * 5; // 5 minutes
// Cache for star count
let cachedStarCount: number | null = null;
let starCacheTimestamp: number = 0;
/**
* Fetches the latest release from GitHub and extracts download links
*/
export async function getLatestRelease(): Promise<ReleaseInfo> {
// Return cached data if still valid
const now = Date.now();
if (cachedReleaseInfo && now - cacheTimestamp < CACHE_DURATION) {
return cachedReleaseInfo;
}
try {
const response = await fetch(`${GITHUB_API_BASE}/repos/${GITHUB_REPO}/releases/latest`, {
cache: 'no-store',
headers: {
Accept: 'application/vnd.github.v3+json',
},
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status}`);
}
const release = await response.json();
const version = release.tag_name;
const assets = release.assets || [];
// Extract download links based on file patterns
const downloadLinks: Partial<DownloadLinks> = {};
for (const asset of assets) {
const name = asset.name.toLowerCase();
const url = asset.browser_download_url;
// Skip signature files and other non-downloadable files
if (name.endsWith('.sig') || name.endsWith('.json') || name.endsWith('.txt')) {
continue;
}
if ((name.includes('aarch64') || name.includes('arm64')) && name.endsWith('.dmg')) {
downloadLinks.macArm = url;
} else if (name.includes('x64') && name.endsWith('.dmg')) {
downloadLinks.macIntel = url;
} else if (name.endsWith('.msi')) {
downloadLinks.windows = url;
} else if (name.endsWith('.appimage') || name.endsWith('.deb')) {
downloadLinks.linux = url;
}
}
// Fetch total downloads across ALL releases
const totalDownloads = await getTotalDownloads();
// Fallback: construct URLs if not found in assets
const baseUrl = `https://github.com/${GITHUB_REPO}/releases/download/${version}`;
const releaseInfo: ReleaseInfo = {
version,
totalDownloads,
downloadLinks: {
macArm:
downloadLinks.macArm || `${baseUrl}/Voicebox_${version.replace('v', '')}_aarch64.dmg`,
macIntel:
downloadLinks.macIntel || `${baseUrl}/Voicebox_${version.replace('v', '')}_x64.dmg`,
windows:
downloadLinks.windows || `${baseUrl}/voicebox_${version.replace('v', '')}_x64_en-US.msi`,
linux: downloadLinks.linux || `${baseUrl}/voicebox_x86_64-unknown-linux-gnu.AppImage`,
},
};
// Update cache
cachedReleaseInfo = releaseInfo;
cacheTimestamp = now;
return releaseInfo;
} catch (error) {
console.error('Failed to fetch latest release:', error);
throw error;
}
}
// Cache for total download count
let cachedTotalDownloads: number | null = null;
let downloadsCacheTimestamp: number = 0;
/**
* Fetches download counts across ALL releases (paginated)
*/
async function getTotalDownloads(): Promise<number> {
const now = Date.now();
if (cachedTotalDownloads !== null && now - downloadsCacheTimestamp < CACHE_DURATION) {
return cachedTotalDownloads;
}
let total = 0;
let page = 1;
try {
while (true) {
const response = await fetch(
`${GITHUB_API_BASE}/repos/${GITHUB_REPO}/releases?per_page=100&page=${page}`,
{
cache: 'no-store',
headers: { Accept: 'application/vnd.github.v3+json' },
},
);
if (!response.ok) break;
const releases = await response.json();
if (!Array.isArray(releases) || releases.length === 0) break;
for (const release of releases) {
for (const asset of release.assets || []) {
total += asset.download_count || 0;
}
}
if (releases.length < 100) break;
page++;
}
cachedTotalDownloads = total;
downloadsCacheTimestamp = now;
} catch (error) {
console.error('Failed to fetch total downloads:', error);
if (cachedTotalDownloads !== null) return cachedTotalDownloads;
}
return total;
}
/**
* 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;
}
}

6
landing/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,81 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
fontFamily: {
sans: ['var(--font-sans)', 'system-ui', 'sans-serif'],
},
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
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))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
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)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [require('tailwindcss-animate')],
};

33
landing/tsconfig.json Normal file
View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}