Initial commit
35
landing/.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
6
landing/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
landing/public/App.png
Normal file
|
After Width: | Height: | Size: 860 KiB |
BIN
landing/public/VoiceBoxAppScreenshot.webp
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
landing/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
landing/public/assets/app-screenshot-1.webp
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
landing/public/assets/app-screenshot-2.webp
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
landing/public/assets/app-screenshot-3.webp
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
landing/public/audio/fireship.webm
Normal file
BIN
landing/public/audio/jarvis.webm
Normal file
BIN
landing/public/audio/linus.webm
Normal file
BIN
landing/public/audio/morganfreeman.webm
Normal file
BIN
landing/public/audio/samaltman.webm
Normal file
BIN
landing/public/audio/samjackson.webm
Normal file
BIN
landing/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
landing/public/favicon.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
landing/public/og.webp
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
landing/public/tutorials/05YBqrWTLQ0.jpg
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
landing/public/tutorials/PyMx4L9mky4.jpg
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
landing/public/tutorials/RRRBxNXgeKQ.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
landing/public/tutorials/kqxqjRsdD5E.jpg
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
landing/public/tutorials/sisnzgc73zc.jpg
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
landing/public/tutorials/woQe90k7g3c.jpg
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
landing/public/voicebox-demo.webm
Normal file
BIN
landing/public/voicebox-logo-2.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
landing/public/voicebox-logo-app.webp
Normal file
|
After Width: | Height: | Size: 594 KiB |
BIN
landing/public/voicebox-logo.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
14
landing/src/app/api/releases/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
15
landing/src/app/api/stars/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
46
landing/src/app/download/[platform]/route.ts
Normal 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);
|
||||
}
|
||||
313
landing/src/app/download/page.tsx
Normal 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
@@ -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;
|
||||
}
|
||||
56
landing/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
landing/src/app/linux-install/page.tsx
Normal 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'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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
86
landing/src/app/og/page.tsx
Normal 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 × 630 — Right-click the card or screenshot at 1:1 zoom
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
474
landing/src/app/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
200
landing/src/components/ApiSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
landing/src/components/Banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
889
landing/src/components/ControlUI.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
landing/src/components/DownloadSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
855
landing/src/components/Features.tsx
Normal 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 }}
|
||||
>
|
||||
→
|
||||
</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>
|
||||
);
|
||||
}
|
||||
143
landing/src/components/Footer.tsx
Normal 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">
|
||||
© {new Date().getFullYear()} Voicebox. Open source under MIT license.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
39
landing/src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
298
landing/src/components/LandingAudioPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
landing/src/components/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
landing/src/components/PlatformIcons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
landing/src/components/TutorialsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
479
landing/src/components/VoiceCreator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
landing/src/components/ui/button.tsx
Normal 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 };
|
||||
58
landing/src/components/ui/card.tsx
Normal 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 };
|
||||
35
landing/src/components/ui/feature-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
landing/src/components/ui/hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
landing/src/components/ui/section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
landing/src/components/ui/separator.tsx
Normal 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 };
|
||||
17
landing/src/lib/constants.ts
Normal 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
@@ -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
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
81
landing/tailwind.config.js
Normal 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
@@ -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"]
|
||||
}
|
||||