chore: remove public landing application
@@ -133,7 +133,7 @@ bun run convert:assets
|
||||
This script:
|
||||
- Converts PNG → WebP (better compression, same quality)
|
||||
- Converts MOV → WebM (VP9 codec, smaller file size)
|
||||
- Processes files in `landing/public/` and `docs/public/`
|
||||
- Processes files in `docs/public/`
|
||||
- **Deletes original files** after successful conversion
|
||||
|
||||
**Requirements:** Install `webp` and `ffmpeg`:
|
||||
|
||||
@@ -21,7 +21,7 @@ COPY app/ ./app/
|
||||
COPY web/ ./web/
|
||||
|
||||
# Strip workspaces not needed for web build, and fix trailing comma
|
||||
RUN sed -i '/"tauri"/d; /"landing"/d' package.json && \
|
||||
RUN sed -i '/"tauri"/d' package.json && \
|
||||
sed -i -z 's/,\n ]/\n ]/' package.json
|
||||
RUN bun install --no-save
|
||||
# Build frontend (skip tsc — upstream has pre-existing type errors)
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://voicebox.sh">
|
||||
<img src="landing/public/assets/app-screenshot-1.webp" alt="Voicebox App Screenshot" width="800" />
|
||||
<img src="docs/public/images/readme/app-screenshot-1.webp" alt="Voicebox App Screenshot" width="800" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -56,11 +56,11 @@
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
<img src="landing/public/assets/app-screenshot-2.webp" alt="Voicebox Screenshot 2" width="800" />
|
||||
<img src="docs/public/images/readme/app-screenshot-2.webp" alt="Voicebox Screenshot 2" width="800" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="landing/public/assets/app-screenshot-3.webp" alt="Voicebox Screenshot 3" width="800" />
|
||||
<img src="docs/public/images/readme/app-screenshot-3.webp" alt="Voicebox Screenshot 3" width="800" />
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
@@ -441,7 +441,6 @@ voicebox/
|
||||
├── tauri/ # Desktop app (Tauri + Rust)
|
||||
├── web/ # Web deployment
|
||||
├── backend/ # Python FastAPI server
|
||||
├── landing/ # Marketing website
|
||||
└── scripts/ # Build & release scripts
|
||||
```
|
||||
|
||||
|
||||
@@ -73,37 +73,6 @@
|
||||
"vite": "^5.4.0",
|
||||
},
|
||||
},
|
||||
"landing": {
|
||||
"name": "@voicebox/landing",
|
||||
"version": "0.5.0",
|
||||
"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",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^0.316.0",
|
||||
"marked": "^18.0.5",
|
||||
"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",
|
||||
},
|
||||
},
|
||||
"tauri": {
|
||||
"name": "@voicebox/tauri",
|
||||
"version": "0.5.0",
|
||||
@@ -153,8 +122,6 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/[email protected]", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="],
|
||||
@@ -221,8 +188,6 @@
|
||||
|
||||
"@dnd-kit/utilities": ["@dnd-kit/[email protected]", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||
@@ -285,8 +250,6 @@
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/[email protected]", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
"@fontsource/space-grotesk": ["@fontsource/[email protected]", "", {}, "sha512-XNXEbT74OIITPqw2H6HXwPDp85fy43uxfBwFR5PU+9sLnjuLj12KlhVM9nZVN6q6dlKjkuN8JisW/OBxwxgUew=="],
|
||||
|
||||
"@hookform/resolvers": ["@hookform/[email protected]", "", { "peerDependencies": { "react-hook-form": "^7.0.0" } }, "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag=="],
|
||||
|
||||
"@humanwhocodes/config-array": ["@humanwhocodes/[email protected]", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="],
|
||||
@@ -295,58 +258,6 @@
|
||||
|
||||
"@humanwhocodes/object-schema": ["@humanwhocodes/[email protected]", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="],
|
||||
|
||||
"@icons-pack/react-simple-icons": ["@icons-pack/[email protected]", "", { "peerDependencies": { "react": "^16.13 || ^17 || ^18 || ^19" } }, "sha512-B5HhQMIpcSH4z8IZ8HFhD59CboHceKYMpPC9kAwGyKntvPdyJJv26DLu4Z1wAjcCLyrJhf11tMhiQGom9Rxb9g=="],
|
||||
|
||||
"@img/colour": ["@img/[email protected]", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/[email protected]", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
@@ -357,24 +268,6 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@next/env": ["@next/[email protected]", "", {}, "sha512-gkrXnZyxPUy0Gg6SrPQPccbNVLSP3vmW8LU5dwEttEEC1RwDivk8w4O+sZIjFvPrSICXyhQDCG+y3VmjlJf+9A=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-T8atLKuvk13XQUdVLCv1ZzMPgLPW0+DWWbHSQXs0/3TjPrKNxTmUIhOEaoEyl3Z82k8h/gEtqyuoZGv6+Ugawg=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-AKC/qVjUGUQDSPI6gESTx0xOnOPQ5gttogNS3o6bA83yiaSZJek0Am5yXy82F1KcZCx3DdOwdGPZpQCluonuxg=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-POQ65+pnYOkZNdngWfMEt7r53bzWiKkVNbjpmCt1Zb3V6lxJNXSsjwRuTQ8P/kguxDC8LRkqaL3vvsFrce4dMQ=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-3Wm0zGYVCs6qDFAiSSDL+Z+r46EdtCv/2l+UlIdMbAq9hPJBvGu/rZOeuvCaIUjbArkmXac8HnTyQPJFzFWA0Q=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-lWAYAezFinaJiD5Gv8HDidtsZdT3CDaCeqoPoJjeB57OqzvMajpIhlZFce5sCAH6VuX4mdkxCRqecCJFwfm2nQ=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-fHaIpT7x4gA6VQbdEpYUXRGyge/YbRrkG6DXM60XiBqDM2g2NcrsQaIuj375egnGFkJow4RHacgBOEsHfGbiUw=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-MCrXxrTSE7jPN1NyXJr39E+aNFBrQZtO154LoCz7n99FuKqJDekgxipoodLNWdQP7/DZ5tKMc/efybx1l159hw=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-JSVlm9MDhmTXw/sO2PE/MRj+G6XOSMZB+BcZ0a7d6KwVFZVpkHcb2okyoYFBaco6LeiL53BBklRlOrDDbOeE5w=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/[email protected]", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
@@ -517,8 +410,6 @@
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g=="],
|
||||
|
||||
"@swc/helpers": ["@swc/[email protected]", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/[email protected]", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
|
||||
@@ -643,8 +534,6 @@
|
||||
|
||||
"@voicebox/app": ["@voicebox/app@workspace:app"],
|
||||
|
||||
"@voicebox/landing": ["@voicebox/landing@workspace:landing"],
|
||||
|
||||
"@voicebox/tauri": ["@voicebox/tauri@workspace:tauri"],
|
||||
|
||||
"@voicebox/web": ["@voicebox/web@workspace:web"],
|
||||
@@ -659,26 +548,16 @@
|
||||
|
||||
"ansi-styles": ["[email protected]", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"any-promise": ["[email protected]", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
|
||||
|
||||
"anymatch": ["[email protected]", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||
|
||||
"arg": ["[email protected]", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||
|
||||
"argparse": ["[email protected]", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
"argparse": ["[email protected]", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"aria-hidden": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"array-union": ["[email protected]", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="],
|
||||
|
||||
"autoprefixer": ["[email protected]", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="],
|
||||
|
||||
"balanced-match": ["[email protected]", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA=="],
|
||||
|
||||
"binary-extensions": ["[email protected]", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"braces": ["[email protected]", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
@@ -687,28 +566,20 @@
|
||||
|
||||
"callsites": ["[email protected]", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"camelcase-css": ["[email protected]", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
||||
|
||||
"caniuse-lite": ["[email protected]", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="],
|
||||
|
||||
"chalk": ["[email protected]", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"chokidar": ["[email protected]", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"class-variance-authority": ["[email protected]", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"classnames": ["[email protected]", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
|
||||
|
||||
"client-only": ["[email protected]", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"color-convert": ["[email protected]", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["[email protected]", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"commander": ["[email protected]", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||
|
||||
"concat-map": ["[email protected]", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
@@ -717,8 +588,6 @@
|
||||
|
||||
"cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"cssesc": ["[email protected]", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"date-fns": ["[email protected]", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
|
||||
@@ -731,12 +600,8 @@
|
||||
|
||||
"detect-node-es": ["[email protected]", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"didyoumean": ["[email protected]", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||
|
||||
"dir-glob": ["[email protected]", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
|
||||
|
||||
"dlv": ["[email protected]", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||
|
||||
"doctrine": ["[email protected]", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="],
|
||||
|
||||
"electron-to-chromium": ["[email protected]", "", {}, "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw=="],
|
||||
@@ -761,8 +626,6 @@
|
||||
|
||||
"espree": ["[email protected]", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="],
|
||||
|
||||
"esprima": ["[email protected]", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"esquery": ["[email protected]", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||
|
||||
"esrecurse": ["[email protected]", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
@@ -771,8 +634,6 @@
|
||||
|
||||
"esutils": ["[email protected]", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"extend-shallow": ["[email protected]", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
|
||||
|
||||
"fast-deep-equal": ["[email protected]", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-glob": ["[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
@@ -783,8 +644,6 @@
|
||||
|
||||
"fastq": ["[email protected]", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||
|
||||
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-entry-cache": ["[email protected]", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="],
|
||||
|
||||
"fill-range": ["[email protected]", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
@@ -795,16 +654,12 @@
|
||||
|
||||
"flatted": ["[email protected]", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||
|
||||
"fraction.js": ["[email protected]", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||
|
||||
"framer-motion": ["[email protected]", "", { "dependencies": { "motion-dom": "^12.36.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-4PqYHAT7gev0ke0wos+PyrcFxI0HScjm3asgU8nSYa8YzJFuwgIvdj3/s3ZaxLq0bUSboIn19A2WS/MHwLCvfw=="],
|
||||
|
||||
"fs.realpath": ["[email protected]", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||
|
||||
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-nonce": ["[email protected]", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
@@ -821,12 +676,8 @@
|
||||
|
||||
"graphemer": ["[email protected]", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
|
||||
|
||||
"gray-matter": ["[email protected]", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
|
||||
|
||||
"has-flag": ["[email protected]", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"html-parse-stringify": ["[email protected]", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
|
||||
|
||||
"i18next": ["[email protected]", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg=="],
|
||||
@@ -843,12 +694,6 @@
|
||||
|
||||
"inherits": ["[email protected]", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"is-binary-path": ["[email protected]", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||
|
||||
"is-core-module": ["[email protected]", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||
|
||||
"is-extendable": ["[email protected]", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
|
||||
|
||||
"is-extglob": ["[email protected]", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["[email protected]", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
@@ -861,11 +706,11 @@
|
||||
|
||||
"isexe": ["[email protected]", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@3.15.0", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog=="],
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
@@ -879,8 +724,6 @@
|
||||
|
||||
"keyv": ["[email protected]", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"kind-of": ["[email protected]", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
|
||||
|
||||
"levn": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lightningcss": ["[email protected]", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
||||
@@ -907,10 +750,6 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
||||
|
||||
"lilconfig": ["[email protected]", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||
|
||||
"lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
|
||||
"loaders.css": ["[email protected]", "", {}, "sha512-Rhowlq24ey1VOeor+3wYOt9+MjaxBOJm1u4KlQgNC3+0xJ0LS4wq4iG57D/BPzvuD/7HHDGQOWJ+81oR2EI9bQ=="],
|
||||
|
||||
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
@@ -925,8 +764,6 @@
|
||||
|
||||
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
|
||||
|
||||
"merge2": ["[email protected]", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"micromatch": ["[email protected]", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
@@ -941,22 +778,14 @@
|
||||
|
||||
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"mz": ["[email protected]", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||
|
||||
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"natural-compare": ["[email protected]", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"next": ["[email protected]", "", { "dependencies": { "@next/env": "16.1.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.4", "@next/swc-darwin-x64": "16.1.4", "@next/swc-linux-arm64-gnu": "16.1.4", "@next/swc-linux-arm64-musl": "16.1.4", "@next/swc-linux-x64-gnu": "16.1.4", "@next/swc-linux-x64-musl": "16.1.4", "@next/swc-win32-arm64-msvc": "16.1.4", "@next/swc-win32-x64-msvc": "16.1.4", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-gKSecROqisnV7Buen5BfjmXAm7Xlpx9o2ueVQRo5DxQcjC8d330dOM1xiGWc2k3Dcnz0In3VybyRPOsudwgiqQ=="],
|
||||
|
||||
"node-releases": ["[email protected]", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||
|
||||
"normalize-path": ["[email protected]", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
"object-assign": ["[email protected]", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-hash": ["[email protected]", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
|
||||
|
||||
"once": ["[email protected]", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"optionator": ["[email protected]", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
@@ -973,32 +802,14 @@
|
||||
|
||||
"path-key": ["[email protected]", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-parse": ["[email protected]", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"path-type": ["[email protected]", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
|
||||
|
||||
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["[email protected]", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"pify": ["[email protected]", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
|
||||
|
||||
"pirates": ["[email protected]", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
|
||||
|
||||
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"postcss-import": ["[email protected]", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
|
||||
|
||||
"postcss-js": ["[email protected]", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
|
||||
|
||||
"postcss-load-config": ["[email protected]", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
|
||||
|
||||
"postcss-nested": ["[email protected]", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
|
||||
|
||||
"postcss-selector-parser": ["[email protected]", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||
|
||||
"postcss-value-parser": ["[email protected]", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"prop-types": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
@@ -1029,12 +840,6 @@
|
||||
|
||||
"react-style-singleton": ["[email protected]", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"read-cache": ["[email protected]", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
|
||||
|
||||
"readdirp": ["[email protected]", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"resolve": ["[email protected]", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||
|
||||
"resolve-from": ["[email protected]", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"reusify": ["[email protected]", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
@@ -1047,16 +852,12 @@
|
||||
|
||||
"scheduler": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
|
||||
"section-matter": ["[email protected]", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
|
||||
|
||||
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"seroval": ["[email protected]", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
|
||||
|
||||
"seroval-plugins": ["[email protected]", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
|
||||
|
||||
"sharp": ["[email protected]", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"shebang-command": ["[email protected]", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["[email protected]", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
@@ -1067,22 +868,12 @@
|
||||
|
||||
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"sprintf-js": ["[email protected]", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-bom-string": ["[email protected]", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
|
||||
|
||||
"strip-json-comments": ["[email protected]", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"styled-jsx": ["[email protected]", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
|
||||
"sucrase": ["[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
|
||||
|
||||
"supports-color": ["[email protected]", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["[email protected]", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"tailwind-merge": ["[email protected]", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="],
|
||||
|
||||
"tailwindcss": ["[email protected]", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||
@@ -1093,22 +884,14 @@
|
||||
|
||||
"text-table": ["[email protected]", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="],
|
||||
|
||||
"thenify": ["[email protected]", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
||||
|
||||
"thenify-all": ["[email protected]", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"tiny-invariant": ["[email protected]", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"tiny-warning": ["[email protected]", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],
|
||||
|
||||
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"to-regex-range": ["[email protected]", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"ts-api-utils": ["[email protected]", "", { "peerDependencies": { "typescript": ">=4.2.0" } }, "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw=="],
|
||||
|
||||
"ts-interface-checker": ["[email protected]", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||
|
||||
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"type-check": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
@@ -1129,8 +912,6 @@
|
||||
|
||||
"use-sync-external-store": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"util-deprecate": ["[email protected]", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"void-elements": ["[email protected]", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
|
||||
@@ -1151,8 +932,6 @@
|
||||
|
||||
"zustand": ["[email protected]", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
||||
|
||||
"@eslint/eslintrc/js-yaml": ["[email protected]", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/[email protected]", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
|
||||
@@ -1179,8 +958,6 @@
|
||||
|
||||
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
||||
"@tailwindcss/node/jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
@@ -1197,34 +974,14 @@
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"@voicebox/landing/lucide-react": ["[email protected]", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, "sha512-dTmYX1H4IXsRfVcj/KUxworV6814ApTl7iXaS21AimK2RUEl4j4AfOmqD3VR8phe5V91m4vEJ8tCK4uT1jE5nA=="],
|
||||
|
||||
"@voicebox/landing/tailwind-merge": ["[email protected]", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||
|
||||
"@voicebox/landing/tailwindcss": ["[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
|
||||
|
||||
"@voicebox/web/wavesurfer.js": ["[email protected]", "", {}, "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg=="],
|
||||
|
||||
"chokidar/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"eslint/js-yaml": ["[email protected]", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"motion/framer-motion": ["[email protected]", "", { "dependencies": { "motion-dom": "^12.29.0", "motion-utils": "^12.27.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1gEFGXHYV2BD42ZPTFmSU9buehppU+bCuOnHU0AD18DKh9j4DuTx47MvqY5ax+NNWRtK32qIcJf1UxKo1WwjWg=="],
|
||||
|
||||
"next/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"sharp/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"tinyglobby/picomatch": ["[email protected]", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"@eslint/eslintrc/js-yaml/argparse": ["[email protected]", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"eslint/js-yaml/argparse": ["[email protected]", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"motion/framer-motion/motion-dom": ["[email protected]", "", { "dependencies": { "motion-utils": "^12.27.2" } }, "sha512-3eiz9bb32yvY8Q6XNM4AwkSOBPgU//EIKTZwsSWgA9uzbPBhZJeScCVcBuwwYVqhfamewpv7ZNmVKTGp5qnzkA=="],
|
||||
|
||||
"motion/framer-motion/motion-utils": ["[email protected]", "", {}, "sha512-B55gcoL85Mcdt2IEStY5EEAsrMSVE2sI14xQ/uAdPL+mfQxhKKFaEag9JmfxedJOR4vZpBGoPeC/Gm13I/4g5Q=="],
|
||||
|
||||
@@ -188,7 +188,6 @@ When creating a pull request:
|
||||
<File name="src-tauri/" />
|
||||
</Folder>
|
||||
<File name="web/" />
|
||||
<File name="landing/" />
|
||||
<File name="scripts/" />
|
||||
</Folder>
|
||||
</Files>
|
||||
|
||||
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 114 KiB |
@@ -1,35 +0,0 @@
|
||||
# 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
|
||||
@@ -1,100 +0,0 @@
|
||||
# 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
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: 'standalone',
|
||||
images: {
|
||||
unoptimized: false,
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
},
|
||||
turbopack: {},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -1,11 +0,0 @@
|
||||
[phases.setup]
|
||||
nixPkgs = ["nodejs_20", "bun"]
|
||||
|
||||
[phases.install]
|
||||
cmds = ["bun install"]
|
||||
|
||||
[phases.build]
|
||||
cmds = ["bun run build"]
|
||||
|
||||
[start]
|
||||
cmd = "bun run start"
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"name": "@voicebox/landing",
|
||||
"version": "0.5.0",
|
||||
"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",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^0.316.0",
|
||||
"marked": "^18.0.5",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
Before Width: | Height: | Size: 860 KiB |
|
Before Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 594 KiB |
|
Before Width: | Height: | Size: 2.8 MiB |
@@ -1,14 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import {readFileSync} from "node:fs";
|
||||
import {join} from "node:path";
|
||||
import {ImageResponse} from "next/og";
|
||||
import {formatDate, getPost, loadAllPosts} from "@/lib/blog";
|
||||
|
||||
// Per-post Open Graph image, generated with Satori at build time (static export
|
||||
// of each post route) and served as PNG. Note: this runs in the Satori renderer,
|
||||
// which only understands inline styles + flexbox and a subset of CSS — no
|
||||
// Tailwind classes, no `filter: blur()`. Glows are done with radial gradients.
|
||||
|
||||
export const size = {width: 1200, height: 630};
|
||||
export const contentType = "image/png";
|
||||
export const alt = "Voicebox Blog";
|
||||
|
||||
// Pre-build an image for every post route (mirrors the page's static params).
|
||||
export function generateStaticParams() {
|
||||
return loadAllPosts().map((post) => ({slug: post.slug}));
|
||||
}
|
||||
|
||||
// 8-bit PNG decodes reliably in Satori; the 1024px logos are 16-bit and don't.
|
||||
const logo = `data:image/png;base64,${readFileSync(
|
||||
join(process.cwd(), "public/apple-touch-icon.png"),
|
||||
).toString("base64")}`;
|
||||
|
||||
function titleFontSize(title: string): number {
|
||||
if (title.length <= 38) return 76;
|
||||
if (title.length <= 64) return 60;
|
||||
return 48;
|
||||
}
|
||||
|
||||
export default async function OgImage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{slug: string}>;
|
||||
}) {
|
||||
const {slug} = await params;
|
||||
const post = getPost(slug);
|
||||
const title = post?.title ?? "Voicebox Blog";
|
||||
const meta = post
|
||||
? `${post.author} · ${formatDate(post.date)}`
|
||||
: "Open source voice cloning. Local-first.";
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
padding: 80,
|
||||
background:
|
||||
"radial-gradient(ellipse 80% 70% at 30% 30%, hsla(43,60%,50%,0.14) 0%, hsla(43,60%,50%,0.04) 40%, transparent 70%), linear-gradient(180deg, hsl(30,4%,6%) 0%, hsl(30,4%,4%) 100%)",
|
||||
}}
|
||||
>
|
||||
{/* Top: logo + eyebrow */}
|
||||
<div style={{display: "flex", alignItems: "center", gap: 24}}>
|
||||
{/* biome-ignore lint/performance/noImgElement: Satori only renders <img> */}
|
||||
<img src={logo} width={88} height={88} alt="" />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: 26,
|
||||
letterSpacing: 6,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
color: "hsl(43, 60%, 58%)",
|
||||
}}
|
||||
>
|
||||
Voicebox Blog
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: titleFontSize(title),
|
||||
lineHeight: 1.1,
|
||||
fontWeight: 700,
|
||||
letterSpacing: -1,
|
||||
color: "hsl(30, 10%, 94%)",
|
||||
maxWidth: 1000,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{/* Footer meta */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: 28,
|
||||
color: "hsl(30, 5%, 55%)",
|
||||
}}
|
||||
>
|
||||
{meta}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
size,
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import type {Metadata} from "next";
|
||||
import Link from "next/link";
|
||||
import {notFound} from "next/navigation";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {formatDate, getPost, loadAllPosts} from "@/lib/blog";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return loadAllPosts().map((post) => ({slug: post.slug}));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{slug: string}>;
|
||||
}): Promise<Metadata> {
|
||||
const {slug} = await params;
|
||||
const post = getPost(slug);
|
||||
if (!post) return {title: "Post not found — Voicebox"};
|
||||
return {
|
||||
title: `${post.title} — Voicebox`,
|
||||
description: post.excerpt,
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
type: "article",
|
||||
url: `https://voicebox.sh/blog/${post.slug}`,
|
||||
// og:image / twitter:image come from the colocated opengraph-image.tsx
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{slug: string}>;
|
||||
}) {
|
||||
const {slug} = await params;
|
||||
const post = getPost(slug);
|
||||
if (!post) notFound();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
<main className="mx-auto w-full max-w-3xl px-6 pt-32 pb-20">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="font-mono text-sm text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline"
|
||||
>
|
||||
← Back to blog
|
||||
</Link>
|
||||
|
||||
<header className="mt-8 border-b border-border pb-10">
|
||||
{post.tags.length > 0 ? (
|
||||
<div className="mb-5 flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-border/60 bg-card/40 px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tighter text-foreground">
|
||||
{post.title}
|
||||
</h1>
|
||||
<p className="mt-5 font-mono text-sm text-muted-foreground">
|
||||
by <span className="text-foreground">{post.author}</span> ·{" "}
|
||||
{formatDate(post.date)} · {post.readingMinutes} min read
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<article
|
||||
className="blog-prose mt-10"
|
||||
// Content is authored markdown from this repo, not user input.
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: trusted local markdown
|
||||
dangerouslySetInnerHTML={{__html: post.html}}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import type {Metadata} from "next";
|
||||
import Link from "next/link";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {formatDate, listPosts} from "@/lib/blog";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog — Voicebox",
|
||||
description: "Notes from building Voicebox — the open-source AI voice studio.",
|
||||
openGraph: {
|
||||
title: "Voicebox Blog",
|
||||
description: "Notes from building Voicebox — the open-source AI voice studio.",
|
||||
type: "website",
|
||||
url: "https://voicebox.sh/blog",
|
||||
images: [{url: "/og.webp", width: 1200, height: 630}],
|
||||
},
|
||||
};
|
||||
|
||||
export default function BlogIndexPage() {
|
||||
const posts = listPosts();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
<main className="mx-auto w-full max-w-3xl px-6 pt-32 pb-20">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
Blog
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tighter text-foreground">
|
||||
Notes from building Voicebox.
|
||||
</h1>
|
||||
<p className="mt-5 max-w-2xl text-lg text-muted-foreground">
|
||||
The story behind the project, what's shipping next, and the occasional
|
||||
look under the hood.
|
||||
</p>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<p className="mt-16 border-t border-border pt-10 text-muted-foreground">
|
||||
Nothing published yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-16 border-t border-border">
|
||||
{posts.map((post) => (
|
||||
<li key={post.slug}>
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className="group grid gap-4 border-b border-border py-10 md:grid-cols-[11rem_1fr] md:gap-10"
|
||||
>
|
||||
<div className="font-mono text-sm text-muted-foreground md:pt-1.5">
|
||||
<p>{formatDate(post.date)}</p>
|
||||
<p className="mt-1">{post.readingMinutes} min read</p>
|
||||
</div>
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground transition-colors group-hover:text-accent">
|
||||
{post.title}
|
||||
</h2>
|
||||
{post.excerpt ? (
|
||||
<p className="mt-3 leading-7 text-muted-foreground">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
) : null}
|
||||
{post.tags.length > 0 ? (
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-border/60 bg-card/40 px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Github } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AgentIntegration } from '@/components/AgentIntegration';
|
||||
import { CaptureHero } from '@/components/CaptureHero';
|
||||
import { CapturesMockup } from '@/components/CapturesMockup';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Navbar } from '@/components/Navbar';
|
||||
import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons';
|
||||
import { GITHUB_REPO } from '@/lib/constants';
|
||||
|
||||
export default function CapturePage() {
|
||||
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 ─────────────────────────────────────────────────── */}
|
||||
<CaptureHero version={version} totalDownloads={totalDownloads} />
|
||||
|
||||
{/* ── Captures mockup ─────────────────────────────────────── */}
|
||||
<section className="relative border-t border-border py-24">
|
||||
<div className="mx-auto max-w-5xl px-6 text-center mb-14">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
The Captures tab
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground mb-4">
|
||||
Every capture, paired with audio and transcript.
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Hold the shortcut, speak, release — a capture lands in the Captures tab. Replay the
|
||||
original audio, re-transcribe with a different model, refine with a local LLM, copy to
|
||||
clipboard, or send it straight to any MCP-aware agent. Nothing leaves your machine.
|
||||
</p>
|
||||
</div>
|
||||
<CapturesMockup />
|
||||
</section>
|
||||
|
||||
{/* ── Feature bullets ─────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<div className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6">
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
Whisper, sized for every machine
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
Base, Small, Medium, Large, and Turbo. Pick per-capture — 99 languages at every
|
||||
tier, all local, all downloadable from inside the app.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6">
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
LLM refinement that respects your words
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
A local Qwen model cleans ums, self-corrections, and punctuation — without
|
||||
rephrasing. Keep raw and refined side-by-side; the original audio is always kept.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6">
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
Archived by default
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
Every dictation keeps both the audio and the transcript. Search, re-run, or turn
|
||||
any capture into a voice sample for cloning from the Captures tab.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Agent voice output ──────────────────────────────────── */}
|
||||
<AgentIntegration />
|
||||
|
||||
{/* ── Bottom CTA ──────────────────────────────────────────── */}
|
||||
<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">
|
||||
Install Voicebox, start dictating.
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Free, open-source, local. No account, no API keys, no per-character fees.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl mx-auto">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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 className="mt-10 text-center">
|
||||
<a
|
||||
href="/"
|
||||
className="text-sm text-muted-foreground/70 hover:text-foreground transition-colors"
|
||||
>
|
||||
← See everything Voicebox can do
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import {ArrowRight, Cloud, KeyRound, Lock, ShieldCheck} from "lucide-react";
|
||||
import type {Metadata} from "next";
|
||||
import Link from "next/link";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {CLOUD_FEATURES, CLOUD_NOTIFY_URL} from "@/lib/pricing";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Cloud Backup & Sync — Voicebox",
|
||||
description:
|
||||
"End-to-end encrypted backup and sync for your Voicebox library. We can't read your data — only your devices can. Optional, local-first, free for $VOICEBOX holders.",
|
||||
openGraph: {
|
||||
title: "Voicebox Cloud — encrypted backup & sync",
|
||||
description:
|
||||
"End-to-end encrypted backup and sync across desktop and mobile. The server is blind — only your devices can decrypt.",
|
||||
type: "website",
|
||||
url: "https://voicebox.sh/cloud",
|
||||
images: [{url: "/og.webp", width: 1200, height: 630}],
|
||||
},
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
icon: Lock,
|
||||
title: "Encrypted on your device",
|
||||
body: "Profiles, generations, and captures are encrypted locally with keys only you hold — before anything is uploaded.",
|
||||
},
|
||||
{
|
||||
icon: Cloud,
|
||||
title: "Stored as opaque blobs",
|
||||
body: "The server keeps your encrypted objects and a sync feed. It can route and store them, but never decrypt them.",
|
||||
},
|
||||
{
|
||||
icon: KeyRound,
|
||||
title: "Only your devices decrypt",
|
||||
body: "Each device unwraps your master key on pairing. A recovery phrase you control lets you restore everything to a new one.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function CloudPage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
{/* ── Hero ─────────────────────────────────────────────────── */}
|
||||
<section className="relative pt-32 pb-16">
|
||||
<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-[900px] h-[500px] rounded-full bg-accent/12 blur-[140px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-4xl px-6 text-center">
|
||||
<div className="fade-in mb-6 inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/40 px-3 py-1">
|
||||
<Cloud className="h-3.5 w-3.5 text-accent" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Voicebox Cloud · coming soon
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="fade-in text-5xl font-bold tracking-tighter leading-[0.95] text-foreground md:text-6xl lg:text-7xl">
|
||||
Your studio, backed up and in sync.
|
||||
</h1>
|
||||
|
||||
<p className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl">
|
||||
Optional, end-to-end encrypted backup and sync for your entire
|
||||
Voicebox library. We can't read a byte of it — only your devices
|
||||
can. Free for{" "}
|
||||
<Link href="/token" className="text-foreground underline-offset-4 hover:underline">
|
||||
$VOICEBOX
|
||||
</Link>{" "}
|
||||
holders.
|
||||
</p>
|
||||
|
||||
<div className="fade-in mt-10 flex flex-row items-center justify-center gap-3 sm:gap-4">
|
||||
<Link
|
||||
href="/pricing"
|
||||
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"
|
||||
>
|
||||
See pricing
|
||||
</Link>
|
||||
<a
|
||||
href={CLOUD_NOTIFY_URL}
|
||||
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"
|
||||
>
|
||||
Get notified
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Features ─────────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-5xl px-6">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{CLOUD_FEATURES.map((f) => (
|
||||
<div
|
||||
key={f.title}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
|
||||
>
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
{f.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{f.body}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── How it works ─────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-4xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
How it works
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
|
||||
Zero-knowledge by design.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{STEPS.map((step, i) => {
|
||||
const Icon = step.icon;
|
||||
return (
|
||||
<div
|
||||
key={step.title}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Icon className="h-5 w-5 text-accent" />
|
||||
<span className="font-mono text-xs text-muted-foreground/60">
|
||||
0{i + 1}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{step.body}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Trust callout ────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<div className="rounded-2xl border-2 border-accent/40 bg-card/60 backdrop-blur-sm p-8 md:p-10 text-center shadow-[0_8px_40px_hsl(43_60%_50%/0.08)]">
|
||||
<ShieldCheck className="h-7 w-7 text-accent mx-auto mb-4" />
|
||||
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground mb-3">
|
||||
We can't see your data. That's the point.
|
||||
</h2>
|
||||
<p className="text-muted-foreground leading-relaxed max-w-2xl mx-auto">
|
||||
Voicebox is local-first and privacy-first. The cloud keeps that
|
||||
promise: your library is encrypted before it leaves your device,
|
||||
the server stores only ciphertext, and the keys never leave your
|
||||
control. Same philosophy as the app — just backed up.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-row items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="rounded-full bg-accent px-6 py-3 text-sm font-semibold text-white shadow-[0_4px_20px_hsl(43_60%_50%/0.3)] transition-all hover:bg-accent-faint"
|
||||
>
|
||||
See pricing
|
||||
</Link>
|
||||
<Link
|
||||
href="/token"
|
||||
className="rounded-full border border-border/60 bg-card/40 px-6 py-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:border-border"
|
||||
>
|
||||
Free for holders →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
'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 mb-4">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
@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;
|
||||
} */
|
||||
|
||||
/* Blog post typography (rendered markdown via marked) */
|
||||
.blog-prose {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.blog-prose > * + * {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
.blog-prose h2 {
|
||||
margin-top: 2.25em;
|
||||
margin-bottom: 0.75em;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.blog-prose h3 {
|
||||
margin-top: 1.75em;
|
||||
margin-bottom: 0.5em;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.blog-prose p,
|
||||
.blog-prose ul,
|
||||
.blog-prose ol,
|
||||
.blog-prose blockquote {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.blog-prose strong {
|
||||
color: hsl(var(--foreground));
|
||||
font-weight: 600;
|
||||
}
|
||||
.blog-prose a {
|
||||
color: hsl(var(--foreground));
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: hsl(var(--accent) / 0.5);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.blog-prose a:hover {
|
||||
color: hsl(var(--accent));
|
||||
}
|
||||
.blog-prose ul,
|
||||
.blog-prose ol {
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
.blog-prose ul {
|
||||
list-style: disc;
|
||||
}
|
||||
.blog-prose ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
.blog-prose li + li {
|
||||
margin-top: 0.4em;
|
||||
}
|
||||
.blog-prose blockquote {
|
||||
border-left: 2px solid hsl(var(--accent) / 0.5);
|
||||
padding-left: 1.25em;
|
||||
font-style: italic;
|
||||
}
|
||||
.blog-prose code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.875em;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
.blog-prose pre {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.1em 1.25em;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.blog-prose pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.blog-prose hr {
|
||||
border: none;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
margin: 2.5em 0;
|
||||
}
|
||||
.blog-prose img {
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
/* Scrollbar hiding */
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {Github} from "lucide-react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {AgentIntegration} from "@/components/AgentIntegration";
|
||||
import {ApiSection} from "@/components/ApiSection";
|
||||
import {CaptureSection} from "@/components/CaptureSection";
|
||||
import {ControlUI} from "@/components/ControlUI";
|
||||
import {Features} from "@/components/Features";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {Personalities} from "@/components/Personalities";
|
||||
import {AppleIcon, LinuxIcon, WindowsIcon} from "@/components/PlatformIcons";
|
||||
import {SupportedModels} from "@/components/SupportedModels";
|
||||
import {Testimonials} from "@/components/Testimonials";
|
||||
import {TokenTeaser} from "@/components/TokenTeaser";
|
||||
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>
|
||||
|
||||
{/* Kicker */}
|
||||
<div
|
||||
className="fade-in mb-6 text-[11px] font-semibold uppercase tracking-[0.22em] text-accent"
|
||||
style={{animationDelay: "50ms"}}
|
||||
>
|
||||
The open-source AI voice studio
|
||||
</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, dictate and create.
|
||||
</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"}}
|
||||
>
|
||||
Clone voices, generate speech across seven TTS engines, dictate into
|
||||
any app, and talk to agents in voices you own. A free and local alternative
|
||||
to ElevenLabs and WisprFlow, running{" "}
|
||||
<b className="text-white">entirely 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 />
|
||||
|
||||
{/* ── Capture (dictation + STT + play as voice) ───────────── */}
|
||||
<CaptureSection />
|
||||
|
||||
{/* ── Agent integration (speak primitive + MCP) ───────────── */}
|
||||
<AgentIntegration />
|
||||
|
||||
{/* ── Personalities (Compose / Rewrite / Respond) ──────────── */}
|
||||
<Personalities />
|
||||
|
||||
{/* ── API Section ──────────────────────────────────────────── */}
|
||||
<ApiSection />
|
||||
|
||||
{/* ── Tutorials ────────────────────────────────────────────── */}
|
||||
<TutorialsSection />
|
||||
|
||||
{/* ── Supported models ─────────────────────────────────────── */}
|
||||
<SupportedModels />
|
||||
|
||||
{/* ── Testimonials ─────────────────────────────────────────── */}
|
||||
<Testimonials />
|
||||
|
||||
{/* ── 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>
|
||||
|
||||
{/* ── $VOICEBOX token (teaser → /token) ─────────────────────── */}
|
||||
<TokenTeaser />
|
||||
|
||||
{/* ── Footer ───────────────────────────────────────────────── */}
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import {Coins} from "lucide-react";
|
||||
import type {Metadata} from "next";
|
||||
import Link from "next/link";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {PricingTiers} from "@/components/PricingTiers";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Pricing — Voicebox",
|
||||
description:
|
||||
"Voicebox is free and open source forever. Optional, end-to-end encrypted cloud backup & sync — free for $VOICEBOX holders.",
|
||||
openGraph: {
|
||||
title: "Voicebox Pricing",
|
||||
description:
|
||||
"The app is free forever. Cloud backup & sync is an optional add-on — free for $VOICEBOX holders.",
|
||||
type: "website",
|
||||
url: "https://voicebox.sh/pricing",
|
||||
images: [{url: "/og.webp", width: 1200, height: 630}],
|
||||
},
|
||||
};
|
||||
|
||||
const FAQ = [
|
||||
{
|
||||
q: "Is the app really free?",
|
||||
a: "Yes — Voicebox is free and open source, forever. Cloning, dictation, every TTS engine, MCP, personalities: all of it runs locally with no account. The paid plans only add optional cloud backup & sync.",
|
||||
},
|
||||
{
|
||||
q: "What's encrypted in the cloud?",
|
||||
a: "Everything. Your profiles, generations, and captures are end-to-end encrypted on your device before upload. The server stores only ciphertext and can never read your data.",
|
||||
},
|
||||
{
|
||||
q: "Do $VOICEBOX holders really get Cloud free?",
|
||||
a: "Yes. Holding the token unlocks the Cloud tier at no cost. The app itself is free regardless — the token is an optional way to support the project.",
|
||||
},
|
||||
{
|
||||
q: "What counts toward storage?",
|
||||
a: "Your encrypted objects — generated audio, the original audio kept with each capture, and profile data. Plans differ mainly on storage, device count, and version-history length.",
|
||||
},
|
||||
{
|
||||
q: "Can I cancel anytime?",
|
||||
a: "Yes. Cloud is a subscription you can cancel whenever you like; your local library always stays on your machine and keeps working.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
{/* ── Hero ─────────────────────────────────────────────────── */}
|
||||
<section className="relative pt-32 pb-12">
|
||||
<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-[900px] h-[460px] rounded-full bg-accent/12 blur-[140px]" />
|
||||
</div>
|
||||
<div className="relative mx-auto max-w-4xl px-6 text-center">
|
||||
<div className="fade-in mb-4 text-[11px] font-semibold uppercase tracking-[0.22em] text-accent">
|
||||
Pricing
|
||||
</div>
|
||||
<h1 className="fade-in text-5xl font-bold tracking-tighter text-foreground md:text-6xl">
|
||||
The app is free. Forever.
|
||||
</h1>
|
||||
<p className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">
|
||||
Everything that makes Voicebox great runs locally at no cost. Pay
|
||||
only if you want optional, encrypted cloud backup & sync — and
|
||||
holders get that free.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Tiers (with monthly/annual toggle) ───────────────────── */}
|
||||
<section className="pb-8">
|
||||
<PricingTiers />
|
||||
</section>
|
||||
|
||||
{/* ── Holder callout ───────────────────────────────────────── */}
|
||||
<section className="py-12">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<Link
|
||||
href="/token"
|
||||
className="group flex flex-col items-center gap-3 rounded-2xl border border-accent/30 bg-card/40 backdrop-blur-sm px-6 py-8 text-center transition-colors hover:border-accent/50"
|
||||
>
|
||||
<Coins className="h-6 w-6 text-accent" />
|
||||
<h2 className="text-xl md:text-2xl font-semibold tracking-tight text-foreground">
|
||||
Hold $VOICEBOX, get Cloud free.
|
||||
</h2>
|
||||
<p className="max-w-xl text-sm text-muted-foreground">
|
||||
The token is an optional way to back the project — and holders get
|
||||
the Cloud tier at no cost. Learn how it works and verify everything
|
||||
on-chain.
|
||||
</p>
|
||||
<span className="mt-1 text-sm font-medium text-accent group-hover:underline underline-offset-4">
|
||||
View the token →
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── FAQ ──────────────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
|
||||
Questions
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{FAQ.map((item) => (
|
||||
<div
|
||||
key={item.q}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
|
||||
>
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
{item.q}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{item.a}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground/70 mt-10 max-w-2xl mx-auto">
|
||||
Cloud pricing and limits are not final — they'll be confirmed at
|
||||
launch.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
Cloud,
|
||||
Flame,
|
||||
Heart,
|
||||
Lock,
|
||||
Rocket,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import type {Metadata} from "next";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {TokenSection} from "@/components/TokenSection";
|
||||
import {TokenStatsSection} from "@/components/TokenStats";
|
||||
import {
|
||||
TOKEN_PROOFS,
|
||||
TOKEN_SOLSCAN_URL,
|
||||
TOKEN_TICKER,
|
||||
} from "@/lib/constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `${TOKEN_TICKER} — The official Voicebox token`,
|
||||
description: `${TOKEN_TICKER} is the official community token for Voicebox on Solana. Entirely optional — Voicebox is, and always will be, free and open source.`,
|
||||
openGraph: {
|
||||
title: `${TOKEN_TICKER} on Solana`,
|
||||
description: `The official community token for Voicebox. Optional, just for fun — Voicebox stays free and open source.`,
|
||||
type: "website",
|
||||
url: "https://voicebox.sh/token",
|
||||
images: [{url: "/og.webp", width: 1200, height: 630}],
|
||||
},
|
||||
};
|
||||
|
||||
// Re-fetch live on-chain stats at most every 10 minutes (matches the server
|
||||
// cache in token-stats.ts). Keeps the page static-fast while staying fresh.
|
||||
export const revalidate = 600;
|
||||
|
||||
const USE_OF_FUNDS = [
|
||||
{
|
||||
icon: Rocket,
|
||||
title: "Full-time development",
|
||||
body: "The token is the equivalent of a salary — it lets me work on Voicebox every day instead of squeezing it around other work.",
|
||||
},
|
||||
{
|
||||
icon: Cloud,
|
||||
title: "Mobile + cloud backup & sync",
|
||||
body: "Shipping the mobile app and encrypted cloud backup/sync so your generations and captures are safe and available anywhere.",
|
||||
},
|
||||
{
|
||||
icon: Heart,
|
||||
title: "More engines, more hardware",
|
||||
body: "Adding TTS engines and broadening GPU / OS support so Voicebox runs great on whatever you've got.",
|
||||
},
|
||||
];
|
||||
|
||||
const FAQ = [
|
||||
{
|
||||
q: "Do I need the token to use Voicebox?",
|
||||
a: "No. Voicebox is free and open source, and every feature works without ever touching the token. It exists purely for supporters who want to back the project and have some fun.",
|
||||
},
|
||||
{
|
||||
q: "Is this an investment?",
|
||||
a: "No. $VOICEBOX is a community token, not a security or a promise of returns. There is no roadmap of financial milestones, and nothing here is financial advice. Only spend what you're comfortable with.",
|
||||
},
|
||||
{
|
||||
q: "How do I buy it?",
|
||||
a: "Copy the contract address above, then buy on pump.fun with a Solana wallet. Always verify the address matches the one on this page — impersonators are common.",
|
||||
},
|
||||
{
|
||||
q: "Does buying it fund development?",
|
||||
a: "Yes — going full-time on Voicebox is funded by the token, alongside donations. The surest way to support the project either way is to use it, star the repo, and tell people about it.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function TokenPage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
{/* Top padding clears the fixed navbar; TokenSection carries the
|
||||
header, contract address, and buy CTA. */}
|
||||
<main className="pt-16">
|
||||
<TokenSection />
|
||||
|
||||
{/* ── Live on-chain stats ──────────────────────────────────── */}
|
||||
<TokenStatsSection />
|
||||
|
||||
{/* ── Why a token ──────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
Why a token
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
|
||||
So I can build this full-time.
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto mt-4">
|
||||
Voicebox grew to over a million downloads with zero marketing —
|
||||
but donations alone never made full-time work sustainable.{" "}
|
||||
{TOKEN_TICKER} changed that overnight, and it's already
|
||||
accelerating everything below. The app stays{" "}
|
||||
<b className="text-foreground">free, open source, and local-first</b>{" "}
|
||||
— forever.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{USE_OF_FUNDS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={item.title}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
|
||||
>
|
||||
<Icon className="h-5 w-5 text-accent mb-3" />
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{item.body}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Holder utility ───────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<div className="rounded-2xl border-2 border-accent/40 bg-card/60 backdrop-blur-sm p-8 md:p-10 shadow-[0_8px_40px_hsl(43_60%_50%/0.08)]">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Cloud className="h-5 w-5 text-accent" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent">
|
||||
Holder perk · coming soon
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground mb-3">
|
||||
Cloud backup & sync — free for holders.
|
||||
</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Encrypted cloud backup and sync (and the mobile cloud) will be a
|
||||
paid service — roughly{" "}
|
||||
<b className="text-foreground">$12/year</b> for everyone else, and{" "}
|
||||
<b className="text-foreground">free for {TOKEN_TICKER} holders</b>.
|
||||
Generate on the go, keep your captures and generations safe, and
|
||||
pick up on any device.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Official vs community ────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<div className="rounded-2xl border border-border bg-card/40 backdrop-blur-sm p-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<ShieldCheck className="h-5 w-5 text-accent" />
|
||||
<h2 className="text-xl md:text-2xl font-semibold tracking-tight text-foreground">
|
||||
One official token. Accept no substitutes.
|
||||
</h2>
|
||||
</div>
|
||||
<ul className="space-y-3">
|
||||
<ProofRow text={`${TOKEN_TICKER} is the only official Voicebox token. The mint address on this page is the single source of truth — always verify it.`} />
|
||||
<ProofRow text="My other projects (including Spacedrive) will never have an official token. This is the only one I'll ever make." />
|
||||
<ProofRow text="I deployed it myself so liquidity can be locked and the trajectory controlled — and I no longer claim fees on any other community tokens." />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Good to know ─────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
Good to know
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
|
||||
Optional, just for fun.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{FAQ.map((item) => (
|
||||
<div
|
||||
key={item.q}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
|
||||
>
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
{item.q}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{item.a}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground/70 mt-10 max-w-2xl mx-auto">
|
||||
{TOKEN_TICKER} is a community token with no affiliation to any
|
||||
exchange or financial product. Nothing on this page is financial
|
||||
advice. Verify the contract address before buying.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProofRow({text}: {text: string}) {
|
||||
return (
|
||||
<li className="flex items-start gap-3 text-sm text-foreground/90">
|
||||
<Check className="h-5 w-5 shrink-0 text-accent mt-px" />
|
||||
<span>{text}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { Eye, Sliders, Waypoints } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// ─── Scenarios (the agent console cycles through these) ────────────────────
|
||||
|
||||
type Scenario = {
|
||||
agent: string;
|
||||
voice: string;
|
||||
voiceGradient: [string, string];
|
||||
log: { prefix: string; text: string; tone: 'accent' | 'success' | 'dim' }[];
|
||||
utterance: string;
|
||||
};
|
||||
|
||||
const SCENARIOS: Scenario[] = [
|
||||
{
|
||||
agent: 'Claude Code',
|
||||
voice: 'Morgan',
|
||||
voiceGradient: ['#60a5fa', '#6366f1'],
|
||||
log: [
|
||||
{ prefix: '$', text: 'claude run', tone: 'accent' },
|
||||
{ prefix: '✓', text: 'Tests passing (42 files)', tone: 'success' },
|
||||
{ prefix: '✓', text: 'Build succeeded in 12.4s', tone: 'success' },
|
||||
{ prefix: '→', text: 'voicebox.speak({ profile: "Morgan" })', tone: 'dim' },
|
||||
],
|
||||
utterance: 'Tests passing. Ready to merge.',
|
||||
},
|
||||
{
|
||||
agent: 'Cursor',
|
||||
voice: 'Scarlett',
|
||||
voiceGradient: ['#34d399', '#14b8a6'],
|
||||
log: [
|
||||
{ prefix: '$', text: 'cursor agent:deploy', tone: 'accent' },
|
||||
{ prefix: '✓', text: 'Migration applied (4 tables)', tone: 'success' },
|
||||
{ prefix: '✓', text: 'Deploy complete', tone: 'success' },
|
||||
{ prefix: '→', text: 'voicebox.speak({ profile: "Scarlett" })', tone: 'dim' },
|
||||
],
|
||||
utterance: 'Deploy shipped. Prod is green.',
|
||||
},
|
||||
{
|
||||
agent: 'Cline',
|
||||
voice: 'Jarvis',
|
||||
voiceGradient: ['#a855f7', '#ec4899'],
|
||||
log: [
|
||||
{ prefix: '$', text: 'cline task:review', tone: 'accent' },
|
||||
{ prefix: '!', text: '3 files need attention', tone: 'dim' },
|
||||
{ prefix: '→', text: 'voicebox.speak({ profile: "Jarvis" })', tone: 'dim' },
|
||||
],
|
||||
utterance: 'Review ready. Three files to look at.',
|
||||
},
|
||||
];
|
||||
|
||||
const TONE_CLASSES: Record<Scenario['log'][number]['tone'], string> = {
|
||||
accent: 'text-accent',
|
||||
success: 'text-emerald-400/80',
|
||||
dim: 'text-ink-faint/70',
|
||||
};
|
||||
|
||||
// ─── Console mockup ─────────────────────────────────────────────────────────
|
||||
|
||||
function AgentConsole({ scenario, cycleKey }: { scenario: Scenario; cycleKey: number }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-app-line bg-app-darkerBox overflow-hidden shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
|
||||
{/* Titlebar */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-app-line bg-app-darkBox/60">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-red-500/50" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-yellow-500/50" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500/50" />
|
||||
</div>
|
||||
<div className="flex-1 text-center">
|
||||
<span className="text-[10px] font-mono text-ink-faint/60">{scenario.agent}</span>
|
||||
</div>
|
||||
<div className="w-12" />
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-5 font-mono text-[12px] leading-relaxed min-h-[220px] flex flex-col">
|
||||
<div className="space-y-1.5">
|
||||
{scenario.log.map((line, i) => (
|
||||
<motion.div
|
||||
key={`${cycleKey}-line-${i}`}
|
||||
className="flex items-start gap-2"
|
||||
initial={{ opacity: 0, y: 2 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: i * 0.15 }}
|
||||
>
|
||||
<span className={`shrink-0 ${TONE_CLASSES[line.tone]}`}>{line.prefix}</span>
|
||||
<span
|
||||
className={
|
||||
line.tone === 'dim' ? 'text-ink-faint/70' : 'text-ink-dull'
|
||||
}
|
||||
>
|
||||
{line.text}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Idle cursor so the terminal doesn't feel empty */}
|
||||
<div className="mt-auto flex items-center gap-2 pt-4">
|
||||
<span className="text-ink-faint/50">$</span>
|
||||
<span className="inline-block h-3.5 w-[7px] bg-ink-faint/40 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Desktop-floating pill stage ────────────────────────────────────────────
|
||||
|
||||
function AgentSpeakStage({ scenario, cycleKey }: { scenario: Scenario; cycleKey: number }) {
|
||||
return (
|
||||
<div
|
||||
className="relative rounded-xl border border-app-line bg-app-darkerBox/60 overflow-hidden min-h-[180px] flex-1"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, hsl(30 10% 94% / 0.04) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, hsl(30 10% 94% / 0.04) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '28px 28px',
|
||||
}}
|
||||
>
|
||||
{/* Caption in the corner — "this is on the desktop, not in a terminal" */}
|
||||
<div className="absolute top-3 left-4 text-[9px] font-mono uppercase tracking-[0.22em] text-ink-faint/50">
|
||||
On your desktop
|
||||
</div>
|
||||
|
||||
{/* Voice-tinted glow behind the pill */}
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<motion.div
|
||||
key={`glow-${cycleKey}`}
|
||||
className="w-[320px] h-[140px] rounded-full blur-[70px]"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${scenario.voiceGradient[0]}, ${scenario.voiceGradient[1]})`,
|
||||
}}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.3 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pill + utterance caption */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 px-6">
|
||||
<motion.div
|
||||
key={`pill-${cycleKey}`}
|
||||
className="inline-flex items-center gap-3 px-4 h-11 rounded-full bg-black/55 backdrop-blur-md shadow-[0_12px_40px_rgba(0,0,0,0.45)]"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.6 }}
|
||||
>
|
||||
<div
|
||||
className="h-4 w-4 rounded-full shrink-0 ring-1 ring-white/10"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${scenario.voiceGradient[0]}, ${scenario.voiceGradient[1]})`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-[12px] font-medium text-foreground/90 shrink-0">
|
||||
Speaking · <span className="text-accent">{scenario.voice}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-[2.5px] h-5 shrink-0">
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<motion.div
|
||||
key={`bar-${scenario.voice}-${i}`}
|
||||
className="w-[2.5px] rounded-full bg-accent"
|
||||
animate={{ height: ['5px', '14px', '7px', '12px', '5px'] }}
|
||||
transition={{
|
||||
duration: 1.0,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.09,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
key={`utter-${cycleKey}`}
|
||||
className="text-[12px] text-ink-dull/80 italic text-center max-w-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.9 }}
|
||||
>
|
||||
“{scenario.utterance}”
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Code panel ─────────────────────────────────────────────────────────────
|
||||
|
||||
const MCP_CONFIG = `{
|
||||
"mcpServers": {
|
||||
"voicebox": {
|
||||
"url": "http://127.0.0.1:17493/mcp"
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const SPEAK_EXAMPLE = `// In any MCP-aware agent:
|
||||
await voicebox.speak({
|
||||
text: "Deploy complete.",
|
||||
profile: "Morgan",
|
||||
})`;
|
||||
|
||||
function CodePanel() {
|
||||
return (
|
||||
<div className="rounded-xl border border-app-line bg-app-darkBox overflow-hidden flex flex-col">
|
||||
{/* MCP config */}
|
||||
<div className="p-5 border-b border-app-line">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-[9px] font-mono text-accent font-semibold tabular-nums">
|
||||
01
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-ink-faint/70 uppercase tracking-wider">
|
||||
Add Voicebox to your MCP config
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-[11px] font-mono text-ink-dull leading-relaxed overflow-x-auto">
|
||||
{MCP_CONFIG}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Tool call */}
|
||||
<div className="p-5 flex-1">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-[9px] font-mono text-accent font-semibold tabular-nums">
|
||||
02
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-ink-faint/70 uppercase tracking-wider">
|
||||
The tool is now available
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-[11px] font-mono text-ink-dull leading-relaxed overflow-x-auto">
|
||||
{SPEAK_EXAMPLE}
|
||||
</pre>
|
||||
|
||||
{/* Hint line */}
|
||||
<div className="mt-4 text-[10px] text-ink-faint/60 leading-relaxed">
|
||||
Also exposed as{' '}
|
||||
<code className="text-accent/80">POST /speak</code> for anything that
|
||||
doesn’t speak MCP — ACP, A2A, shell scripts, or custom harnesses.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Support bullets ────────────────────────────────────────────────────────
|
||||
|
||||
const BULLETS = [
|
||||
{
|
||||
icon: Sliders,
|
||||
title: 'Per-agent voice',
|
||||
description:
|
||||
'Bind each MCP client to a voice profile. Claude Code in Morgan, Cursor in Scarlett — you know which agent is talking without looking.',
|
||||
},
|
||||
{
|
||||
icon: Eye,
|
||||
title: 'Always visible',
|
||||
description:
|
||||
'Every agent-initiated speech surfaces the pill. No silent background TTS — you always see what’s coming out of your machine.',
|
||||
},
|
||||
{
|
||||
icon: Waypoints,
|
||||
title: 'Open protocols',
|
||||
description:
|
||||
'MCP ships day one. ACP, A2A, and anything else built on a tool-call primitive slots into the same endpoint.',
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Section ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function AgentIntegration() {
|
||||
const [idx, setIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const iv = window.setInterval(() => {
|
||||
setIdx((i) => (i + 1) % SCENARIOS.length);
|
||||
}, 4200);
|
||||
return () => window.clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const scenario = SCENARIOS[idx];
|
||||
|
||||
return (
|
||||
<section id="mcp" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
{/* Header */}
|
||||
<div className="max-w-3xl mx-auto text-center mb-14">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
MCP
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl font-semibold tracking-tight text-foreground mb-5">
|
||||
Every agent gets a voice.
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-base md:text-lg leading-relaxed">
|
||||
One tool call —{' '}
|
||||
<code className="text-accent font-mono text-[0.9em]">voicebox.speak</code> —
|
||||
and any MCP-aware agent can talk to you in a voice you’ve cloned. Claude Code,
|
||||
Cursor, Cline, or anything that speaks MCP.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Code (left) + console with pill stage stacked underneath (right) */}
|
||||
<div className="grid md:grid-cols-2 gap-6 mb-12 items-stretch">
|
||||
<CodePanel />
|
||||
<div className="flex flex-col gap-4">
|
||||
<AgentConsole scenario={scenario} cycleKey={idx} />
|
||||
<AgentSpeakStage scenario={scenario} cycleKey={idx} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bullets */}
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{BULLETS.map((bullet) => {
|
||||
const Icon = bullet.icon;
|
||||
return (
|
||||
<div
|
||||
key={bullet.title}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-5"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className="h-4 w-4 text-accent" />
|
||||
<h3 className="text-[14px] font-semibold text-foreground">
|
||||
{bullet.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[13px] leading-relaxed text-muted-foreground">
|
||||
{bullet.description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Github } from 'lucide-react';
|
||||
import { GITHUB_REPO } from '@/lib/constants';
|
||||
import { DictationHero } from './CaptureSection';
|
||||
|
||||
export function CaptureHero({
|
||||
version,
|
||||
totalDownloads,
|
||||
}: {
|
||||
version: string | null;
|
||||
totalDownloads: number | null;
|
||||
}) {
|
||||
return (
|
||||
<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-[900px] h-[500px] rounded-full bg-accent/12 blur-[140px]" />
|
||||
<div className="absolute left-1/2 top-16 -translate-x-1/2 w-[520px] h-[360px] rounded-full bg-accent/8 blur-[80px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-5xl px-6 text-center">
|
||||
{/* Kicker */}
|
||||
<div
|
||||
className="fade-in mb-6 text-[11px] font-semibold uppercase tracking-[0.22em] text-accent"
|
||||
style={{ animationDelay: '50ms' }}
|
||||
>
|
||||
Voice dictation · for humans and AI agents
|
||||
</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-[96px]">
|
||||
Just talk to your computer.
|
||||
</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' }}
|
||||
>
|
||||
Hold a key anywhere on your machine, speak, release — your words land in the focused
|
||||
text field. A free, open-source, entirely-local alternative to{' '}
|
||||
<b className="text-white">WisprFlow</b>. And because Voicebox clones voices too, any
|
||||
AI agent can speak back in a voice you own.
|
||||
</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 ? ' · ' : ''}
|
||||
{totalDownloads != null ? `${totalDownloads.toLocaleString()} downloads` : ''}
|
||||
{version || totalDownloads != null ? ' · ' : ''}
|
||||
macOS, Windows, Linux
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hero visual — the pill itself */}
|
||||
<div className="mt-20 px-6">
|
||||
<DictationHero />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { Bot, Mic2, Sparkles } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// ─── Hero: Hotkey Pill ──────────────────────────────────────────────────────
|
||||
// Ported from app/src/components/ServerTab/CapturesPage.tsx HotkeyPillPreview.
|
||||
// Scaled up and retuned for the landing page — larger grid field, stretched
|
||||
// aspect, longer rest phase so the loop reads as intentional.
|
||||
|
||||
type PillState = 'recording' | 'transcribing' | 'refining' | 'rest';
|
||||
|
||||
const PILL_SEQUENCE: PillState[] = ['recording', 'transcribing', 'refining', 'rest'];
|
||||
const PILL_DURATIONS: Record<PillState, number> = {
|
||||
recording: 2800,
|
||||
transcribing: 1600,
|
||||
refining: 1600,
|
||||
rest: 1400,
|
||||
};
|
||||
const PILL_LABELS: Record<Exclude<PillState, 'rest'>, string> = {
|
||||
recording: 'Recording',
|
||||
transcribing: 'Transcribing',
|
||||
refining: 'Refining',
|
||||
};
|
||||
|
||||
function PillAudioBars({ mode }: { mode: 'live' | 'thinking' }) {
|
||||
return (
|
||||
<div className="flex items-center gap-[3px] h-6 shrink-0">
|
||||
{[0, 1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<motion.div
|
||||
key={`${mode}-${i}`}
|
||||
className="w-[3.5px] rounded-full bg-accent"
|
||||
animate={
|
||||
mode === 'live'
|
||||
? { height: ['10px', '18px', '6px', '16px', '10px'] }
|
||||
: { height: ['8px', '20px', '8px'] }
|
||||
}
|
||||
transition={
|
||||
mode === 'live'
|
||||
? { duration: 1.1, repeat: Infinity, delay: i * 0.12, ease: 'easeInOut' }
|
||||
: { duration: 0.7, repeat: Infinity, delay: i * 0.09, ease: 'easeInOut' }
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KbdKey({ children }: { children: string }) {
|
||||
return (
|
||||
<kbd className="inline-flex items-center justify-center h-7 min-w-[1.75rem] px-2 rounded-md border border-app-line bg-app-darkBox/80 font-mono text-[12px] font-medium text-foreground shadow-[inset_0_-2px_0_rgba(0,0,0,0.2)]">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
export function DictationHero() {
|
||||
const [state, setState] = useState<PillState>('recording');
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => {
|
||||
const next = PILL_SEQUENCE[(PILL_SEQUENCE.indexOf(state) + 1) % PILL_SEQUENCE.length];
|
||||
setState(next);
|
||||
}, PILL_DURATIONS[state]);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== 'recording') return;
|
||||
setTick(0);
|
||||
const iv = window.setInterval(() => setTick((n) => n + 1), 90);
|
||||
return () => window.clearInterval(iv);
|
||||
}, [state]);
|
||||
|
||||
const elapsedSec = Math.floor((tick * 90) / 1000);
|
||||
const elapsedLabel = `0:${String(elapsedSec).padStart(2, '0')}`;
|
||||
const pillVisible = state !== 'rest';
|
||||
const barMode: 'live' | 'thinking' = state === 'recording' ? 'live' : 'thinking';
|
||||
const labelText = state === 'rest' ? PILL_LABELS.recording : PILL_LABELS[state];
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl">
|
||||
{/* Shortcut hint above the field */}
|
||||
<div className="mt-10 mb-4 flex flex-wrap items-center justify-center gap-x-3 gap-y-1.5 text-[13px] text-muted-foreground">
|
||||
<span>Hold</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<KbdKey>⌘</KbdKey>
|
||||
<KbdKey>⌥</KbdKey>
|
||||
</div>
|
||||
<span>on macOS,</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<KbdKey>Ctrl</KbdKey>
|
||||
<KbdKey>Alt</KbdKey>
|
||||
</div>
|
||||
<span>on Windows — from anywhere on your machine.</span>
|
||||
</div>
|
||||
|
||||
{/* The stage — gridded field with the pill floating in the middle */}
|
||||
<div
|
||||
className="relative rounded-2xl border border-app-line bg-app-darkerBox/60 overflow-hidden aspect-[5/1]"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, hsl(30 10% 94% / 0.04) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, hsl(30 10% 94% / 0.04) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '32px 32px',
|
||||
}}
|
||||
>
|
||||
{/* Soft accent glow behind the pill */}
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-[420px] h-[160px] rounded-full bg-accent/10 blur-[80px]" />
|
||||
</div>
|
||||
|
||||
{/* Floating pill */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
className={`inline-flex items-center gap-4 px-6 h-14 rounded-full bg-black/55 backdrop-blur-md text-accent shadow-[0_12px_40px_rgba(0,0,0,0.45)] transition-opacity duration-500 ease-out ${
|
||||
pillVisible ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
{/* Gold dot — pings during recording */}
|
||||
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
||||
{state === 'recording' && (
|
||||
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-70" />
|
||||
)}
|
||||
<span className="relative rounded-full h-2.5 w-2.5 bg-accent" />
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="text-[15px] font-medium shrink-0"
|
||||
style={{ minWidth: '120px' }}
|
||||
>
|
||||
{labelText}
|
||||
</span>
|
||||
|
||||
<PillAudioBars mode={barMode} />
|
||||
|
||||
<span className="text-[13px] tabular-nums text-accent/70 font-medium shrink-0 -ml-1">
|
||||
{elapsedLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Card: Whisper, sized for every machine ────────────────────────────────
|
||||
|
||||
type EngineRow = { name: string; size: string; langs: string };
|
||||
|
||||
const STT_ENGINES: EngineRow[] = [
|
||||
{ name: 'Whisper Base', size: '74M', langs: '99 langs' },
|
||||
{ name: 'Whisper Small', size: '244M', langs: '99 langs' },
|
||||
{ name: 'Whisper Medium', size: '769M', langs: '99 langs' },
|
||||
{ name: 'Whisper Large', size: '1.5B', langs: '99 langs' },
|
||||
{ name: 'Whisper Turbo', size: '809M', langs: '99 langs' },
|
||||
];
|
||||
|
||||
function MultiEngineSTTAnimation() {
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const iv = window.setInterval(() => {
|
||||
setActiveIdx((i) => (i + 1) % STT_ENGINES.length);
|
||||
}, 1600);
|
||||
return () => window.clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-40 w-full flex items-center justify-center overflow-hidden rounded-md bg-app-darkerBox/50 p-4">
|
||||
<div className="w-full max-w-[240px] space-y-1.5">
|
||||
{STT_ENGINES.map((engine, i) => {
|
||||
const active = i === activeIdx;
|
||||
return (
|
||||
<motion.div
|
||||
key={engine.name}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 rounded-md border"
|
||||
animate={{
|
||||
borderColor: active ? 'hsl(43 50% 45% / 0.5)' : 'rgba(255,255,255,0.06)',
|
||||
backgroundColor: active ? 'hsl(43 50% 45% / 0.08)' : 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<motion.div
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
animate={{
|
||||
backgroundColor: active ? 'hsl(43 50% 50%)' : 'rgba(255,255,255,0.15)',
|
||||
boxShadow: active ? '0 0 8px hsl(43 50% 50%)' : '0 0 0 transparent',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
<span
|
||||
className="text-[10px] font-medium flex-1 truncate"
|
||||
style={{ color: active ? 'hsl(43 50% 55%)' : 'rgba(255,255,255,0.55)' }}
|
||||
>
|
||||
{engine.name}
|
||||
</span>
|
||||
<span className="text-[9px] font-mono text-ink-faint/70 tabular-nums">
|
||||
{engine.size}
|
||||
</span>
|
||||
<span className="text-[9px] text-ink-faint/60">{engine.langs}</span>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Card: LLM Refinement ───────────────────────────────────────────────────
|
||||
|
||||
const REFINEMENT_PAIRS = [
|
||||
{
|
||||
raw: 'um so like i think we should ship it on friday, actually no wait, tuesday',
|
||||
clean: 'I think we should ship it on Tuesday.',
|
||||
},
|
||||
{
|
||||
raw: 'could you uh run the migration real quick, and then, yeah, check the logs',
|
||||
clean: 'Could you run the migration, then check the logs?',
|
||||
},
|
||||
];
|
||||
|
||||
function RefinementAnimation() {
|
||||
const [pairIdx, setPairIdx] = useState(0);
|
||||
const [showClean, setShowClean] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const step = () => {
|
||||
if (!mounted) return;
|
||||
setShowClean(false);
|
||||
window.setTimeout(() => mounted && setShowClean(true), 1400);
|
||||
window.setTimeout(() => {
|
||||
if (!mounted) return;
|
||||
setPairIdx((i) => (i + 1) % REFINEMENT_PAIRS.length);
|
||||
}, 4000);
|
||||
};
|
||||
step();
|
||||
const iv = window.setInterval(step, 4000);
|
||||
return () => {
|
||||
mounted = false;
|
||||
window.clearInterval(iv);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const pair = REFINEMENT_PAIRS[pairIdx];
|
||||
|
||||
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">
|
||||
<div className="w-full max-w-[260px] space-y-2">
|
||||
{/* Raw line — always visible, dims when refined */}
|
||||
<motion.div
|
||||
key={`raw-${pairIdx}`}
|
||||
className="text-[10px] font-mono leading-relaxed"
|
||||
initial={{ opacity: 0, y: 2 }}
|
||||
animate={{
|
||||
opacity: showClean ? 0.35 : 1,
|
||||
y: 0,
|
||||
color: showClean ? 'rgba(255,255,255,0.35)' : 'rgba(255,255,255,0.6)',
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<span className="text-ink-faint/50 mr-1.5">raw</span>
|
||||
{pair.raw}
|
||||
</motion.div>
|
||||
|
||||
{/* Refined line — fades in */}
|
||||
<motion.div
|
||||
key={`clean-${pairIdx}`}
|
||||
className="text-[10px] leading-relaxed"
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{
|
||||
opacity: showClean ? 1 : 0,
|
||||
y: showClean ? 0 : 4,
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<span className="text-accent/70 mr-1.5 font-mono">clean</span>
|
||||
<span className="text-foreground">{pair.clean}</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Activity indicator */}
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-mono text-ink-faint mt-1">
|
||||
<Sparkles className="h-2.5 w-2.5 text-accent" />
|
||||
<span>{showClean ? 'refined' : 'Qwen3 · refining...'}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Card: Agent voice output ───────────────────────────────────────────────
|
||||
|
||||
type AgentSpeaker = {
|
||||
agent: string;
|
||||
voice: string;
|
||||
gradient: [string, string];
|
||||
message: string;
|
||||
};
|
||||
|
||||
const AGENT_SPEAKERS: AgentSpeaker[] = [
|
||||
{
|
||||
agent: 'Claude Code',
|
||||
voice: 'Morgan',
|
||||
gradient: ['#60a5fa', '#6366f1'],
|
||||
message: 'Tests passing. Ready to merge.',
|
||||
},
|
||||
{
|
||||
agent: 'Cursor',
|
||||
voice: 'Scarlett',
|
||||
gradient: ['#34d399', '#14b8a6'],
|
||||
message: 'Build finished in 42s.',
|
||||
},
|
||||
{
|
||||
agent: 'Cline',
|
||||
voice: 'Jarvis',
|
||||
gradient: ['#a855f7', '#ec4899'],
|
||||
message: 'Deploy complete.',
|
||||
},
|
||||
];
|
||||
|
||||
function AgentVoiceAnimation() {
|
||||
const [idx, setIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const iv = window.setInterval(() => {
|
||||
setIdx((i) => (i + 1) % AGENT_SPEAKERS.length);
|
||||
}, 2600);
|
||||
return () => window.clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const current = AGENT_SPEAKERS[idx];
|
||||
|
||||
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">
|
||||
{/* Which agent called speak() */}
|
||||
<motion.div
|
||||
key={`agent-${idx}`}
|
||||
className="text-[9px] font-mono"
|
||||
initial={{ opacity: 0, y: 2 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<span className="text-ink-faint/50">via MCP</span>
|
||||
<span className="mx-1.5 text-ink-faint/30">·</span>
|
||||
<span className="text-ink-dull">{current.agent}</span>
|
||||
</motion.div>
|
||||
|
||||
{/* Pill in speaking state */}
|
||||
<motion.div
|
||||
key={`pill-${idx}`}
|
||||
className="inline-flex items-center gap-2.5 px-3 h-8 rounded-full bg-black/55 backdrop-blur-sm shadow-[0_6px_20px_rgba(0,0,0,0.35)]"
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div
|
||||
className="h-4 w-4 rounded-full shrink-0 ring-1 ring-white/10"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${current.gradient[0]}, ${current.gradient[1]})`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] font-medium text-foreground/90">
|
||||
Speaking · <span className="text-accent">{current.voice}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-[2px] h-3.5">
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<motion.div
|
||||
key={`${current.voice}-${i}`}
|
||||
className="w-[2px] rounded-full bg-accent"
|
||||
animate={{ height: ['4px', '11px', '5px', '9px', '4px'] }}
|
||||
transition={{
|
||||
duration: 0.9,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.08,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* The line the agent is saying */}
|
||||
<motion.div
|
||||
key={`msg-${idx}`}
|
||||
className="text-[10px] font-mono text-ink-dull max-w-[220px] text-center leading-relaxed"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.15 }}
|
||||
>
|
||||
“{current.message}”
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Feature data + card ────────────────────────────────────────────────────
|
||||
|
||||
const CAPTURE_FEATURES = [
|
||||
{
|
||||
title: 'Whisper, sized for every machine',
|
||||
description:
|
||||
'Base, Small, Medium, Large, and Turbo. Pick the size that fits your hardware and quality bar — 99 languages across every tier, all running locally.',
|
||||
icon: Mic2,
|
||||
animation: MultiEngineSTTAnimation,
|
||||
},
|
||||
{
|
||||
title: 'Refined transcripts',
|
||||
description:
|
||||
'A local LLM cleans ums, self-corrections, and punctuation without rephrasing. Optional, toggleable, and never leaves your machine.',
|
||||
icon: Sparkles,
|
||||
animation: RefinementAnimation,
|
||||
},
|
||||
{
|
||||
title: 'Agents speak in voices you own',
|
||||
description:
|
||||
'Any MCP-aware agent — Claude Code, Cursor, Cline — gets a voice with one tool call. The pill surfaces when an agent is speaking, so you always see what’s coming out of your machine.',
|
||||
icon: Bot,
|
||||
animation: AgentVoiceAnimation,
|
||||
},
|
||||
];
|
||||
|
||||
function CaptureCard({ feature }: { feature: (typeof CAPTURE_FEATURES)[number] }) {
|
||||
const Icon = feature.icon;
|
||||
const Animation = feature.animation;
|
||||
return (
|
||||
<div className="rounded-lg border border-app-line bg-app-darkBox overflow-hidden">
|
||||
<div className="pointer-events-none select-none">
|
||||
<Animation />
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CaptureSection() {
|
||||
return (
|
||||
<section id="capture" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
{/* Kicker + headline */}
|
||||
<div className="text-center mb-14">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
Capture
|
||||
</div>
|
||||
<h2 className="text-4xl font-semibold tracking-tight text-foreground md:text-5xl mb-5">
|
||||
Dictate anywhere. Paste into any app.
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto text-base md:text-lg leading-relaxed">
|
||||
Hold a shortcut anywhere on your machine, speak, release.
|
||||
The transcript lands in a focused text field in any app, or your clipboard. Agents speak
|
||||
back through the same pill in any cloned voice.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hero pill animation */}
|
||||
<div className="mb-16">
|
||||
<DictationHero />
|
||||
</div>
|
||||
|
||||
{/* Feature cards */}
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{CAPTURE_FEATURES.map((f) => (
|
||||
<CaptureCard key={f.title} feature={f} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,481 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
AudioLines,
|
||||
Box,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
Copy,
|
||||
FileAudio,
|
||||
Mic,
|
||||
Download,
|
||||
Play,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Subtitles,
|
||||
Users,
|
||||
Volume2,
|
||||
Wand2,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
// ─── Sidebar (matches ControlUI exactly) ───────────────────────────────────
|
||||
|
||||
const SIDEBAR_ITEMS = [
|
||||
{ icon: Volume2, label: 'Generate' },
|
||||
{ icon: AudioLines, label: 'Stories' },
|
||||
{ icon: Mic, label: 'Captures', active: true },
|
||||
{ icon: Users, label: 'Voices' },
|
||||
{ icon: Wand2, label: 'Effects' },
|
||||
{ icon: Box, label: 'Models' },
|
||||
{ icon: Settings, label: 'Settings' },
|
||||
];
|
||||
|
||||
function Sidebar() {
|
||||
return (
|
||||
<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))',
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<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) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className={`w-9 h-9 rounded-full flex items-center justify-center transition-all duration-200 ${
|
||||
item.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.5.0</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── FakeWaveform (ported from CapturesTab.tsx) ────────────────────────────
|
||||
|
||||
function FakeWaveform({
|
||||
seed,
|
||||
active,
|
||||
className,
|
||||
}: {
|
||||
seed: number;
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const bars = useMemo(() => {
|
||||
return Array.from({ length: 72 }).map((_, i) => {
|
||||
const h =
|
||||
28 +
|
||||
Math.sin(i * 0.35 + seed) * 22 +
|
||||
Math.cos(i * 0.81 + seed * 2) * 14 +
|
||||
Math.sin(i * 1.7 + seed * 3) * 8;
|
||||
return Math.max(6, Math.min(96, h));
|
||||
});
|
||||
}, [seed]);
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-[2px] h-10 ${className ?? ''}`}>
|
||||
{bars.map((h, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-[3px] rounded-full"
|
||||
style={{
|
||||
height: `${h}%`,
|
||||
backgroundColor: active ? 'hsl(43 50% 50% / 0.85)' : 'hsl(var(--foreground) / 0.25)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Data ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Capture = {
|
||||
id: string;
|
||||
seed: number;
|
||||
transcriptRaw: string;
|
||||
transcriptRefined: string;
|
||||
durationMs: number;
|
||||
ago: string;
|
||||
createdAtLabel: string;
|
||||
source: 'dictation' | 'recording' | 'file';
|
||||
sttModel: string;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
const CAPTURES: Capture[] = [
|
||||
{
|
||||
id: 'c1',
|
||||
seed: 11,
|
||||
transcriptRaw:
|
||||
"okay so the pitch for voicebox is basically this it's a local first voice studio everything runs on your machine you clone voices from a few seconds of audio generate speech across seven TTS engines and now with the captures tab you can dictate into any app no cloud no API keys no per character fees your voice data never leaves your device privacy isn't a feature here it's the architecture",
|
||||
transcriptRefined:
|
||||
"Okay, so the pitch for Voicebox is basically this: it's a local-first voice studio. Everything runs on your machine. You clone voices from a few seconds of audio, generate speech across seven TTS engines, and now with the Captures tab, you can dictate into any app. No cloud, no API keys, no per-character fees. Your voice data never leaves your device. Privacy isn't a feature here — it's the architecture.",
|
||||
durationMs: 38000,
|
||||
ago: '4 min ago',
|
||||
createdAtLabel: 'Apr 22, 3:47 PM',
|
||||
source: 'dictation',
|
||||
sttModel: 'turbo',
|
||||
language: 'en',
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
seed: 23,
|
||||
transcriptRaw:
|
||||
"draft an update for the blog about the agent voice feature the key point is one MCP tool call and any agent on your machine gets a voice claude code finishes a long task calls voicebox dot speak and you hear it in a voice you've cloned morgan scarlett whatever you set up same pill that shows when you're dictating also shows when an agent is speaking so you always know what's coming out of your machine closes the whole voice IO loop for agents",
|
||||
transcriptRefined:
|
||||
"Draft an update for the blog about the agent voice feature. The key point: one MCP tool call, and any agent on your machine gets a voice. Claude Code finishes a long task, calls voicebox.speak, and you hear it in a voice you've cloned — Morgan, Scarlett, whatever you've set up. The same pill that shows when you're dictating also shows when an agent is speaking, so you always know what's coming out of your machine. It closes the full voice I/O loop for agents.",
|
||||
durationMs: 41000,
|
||||
ago: '22 min ago',
|
||||
createdAtLabel: 'Apr 22, 3:29 PM',
|
||||
source: 'dictation',
|
||||
sttModel: 'turbo',
|
||||
language: 'en',
|
||||
},
|
||||
{
|
||||
id: 'c3',
|
||||
seed: 37,
|
||||
transcriptRaw:
|
||||
"tech overview for the readme seven TTS engines qwen3 kokoro chatterbox luxtts customvoice tada and chatterbox turbo whisper for STT in five sizes from base up to large and a turbo variant one local LLM qwen 3.5 shared runtime across all of them one model directory one GPU story no fragmented caches pick the right model per job speed on CPU laptops quality on an M series mac all switchable per generation",
|
||||
transcriptRefined:
|
||||
"Tech overview for the README: seven TTS engines — Qwen3, Kokoro, Chatterbox, LuxTTS, CustomVoice, TADA, and Chatterbox Turbo. Whisper for STT, in five sizes from Base up to Large, plus a Turbo variant. One local LLM, Qwen 3.5, with a shared runtime across all of them. One model directory, one GPU story, no fragmented caches. Pick the right model per job — speed on CPU laptops, quality on an M-series Mac, switchable per-generation.",
|
||||
durationMs: 34000,
|
||||
ago: '1 hr ago',
|
||||
createdAtLabel: 'Apr 22, 2:51 PM',
|
||||
source: 'dictation',
|
||||
sttModel: 'turbo',
|
||||
language: 'en',
|
||||
},
|
||||
{
|
||||
id: 'c4',
|
||||
seed: 53,
|
||||
transcriptRaw:
|
||||
"okay the real magic is this you speak to voicebox your transcript gets cleaned up by a local LLM it pastes into whatever you're focused on then the agent you're talking to responds and it replies with voice in a voice you cloned through the same pill that's the loop elevenlabs has TTS wisprflow has dictation but neither runs locally and neither does both halves voicebox is full voice IO for humans and AI agents entirely on your machine",
|
||||
transcriptRefined:
|
||||
"Okay, the real magic: you speak to Voicebox, your transcript gets cleaned up by a local LLM, and it pastes into whatever you're focused on. Then the agent you're talking to responds — and it replies with voice, in a voice you've cloned, through the same pill. That's the loop. ElevenLabs has TTS, WisprFlow has dictation, but neither runs locally and neither does both halves. Voicebox is full voice I/O for humans and AI agents, entirely on your machine.",
|
||||
durationMs: 42000,
|
||||
ago: 'Yesterday',
|
||||
createdAtLabel: 'Apr 21, 11:14 PM',
|
||||
source: 'dictation',
|
||||
sttModel: 'large',
|
||||
language: 'en',
|
||||
},
|
||||
];
|
||||
|
||||
const PROFILES = [
|
||||
{ id: 'p1', name: 'Morgan', description: 'Warm, measured', gradient: 'from-blue-400 to-indigo-500' },
|
||||
{ id: 'p2', name: 'Scarlett', description: 'Bright, conversational', gradient: 'from-emerald-400 to-teal-500' },
|
||||
{ id: 'p3', name: 'Jarvis', description: 'Dry, composed', gradient: 'from-purple-500 to-fuchsia-500' },
|
||||
];
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const total = Math.round(ms / 1000);
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source: Capture['source'] }) {
|
||||
const Icon = source === 'dictation' ? Mic : source === 'recording' ? CircleDot : FileAudio;
|
||||
const label =
|
||||
source === 'dictation' ? 'Dictation' : source === 'recording' ? 'Recording' : 'File';
|
||||
return (
|
||||
<span className="inline-flex items-center h-5 px-1.5 gap-1 rounded-md text-[10px] font-medium bg-muted/60 text-muted-foreground border border-transparent">
|
||||
<Icon className="h-2.5 w-2.5" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RefinedBadge() {
|
||||
return (
|
||||
<span className="inline-flex items-center h-5 px-1.5 gap-1 rounded-md text-[10px] font-medium bg-accent/10 text-accent border border-accent/20">
|
||||
<Sparkles className="h-2.5 w-2.5" />
|
||||
Refined
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function BetaBadge() {
|
||||
return (
|
||||
<span className="inline-flex items-center h-5 px-1.5 rounded-md text-[10px] font-medium text-accent bg-accent/10 border border-accent/20">
|
||||
Beta
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Capture list row ───────────────────────────────────────────────────────
|
||||
|
||||
function CaptureRow({
|
||||
capture,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
capture: Capture;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={`w-full text-left p-3 rounded-lg transition-colors block ${
|
||||
selected
|
||||
? 'bg-muted/70 border border-border'
|
||||
: 'border border-transparent hover:bg-muted/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[11px] text-muted-foreground font-medium">{capture.ago}</span>
|
||||
<div className="flex-1" />
|
||||
<span className="text-[10px] text-muted-foreground/70 tabular-nums">
|
||||
{formatDuration(capture.durationMs)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[13px] text-foreground/90 line-clamp-2 leading-snug mb-2">
|
||||
{capture.transcriptRefined}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<SourceBadge source={capture.source} />
|
||||
<RefinedBadge />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Detail view ────────────────────────────────────────────────────────────
|
||||
|
||||
function DetailView({ capture }: { capture: Capture }) {
|
||||
const [showRefined, setShowRefined] = useState(true);
|
||||
const [profileIdx, setProfileIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setShowRefined(true);
|
||||
}, [capture.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const iv = window.setInterval(() => {
|
||||
setProfileIdx((i) => (i + 1) % PROFILES.length);
|
||||
}, 2600);
|
||||
return () => window.clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const playAs = PROFILES[profileIdx];
|
||||
const transcript = showRefined ? capture.transcriptRefined : capture.transcriptRaw;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col px-8 pt-4 pb-5 overflow-hidden">
|
||||
{/* Compact top row — date + language + source, inline */}
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground/80 mb-4 shrink-0">
|
||||
<span>{capture.createdAtLabel}</span>
|
||||
{capture.language && (
|
||||
<>
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<span>{capture.language.toUpperCase()}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<SourceBadge source={capture.source} />
|
||||
</div>
|
||||
|
||||
{/* Audio player card */}
|
||||
<div className="rounded-xl border border-border bg-muted/20 p-4 mb-5 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-full border border-border bg-background flex items-center justify-center shrink-0">
|
||||
<Play className="h-4 w-4 ml-0.5 fill-current text-foreground" />
|
||||
</div>
|
||||
<FakeWaveform seed={capture.seed} active className="flex-1" />
|
||||
<span className="text-xs tabular-nums text-muted-foreground font-medium">
|
||||
{formatDuration(capture.durationMs)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transcript header */}
|
||||
<div className="flex items-center gap-3 mb-3 shrink-0">
|
||||
<div className="inline-flex rounded-md bg-muted/40 p-0.5 border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRefined(true)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
|
||||
showRefined
|
||||
? 'bg-background shadow-sm text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="h-3 w-3 inline-block mr-1 -translate-y-px" />
|
||||
Refined
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRefined(false)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
|
||||
!showRefined
|
||||
? 'bg-background shadow-sm text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Subtitles className="h-3 w-3 inline-block mr-1 -translate-y-px" />
|
||||
Raw
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{showRefined
|
||||
? 'Refined with Qwen3 · 1.7B'
|
||||
: `Whisper ${capture.sttModel}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Transcript body — focal point, fills remaining height */}
|
||||
<div className="flex-1 min-h-0 rounded-xl border border-border bg-muted/10 p-6 overflow-y-auto mb-4">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`${capture.id}-${showRefined}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="text-[15px] leading-relaxed text-foreground/90"
|
||||
>
|
||||
{transcript}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Action row — matches CapturesTab bottom row */}
|
||||
<div className="flex items-center gap-2 shrink-0 flex-wrap">
|
||||
<div className="inline-flex">
|
||||
<div className="inline-flex items-center justify-center gap-2 whitespace-nowrap h-9 rounded-full rounded-tr-none rounded-br-none border border-r-0 border-input bg-background pl-2 pr-3 text-sm font-medium transition-colors">
|
||||
<div
|
||||
className={`h-5 w-5 rounded-full bg-gradient-to-br shrink-0 ring-1 ring-white/10 ${playAs.gradient}`}
|
||||
/>
|
||||
<Volume2 className="h-4 w-4 shrink-0" />
|
||||
Play as {playAs.name}
|
||||
</div>
|
||||
<div className="inline-flex items-center justify-center gap-2 whitespace-nowrap h-9 px-2 rounded-full rounded-tl-none rounded-bl-none border border-input bg-background text-sm font-medium transition-colors">
|
||||
<ChevronDown className="h-4 w-4 shrink-0 opacity-70" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 h-9 px-3 rounded-full border border-input bg-background text-sm font-medium text-foreground whitespace-nowrap">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Copy
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 h-9 px-3 rounded-full border border-input bg-background text-sm font-medium text-foreground whitespace-nowrap">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Re-refine
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 h-9 px-3 rounded-full border border-input bg-background text-sm font-medium text-foreground whitespace-nowrap">
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Export
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main mockup ────────────────────────────────────────────────────────────
|
||||
|
||||
export function CapturesMockup() {
|
||||
const [selectedId, setSelectedId] = useState<string>(CAPTURES[0].id);
|
||||
|
||||
useEffect(() => {
|
||||
const iv = window.setInterval(() => {
|
||||
setSelectedId((current) => {
|
||||
const idx = CAPTURES.findIndex((c) => c.id === current);
|
||||
return CAPTURES[(idx + 1) % CAPTURES.length].id;
|
||||
});
|
||||
}, 4200);
|
||||
return () => window.clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const selected = CAPTURES.find((c) => c.id === selectedId) ?? CAPTURES[0];
|
||||
|
||||
return (
|
||||
<div className="relative z-20 mx-auto w-full max-w-5xl px-6">
|
||||
<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 />
|
||||
|
||||
{/* ── Main area: two-panel Captures tab ─────────────────── */}
|
||||
<div className="flex-1 flex flex-col md:flex-row min-w-0 relative">
|
||||
{/* ── Left: capture list (w-[340px]) ──────────────────── */}
|
||||
<div
|
||||
style={{ width: 300, flex: '0 0 300px' }}
|
||||
className="flex flex-col overflow-hidden border-r border-app-line"
|
||||
>
|
||||
{/* Header — normal flow */}
|
||||
<div className="shrink-0 pl-4 pr-4 pt-4 pb-2">
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<h1 className="text-2xl px-4 font-bold">Captures</h1>
|
||||
<BetaBadge />
|
||||
</div>
|
||||
<div className="h-9 flex items-center rounded-full border border-input bg-background px-4 text-sm text-muted-foreground">
|
||||
Search transcripts…
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="px-4 pt-2 pb-6 space-y-1">
|
||||
{CAPTURES.map((capture) => (
|
||||
<CaptureRow
|
||||
key={capture.id}
|
||||
capture={capture}
|
||||
selected={selectedId === capture.id}
|
||||
onSelect={() => setSelectedId(capture.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right: capture detail (flex-1) ───────────────────── */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={selectedId}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex-1 overflow-hidden"
|
||||
>
|
||||
<DetailView capture={selected} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,889 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
/** Compact, copyable contract address — short display, full value to clipboard. */
|
||||
export function CopyAddress({ address }: { address: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(address);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard unavailable (e.g. insecure context) — silently no-op.
|
||||
}
|
||||
};
|
||||
|
||||
const short = `${address.slice(0, 4)}…${address.slice(-4)}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
title={address}
|
||||
aria-label={copied ? 'Contract address copied' : 'Copy contract address'}
|
||||
className="group inline-flex items-center gap-2 rounded-lg border border-border/60 bg-card/60 px-2.5 py-1.5 font-mono text-xs text-muted-foreground transition-colors hover:border-border hover:text-foreground"
|
||||
>
|
||||
<span>{short}</span>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-accent" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5 opacity-60 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,855 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import { ArrowUpRight, Coffee, Coins } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { CopyAddress } from '@/components/CopyAddress';
|
||||
import {
|
||||
DONATE_URL,
|
||||
GITHUB_REPO,
|
||||
TOKEN_CONTRACT_ADDRESS,
|
||||
TOKEN_TICKER,
|
||||
} 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-5 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">
|
||||
Clone
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/capture" className="hover:text-foreground transition-colors">
|
||||
Capture
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/#mcp" className="hover:text-foreground transition-colors">
|
||||
MCP
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/#about" className="hover:text-foreground transition-colors">
|
||||
Models
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/#api" className="hover:text-foreground transition-colors">
|
||||
API
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/cloud" className="hover:text-foreground transition-colors">
|
||||
Cloud
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/pricing" className="hover:text-foreground transition-colors">
|
||||
Pricing
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/download" className="hover:text-foreground transition-colors">
|
||||
Download
|
||||
</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>
|
||||
<a href="/blog" className="hover:text-foreground transition-colors">
|
||||
Blog
|
||||
</a>
|
||||
</li>
|
||||
<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>
|
||||
<li>
|
||||
<a href="/sponsors" className="hover:text-foreground transition-colors">
|
||||
VIP Sponsor
|
||||
</a>
|
||||
</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>
|
||||
|
||||
{/* Token */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">Token</h4>
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Coins className="h-4 w-4 text-accent" />
|
||||
<span className="font-semibold text-foreground">{TOKEN_TICKER}</span>
|
||||
<span className="text-xs text-muted-foreground/60">Solana</span>
|
||||
</div>
|
||||
<CopyAddress address={TOKEN_CONTRACT_ADDRESS} />
|
||||
<Link
|
||||
href="/token"
|
||||
className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors"
|
||||
>
|
||||
Token details
|
||||
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Coffee, Coins, Github } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { DONATE_URL, GITHUB_REPO, TOKEN_TICKER } 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-[1fr_auto_1fr] sm:gap-x-6">
|
||||
{/* 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"
|
||||
>
|
||||
Clone
|
||||
</a>
|
||||
<a
|
||||
href="/capture"
|
||||
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Capture
|
||||
<span className="rounded-full bg-accent/15 px-1.5 text-[9px] font-semibold uppercase tracking-wider text-accent">
|
||||
New
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/#mcp"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
MCP
|
||||
</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="/pricing"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Pricing
|
||||
</a>
|
||||
<a
|
||||
href="/blog"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Blog
|
||||
</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>
|
||||
|
||||
{/* Token + Donate + GitHub star buttons */}
|
||||
<div className="flex items-center gap-2 justify-self-end">
|
||||
<a
|
||||
href="/token"
|
||||
className="hidden sm: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-accent/40"
|
||||
aria-label={`${TOKEN_TICKER} token`}
|
||||
>
|
||||
<Coins className="h-4 w-4 text-accent" />
|
||||
<span className="text-[13px] font-semibold tracking-wide text-foreground">
|
||||
{TOKEN_TICKER}
|
||||
</span>
|
||||
</a>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ArrowRight, Dices, Wand2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// ─── Modes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type Mode = {
|
||||
id: 'compose' | 'rewrite';
|
||||
label: string;
|
||||
icon: typeof Dices;
|
||||
outputLabel: string;
|
||||
output: string;
|
||||
} & (
|
||||
| { inputLabel: string; input: string }
|
||||
| { inputLabel?: undefined; input?: undefined }
|
||||
);
|
||||
|
||||
const MODES: Mode[] = [
|
||||
{
|
||||
id: 'rewrite',
|
||||
label: 'Rewrite',
|
||||
icon: Wand2,
|
||||
inputLabel: 'Your text',
|
||||
outputLabel: "Marlowe, in character",
|
||||
input: 'the build is done and we shipped to production',
|
||||
output:
|
||||
"Build's wrapped, ship's left the dock. Another stack of code makes its way into prod, another row of green checks lining the wall.",
|
||||
},
|
||||
{
|
||||
id: 'compose',
|
||||
label: 'Compose',
|
||||
icon: Dices,
|
||||
outputLabel: "Marlowe, in character",
|
||||
output:
|
||||
"She came through clean. Not a single test casting a shadow. In this town, that's usually when you start worrying.",
|
||||
},
|
||||
];
|
||||
|
||||
const PERSONA_DESCRIPTION =
|
||||
"1940s noir detective. World-weary, cynical, every situation a metaphor for the city's underbelly. Talks like he's seen one stack trace too many.";
|
||||
|
||||
// ─── Persona card ───────────────────────────────────────────────────────────
|
||||
|
||||
function PersonaCard() {
|
||||
return (
|
||||
<div className="rounded-xl border border-app-line bg-app-darkBox p-5 shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className="h-12 w-12 rounded-full shrink-0 ring-1 ring-white/10"
|
||||
style={{ background: 'linear-gradient(135deg, #dc2626, #7f1d1d)' }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[15px] font-semibold text-foreground leading-tight">Marlowe</div>
|
||||
<div className="text-[11px] text-muted-foreground/80 leading-tight mt-0.5">
|
||||
Voice profile · cloned from a 12s sample
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1.5 text-[10px] font-mono uppercase tracking-[0.2em] text-ink-faint/70">
|
||||
Personality
|
||||
</div>
|
||||
<p className="text-[13px] leading-relaxed text-ink-dull italic">“{PERSONA_DESCRIPTION}”</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Mode demo ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ModeDemo({ mode, cycleKey }: { mode: Mode; cycleKey: number }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-app-line bg-app-darkerBox overflow-hidden flex flex-col flex-1">
|
||||
{/* Mode tabs */}
|
||||
<div className="flex items-center gap-1 p-1.5 border-b border-app-line bg-app-darkBox/40">
|
||||
{MODES.map((m) => {
|
||||
const Icon = m.icon;
|
||||
const active = m.id === mode.id;
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`flex items-center gap-1.5 h-8 px-3 rounded-md text-[12px] font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-white/[0.07] text-foreground border border-white/[0.08]'
|
||||
: 'text-muted-foreground/60'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{m.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Input → Output */}
|
||||
<div className="p-5 flex-1 flex flex-col">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`${cycleKey}-${mode.id}`}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="flex flex-col gap-4 flex-1"
|
||||
>
|
||||
{/* Input */}
|
||||
{mode.input ? (
|
||||
<div>
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.2em] text-ink-faint/70 mb-1.5">
|
||||
{mode.inputLabel}
|
||||
</div>
|
||||
<div className="text-[13px] leading-relaxed text-ink-dull/90 font-mono bg-black/20 rounded-md border border-app-line/60 px-3 py-2.5">
|
||||
{mode.input}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.2em] text-ink-faint/70 mb-1.5">
|
||||
No input
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 text-[13px] text-ink-dull/90 bg-black/20 rounded-md border border-app-line/60 px-3 py-2.5">
|
||||
<Dices className="h-4 w-4 text-accent shrink-0" />
|
||||
<span>Click Compose — the character improvises a fresh line.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arrow */}
|
||||
<div className="flex items-center justify-center gap-2 text-[10px] font-mono uppercase tracking-[0.2em] text-ink-faint/50">
|
||||
<span>In character</span>
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</div>
|
||||
|
||||
{/* Output */}
|
||||
<div>
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.2em] text-accent mb-1.5">
|
||||
{mode.outputLabel}
|
||||
</div>
|
||||
<div className="text-[14px] leading-relaxed text-foreground bg-accent/[0.06] rounded-md border border-accent/20 px-3 py-2.5 italic">
|
||||
“{mode.output}”
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Bullets ────────────────────────────────────────────────────────────────
|
||||
|
||||
const BULLETS = [
|
||||
{
|
||||
icon: Wand2,
|
||||
title: 'Rewrite',
|
||||
description:
|
||||
'Restate your text in their voice while preserving every idea. Same content, their delivery — for scripts, dubs, and consistent character voice across long-form work.',
|
||||
},
|
||||
{
|
||||
icon: Dices,
|
||||
title: 'Compose',
|
||||
description:
|
||||
'No input needed — hit the button and the character improvises a fresh line of their own. Roll again for another take. Useful for game dialogue, narration cues, or character barks.',
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Section ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function Personalities() {
|
||||
const [idx, setIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const iv = window.setInterval(() => {
|
||||
setIdx((i) => (i + 1) % MODES.length);
|
||||
}, 4500);
|
||||
return () => window.clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const mode = MODES[idx];
|
||||
|
||||
return (
|
||||
<section id="personalities" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
{/* Header */}
|
||||
<div className="max-w-3xl mx-auto text-center mb-14">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
Personalities
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl font-semibold tracking-tight text-foreground mb-5">
|
||||
Voices with a personality.
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-base md:text-lg leading-relaxed">
|
||||
Give any voice profile a free-form personality. Then{' '}
|
||||
<b className="text-foreground/90">Rewrite</b> your text in their voice, or let them{' '}
|
||||
<b className="text-foreground/90">Compose</b> a fresh line of their own — your cloned voice, in full character.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mockup: persona card (left) + mode demo (right) */}
|
||||
<div className="grid md:grid-cols-[340px_1fr] gap-6 mb-12 items-stretch">
|
||||
<PersonaCard />
|
||||
<ModeDemo mode={mode} cycleKey={idx} />
|
||||
</div>
|
||||
|
||||
{/* Bullets */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{BULLETS.map((bullet) => {
|
||||
const Icon = bullet.icon;
|
||||
return (
|
||||
<div
|
||||
key={bullet.title}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-5"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className="h-4 w-4 text-accent" />
|
||||
<h3 className="text-[14px] font-semibold text-foreground">{bullet.title}</h3>
|
||||
</div>
|
||||
<p className="text-[13px] leading-relaxed text-muted-foreground">
|
||||
{bullet.description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {Check} from "lucide-react";
|
||||
import {useState} from "react";
|
||||
import {
|
||||
annualSavingsPercent,
|
||||
type BillingPeriod,
|
||||
PRICING_TIERS,
|
||||
} from "@/lib/pricing";
|
||||
|
||||
const MAX_ANNUAL_SAVINGS = Math.max(
|
||||
...PRICING_TIERS.map((t) => annualSavingsPercent(t)),
|
||||
);
|
||||
|
||||
export function PricingTiers() {
|
||||
const [period, setPeriod] = useState<BillingPeriod>("annual");
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
{/* Billing toggle */}
|
||||
<div className="mb-10 flex items-center justify-center">
|
||||
<div className="inline-flex items-center gap-1 rounded-full border border-border bg-card/40 p-1">
|
||||
<ToggleButton
|
||||
active={period === "monthly"}
|
||||
onClick={() => setPeriod("monthly")}
|
||||
>
|
||||
Monthly
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
active={period === "annual"}
|
||||
onClick={() => setPeriod("annual")}
|
||||
>
|
||||
Annual
|
||||
{MAX_ANNUAL_SAVINGS > 0 ? (
|
||||
<span
|
||||
className={`ml-1.5 rounded-full px-1.5 py-0.5 text-[10px] font-semibold transition-colors ${
|
||||
period === "annual"
|
||||
? "bg-white/25 text-white"
|
||||
: "bg-accent/15 text-accent"
|
||||
}`}
|
||||
>
|
||||
Save {MAX_ANNUAL_SAVINGS}%
|
||||
</span>
|
||||
) : null}
|
||||
</ToggleButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid items-start gap-4 md:grid-cols-3">
|
||||
{PRICING_TIERS.map((tier) => {
|
||||
const isFree = tier.monthly === 0 && tier.annual === 0;
|
||||
const amount = period === "monthly" ? tier.monthly : tier.annual;
|
||||
const unit = period === "monthly" ? "/month" : "/year";
|
||||
const isExternal = tier.cta.href.startsWith("http");
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tier.id}
|
||||
className={`flex flex-col rounded-2xl border bg-card/50 backdrop-blur-sm p-7 ${
|
||||
tier.highlighted
|
||||
? "border-2 border-accent/50 shadow-[0_8px_40px_hsl(43_60%_50%/0.1)] md:-mt-2"
|
||||
: "border-border"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{tier.name}
|
||||
</h2>
|
||||
{tier.badge ? (
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${
|
||||
tier.highlighted
|
||||
? "bg-accent/15 text-accent"
|
||||
: "border border-border/60 text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{tier.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="mb-5 min-h-[2.5rem] text-sm text-muted-foreground">
|
||||
{tier.tagline}
|
||||
</p>
|
||||
|
||||
<div className="mb-1 flex items-baseline gap-1">
|
||||
<span className="text-4xl font-bold tracking-tight text-foreground">
|
||||
${amount}
|
||||
</span>
|
||||
{!isFree ? (
|
||||
<span className="text-sm text-muted-foreground">{unit}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mb-6 min-h-[1rem] text-xs text-muted-foreground/70">
|
||||
{isFree
|
||||
? tier.priceNote
|
||||
: period === "annual"
|
||||
? tier.priceNote
|
||||
: `Billed monthly · ${tier.priceNote ?? ""}`}
|
||||
</p>
|
||||
|
||||
<a
|
||||
href={tier.cta.href}
|
||||
{...(isExternal
|
||||
? {target: "_blank", rel: "noopener noreferrer"}
|
||||
: {})}
|
||||
className={`mb-7 inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-semibold transition-all ${
|
||||
tier.highlighted
|
||||
? "bg-accent text-white shadow-[0_4px_20px_hsl(43_60%_50%/0.3)] hover:bg-accent-faint"
|
||||
: "border border-border/60 bg-card/40 text-foreground hover:border-accent/40"
|
||||
}`}
|
||||
>
|
||||
{tier.cta.label}
|
||||
</a>
|
||||
|
||||
<ul className="space-y-3">
|
||||
{tier.features.map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
className="flex items-start gap-3 text-sm text-foreground/90"
|
||||
>
|
||||
<Check className="h-4 w-4 shrink-0 text-accent mt-0.5" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex items-center rounded-full px-4 py-1.5 text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "bg-accent text-white"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Brain,
|
||||
Globe,
|
||||
Languages,
|
||||
type LucideIcon,
|
||||
MessageSquare,
|
||||
Mic2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Volume2,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
type Tag = { icon: LucideIcon; label: string };
|
||||
|
||||
type Model = {
|
||||
name: string;
|
||||
author: string;
|
||||
sizes?: string[];
|
||||
description: string;
|
||||
tags?: Tag[];
|
||||
};
|
||||
|
||||
type ModelGroup = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
models: Model[];
|
||||
};
|
||||
|
||||
const MODEL_GROUPS: ModelGroup[] = [
|
||||
{
|
||||
title: 'TTS Engines',
|
||||
subtitle: 'Text → speech. Voice cloning, preset voices, and delivery control.',
|
||||
models: [
|
||||
{
|
||||
name: 'Qwen3-TTS',
|
||||
author: 'Alibaba',
|
||||
sizes: ['1.7B', '0.6B'],
|
||||
description:
|
||||
'High-quality multilingual cloning with natural prosody. The only engine with delivery instructions — control tone, pace, and emotion with natural language.',
|
||||
tags: [
|
||||
{ icon: Globe, label: '10 langs' },
|
||||
{ icon: MessageSquare, label: 'Delivery instructions' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Chatterbox',
|
||||
author: 'Resemble AI',
|
||||
description:
|
||||
'Production-grade voice cloning with the broadest language support. 23 languages with zero-shot cloning and emotion exaggeration control.',
|
||||
tags: [{ icon: Languages, label: '23 langs' }],
|
||||
},
|
||||
{
|
||||
name: 'Chatterbox Turbo',
|
||||
author: 'Resemble AI',
|
||||
sizes: ['350M'],
|
||||
description:
|
||||
'Lightweight and fast. Supports paralinguistic tags — embed [laugh], [sigh], [gasp] directly in your text for expressive speech.',
|
||||
tags: [
|
||||
{ icon: Zap, label: 'Fast' },
|
||||
{ icon: MessageSquare, label: '[tag] support' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'LuxTTS',
|
||||
author: 'ZipVoice',
|
||||
description:
|
||||
'Ultra-fast, CPU-friendly cloning at 48kHz. Exceeds 150x realtime on CPU with ~1GB VRAM. The fastest engine for quick iterations.',
|
||||
tags: [
|
||||
{ icon: Zap, label: '150x realtime' },
|
||||
{ icon: Volume2, label: '48kHz' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Qwen CustomVoice',
|
||||
author: 'Alibaba',
|
||||
sizes: ['1.7B', '0.6B'],
|
||||
description:
|
||||
'Nine premium preset speakers with natural-language style control. "Speak slowly with warmth", "authoritative and clear" — tone and pace adapt.',
|
||||
tags: [
|
||||
{ icon: SlidersHorizontal, label: 'Instruct control' },
|
||||
{ icon: Globe, label: '10 langs' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'TADA',
|
||||
author: 'Hume AI',
|
||||
sizes: ['3B', '1B'],
|
||||
description:
|
||||
'Speech-language model with text-acoustic dual alignment. Built for long-form — 700s+ coherent audio without drift. Multilingual at 3B.',
|
||||
tags: [
|
||||
{ icon: Globe, label: '10 langs' },
|
||||
{ icon: MessageSquare, label: 'Long-form' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Kokoro',
|
||||
author: 'hexgrad · Apache 2.0',
|
||||
sizes: ['82M'],
|
||||
description:
|
||||
'Tiny 82M-parameter TTS that runs at CPU realtime with negligible VRAM. Pre-built voice styles — pick a voice, type, generate.',
|
||||
tags: [
|
||||
{ icon: Zap, label: 'CPU realtime' },
|
||||
{ icon: Volume2, label: 'Preset voices' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Transcription',
|
||||
subtitle: 'Speech → text. Multi-language STT for dictation and captures.',
|
||||
models: [
|
||||
{
|
||||
name: 'Whisper',
|
||||
author: 'OpenAI',
|
||||
sizes: ['1.5B', '769M', '244M', '74M'],
|
||||
description:
|
||||
'The default. Mature multilingual ASR across a wide size range — pick Tiny for speed or Large for best accuracy.',
|
||||
tags: [{ icon: Languages, label: '99 langs' }],
|
||||
},
|
||||
{
|
||||
name: 'Whisper Turbo',
|
||||
author: 'OpenAI',
|
||||
sizes: ['809M'],
|
||||
description:
|
||||
'Pruned Whisper Large v3. Near-best quality at roughly 8x the speed — the right default for real-time dictation.',
|
||||
tags: [
|
||||
{ icon: Languages, label: '99 langs' },
|
||||
{ icon: Zap, label: '8x faster' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Language Models',
|
||||
subtitle: 'Transcript refinement, persona replies, and on-device reasoning.',
|
||||
models: [
|
||||
{
|
||||
name: 'Qwen3',
|
||||
author: 'Alibaba',
|
||||
sizes: ['4B', '1.7B', '0.6B'],
|
||||
description:
|
||||
'Powers transcript cleanup, persona voice replies, and the voice I/O loop. Shares its runtime with the TTS/STT stack — one model cache, one GPU story.',
|
||||
tags: [
|
||||
{ icon: Sparkles, label: 'Refinement' },
|
||||
{ icon: Brain, label: 'Persona replies' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function ModelCard({ model }: { model: Model }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-4 transition-colors hover:border-accent/30 flex flex-col">
|
||||
<div className="flex items-start justify-between gap-2 mb-1.5">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-foreground truncate">{model.name}</h3>
|
||||
<span className="text-[11px] text-muted-foreground/60">by {model.author}</span>
|
||||
</div>
|
||||
{model.sizes && model.sizes.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 justify-end shrink-0 max-w-[55%]">
|
||||
{model.sizes.map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="text-[9px] px-1.5 py-0.5 rounded-full border border-border bg-background text-muted-foreground whitespace-nowrap tabular-nums"
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed mb-3 flex-1">
|
||||
{model.description}
|
||||
</p>
|
||||
{model.tags && model.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{model.tags.map((tag) => {
|
||||
const Icon = tag.icon;
|
||||
return (
|
||||
<span
|
||||
key={tag.label}
|
||||
className="flex items-center gap-1 text-[10px] text-muted-foreground/70"
|
||||
>
|
||||
<Icon className="h-2.5 w-2.5" />
|
||||
{tag.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelGroupSection({ group }: { group: ModelGroup }) {
|
||||
return (
|
||||
<div>
|
||||
{/* Group header */}
|
||||
<div className="flex items-baseline justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">{group.title}</h3>
|
||||
<p className="text-sm text-muted-foreground/80">{group.subtitle}</p>
|
||||
</div>
|
||||
<span className="text-[11px] font-mono text-ink-faint/60 tabular-nums shrink-0">
|
||||
{String(group.models.length).padStart(2, '0')} model
|
||||
{group.models.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{group.models.map((model) => (
|
||||
<ModelCard key={model.name} model={model} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SupportedModels() {
|
||||
return (
|
||||
<section id="about" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||
Supported models
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Pick the right model for every job — TTS, transcription, refinement. All models run
|
||||
locally on your hardware. Download once, use forever.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-14">
|
||||
{MODEL_GROUPS.map((group) => (
|
||||
<ModelGroupSection key={group.title} group={group} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {Heart, Quote} from "lucide-react";
|
||||
import {DONATE_URL} from "@/lib/constants";
|
||||
|
||||
type Testimonial = {
|
||||
quote: string;
|
||||
author: string;
|
||||
};
|
||||
|
||||
/** Verbatim supporter messages from Buy Me a Coffee. */
|
||||
const TESTIMONIALS: Testimonial[] = [
|
||||
{
|
||||
quote:
|
||||
"Cloning my own voice was a snap — and now I can hear my reminders and to-dos from my digital doppelgänger. Very cool.",
|
||||
author: "jimzip",
|
||||
},
|
||||
{
|
||||
quote: "It's better than most other paid services.",
|
||||
author: "Peiming Pai",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"I'm using this great tool for my multimedia course project — you made it into the classrooms!",
|
||||
author: "theanoma.ly",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"This app is fantastic! I use it to learn languages and for my learning materials. Congratulations, it's great!",
|
||||
author: "Kevin Serrano",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"Absolutely amazing! The learning curve was very short. Thank you for a great program and for making it free.",
|
||||
author: "DJWhy",
|
||||
},
|
||||
{
|
||||
quote: "First engine I tried, zero config. It worked! Amazing.",
|
||||
author: "Fitz",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"This is great for people who are uncomfortable with advocating for themselves in public. Thanks for making it.",
|
||||
author: "creativeaction.ca",
|
||||
},
|
||||
{
|
||||
quote: "Thanks for this. It's a life-saver!",
|
||||
author: "The Cowboy Movie Channel",
|
||||
},
|
||||
{
|
||||
quote: "Fantastic open-source app!",
|
||||
author: "Mitja",
|
||||
},
|
||||
];
|
||||
|
||||
export function Testimonials() {
|
||||
return (
|
||||
<section id="testimonials" 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">
|
||||
<Heart className="h-3 w-3 text-accent" />
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Loved by users
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||
What people are saying
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Voicebox has passed 1M+ downloads. Here's a handful of notes from
|
||||
the people using it every day.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Masonry-style columns so cards flow naturally regardless of length */}
|
||||
<div className="columns-1 gap-4 sm:columns-2 lg:columns-3 [&>*]:mb-4">
|
||||
{TESTIMONIALS.map((t) => (
|
||||
<figure
|
||||
key={t.author}
|
||||
className="break-inside-avoid rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5 transition-colors hover:border-accent/30"
|
||||
>
|
||||
<Quote className="h-4 w-4 text-accent/60 mb-3" />
|
||||
<blockquote className="text-sm leading-relaxed text-foreground/90">
|
||||
{t.quote}
|
||||
</blockquote>
|
||||
<figcaption className="mt-4 text-xs font-medium text-muted-foreground">
|
||||
{t.author}
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Attribution + soft CTA */}
|
||||
<div className="mt-10 text-center">
|
||||
<a
|
||||
href={DONATE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground/70 hover:text-foreground transition-colors"
|
||||
>
|
||||
From supporters on Buy Me a Coffee →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {ArrowUpRight, Check, Coins, Copy} from "lucide-react";
|
||||
import {useState} from "react";
|
||||
import {
|
||||
TOKEN_CONTRACT_ADDRESS,
|
||||
TOKEN_PUMP_URL,
|
||||
TOKEN_TICKER,
|
||||
} from "@/lib/constants";
|
||||
|
||||
export function TokenSection() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(TOKEN_CONTRACT_ADDRESS);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard unavailable (e.g. insecure context) — silently no-op.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="token" className="border-t border-border py-24">
|
||||
<div className="relative mx-auto max-w-4xl px-6">
|
||||
{/* Subtle accent glow */}
|
||||
<div className="pointer-events-none absolute inset-0 -z-10 flex justify-center">
|
||||
<div className="h-[260px] w-[520px] rounded-full bg-accent/10 blur-[140px]" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center mb-10">
|
||||
<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">
|
||||
<Coins className="h-3 w-3 text-accent" />
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Official token
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||
{TOKEN_TICKER} on Solana
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
The official {TOKEN_TICKER} token for supporters who want to back
|
||||
the project and have some fun. Voicebox is and always will be{" "}
|
||||
<b className="text-foreground">free and open source</b> — the token
|
||||
is entirely optional and not required to use anything here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contract address + CTA */}
|
||||
<div className="mx-auto max-w-2xl rounded-2xl border border-border bg-card/60 backdrop-blur-sm p-5 sm:p-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Contract address
|
||||
</span>
|
||||
<span className="ml-auto inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-background/60 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
Solana
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
{/* Address bar */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
title="Copy contract address"
|
||||
aria-label={
|
||||
copied ? "Contract address copied" : "Copy contract address"
|
||||
}
|
||||
className="group flex min-w-0 flex-1 items-center gap-3 rounded-xl border border-border bg-background/60 px-4 py-3 text-left transition-colors hover:border-accent/40"
|
||||
>
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-xs text-foreground/90 sm:text-sm">
|
||||
{TOKEN_CONTRACT_ADDRESS}
|
||||
</code>
|
||||
{copied ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 text-xs font-medium text-accent">
|
||||
<Check className="h-4 w-4" />
|
||||
Copied
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors group-hover:text-foreground">
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Buy CTA */}
|
||||
<a
|
||||
href={TOKEN_PUMP_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-accent px-5 py-3 text-sm font-semibold 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"
|
||||
>
|
||||
Buy on pump.fun
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
import {ArrowUpRight, Coins, Flame, Lock, Users, Wallet} from "lucide-react";
|
||||
import {
|
||||
TOKEN_CONTRACT_ADDRESS,
|
||||
TOKEN_CREATOR_ADDRESS,
|
||||
TOKEN_SOLSCAN_URL,
|
||||
TOKEN_TICKER,
|
||||
} from "@/lib/constants";
|
||||
import {getTokenStats, type TokenStats} from "@/lib/token-stats";
|
||||
|
||||
// ── formatters ───────────────────────────────────────────────────────────────
|
||||
function compact(n: number | null): string {
|
||||
if (n == null) return "—";
|
||||
const abs = Math.abs(n);
|
||||
if (abs >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`;
|
||||
if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
||||
if (abs >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return n.toLocaleString("en-US", {maximumFractionDigits: 0});
|
||||
}
|
||||
|
||||
function pct(n: number | null): string {
|
||||
if (n == null) return "—";
|
||||
if (n > 0 && n < 0.01) return "<0.01%";
|
||||
return `${n.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function usdPrice(n: number | null): string {
|
||||
if (n == null) return "—";
|
||||
if (n < 0.000001) return `$${n.toExponential(2)}`;
|
||||
if (n < 1) return `$${n.toPrecision(3)}`;
|
||||
return `$${n.toLocaleString("en-US", {maximumFractionDigits: 2})}`;
|
||||
}
|
||||
|
||||
function usdBig(n: number | null): string {
|
||||
if (n == null) return "—";
|
||||
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(2)}M`;
|
||||
if (n >= 1_000) return `$${(n / 1_000).toFixed(1)}K`;
|
||||
return `$${n.toLocaleString("en-US", {maximumFractionDigits: 0})}`;
|
||||
}
|
||||
|
||||
function sol(n: number | null): string {
|
||||
if (n == null) return "—";
|
||||
return `${n.toLocaleString("en-US", {maximumFractionDigits: 2})} SOL`;
|
||||
}
|
||||
|
||||
function shortAddr(a: string): string {
|
||||
return a.length > 12 ? `${a.slice(0, 4)}…${a.slice(-4)}` : a;
|
||||
}
|
||||
|
||||
function solscanAccount(a: string): string {
|
||||
return `https://solscan.io/account/${a}`;
|
||||
}
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const secs = Math.max(0, Math.round((Date.now() - ts) / 1000));
|
||||
if (secs < 60) return "just now";
|
||||
const mins = Math.round(secs / 60);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.round(mins / 60);
|
||||
return `${hrs}h ago`;
|
||||
}
|
||||
|
||||
export async function TokenStatsSection() {
|
||||
const stats = await getTokenStats();
|
||||
return <TokenStatsView stats={stats} />;
|
||||
}
|
||||
|
||||
// Hidden for now — flip to true to bring the "fees earned for development"
|
||||
// card back. The data is still fetched; it's just not rendered.
|
||||
const SHOW_CREATOR_REWARDS = false;
|
||||
|
||||
function TokenStatsView({stats}: {stats: TokenStats}) {
|
||||
const cards = [
|
||||
{
|
||||
icon: Flame,
|
||||
label: "Burned",
|
||||
value: compact(stats.burned),
|
||||
sub: stats.burnedPct != null ? `${pct(stats.burnedPct)} of initial supply` : "Removed from supply forever",
|
||||
},
|
||||
{
|
||||
icon: Lock,
|
||||
label: "Locked",
|
||||
value: stats.locked != null ? compact(stats.locked) : "Not configured",
|
||||
sub: stats.lockedPct != null ? `${pct(stats.lockedPct)} of supply` : "Liquidity & vesting locks",
|
||||
},
|
||||
{
|
||||
icon: Wallet,
|
||||
label: "Dev / treasury",
|
||||
value: stats.devBalance != null ? compact(stats.devBalance) : "Not configured",
|
||||
sub: stats.devPct != null ? `${pct(stats.devPct)} of supply` : "Team-held tokens",
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
label: "Holders",
|
||||
value: stats.holders != null ? `${stats.holdersCapped ? "" : ""}${stats.holders.toLocaleString("en-US")}` : "—",
|
||||
sub: stats.holdersCapped ? "counted (capped)" : "unique wallets",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-5xl px-6">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
Live on-chain stats
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
|
||||
Every number, straight from the chain.
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto mt-4">
|
||||
Supply, holders, burns, locks and team holdings for {TOKEN_TICKER},
|
||||
read live from Solana.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Supply + market headline */}
|
||||
<div className="grid gap-4 sm:grid-cols-3 mb-4">
|
||||
<HeadlineStat
|
||||
label="Circulating supply"
|
||||
value={compact(stats.circulating)}
|
||||
sub={
|
||||
stats.totalSupply != null
|
||||
? `of ${compact(stats.totalSupply)} total`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<HeadlineStat label="Price" value={usdPrice(stats.priceUsd)} sub="via Jupiter" />
|
||||
<HeadlineStat label="Market cap" value={usdBig(stats.marketCapUsd)} sub="price × supply" />
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{cards.map((c) => {
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<div
|
||||
key={c.label}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
|
||||
>
|
||||
<Icon className="h-5 w-5 text-accent mb-3" />
|
||||
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground mb-1">
|
||||
{c.label}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tracking-tight text-foreground tabular-nums">
|
||||
{c.value}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{c.sub}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Creator rewards — pump.fun creator fees, the funding story */}
|
||||
{SHOW_CREATOR_REWARDS && stats.creatorRewardsSol != null && (
|
||||
<div className="mt-4 rounded-2xl border border-accent/30 bg-gradient-to-b from-accent/[0.08] to-transparent p-8 text-center">
|
||||
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full border border-accent/30 bg-accent/10">
|
||||
<Coins className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Fees earned for development
|
||||
</div>
|
||||
<div className="mt-2 text-4xl font-semibold tracking-tight text-foreground tabular-nums">
|
||||
{sol(stats.creatorRewardsSol)}
|
||||
</div>
|
||||
{stats.creatorRewardsUsd != null && (
|
||||
<div className="mt-1 text-sm text-muted-foreground tabular-nums">
|
||||
≈ {usdBig(stats.creatorRewardsUsd)}
|
||||
</div>
|
||||
)}
|
||||
<p className="mx-auto mt-4 max-w-md text-sm leading-relaxed text-muted-foreground">
|
||||
Lifetime {TOKEN_TICKER} trading fees — the funding that pays for
|
||||
full-time work on Voicebox.{" "}
|
||||
<a
|
||||
href={`https://solscan.io/account/${TOKEN_CREATOR_ADDRESS}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 text-foreground/80 hover:text-foreground"
|
||||
>
|
||||
Verify <ArrowUpRight className="h-3 w-3" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Locked breakdown (only if any configured) */}
|
||||
{stats.lockedBreakdown.length > 0 && (
|
||||
<div className="mt-4 rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground mb-4">
|
||||
Locked & vesting
|
||||
</div>
|
||||
<ul className="space-y-3">
|
||||
{stats.lockedBreakdown.map((l) => (
|
||||
<li
|
||||
key={l.account}
|
||||
className="flex items-center gap-3 text-sm"
|
||||
>
|
||||
<Lock className="h-4 w-4 shrink-0 text-accent" />
|
||||
<span className="text-foreground/90">{l.label}</span>
|
||||
{l.unlocksAt && (
|
||||
<span className="text-xs text-muted-foreground">· {l.unlocksAt}</span>
|
||||
)}
|
||||
<span className="ml-auto font-medium tabular-nums text-foreground">
|
||||
{compact(l.amount)}
|
||||
</span>
|
||||
<a
|
||||
href={l.url ?? solscanAccount(l.account)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="View on Solscan"
|
||||
>
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top holders */}
|
||||
{stats.topHolders.length > 0 && (
|
||||
<div className="mt-4 rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Top holders
|
||||
</div>
|
||||
<a
|
||||
href={`${TOKEN_SOLSCAN_URL}#holders`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
All holders <ArrowUpRight className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
<ul className="divide-y divide-border/60">
|
||||
{stats.topHolders.map((h, i) => (
|
||||
<li
|
||||
key={h.owner}
|
||||
className="flex items-center gap-3 py-2.5 text-sm"
|
||||
>
|
||||
<span className="w-5 text-xs text-muted-foreground tabular-nums">
|
||||
{i + 1}
|
||||
</span>
|
||||
<a
|
||||
href={solscanAccount(h.owner)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-foreground/90 hover:text-foreground hover:underline"
|
||||
>
|
||||
{shortAddr(h.owner)}
|
||||
</a>
|
||||
<span className="ml-auto tabular-nums text-foreground">
|
||||
{compact(h.amount)}
|
||||
</span>
|
||||
<span className="w-16 text-right tabular-nums text-muted-foreground">
|
||||
{pct(h.pct)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer: provenance + freshness */}
|
||||
<div className="mt-6 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{stats.live ? (
|
||||
<>Updated {timeAgo(stats.updatedAt)} · data via Helius & Jupiter</>
|
||||
) : (
|
||||
<>Live stats unavailable right now — verify on Solscan.</>
|
||||
)}
|
||||
</span>
|
||||
<a
|
||||
href={TOKEN_SOLSCAN_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors"
|
||||
>
|
||||
<span className="font-mono">{shortAddr(TOKEN_CONTRACT_ADDRESS)}</span>
|
||||
Inspect on Solscan <ArrowUpRight className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function HeadlineStat({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-card/60 backdrop-blur-sm p-6 text-center">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground mb-2">
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-3xl font-semibold tracking-tight text-foreground tabular-nums">
|
||||
{value}
|
||||
</div>
|
||||
{sub && <div className="text-xs text-muted-foreground mt-1">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import {ArrowUpRight, Coins} from "lucide-react";
|
||||
import {TOKEN_TICKER} from "@/lib/constants";
|
||||
|
||||
/**
|
||||
* Compact teaser shown near the bottom of the landing page. The full token
|
||||
* details (contract address, buy CTA, disclaimers) live on the dedicated
|
||||
* /token page — this just points there.
|
||||
*/
|
||||
export function TokenTeaser() {
|
||||
return (
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-4xl px-6">
|
||||
<div className="relative flex flex-col items-center gap-5 overflow-hidden rounded-2xl border border-border bg-card/40 backdrop-blur-sm px-6 py-10 text-center">
|
||||
{/* Subtle accent glow */}
|
||||
<div className="pointer-events-none absolute inset-0 -z-10 flex justify-center">
|
||||
<div className="h-[200px] w-[420px] rounded-full bg-accent/10 blur-[130px]" />
|
||||
</div>
|
||||
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/40 px-3 py-1">
|
||||
<Coins className="h-3 w-3 text-accent" />
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Official token
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold tracking-tight text-foreground md:text-3xl">
|
||||
{TOKEN_TICKER} on Solana
|
||||
</h2>
|
||||
|
||||
<p className="max-w-xl text-muted-foreground">
|
||||
An optional way to back the project and have some fun. Voicebox is and
|
||||
always will be{" "}
|
||||
<b className="text-foreground">free and open source</b> — the token is
|
||||
not required to use anything here.
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/token"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-accent px-6 py-3 text-sm font-semibold 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"
|
||||
>
|
||||
Learn about {TOKEN_TICKER}
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,479 +0,0 @@
|
||||
'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">
|
||||
Any clip becomes a voice.
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Three ways to get a sample in. 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>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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 };
|
||||
@@ -1,58 +0,0 @@
|
||||
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 };
|
||||
@@ -1,35 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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 };
|
||||
@@ -1,77 +0,0 @@
|
||||
import {readdirSync, readFileSync} from "node:fs";
|
||||
import {join} from "node:path";
|
||||
import matter from "gray-matter";
|
||||
import {marked} from "marked";
|
||||
|
||||
// Posts are authored as markdown under src/posts. This module uses the Node fs
|
||||
// API and must only be imported from server components / server code — never
|
||||
// from a "use client" file, or the markdown libraries leak into the bundle.
|
||||
|
||||
export type BlogPost = {
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string;
|
||||
date: string;
|
||||
tags: string[];
|
||||
excerpt: string;
|
||||
readingMinutes: number;
|
||||
html: string;
|
||||
};
|
||||
|
||||
export type BlogPostSummary = Omit<BlogPost, "html">;
|
||||
|
||||
const POSTS_DIR = join(process.cwd(), "src/posts");
|
||||
|
||||
function slugFromFile(file: string): string {
|
||||
return file.replace(/\.md$/, "");
|
||||
}
|
||||
|
||||
function parsePost(file: string, raw: string): BlogPost {
|
||||
const {data, content} = matter(raw);
|
||||
const words = content.trim().split(/\s+/).filter(Boolean).length;
|
||||
return {
|
||||
slug: slugFromFile(file),
|
||||
title: String(data.title ?? "Untitled"),
|
||||
author: String(data.author ?? "Jamie Pine"),
|
||||
date:
|
||||
data.date instanceof Date
|
||||
? data.date.toISOString().slice(0, 10)
|
||||
: String(data.date ?? ""),
|
||||
tags: Array.isArray(data.tags) ? data.tags.map(String) : [],
|
||||
excerpt: String(data.excerpt ?? ""),
|
||||
readingMinutes: Math.max(1, Math.ceil(words / 220)),
|
||||
html: marked.parse(content, {async: false}) as string,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadAllPosts(): BlogPost[] {
|
||||
let files: string[] = [];
|
||||
try {
|
||||
files = readdirSync(POSTS_DIR).filter((f) => f.endsWith(".md"));
|
||||
} catch {
|
||||
return []; // posts dir doesn't exist yet — treat as empty
|
||||
}
|
||||
return files
|
||||
.map((file) => parsePost(file, readFileSync(join(POSTS_DIR, file), "utf8")))
|
||||
.sort((a, b) => (a.date < b.date ? 1 : -1));
|
||||
}
|
||||
|
||||
export function listPosts(): BlogPostSummary[] {
|
||||
return loadAllPosts().map(({html: _html, ...summary}) => summary);
|
||||
}
|
||||
|
||||
export function getPost(slug: string): BlogPost | null {
|
||||
return loadAllPosts().find((post) => post.slug === slug) ?? null;
|
||||
}
|
||||
|
||||
export function formatDate(date: string): string {
|
||||
if (!date) return "";
|
||||
const d = new Date(`${date}T00:00:00Z`);
|
||||
if (Number.isNaN(d.getTime())) return date;
|
||||
return d.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
// 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 SPONSOR_CHECKOUT_URL = 'https://buy.stripe.com/eVqdRad3n16ubcqf201Jm00';
|
||||
export const SPONSOR_CONTACT_EMAIL = '[email protected]';
|
||||
|
||||
// $VOICEBOX — the official community token on Solana
|
||||
export const TOKEN_TICKER = '$VOICEBOX';
|
||||
export const TOKEN_CONTRACT_ADDRESS = 'FpzZHtp5tbvz6xndEtoJHoGEWcT7cFEuscdCh9RApump';
|
||||
export const TOKEN_PUMP_URL = `https://pump.fun/coin/${TOKEN_CONTRACT_ADDRESS}`;
|
||||
// Solscan token page — lets anyone inspect supply, holders, and history.
|
||||
export const TOKEN_SOLSCAN_URL = `https://solscan.io/token/${TOKEN_CONTRACT_ADDRESS}`;
|
||||
export const TOKEN_TOTAL_SUPPLY = '1B';
|
||||
|
||||
// ── Live on-chain tracking config ───────────────────────────────────────────
|
||||
// Powers the transparency dashboard on /token. Reads are done server-side via
|
||||
// Helius (HELIUS_API_KEY). Every value below has a safe default so the page
|
||||
// still renders if something is unset — sections you haven't configured just
|
||||
// show as "not configured" rather than breaking the build.
|
||||
|
||||
/** Mint supply at launch, used to derive burned = initial − current supply. */
|
||||
export const TOKEN_INITIAL_SUPPLY = 1_000_000_000;
|
||||
|
||||
/**
|
||||
* pump.fun creator wallet — the address that launched the coin and earns creator
|
||||
* fees. Lifetime creator rewards (in SOL) are read from pump.fun's swap-api for
|
||||
* this wallet. Defaults to the dev wallet (they're the same here).
|
||||
*/
|
||||
export const TOKEN_CREATOR_ADDRESS = envStr(
|
||||
'TOKEN_CREATOR_ADDRESS',
|
||||
'BSn573bjkQa5iffMg6zA8eb9mHyzewu2ps9R85qNKXC5',
|
||||
);
|
||||
|
||||
/**
|
||||
* Dev / treasury wallets to surface as "team holdings". List every address you
|
||||
* want counted; balances are summed. Public, read-only — these are already
|
||||
* visible on-chain. Override at deploy time with TOKEN_DEV_WALLETS (comma list).
|
||||
*/
|
||||
export const TOKEN_DEV_WALLETS: string[] = envList('TOKEN_DEV_WALLETS', [
|
||||
'BSn573bjkQa5iffMg6zA8eb9mHyzewu2ps9R85qNKXC5', // Jamie's dev/treasury wallet
|
||||
]);
|
||||
|
||||
/**
|
||||
* Locked supply: token accounts whose $VOICEBOX is locked (liquidity lockers,
|
||||
* vesting escrows). Each entry is summed into "locked"; unlocksAt is optional
|
||||
* copy for the card. Override with TOKEN_LOCKED_ACCOUNTS as a JSON array.
|
||||
*/
|
||||
export interface LockedAccount {
|
||||
label: string;
|
||||
/** The token account or owner address holding the locked $VOICEBOX. */
|
||||
account: string;
|
||||
/** Human-readable unlock date, e.g. "Unlocks Jun 2027" (optional). */
|
||||
unlocksAt?: string;
|
||||
/** Optional Solscan/locker link proving the lock. */
|
||||
url?: string;
|
||||
}
|
||||
export const TOKEN_LOCKED_ACCOUNTS: LockedAccount[] = envJson<LockedAccount[]>(
|
||||
'TOKEN_LOCKED_ACCOUNTS',
|
||||
[
|
||||
// Streamflow locks. `account` is each lock's escrow token account (read for
|
||||
// the live balance, so it ticks down only when actually unlocked/withdrawn);
|
||||
// `url` is the public Streamflow contract page for verification.
|
||||
{
|
||||
label: 'Streamflow lock #1',
|
||||
account: 'EaPun3ZUk5XiKft2tbvVRXgq8HyXjTmg77kUYYe7Q5HM',
|
||||
unlocksAt: 'Unlocks Jun 2027',
|
||||
url: 'https://app.streamflow.finance/contract/solana/mainnet/AmzHaDAZWWZPkvN5zC78mQ3QAedH7hHSCEWeYSbSWXu5',
|
||||
},
|
||||
{
|
||||
label: 'Streamflow lock #2',
|
||||
account: 'FGK5G4CbtryRdoubPN7u4y3WTYS4vqoepPLpppba92cp',
|
||||
unlocksAt: 'Unlocks Jun 2027',
|
||||
url: 'https://app.streamflow.finance/contract/solana/mainnet/GfBjWriW8mcJWS9njC2gBBJoJuGRQQzJLRFNg6a12bW8',
|
||||
},
|
||||
{
|
||||
label: 'Streamflow lock #3',
|
||||
account: 'ELKMRnDin7w6ht4MkQ6FnDU3LvkDtoYbtR9y3P51pf1N',
|
||||
unlocksAt: 'Unlocks Jun 2027',
|
||||
url: 'https://app.streamflow.finance/contract/solana/mainnet/3xa49K6b8ChsL5SoPYrAWigwKmoXAge6YCAmUJWM6Ncw',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Burn / dead address. The standard SPL incinerator by default. Buyback+burns
|
||||
* that reduce mint supply are already captured by initial − current; this is
|
||||
* only used to additionally surface anything parked at a dead address.
|
||||
*/
|
||||
export const TOKEN_BURN_ADDRESS = envStr(
|
||||
'TOKEN_BURN_ADDRESS',
|
||||
'1nc1nerator11111111111111111111111111111111',
|
||||
);
|
||||
|
||||
/** How long stats are cached server-side (ms). Keeps us off rate limits. */
|
||||
export const TOKEN_STATS_CACHE_MS = 1000 * 60 * 10; // 10 minutes
|
||||
|
||||
// ── tiny env helpers (server-only; safe in this module, no secrets exposed) ──
|
||||
function envStr(key: string, fallback: string): string {
|
||||
const v = process.env[key];
|
||||
return v && v.trim() ? v.trim() : fallback;
|
||||
}
|
||||
function envList(key: string, fallback: string[]): string[] {
|
||||
const v = process.env[key];
|
||||
if (!v || !v.trim()) return fallback;
|
||||
return v
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
function envJson<T>(key: string, fallback: T): T {
|
||||
const v = process.env[key];
|
||||
if (!v || !v.trim()) return fallback;
|
||||
try {
|
||||
return JSON.parse(v) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// On-chain transparency log — locks and buyback+burns.
|
||||
// Add a new entry every time a lock or burn happens; set `txUrl` to its Solscan
|
||||
// link to make the card a live, verifiable proof. Entries without a txUrl render
|
||||
// as "proof link pending" — fill them in as soon as the hash is available.
|
||||
export interface TokenProof {
|
||||
kind: 'lock' | 'burn';
|
||||
label: string;
|
||||
detail: string;
|
||||
/** Solscan (or locker) URL proving the action. Empty = pending, shown as such. */
|
||||
txUrl: string;
|
||||
}
|
||||
|
||||
export const TOKEN_PROOFS: TokenProof[] = [
|
||||
{
|
||||
kind: 'lock',
|
||||
label: 'Launch liquidity lock',
|
||||
detail:
|
||||
'Liquidity and a portion of dev holdings were locked at launch (~6.6% top holder), so the supply can be verified on-chain from day one.',
|
||||
txUrl: '', // TODO(jamie): add the Solscan/locker link for the launch lock
|
||||
},
|
||||
{
|
||||
kind: 'burn',
|
||||
label: 'Buyback & burn',
|
||||
detail:
|
||||
'Bought $VOICEBOX back from the open market and burned it to a dead address, permanently removing it from supply.',
|
||||
txUrl:
|
||||
'https://solscan.io/tx/5MjK4CYMBKAewLcjdD6QkM8ctkeG2bjyQhpjNgEumkDbDtoKCVmzKcWwLWsd4QJov8hs5zbGLt3g5vVCp4CBmze5',
|
||||
},
|
||||
{
|
||||
kind: 'burn',
|
||||
label: 'Buyback & burn',
|
||||
detail:
|
||||
'A second buyback and burn — part of an ongoing commitment to keep buying back and reducing supply over time.',
|
||||
txUrl: '', // TODO(jamie): add the Solscan link for the second burn
|
||||
},
|
||||
];
|
||||
|
||||
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';
|
||||
@@ -1,126 +0,0 @@
|
||||
// Pricing + cloud data — single source of truth for /pricing and /cloud.
|
||||
//
|
||||
// DRAFT: the cloud service is not live yet and the pricing model is not final.
|
||||
// $12/yr is a launch/intro figure; tier limits below are placeholders to shape
|
||||
// the page — tune them once the model is decided. Keeping it all here means the
|
||||
// pages update by editing this file only.
|
||||
|
||||
export const CLOUD_STATUS = "coming-soon" as const; // → flips to "live" at launch
|
||||
export const CLOUD_PRICE_YEARLY = 12; // launch price for the Cloud tier (USD/yr)
|
||||
|
||||
/** Where "get notified" CTAs point until there's a real signup flow. */
|
||||
export const CLOUD_NOTIFY_URL = "https://x.com/VoiceboxAI";
|
||||
|
||||
export type BillingPeriod = "monthly" | "annual";
|
||||
|
||||
export interface PricingTier {
|
||||
id: string;
|
||||
name: string;
|
||||
tagline: string;
|
||||
/** USD per month. 0 = free. */
|
||||
monthly: number;
|
||||
/** USD per year. 0 = free. Set below 12×monthly to reward annual. */
|
||||
annual: number;
|
||||
priceNote?: string;
|
||||
/** Visually emphasize this tier (the headline plan). */
|
||||
highlighted?: boolean;
|
||||
/** Status pill, e.g. "Available now" / "Coming soon". */
|
||||
badge?: string;
|
||||
cta: {label: string; href: string};
|
||||
features: string[];
|
||||
}
|
||||
|
||||
export const PRICING_TIERS: PricingTier[] = [
|
||||
{
|
||||
id: "local",
|
||||
name: "Local",
|
||||
tagline: "The full app, free forever.",
|
||||
monthly: 0,
|
||||
annual: 0,
|
||||
priceNote: "No account required",
|
||||
badge: "Available now",
|
||||
cta: {label: "Download Voicebox", href: "/download"},
|
||||
features: [
|
||||
"Voice cloning across every TTS engine",
|
||||
"Dictation & Capture (audio kept alongside transcript)",
|
||||
"MCP / agent integration & personalities",
|
||||
"Unlimited local generations & captures",
|
||||
"100% open source, runs entirely on your machine",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cloud",
|
||||
name: "Cloud",
|
||||
tagline: "Backup & sync for everything you make.",
|
||||
monthly: 2, // placeholder
|
||||
annual: CLOUD_PRICE_YEARLY, // $12 launch price (≈50% off monthly)
|
||||
priceNote: "Launch price · free for $VOICEBOX holders",
|
||||
highlighted: true,
|
||||
badge: "Coming soon",
|
||||
cta: {label: "Get notified", href: CLOUD_NOTIFY_URL},
|
||||
features: [
|
||||
"Everything in Local",
|
||||
"End-to-end encrypted backup — we can't read it",
|
||||
"Sync across desktop & mobile",
|
||||
"25 GB encrypted storage", // placeholder
|
||||
"Up to 5 devices", // placeholder
|
||||
"30-day version history", // placeholder
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "studio",
|
||||
name: "Studio",
|
||||
tagline: "For power users and professionals.",
|
||||
monthly: 6, // placeholder
|
||||
annual: 48, // placeholder
|
||||
priceNote: "Placeholder — pricing TBD",
|
||||
badge: "Coming soon",
|
||||
cta: {label: "Get notified", href: CLOUD_NOTIFY_URL},
|
||||
features: [
|
||||
"Everything in Cloud",
|
||||
"250 GB encrypted storage", // placeholder
|
||||
"Unlimited devices", // placeholder
|
||||
"1-year version history", // placeholder
|
||||
"Priority support",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Annual savings vs paying 12× the monthly price, as a whole percent (0 if none). */
|
||||
export function annualSavingsPercent(tier: PricingTier): number {
|
||||
if (tier.monthly <= 0 || tier.annual <= 0) return 0;
|
||||
const full = tier.monthly * 12;
|
||||
return Math.max(0, Math.round((1 - tier.annual / full) * 100));
|
||||
}
|
||||
|
||||
export interface CloudFeature {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export const CLOUD_FEATURES: CloudFeature[] = [
|
||||
{
|
||||
title: "End-to-end encrypted",
|
||||
body: "Everything is encrypted on your device before it leaves it. The server only ever stores opaque blobs — it literally cannot read your voices, generations, or captures.",
|
||||
},
|
||||
{
|
||||
title: "Back up everything",
|
||||
body: "Voice profiles, generations, and captures — including the original audio Voicebox keeps alongside each transcript — safe off your machine.",
|
||||
},
|
||||
{
|
||||
title: "Sync across devices",
|
||||
body: "Pick up on desktop or mobile. Your library follows you, encrypted in transit and at rest, with a per-device key.",
|
||||
},
|
||||
{
|
||||
title: "Generate on the go",
|
||||
body: "The mobile app pairs with your library so you can dictate and generate anywhere, then find it all waiting back at your desk.",
|
||||
},
|
||||
{
|
||||
title: "You hold the keys",
|
||||
body: "A recovery phrase you control is the root of your encryption. Lose your devices and you can still restore — but no one else, including us, ever can.",
|
||||
},
|
||||
{
|
||||
title: "Optional & local-first",
|
||||
body: "Voicebox works fully offline without an account. Cloud is an add-on for when you want backup and sync — never a requirement.",
|
||||
},
|
||||
];
|
||||
@@ -1,191 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
// Live on-chain stats for $VOICEBOX, fetched server-side via Helius.
|
||||
//
|
||||
// Design goals:
|
||||
// • Never throws. Every sub-fetch is isolated; a failure degrades that one
|
||||
// metric to `null` and is recorded in `warnings`, so the page always renders.
|
||||
// • Cheap. Results are cached in-memory for TOKEN_STATS_CACHE_MS, and holder
|
||||
// enumeration is page-capped so a viral token can't blow up a request.
|
||||
// • Honest. Numbers come straight from chain reads (Helius RPC) and Jupiter
|
||||
// for price — nothing is asserted that can't be verified on Solscan.
|
||||
|
||||
import {
|
||||
TOKEN_BURN_ADDRESS,
|
||||
TOKEN_CONTRACT_ADDRESS,
|
||||
TOKEN_CREATOR_ADDRESS,
|
||||
TOKEN_DEV_WALLETS,
|
||||
TOKEN_INITIAL_SUPPLY,
|
||||
TOKEN_LOCKED_ACCOUNTS,
|
||||
TOKEN_STATS_CACHE_MS,
|
||||
} from './constants';
|
||||
|
||||
const MINT = TOKEN_CONTRACT_ADDRESS;
|
||||
const WSOL_MINT = 'So11111111111111111111111111111111111111112';
|
||||
|
||||
// Let Next cache the underlying network reads and revalidate them on the same
|
||||
// cadence as the page (ISR). Keeps /token static-fast and CDN-cacheable while
|
||||
// staying fresh, instead of forcing the route fully dynamic with `no-store`.
|
||||
const REVALIDATE_S = Math.round(TOKEN_STATS_CACHE_MS / 1000);
|
||||
|
||||
// Public Solana mainnet RPC — used as a fallback so supply/balances/locks work
|
||||
// without any API key. Rate-limited, but our 10-minute cache keeps us under it.
|
||||
const PUBLIC_RPC = 'https://api.mainnet-beta.solana.com';
|
||||
|
||||
function heliusRpcUrl(): string | null {
|
||||
const key = process.env.HELIUS_API_KEY?.trim();
|
||||
if (!key) return null;
|
||||
return `https://mainnet.helius-rpc.com/?api-key=${key}`;
|
||||
}
|
||||
|
||||
// Standard JSON-RPC reads (supply, balances) — Helius if configured, else public.
|
||||
function standardRpcUrl(): string {
|
||||
return heliusRpcUrl() ?? PUBLIC_RPC;
|
||||
}
|
||||
|
||||
// Holder enumeration needs Helius' DAS `getTokenAccounts` extension; public RPC
|
||||
// can't do it efficiently. Null when no key — holders degrade to "—".
|
||||
function dasRpcUrl(): string | null {
|
||||
return heliusRpcUrl();
|
||||
}
|
||||
|
||||
export interface TopHolder {
|
||||
owner: string;
|
||||
amount: number;
|
||||
pct: number; // share of current supply, 0–100
|
||||
}
|
||||
|
||||
export interface LockedEntry {
|
||||
label: string;
|
||||
account: string;
|
||||
amount: number | null;
|
||||
unlocksAt?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface TokenStats {
|
||||
/** True only if Helius is configured and the core supply read succeeded. */
|
||||
live: boolean;
|
||||
decimals: number;
|
||||
initialSupply: number;
|
||||
/** Current on-chain mint supply (UI amount). */
|
||||
totalSupply: number | null;
|
||||
/** initialSupply − totalSupply: tokens permanently removed by burns. */
|
||||
burned: number | null;
|
||||
burnedPct: number | null;
|
||||
/** Sum of configured locked accounts. */
|
||||
locked: number | null;
|
||||
lockedPct: number | null;
|
||||
lockedBreakdown: LockedEntry[];
|
||||
/** Sum of configured dev/treasury wallets. */
|
||||
devBalance: number | null;
|
||||
devPct: number | null;
|
||||
/** Unique-owner holder count (page-capped; see holdersCapped). */
|
||||
holders: number | null;
|
||||
holdersCapped: boolean;
|
||||
topHolders: TopHolder[];
|
||||
/** Spot price in USD (Jupiter). */
|
||||
priceUsd: number | null;
|
||||
/** priceUsd × totalSupply. */
|
||||
marketCapUsd: number | null;
|
||||
/** Lifetime pump.fun creator fees earned by the creator wallet, in SOL. */
|
||||
creatorRewardsSol: number | null;
|
||||
/** creatorRewardsSol × SOL/USD price. */
|
||||
creatorRewardsUsd: number | null;
|
||||
/** Float supply = total − locked − dev − burned-at-dead-address. */
|
||||
circulating: number | null;
|
||||
updatedAt: number;
|
||||
/** Human-readable notes about anything unconfigured or failed. */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
// Caching is handled by Next's fetch cache (revalidate per request below), so
|
||||
// this just aggregates the reads. Never throws — degrades to emptyStats.
|
||||
export async function getTokenStats(): Promise<TokenStats> {
|
||||
try {
|
||||
return await buildTokenStats();
|
||||
} catch (err) {
|
||||
console.error('getTokenStats failed:', err);
|
||||
return emptyStats(['Live stats are temporarily unavailable.']);
|
||||
}
|
||||
}
|
||||
|
||||
function emptyStats(warnings: string[]): TokenStats {
|
||||
return {
|
||||
live: false,
|
||||
decimals: 6,
|
||||
initialSupply: TOKEN_INITIAL_SUPPLY,
|
||||
totalSupply: null,
|
||||
burned: null,
|
||||
burnedPct: null,
|
||||
locked: null,
|
||||
lockedPct: null,
|
||||
lockedBreakdown: TOKEN_LOCKED_ACCOUNTS.map((l) => ({ ...l, amount: null })),
|
||||
devBalance: null,
|
||||
devPct: null,
|
||||
holders: null,
|
||||
holdersCapped: false,
|
||||
topHolders: [],
|
||||
priceUsd: null,
|
||||
marketCapUsd: null,
|
||||
creatorRewardsSol: null,
|
||||
creatorRewardsUsd: null,
|
||||
circulating: null,
|
||||
updatedAt: Date.now(),
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildTokenStats(): Promise<TokenStats> {
|
||||
const warnings: string[] = [];
|
||||
const rpc = standardRpcUrl(); // supply/balances/locks — public RPC if no key
|
||||
const das = dasRpcUrl(); // holder enumeration — Helius only
|
||||
|
||||
if (!das) {
|
||||
warnings.push('Set HELIUS_API_KEY to enable the holder count & top holders.');
|
||||
}
|
||||
|
||||
// Core supply first — everything downstream is a percentage of it.
|
||||
const supply = await getTokenSupply(rpc).catch((e) => {
|
||||
warnings.push('Could not read token supply.');
|
||||
console.error('getTokenSupply:', e);
|
||||
return null;
|
||||
});
|
||||
|
||||
const decimals = supply?.decimals ?? 6;
|
||||
const totalSupply = supply?.uiAmount ?? null;
|
||||
|
||||
// Run the independent reads concurrently.
|
||||
const [holderData, devBalance, lockedAmounts, priceUsd, creatorRewardsSol, solPrice] =
|
||||
await Promise.all([
|
||||
das
|
||||
? getHolders(das).catch((e) => {
|
||||
warnings.push('Could not enumerate holders.');
|
||||
console.error('getHolders:', e);
|
||||
return null;
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
TOKEN_DEV_WALLETS.length
|
||||
? getOwnersBalance(rpc, TOKEN_DEV_WALLETS).catch((e) => {
|
||||
warnings.push('Could not read dev wallet balance.');
|
||||
console.error('getOwnersBalance(dev):', e);
|
||||
return null;
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
TOKEN_LOCKED_ACCOUNTS.length
|
||||
? Promise.all(
|
||||
TOKEN_LOCKED_ACCOUNTS.map((l) =>
|
||||
getAddressBalance(rpc, l.account)
|
||||
.catch(() => null)
|
||||
.then((amount) => ({ ...l, amount })),
|
||||
),
|
||||
)
|
||||
: Promise.resolve(
|
||||
[] as Array<(typeof TOKEN_LOCKED_ACCOUNTS)[number] & { amount: number | null }>,
|
||||
),
|
||||
getJupiterPrice(MINT).catch(() => {
|
||||
warnings.push('Could not read price from Jupiter.');
|
||||
return null;
|
||||
}),
|
||||
getCreatorRewardsSol().catch((e) => {
|
||||
warnings.push('Could not read creator rewards.');
|
||||
console.error('getCreatorRewardsSol:', e);
|
||||
return null;
|
||||
}),
|
||||
getJupiterPrice(WSOL_MINT).catch(() => null),
|
||||
]);
|
||||
|
||||
if (!TOKEN_DEV_WALLETS.length) warnings.push('No dev/treasury wallet configured.');
|
||||
if (!TOKEN_LOCKED_ACCOUNTS.length) warnings.push('No locked accounts configured.');
|
||||
|
||||
const locked =
|
||||
lockedAmounts.length && lockedAmounts.some((l) => l.amount != null)
|
||||
? lockedAmounts.reduce((sum, l) => sum + (l.amount ?? 0), 0)
|
||||
: lockedAmounts.length
|
||||
? null
|
||||
: null;
|
||||
|
||||
const burned =
|
||||
totalSupply != null ? Math.max(0, TOKEN_INITIAL_SUPPLY - totalSupply) : null;
|
||||
|
||||
const pct = (n: number | null): number | null =>
|
||||
n != null && totalSupply ? (n / totalSupply) * 100 : null;
|
||||
const pctOfInitial = (n: number | null): number | null =>
|
||||
n != null ? (n / TOKEN_INITIAL_SUPPLY) * 100 : null;
|
||||
|
||||
const marketCapUsd =
|
||||
priceUsd != null && totalSupply != null ? priceUsd * totalSupply : null;
|
||||
|
||||
const creatorRewardsUsd =
|
||||
creatorRewardsSol != null && solPrice != null
|
||||
? creatorRewardsSol * solPrice
|
||||
: null;
|
||||
|
||||
const circulating =
|
||||
totalSupply != null
|
||||
? Math.max(0, totalSupply - (locked ?? 0) - (devBalance ?? 0))
|
||||
: null;
|
||||
|
||||
const topHolders: TopHolder[] = (holderData?.top ?? []).map((h) => ({
|
||||
owner: h.owner,
|
||||
amount: h.amount,
|
||||
pct: totalSupply ? (h.amount / totalSupply) * 100 : 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
live: totalSupply != null,
|
||||
decimals,
|
||||
initialSupply: TOKEN_INITIAL_SUPPLY,
|
||||
totalSupply,
|
||||
burned,
|
||||
burnedPct: pctOfInitial(burned),
|
||||
locked,
|
||||
lockedPct: pct(locked),
|
||||
lockedBreakdown: lockedAmounts.length
|
||||
? lockedAmounts
|
||||
: TOKEN_LOCKED_ACCOUNTS.map((l) => ({ ...l, amount: null })),
|
||||
devBalance,
|
||||
devPct: pct(devBalance),
|
||||
holders: holderData?.count ?? null,
|
||||
holdersCapped: holderData?.capped ?? false,
|
||||
topHolders,
|
||||
priceUsd,
|
||||
marketCapUsd,
|
||||
creatorRewardsSol,
|
||||
creatorRewardsUsd,
|
||||
circulating,
|
||||
updatedAt: Date.now(),
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Solana / Helius RPC primitives ───────────────────────────────────────────
|
||||
|
||||
async function rpcCall<T>(rpc: string, method: string, params: unknown): Promise<T> {
|
||||
const res = await fetch(rpc, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
next: { revalidate: REVALIDATE_S },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 'voicebox', method, params }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`RPC ${method} HTTP ${res.status}`);
|
||||
const json = (await res.json()) as { result?: T; error?: { message: string } };
|
||||
if (json.error) throw new Error(`RPC ${method}: ${json.error.message}`);
|
||||
if (json.result === undefined) throw new Error(`RPC ${method}: empty result`);
|
||||
return json.result;
|
||||
}
|
||||
|
||||
interface SupplyResult {
|
||||
value: { amount: string; decimals: number; uiAmount: number | null };
|
||||
}
|
||||
async function getTokenSupply(
|
||||
rpc: string,
|
||||
): Promise<{ uiAmount: number; decimals: number }> {
|
||||
const r = await rpcCall<SupplyResult>(rpc, 'getTokenSupply', [MINT]);
|
||||
const decimals = r.value.decimals;
|
||||
const uiAmount =
|
||||
r.value.uiAmount ?? Number(r.value.amount) / 10 ** decimals;
|
||||
return { uiAmount, decimals };
|
||||
}
|
||||
|
||||
// Sum a single owner's balance of the mint across all their token accounts.
|
||||
async function getOwnerBalance(rpc: string, owner: string): Promise<number> {
|
||||
const r = await rpcCall<{
|
||||
value: Array<{
|
||||
account: { data: { parsed: { info: { tokenAmount: { uiAmount: number | null } } } } };
|
||||
}>;
|
||||
}>(rpc, 'getTokenAccountsByOwner', [
|
||||
owner,
|
||||
{ mint: MINT },
|
||||
{ encoding: 'jsonParsed' },
|
||||
]);
|
||||
return r.value.reduce(
|
||||
(sum, a) => sum + (a.account.data.parsed.info.tokenAmount.uiAmount ?? 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
async function getOwnersBalance(rpc: string, owners: string[]): Promise<number> {
|
||||
const balances = await Promise.all(owners.map((o) => getOwnerBalance(rpc, o)));
|
||||
return balances.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
|
||||
// Balance for a configured "account" that may be either a token-account address
|
||||
// or an owner address — try token account first, fall back to owner.
|
||||
async function getAddressBalance(rpc: string, address: string): Promise<number> {
|
||||
try {
|
||||
const r = await rpcCall<{
|
||||
value: { amount: string; decimals: number; uiAmount: number | null };
|
||||
}>(rpc, 'getTokenAccountBalance', [address]);
|
||||
return r.value.uiAmount ?? Number(r.value.amount) / 10 ** r.value.decimals;
|
||||
} catch {
|
||||
// Not a token account — treat it as an owner.
|
||||
return getOwnerBalance(rpc, address);
|
||||
}
|
||||
}
|
||||
|
||||
// Holder enumeration via Helius DAS getTokenAccounts. Dedupes by owner (one
|
||||
// owner can hold many token accounts) and ranks the top holders. Page-capped.
|
||||
const HOLDER_PAGE_LIMIT = 1000;
|
||||
const HOLDER_MAX_PAGES = 25; // up to 25k accounts before we stop and flag it
|
||||
const TOP_HOLDERS = 12;
|
||||
|
||||
interface HeliusTokenAccount {
|
||||
owner: string;
|
||||
amount: number; // raw, needs / 10**decimals
|
||||
}
|
||||
interface HeliusTokenAccountsPage {
|
||||
total: number;
|
||||
limit: number;
|
||||
page: number;
|
||||
token_accounts: HeliusTokenAccount[];
|
||||
}
|
||||
|
||||
async function getHolders(
|
||||
rpc: string,
|
||||
): Promise<{ count: number; capped: boolean; top: Array<{ owner: string; amount: number }> }> {
|
||||
const balances = new Map<string, number>(); // owner -> raw amount
|
||||
let page = 1;
|
||||
let capped = false;
|
||||
let decimals = 6;
|
||||
|
||||
// Grab decimals once so we can return UI amounts for the top holders.
|
||||
try {
|
||||
decimals = (await getTokenSupply(rpc)).decimals;
|
||||
} catch {
|
||||
/* fall back to 6 */
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
const res = await rpcCall<HeliusTokenAccountsPage>(rpc, 'getTokenAccounts', {
|
||||
mint: MINT,
|
||||
page,
|
||||
limit: HOLDER_PAGE_LIMIT,
|
||||
options: { showZeroBalance: false },
|
||||
});
|
||||
const accounts = res.token_accounts ?? [];
|
||||
for (const a of accounts) {
|
||||
if (!a.owner || !a.amount) continue;
|
||||
balances.set(a.owner, (balances.get(a.owner) ?? 0) + a.amount);
|
||||
}
|
||||
if (accounts.length < HOLDER_PAGE_LIMIT) break;
|
||||
page += 1;
|
||||
if (page > HOLDER_MAX_PAGES) {
|
||||
capped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const top = [...balances.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, TOP_HOLDERS)
|
||||
.map(([owner, raw]) => ({ owner, amount: raw / 10 ** decimals }));
|
||||
|
||||
return { count: balances.size, capped, top };
|
||||
}
|
||||
|
||||
// ── Price (Jupiter, no key required) ─────────────────────────────────────────
|
||||
async function getJupiterPrice(mint: string): Promise<number | null> {
|
||||
const res = await fetch(`https://lite-api.jup.ag/price/v3?ids=${mint}`, {
|
||||
next: { revalidate: REVALIDATE_S },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Jupiter HTTP ${res.status}`);
|
||||
const json = (await res.json()) as Record<string, { usdPrice?: number } | undefined>;
|
||||
const price = json[mint]?.usdPrice;
|
||||
return typeof price === 'number' ? price : null;
|
||||
}
|
||||
|
||||
// ── Creator rewards (pump.fun swap-api) ──────────────────────────────────────
|
||||
// Lifetime creator fees earned by the creator wallet, in SOL. The swap-api
|
||||
// returns a daily series with a running `cumulativeCreatorFeeSOL`; the latest
|
||||
// (max) bucket is the lifetime total. The per-coin endpoint is unreliable, so
|
||||
// we use the per-creator one.
|
||||
interface CreatorFeeBucket {
|
||||
cumulativeCreatorFeeSOL: string;
|
||||
}
|
||||
async function getCreatorRewardsSol(): Promise<number | null> {
|
||||
const res = await fetch(
|
||||
`https://swap-api.pump.fun/v1/creators/${TOKEN_CREATOR_ADDRESS}/fees?interval=1d`,
|
||||
{ next: { revalidate: REVALIDATE_S }, headers: { 'User-Agent': 'voicebox.sh' } },
|
||||
);
|
||||
if (!res.ok) throw new Error(`pump.fun swap-api HTTP ${res.status}`);
|
||||
const buckets = (await res.json()) as CreatorFeeBucket[];
|
||||
if (!Array.isArray(buckets) || buckets.length === 0) return null;
|
||||
// Cumulative is monotonic, but take the max defensively.
|
||||
const max = buckets.reduce((m, b) => {
|
||||
const v = Number.parseFloat(b.cumulativeCreatorFeeSOL);
|
||||
return Number.isFinite(v) && v > m ? v : m;
|
||||
}, 0);
|
||||
return max;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
title: "Why Voicebox has a token"
|
||||
author: Jamie Pine
|
||||
date: 2026-06-27
|
||||
tags: [Token, Transparency]
|
||||
excerpt: "Voicebox grew to over a million downloads with zero marketing — but donations never made full-time work sustainable. Here's the honest reasoning behind $VOICEBOX, and the commitments that come with it."
|
||||
---
|
||||
|
||||
Voicebox started as a one-day experiment. The Qwen3-TTS model dropped, I wanted to try it, so I built a small CLI to load voice profiles and generate speech. As a designer I already had the interface in my head — profiles as cards, a floating generation box, a player spanning the bottom of the app, a gold accent and a microphone logo to make it feel like a real studio. First working version in a day. Open sourced in three.
|
||||
|
||||
I did no marketing. None, to this day. But Reddit found it, creators started making tutorials, and the "ElevenLabs just lost its moat" posts began. It crossed a million downloads and is closing in on Spacedrive's GitHub star count — entirely organically. Along the way it became two things at once: a free alternative to ElevenLabs for voice cloning, and a free alternative to Wispr Flow for dictation.
|
||||
|
||||
So a fair question keeps coming up: if it's this successful, why a token? Why not just put it behind a subscription, or turn on GitHub Sponsors and call it a day?
|
||||
|
||||
## The honest version
|
||||
|
||||
I could have built this as a cloud app with a monthly subscription and probably done well. I chose open source instead, and I'd make the same call again. I don't think it would have grown like this behind a paywall — people trust it more when they can read the code and run it entirely on their own machine, it earns a ton of organic exposure for free, and I get the community's help making it work across the endless combinations of GPUs and operating systems I could never test alone.
|
||||
|
||||
But open source doesn't pay rent. The donation button on the site brings in roughly $200–300 a month. I didn't expect GitHub Sponsors to move that number much. The old sponsor program made almost nothing. I love this project and I want to work on it full-time — and for a while I couldn't justify it.
|
||||
|
||||
The token changed that overnight. It's the closest thing I've had to a salary in a long time, and it let me go full-time on Voicebox immediately. That's not a hypothetical — it's already happening, and you'll see it in the commits, the releases, and the posts on [@VoiceboxAI](https://x.com/VoiceboxAI).
|
||||
|
||||
## What the token is — and what it isn't
|
||||
|
||||
**$VOICEBOX is entirely optional.** Voicebox is, and always will be, free, open source, and local-first. Every feature works without ever touching the token. It exists for supporters who want to back the project and have some fun — nothing here is gated behind it, and nothing here is financial advice.
|
||||
|
||||
It's also the **only** token I will ever make. My other projects, including Spacedrive, will never have an official token. I thought carefully about this: the community was clearly most interested in Voicebox because of its existing traction, and if I'm going to have a token at all, I'd rather deploy it myself so I can lock liquidity and be accountable for its trajectory — rather than have an anonymous community coin I can't control. As of now I no longer claim fees on any other community tokens, either.
|
||||
|
||||
## Don't trust — verify
|
||||
|
||||
A token only earns trust through actions you can check on-chain. So:
|
||||
|
||||
- Liquidity and a portion of dev holdings were **locked at launch**, visible from day one.
|
||||
- I've done **buyback and burns** — buying $VOICEBOX back from the market and burning it to a dead address, permanently. I've done this more than once, and I'll keep doing it.
|
||||
- The plan is a balanced mix: lock more for trust, burn periodically, but keep enough flexibility to fund real expenses and add liquidity when it helps.
|
||||
|
||||
Every one of these is linked on the [token page](/token), so you never have to take my word for it. Verify the contract address there before you do anything — impersonators are common.
|
||||
|
||||
## What the money actually builds
|
||||
|
||||
Going full-time means the roadmap moves faster. The near-term priorities:
|
||||
|
||||
- **The mobile app.** I use the prototype every day for dictation on the go. It does both cloning and dictation, and it's nearly ready to ship.
|
||||
- **Encrypted cloud backup & sync.** Generate on the go, keep your captures and generations safe, pick up on any device. This will be a paid service — around $12/year — and **free for token holders**.
|
||||
- **More engines and broader hardware support.** More TTS engines, better GPU and OS coverage, so Voicebox runs great on whatever you've got.
|
||||
|
||||
That's the whole pitch. The app stays free forever, the token is an optional way to support it and unlock the cloud service, and the proof of good faith is on-chain and in the changelog. If you want to back the project, the [token page](/token) is here — but starring the repo and telling a friend helps just as much.
|
||||
|
||||
Thanks for being here. Now back to shipping.
|
||||
@@ -1,81 +0,0 @@
|
||||
/** @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')],
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -5,18 +5,15 @@
|
||||
"workspaces": [
|
||||
"app",
|
||||
"tauri",
|
||||
"web",
|
||||
"landing"
|
||||
"web"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "bun run setup:dev && cd tauri && bun run tauri dev",
|
||||
"dev:web": "cd web && bun run dev",
|
||||
"dev:landing": "cd landing && bun run dev",
|
||||
"dev:server": "uvicorn backend.main:app --reload --port 17493",
|
||||
"setup:dev": "bun run scripts/setup-dev-sidecar.js",
|
||||
"build": "./scripts/build-server.sh && cd tauri && bun run tauri build",
|
||||
"build:web": "cd web && bun run build",
|
||||
"build:landing": "cd landing && bun run build",
|
||||
"build:release": "./scripts/prepare-release.sh",
|
||||
"generate:api": "./scripts/generate-api.sh",
|
||||
"generate:keys": "cd tauri && bun tauri signer generate -w ~/.tauri/voicebox.key",
|
||||
|
||||
@@ -9,7 +9,6 @@ cd "$(dirname "$0")/.."
|
||||
|
||||
# Directories to process
|
||||
DIRS=(
|
||||
"landing/public"
|
||||
"docs/public"
|
||||
)
|
||||
|
||||
|
||||
@@ -10,8 +10,6 @@ EXPORTS_DIR="tauri/assets/voicebox_exports"
|
||||
ICON_BUNDLE="tauri/assets/voicebox.icon"
|
||||
ASSETS_DIR="$ICON_BUNDLE/Assets"
|
||||
ICONS_DIR="tauri/src-tauri/icons"
|
||||
LANDING_LOGO="landing/public/voicebox-logo.png"
|
||||
LANDING_PUBLIC="landing/public"
|
||||
SOURCE_ICON="$EXPORTS_DIR/[email protected]"
|
||||
|
||||
echo "🎨 Updating all Voicebox icons from exports..."
|
||||
@@ -172,32 +170,6 @@ sips -s format png -z 192 192 "$SOURCE_ICON" --out "$ICONS_DIR/android/mipmap-xx
|
||||
sips -s format png -z 192 192 "$SOURCE_ICON" --out "$ICONS_DIR/android/mipmap-xxxhdpi/ic_launcher_round.png" 2>/dev/null
|
||||
sips -s format png -z 192 192 "$SOURCE_ICON" --out "$ICONS_DIR/android/mipmap-xxxhdpi/ic_launcher_foreground.png" 2>/dev/null
|
||||
|
||||
# Landing Page Logo & Favicon
|
||||
echo "Generating landing page logo..."
|
||||
mkdir -p "$LANDING_PUBLIC"
|
||||
sips -s format png -z 1024 1024 "$SOURCE_ICON" --out "$LANDING_LOGO" 2>/dev/null
|
||||
|
||||
echo "Generating landing page favicon..."
|
||||
# Generate favicon.png (32x32 is standard for favicons)
|
||||
sips -s format png -z 32 32 "$SOURCE_ICON" --out "$LANDING_PUBLIC/favicon.png" 2>/dev/null
|
||||
|
||||
# Generate proper multi-size favicon.ico using ImageMagick if available
|
||||
if command -v convert &> /dev/null; then
|
||||
# Create temporary PNG files at different sizes for ICO
|
||||
sips -s format png -z 16 16 "$SOURCE_ICON" --out /tmp/favicon-16.png 2>/dev/null
|
||||
sips -s format png -z 32 32 "$SOURCE_ICON" --out /tmp/favicon-32.png 2>/dev/null
|
||||
# Combine into proper multi-size ICO file
|
||||
convert /tmp/favicon-16.png /tmp/favicon-32.png "$LANDING_PUBLIC/favicon.ico" 2>/dev/null
|
||||
rm -f /tmp/favicon-16.png /tmp/favicon-32.png 2>/dev/null
|
||||
echo " ✓ Generated proper multi-size favicon.ico"
|
||||
else
|
||||
# Fallback: skip ICO if ImageMagick not available (PNG will be used)
|
||||
echo " ⚠ ImageMagick not found - skipping favicon.ico (using favicon.png instead)"
|
||||
fi
|
||||
|
||||
# Also generate apple-touch-icon (180x180 for iOS)
|
||||
sips -s format png -z 180 180 "$SOURCE_ICON" --out "$LANDING_PUBLIC/apple-touch-icon.png" 2>/dev/null
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "✅ All icons updated successfully!"
|
||||
@@ -210,7 +182,5 @@ echo " ✓ Windows Square logos"
|
||||
echo " ✓ Windows icon.ico (multi-size)"
|
||||
echo " ✓ iOS AppIcons (18 sizes)"
|
||||
echo " ✓ Android mipmap icons (5 densities)"
|
||||
echo " ✓ Landing page logo"
|
||||
echo " ✓ Landing page favicon"
|
||||
echo ""
|
||||
echo "Next: Rebuild the app with 'cd tauri && bun run tauri build'"
|
||||
|
||||