mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 00:55:14 -07:00
tests and better website
This commit is contained in:
@@ -63,3 +63,8 @@ nul
|
|||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
*.tmp
|
*.tmp
|
||||||
|
|
||||||
|
# E2E test artifacts
|
||||||
|
backend/tests/results/
|
||||||
|
backend/tests/fixtures/reference_voice.wav
|
||||||
|
backend/tests/fixtures/reference_voice.txt
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# End-to-End Model Generation Test — Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A single script, runnable on macOS and Windows, that exercises every TTS model against the **frozen PyInstaller binary** (not the dev server), captures per-model pass/fail and error messages, and exits non-zero if any model fails. Generation is strictly sequential — one model loaded at a time.
|
||||||
|
|
||||||
|
## Test matrix (10 runs)
|
||||||
|
|
||||||
|
Derived from `backend/backends/__init__.py:185-316`. Each row maps to one `POST /generate` call.
|
||||||
|
|
||||||
|
| # | engine | model_size | profile kind | notes |
|
||||||
|
|---|-----------------------|------------|--------------|-------|
|
||||||
|
| 1 | `qwen` | `1.7B` | cloned | reference audio required |
|
||||||
|
| 2 | `qwen` | `0.6B` | cloned | |
|
||||||
|
| 3 | `qwen_custom_voice` | `1.7B` | preset | `preset_voice_id="Ryan"` |
|
||||||
|
| 4 | `qwen_custom_voice` | `0.6B` | preset | `preset_voice_id="Ryan"` |
|
||||||
|
| 5 | `luxtts` | — | cloned | English only |
|
||||||
|
| 6 | `chatterbox` | — | cloned | |
|
||||||
|
| 7 | `chatterbox_turbo` | — | cloned | English only |
|
||||||
|
| 8 | `tada` | `1B` | cloned | tada-1b, English only |
|
||||||
|
| 9 | `tada` | `3B` | cloned | tada-3b-ml, multilingual |
|
||||||
|
| 10| `kokoro` | — | preset | `preset_voice_id="af_heart"` |
|
||||||
|
|
||||||
|
Cloned engines (1, 2, 5, 6, 7, 8, 9) share **one** profile created once with the reference WAV. Preset profiles are created separately, one for kokoro and one for qwen_custom_voice.
|
||||||
|
|
||||||
|
Language for every run: `en` (covers every engine's supported set).
|
||||||
|
|
||||||
|
## End-to-end flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Resolve paths → find binary, build if missing
|
||||||
|
2. Launch binary → spawn with --port --data-dir --parent-pid
|
||||||
|
3. Wait for /health → poll until status=="healthy" or 120s timeout
|
||||||
|
4. Create profiles → 1 cloned + 2 preset, via /profiles (+ /samples)
|
||||||
|
5. For each (engine, model_size) in matrix:
|
||||||
|
a. Check cache → GET /models/status → cached? short timeout : long
|
||||||
|
b. POST /generate → get generation_id
|
||||||
|
c. Stream /status → consume SSE until completed/failed/timeout
|
||||||
|
d. Record result → {engine, model_size, status, duration, error, elapsed}
|
||||||
|
6. Write results → JSON + Markdown table to ./results/
|
||||||
|
7. Shutdown binary → SIGTERM, fall back to kill, verify port freed
|
||||||
|
8. Exit code → 0 if all passed, 1 otherwise
|
||||||
|
```
|
||||||
|
|
||||||
|
## Binary resolution
|
||||||
|
|
||||||
|
Search order — **first hit wins**:
|
||||||
|
|
||||||
|
| Platform | Path | Build type |
|
||||||
|
|----------|------|------------|
|
||||||
|
| macOS | `backend/dist/voicebox-server-cuda/voicebox-server-cuda` | onedir (CUDA, rarely on Mac) |
|
||||||
|
| macOS | `backend/dist/voicebox-server` | onefile (CPU) |
|
||||||
|
| Windows | `backend\dist\voicebox-server-cuda\voicebox-server-cuda.exe` | onedir (CUDA) |
|
||||||
|
| Windows | `backend\dist\voicebox-server.exe` | onefile (CPU) |
|
||||||
|
|
||||||
|
If none exist, run `python backend/build_binary.py` and wait for it to finish (can take 5-20 min). Fail with a clear error if the build itself fails. `--skip-build` flag forces "error out if no binary" instead of building.
|
||||||
|
|
||||||
|
## Spawn command
|
||||||
|
|
||||||
|
Mirrors Tauri's launch in `tauri/src-tauri/src/main.rs:369-388`:
|
||||||
|
|
||||||
|
```
|
||||||
|
<binary> --host 127.0.0.1 --port <free-port> --data-dir <tempdir> --parent-pid <test-pid>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Port**: bind to `0` first in Python to grab a free port, then pass that number.
|
||||||
|
- **Data dir**: `tempfile.mkdtemp(prefix="voicebox-e2e-")`. Deleted after the run unless `--keep-data-dir`. Profiles and generated WAVs land here.
|
||||||
|
- **Parent PID**: current Python PID — ensures the backend dies if the test crashes (watchdog in `server.py:102-224`).
|
||||||
|
- **stdout/stderr**: tee to both a log file in `./results/server-<timestamp>.log` and a rolling in-memory buffer. On model failure, last 100 lines of the buffer are attached to that model's error record.
|
||||||
|
|
||||||
|
## Profile setup
|
||||||
|
|
||||||
|
One cloned profile shared across all cloning engines:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /profiles
|
||||||
|
{
|
||||||
|
"name": "e2e-cloned",
|
||||||
|
"voice_type": "cloned",
|
||||||
|
"language": "en"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /profiles/{id}/samples (multipart)
|
||||||
|
file: <reference WAV>
|
||||||
|
reference_text: <exact transcription>
|
||||||
|
```
|
||||||
|
|
||||||
|
Two preset profiles:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /profiles
|
||||||
|
{ "name": "e2e-kokoro", "voice_type": "preset", "language": "en",
|
||||||
|
"preset_engine": "kokoro", "preset_voice_id": "af_heart" }
|
||||||
|
|
||||||
|
POST /profiles
|
||||||
|
{ "name": "e2e-qwen-cv", "voice_type": "preset", "language": "en",
|
||||||
|
"preset_engine": "qwen_custom_voice", "preset_voice_id": "Ryan" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Generation request (per matrix row)
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /generate
|
||||||
|
{
|
||||||
|
"profile_id": "<appropriate profile>",
|
||||||
|
"text": "The quick brown fox jumps over the lazy dog.",
|
||||||
|
"language": "en",
|
||||||
|
"engine": "<engine>",
|
||||||
|
"model_size": "<size or omitted>",
|
||||||
|
"seed": 42,
|
||||||
|
"normalize": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `id` feeds into the SSE status loop (`GET /generate/{id}/status`, `routes/generations.py:190-227`). Loop reads lines until a payload with `status in ("completed", "failed")` arrives, then breaks.
|
||||||
|
|
||||||
|
## Timeout strategy (split)
|
||||||
|
|
||||||
|
Check `GET /models/status` for the target model **before** generation:
|
||||||
|
|
||||||
|
| Cached? | Per-model timeout | Rationale |
|
||||||
|
|---------|-------------------|-----------|
|
||||||
|
| Yes | **3 minutes** | Inference only; generous for CPU builds |
|
||||||
|
| No | **20 minutes** | First-run HF download up to 8 GB (tada-3b-ml) |
|
||||||
|
|
||||||
|
On timeout: cancel the SSE stream, mark the row `timeout`, and continue to the next row. Don't abort the whole run on one timeout.
|
||||||
|
|
||||||
|
## Result format
|
||||||
|
|
||||||
|
`./results/e2e-<platform>-<arch>-<timestamp>.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"platform": "darwin-arm64",
|
||||||
|
"binary": "/abs/path/voicebox-server",
|
||||||
|
"binary_size_mb": 612,
|
||||||
|
"started_at": "2026-04-16T12:34:56Z",
|
||||||
|
"finished_at": "...",
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"engine": "qwen",
|
||||||
|
"model_size": "1.7B",
|
||||||
|
"status": "passed|failed|timeout",
|
||||||
|
"generation_id": "...",
|
||||||
|
"was_cached": true,
|
||||||
|
"elapsed_seconds": 12.4,
|
||||||
|
"audio_duration": 3.1,
|
||||||
|
"audio_path": "/tmp/.../gen.wav",
|
||||||
|
"error": null,
|
||||||
|
"server_log_tail": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Companion `./results/e2e-<...>.md`:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Voicebox E2E — darwin-arm64 — 2026-04-16 12:34
|
||||||
|
|
||||||
|
| Engine | Size | Status | Elapsed | Error |
|
||||||
|
|---------------------|------|--------|---------|-------|
|
||||||
|
| qwen | 1.7B | PASS | 12.4s | |
|
||||||
|
| qwen | 0.6B | FAIL | 4.1s | CUDA OOM: ... |
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI flags
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m backend.tests.test_all_models_e2e [flags]
|
||||||
|
|
||||||
|
--binary PATH Use this binary instead of auto-detecting
|
||||||
|
--skip-build Error if no binary found (no auto-build)
|
||||||
|
--reference-wav PATH Reference audio (default: backend/tests/fixtures/reference_voice.wav)
|
||||||
|
--reference-text STR Transcription (default: read from fixtures/reference_voice.txt)
|
||||||
|
--only ENGINE[,...] Run only these engines (e.g. kokoro,qwen)
|
||||||
|
--skip ENGINE[,...] Skip these engines
|
||||||
|
--keep-data-dir Don't delete tempdir after run
|
||||||
|
--timeout-cached SEC Override 180
|
||||||
|
--timeout-download SEC Override 1200
|
||||||
|
--port N Override auto-picked port
|
||||||
|
--output-dir PATH Default: backend/tests/results/
|
||||||
|
```
|
||||||
|
|
||||||
|
## File layout
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/tests/
|
||||||
|
├── E2E_MODEL_TEST_DESIGN.md (this file)
|
||||||
|
├── test_all_models_e2e.py (main script, ~400-500 LoC)
|
||||||
|
├── fixtures/
|
||||||
|
│ ├── reference_voice.wav (user-provided, ~5-15s clean speech)
|
||||||
|
│ └── reference_voice.txt (exact transcription)
|
||||||
|
└── results/ (gitignored)
|
||||||
|
├── e2e-darwin-arm64-<ts>.json
|
||||||
|
├── e2e-darwin-arm64-<ts>.md
|
||||||
|
└── server-<ts>.log
|
||||||
|
```
|
||||||
|
|
||||||
|
The script uses only stdlib + `httpx` (or `requests`) + `sseclient-py` — all already in `backend/requirements.txt`. No pytest to keep it invocable as a single command on fresh checkouts.
|
||||||
|
|
||||||
|
## Safety & cleanup
|
||||||
|
|
||||||
|
- Always kill the spawned binary in a `try/finally`. On Windows, `taskkill /F /T` the whole tree (Tauri does the same).
|
||||||
|
- Verify the port is free on shutdown (Tauri port-reuse check in `main.rs:114-186` could otherwise pick up a ghost).
|
||||||
|
- Don't touch the user's HF cache by default — let the server use `HF_HUB_CACHE` / `VOICEBOX_MODELS_DIR`. Passing `--isolated-cache` would point both env vars at the tempdir for a true cold-start run (opt-in only; would re-download every time).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Not validating audio quality (no WER, no waveform comparison). Pass = "endpoint returned `completed` and produced a non-empty WAV".
|
||||||
|
- Not testing STT (Whisper), effects chains, channels, or streaming endpoints.
|
||||||
|
- Not running on CI today — human-invoked on dev machines. CI integration is a follow-up once the script is stable.
|
||||||
|
- No model unload between runs — models stay loaded; server manages its own eviction.
|
||||||
|
- No version-drift check on the binary.
|
||||||
|
- No `instruct` parameter exercised on qwen_custom_voice runs.
|
||||||
Vendored
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# E2E Test Fixtures
|
||||||
|
|
||||||
|
Place two files here before running `test_all_models_e2e.py`:
|
||||||
|
|
||||||
|
- `reference_voice.wav` — a clean speech sample, mono, 16–24 kHz, ~5–15 seconds.
|
||||||
|
- `reference_voice.txt` — the **exact** transcription of the WAV (single line, no trailing newline required).
|
||||||
|
|
||||||
|
These are used to create a cloned voice profile for every cloning-capable engine (qwen, luxtts, chatterbox, chatterbox_turbo, tada). Keep them out of version control if they contain personal audio — this directory is not gitignored by default, so add them to `.gitignore` locally if needed.
|
||||||
|
|
||||||
|
You can point the test at different files with:
|
||||||
|
|
||||||
|
```
|
||||||
|
python backend/tests/test_all_models_e2e.py \
|
||||||
|
--reference-wav /path/to/your.wav \
|
||||||
|
--reference-text "exact transcription here"
|
||||||
|
```
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
"""
|
||||||
|
End-to-end model generation test.
|
||||||
|
|
||||||
|
Exercises every TTS model against the frozen PyInstaller binary, captures
|
||||||
|
per-model pass/fail, and writes a JSON + Markdown report.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python backend/tests/test_all_models_e2e.py [flags]
|
||||||
|
|
||||||
|
See E2E_MODEL_TEST_DESIGN.md for the full design.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
BACKEND_DIR = REPO_ROOT / "backend"
|
||||||
|
DIST_DIR = BACKEND_DIR / "dist"
|
||||||
|
FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures"
|
||||||
|
RESULTS_DIR = Path(__file__).resolve().parent / "results"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Test matrix ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MatrixRow:
|
||||||
|
label: str # human-readable (appears in report)
|
||||||
|
engine: str # /generate engine
|
||||||
|
model_size: Optional[str] # /generate model_size (None = omit)
|
||||||
|
profile_kind: str # "cloned" | "preset_kokoro" | "preset_qwen_cv"
|
||||||
|
model_name: str # /models/status key for cache lookup
|
||||||
|
|
||||||
|
|
||||||
|
MATRIX: list[MatrixRow] = [
|
||||||
|
MatrixRow("qwen 1.7B", "qwen", "1.7B", "cloned", "qwen-tts-1.7B"),
|
||||||
|
MatrixRow("qwen 0.6B", "qwen", "0.6B", "cloned", "qwen-tts-0.6B"),
|
||||||
|
MatrixRow("qwen_custom_voice 1.7B", "qwen_custom_voice", "1.7B", "preset_qwen_cv", "qwen-custom-voice-1.7B"),
|
||||||
|
MatrixRow("qwen_custom_voice 0.6B", "qwen_custom_voice", "0.6B", "preset_qwen_cv", "qwen-custom-voice-0.6B"),
|
||||||
|
MatrixRow("luxtts", "luxtts", None, "cloned", "luxtts"),
|
||||||
|
MatrixRow("chatterbox", "chatterbox", None, "cloned", "chatterbox-tts"),
|
||||||
|
MatrixRow("chatterbox_turbo", "chatterbox_turbo", None, "cloned", "chatterbox-turbo"),
|
||||||
|
MatrixRow("tada 1B", "tada", "1B", "cloned", "tada-1b"),
|
||||||
|
MatrixRow("tada 3B", "tada", "3B", "cloned", "tada-3b-ml"),
|
||||||
|
MatrixRow("kokoro", "kokoro", None, "preset_kokoro", "kokoro"),
|
||||||
|
]
|
||||||
|
|
||||||
|
TEXT = "The quick brown fox jumps over the lazy dog."
|
||||||
|
DEFAULT_TIMEOUT_CACHED = 180
|
||||||
|
DEFAULT_TIMEOUT_DOWNLOAD = 1200
|
||||||
|
HEALTH_TIMEOUT = 120
|
||||||
|
|
||||||
|
|
||||||
|
# ── Result record ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelResult:
|
||||||
|
label: str
|
||||||
|
engine: str
|
||||||
|
model_size: Optional[str]
|
||||||
|
status: str # "passed" | "failed" | "timeout"
|
||||||
|
was_cached: Optional[bool] = None
|
||||||
|
generation_id: Optional[str] = None
|
||||||
|
elapsed_seconds: float = 0.0
|
||||||
|
audio_duration: Optional[float] = None
|
||||||
|
audio_path: Optional[str] = None
|
||||||
|
audio_bytes: Optional[int] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
http_status: Optional[int] = None
|
||||||
|
server_log_tail: Optional[list[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Binary resolution ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def find_binary() -> Optional[Path]:
|
||||||
|
"""Return the first existing binary in priority order, or None."""
|
||||||
|
is_win = platform.system() == "Windows"
|
||||||
|
exe = ".exe" if is_win else ""
|
||||||
|
candidates = [
|
||||||
|
DIST_DIR / "voicebox-server-cuda" / f"voicebox-server-cuda{exe}",
|
||||||
|
DIST_DIR / f"voicebox-server{exe}",
|
||||||
|
]
|
||||||
|
for c in candidates:
|
||||||
|
if c.exists() and c.is_file():
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_binary() -> Path:
|
||||||
|
"""Invoke build_binary.py and return the resulting binary path."""
|
||||||
|
print("[build] No frozen binary found — invoking build_binary.py (this may take 5-20 minutes)...", flush=True)
|
||||||
|
script = BACKEND_DIR / "build_binary.py"
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(script)],
|
||||||
|
cwd=str(BACKEND_DIR),
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"build_binary.py exited with code {result.returncode}")
|
||||||
|
found = find_binary()
|
||||||
|
if found is None:
|
||||||
|
raise RuntimeError("build_binary.py finished but no binary was found in backend/dist/")
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
# ── Server spawn + log capture ───────────────────────────────────────
|
||||||
|
|
||||||
|
class ServerProcess:
|
||||||
|
def __init__(self, binary: Path, port: int, data_dir: Path, log_path: Path):
|
||||||
|
self.binary = binary
|
||||||
|
self.port = port
|
||||||
|
self.data_dir = data_dir
|
||||||
|
self.log_path = log_path
|
||||||
|
self.proc: Optional[subprocess.Popen] = None
|
||||||
|
self._log_buffer: deque[str] = deque(maxlen=500)
|
||||||
|
self._reader_thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
args = [
|
||||||
|
str(self.binary),
|
||||||
|
"--host", "127.0.0.1",
|
||||||
|
"--port", str(self.port),
|
||||||
|
"--data-dir", str(self.data_dir),
|
||||||
|
"--parent-pid", str(os.getpid()),
|
||||||
|
]
|
||||||
|
print(f"[spawn] {' '.join(args)}", flush=True)
|
||||||
|
self._log_fh = open(self.log_path, "w", encoding="utf-8", errors="replace")
|
||||||
|
# Combine stderr into stdout so we get a single ordered stream.
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
args,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
bufsize=1,
|
||||||
|
text=True,
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
self._reader_thread = threading.Thread(target=self._pump_logs, daemon=True)
|
||||||
|
self._reader_thread.start()
|
||||||
|
|
||||||
|
def _pump_logs(self) -> None:
|
||||||
|
assert self.proc is not None and self.proc.stdout is not None
|
||||||
|
for line in self.proc.stdout:
|
||||||
|
self._log_buffer.append(line.rstrip("\n"))
|
||||||
|
self._log_fh.write(line)
|
||||||
|
self._log_fh.flush()
|
||||||
|
|
||||||
|
def log_tail(self, n: int = 100) -> list[str]:
|
||||||
|
tail = list(self._log_buffer)[-n:]
|
||||||
|
return tail
|
||||||
|
|
||||||
|
def is_alive(self) -> bool:
|
||||||
|
return self.proc is not None and self.proc.poll() is None
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if self.proc is None:
|
||||||
|
return
|
||||||
|
if self.proc.poll() is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
subprocess.run(
|
||||||
|
["taskkill", "/F", "/T", "/PID", str(self.proc.pid)],
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.proc.send_signal(signal.SIGTERM)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[shutdown] signal failed: {e}", flush=True)
|
||||||
|
try:
|
||||||
|
self.proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print("[shutdown] server didn't exit cleanly, killing", flush=True)
|
||||||
|
self.proc.kill()
|
||||||
|
try:
|
||||||
|
self.proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
|
if self._reader_thread is not None:
|
||||||
|
self._reader_thread.join(timeout=2)
|
||||||
|
try:
|
||||||
|
self._log_fh.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def pick_free_port() -> int:
|
||||||
|
s = socket.socket()
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
port = s.getsockname()[1]
|
||||||
|
s.close()
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTTP helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def wait_for_health(base_url: str, server: ServerProcess, timeout: int) -> None:
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
with httpx.Client(timeout=5.0) as client:
|
||||||
|
while time.time() < deadline:
|
||||||
|
if not server.is_alive():
|
||||||
|
raise RuntimeError("Server process exited before becoming healthy")
|
||||||
|
try:
|
||||||
|
r = client.get(f"{base_url}/health")
|
||||||
|
if r.status_code == 200 and r.json().get("status") == "healthy":
|
||||||
|
return
|
||||||
|
except httpx.HTTPError:
|
||||||
|
pass
|
||||||
|
time.sleep(1.0)
|
||||||
|
raise TimeoutError(f"Server did not become healthy within {timeout}s")
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_cached(client: httpx.Client, base_url: str, model_name: str) -> Optional[bool]:
|
||||||
|
try:
|
||||||
|
r = client.get(f"{base_url}/models/status", timeout=30.0)
|
||||||
|
r.raise_for_status()
|
||||||
|
for m in r.json().get("models", []):
|
||||||
|
if m.get("model_name") == model_name:
|
||||||
|
return bool(m.get("downloaded"))
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_cloned_profile(client: httpx.Client, base_url: str, wav_path: Path, reference_text: str) -> str:
|
||||||
|
r = client.post(f"{base_url}/profiles", json={
|
||||||
|
"name": "e2e-cloned",
|
||||||
|
"voice_type": "cloned",
|
||||||
|
"language": "en",
|
||||||
|
})
|
||||||
|
r.raise_for_status()
|
||||||
|
profile_id = r.json()["id"]
|
||||||
|
|
||||||
|
with open(wav_path, "rb") as f:
|
||||||
|
r = client.post(
|
||||||
|
f"{base_url}/profiles/{profile_id}/samples",
|
||||||
|
files={"file": (wav_path.name, f, "audio/wav")},
|
||||||
|
data={"reference_text": reference_text},
|
||||||
|
timeout=120.0,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return profile_id
|
||||||
|
|
||||||
|
|
||||||
|
def create_preset_profile(client: httpx.Client, base_url: str, name: str, engine: str, voice_id: str) -> str:
|
||||||
|
r = client.post(f"{base_url}/profiles", json={
|
||||||
|
"name": name,
|
||||||
|
"voice_type": "preset",
|
||||||
|
"language": "en",
|
||||||
|
"preset_engine": engine,
|
||||||
|
"preset_voice_id": voice_id,
|
||||||
|
})
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def run_one_generation(
|
||||||
|
client: httpx.Client,
|
||||||
|
base_url: str,
|
||||||
|
row: MatrixRow,
|
||||||
|
profile_id: str,
|
||||||
|
timeout_s: int,
|
||||||
|
) -> tuple[str, dict]:
|
||||||
|
"""Start a generation and stream its status until done/failed/timeout.
|
||||||
|
|
||||||
|
Returns (status, payload) where status is "completed" | "failed" | "timeout".
|
||||||
|
"""
|
||||||
|
body = {
|
||||||
|
"profile_id": profile_id,
|
||||||
|
"text": TEXT,
|
||||||
|
"language": "en",
|
||||||
|
"engine": row.engine,
|
||||||
|
"seed": 42,
|
||||||
|
"normalize": True,
|
||||||
|
}
|
||||||
|
if row.model_size is not None:
|
||||||
|
body["model_size"] = row.model_size
|
||||||
|
|
||||||
|
r = client.post(f"{base_url}/generate", json=body, timeout=30.0)
|
||||||
|
r.raise_for_status()
|
||||||
|
gen = r.json()
|
||||||
|
gen_id = gen["id"]
|
||||||
|
|
||||||
|
deadline = time.time() + timeout_s
|
||||||
|
last_payload: dict = gen
|
||||||
|
status_url = f"{base_url}/generate/{gen_id}/status"
|
||||||
|
|
||||||
|
while time.time() < deadline:
|
||||||
|
remaining = max(1.0, deadline - time.time())
|
||||||
|
try:
|
||||||
|
with client.stream("GET", status_url, timeout=httpx.Timeout(remaining + 5, read=remaining + 5)) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
for line in resp.iter_lines():
|
||||||
|
if not line or not line.startswith("data: "):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
payload = json.loads(line[6:])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
last_payload = payload
|
||||||
|
status = payload.get("status")
|
||||||
|
if status == "not_found":
|
||||||
|
return "failed", {"error": "generation not found", **payload}
|
||||||
|
if status in ("completed", "failed"):
|
||||||
|
return status, payload
|
||||||
|
if time.time() >= deadline:
|
||||||
|
break
|
||||||
|
except httpx.HTTPError:
|
||||||
|
time.sleep(1.0)
|
||||||
|
continue
|
||||||
|
|
||||||
|
return "timeout", last_payload
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_audio_info(
|
||||||
|
client: httpx.Client, base_url: str, generation_id: str, data_dir: Path
|
||||||
|
) -> tuple[Optional[str], Optional[int]]:
|
||||||
|
"""Return (audio_path, audio_bytes) for a completed generation.
|
||||||
|
|
||||||
|
Server stores audio_path relative to data_dir; resolve it to get a size.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
r = client.get(f"{base_url}/history/{generation_id}", timeout=10.0)
|
||||||
|
if r.status_code != 200:
|
||||||
|
return None, None
|
||||||
|
data = r.json()
|
||||||
|
audio_path = data.get("audio_path")
|
||||||
|
if not audio_path:
|
||||||
|
return None, None
|
||||||
|
p = Path(audio_path)
|
||||||
|
if not p.is_absolute():
|
||||||
|
p = data_dir / p
|
||||||
|
if p.exists():
|
||||||
|
return str(p), p.stat().st_size
|
||||||
|
return audio_path, None
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Report writers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def write_reports(
|
||||||
|
output_dir: Path,
|
||||||
|
binary: Path,
|
||||||
|
started_at: datetime,
|
||||||
|
finished_at: datetime,
|
||||||
|
results: list[ModelResult],
|
||||||
|
) -> tuple[Path, Path]:
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
plat = f"{platform.system().lower()}-{platform.machine().lower()}"
|
||||||
|
ts = started_at.strftime("%Y%m%d-%H%M%S")
|
||||||
|
json_path = output_dir / f"e2e-{plat}-{ts}.json"
|
||||||
|
md_path = output_dir / f"e2e-{plat}-{ts}.md"
|
||||||
|
|
||||||
|
doc = {
|
||||||
|
"platform": plat,
|
||||||
|
"binary": str(binary),
|
||||||
|
"binary_size_mb": round(binary.stat().st_size / (1024 * 1024), 1) if binary.exists() else None,
|
||||||
|
"started_at": started_at.isoformat(),
|
||||||
|
"finished_at": finished_at.isoformat(),
|
||||||
|
"elapsed_seconds": (finished_at - started_at).total_seconds(),
|
||||||
|
"results": [asdict(r) for r in results],
|
||||||
|
}
|
||||||
|
json_path.write_text(json.dumps(doc, indent=2))
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"# Voicebox E2E — {plat} — {started_at.strftime('%Y-%m-%d %H:%M UTC')}",
|
||||||
|
"",
|
||||||
|
f"Binary: `{binary}` ",
|
||||||
|
f"Elapsed: {doc['elapsed_seconds']:.1f}s",
|
||||||
|
"",
|
||||||
|
"| Model | Status | Cached | Elapsed | Audio | Error |",
|
||||||
|
"|-------|--------|--------|---------|-------|-------|",
|
||||||
|
]
|
||||||
|
for r in results:
|
||||||
|
status_icon = {"passed": "PASS", "failed": "FAIL", "timeout": "TIMEOUT"}.get(r.status, r.status.upper())
|
||||||
|
cached = "yes" if r.was_cached else ("no" if r.was_cached is False else "?")
|
||||||
|
audio_col = f"{r.audio_duration:.2f}s" if r.audio_duration else ("—" if r.status != "passed" else "?")
|
||||||
|
error_col = (r.error or "").replace("\n", " ")[:120]
|
||||||
|
lines.append(f"| {r.label} | {status_icon} | {cached} | {r.elapsed_seconds:.1f}s | {audio_col} | {error_col} |")
|
||||||
|
|
||||||
|
failed_rows = [r for r in results if r.status != "passed"]
|
||||||
|
if failed_rows:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## Failures")
|
||||||
|
for r in failed_rows:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"### {r.label} — {r.status}")
|
||||||
|
if r.error:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("```")
|
||||||
|
lines.append(r.error)
|
||||||
|
lines.append("```")
|
||||||
|
if r.server_log_tail:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("<details><summary>server log (last lines)</summary>")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("```")
|
||||||
|
lines.extend(r.server_log_tail)
|
||||||
|
lines.append("```")
|
||||||
|
lines.append("</details>")
|
||||||
|
|
||||||
|
md_path.write_text("\n".join(lines) + "\n")
|
||||||
|
return json_path, md_path
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
p = argparse.ArgumentParser(description="Voicebox E2E model generation test")
|
||||||
|
p.add_argument("--binary", type=Path, help="Path to voicebox-server binary (overrides auto-detect)")
|
||||||
|
p.add_argument("--skip-build", action="store_true", help="Error if binary missing instead of building")
|
||||||
|
p.add_argument(
|
||||||
|
"--reference-wav",
|
||||||
|
type=Path,
|
||||||
|
default=FIXTURES_DIR / "reference_voice.wav",
|
||||||
|
help="Reference audio for cloning engines",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--reference-text",
|
||||||
|
help="Transcription of reference-wav (default: read from fixtures/reference_voice.txt)",
|
||||||
|
)
|
||||||
|
p.add_argument("--only", help="Comma-separated engines to run (e.g. kokoro,qwen)")
|
||||||
|
p.add_argument("--skip", help="Comma-separated engines to skip")
|
||||||
|
p.add_argument("--keep-data-dir", action="store_true", help="Don't delete tempdir after run")
|
||||||
|
p.add_argument("--timeout-cached", type=int, default=DEFAULT_TIMEOUT_CACHED)
|
||||||
|
p.add_argument("--timeout-download", type=int, default=DEFAULT_TIMEOUT_DOWNLOAD)
|
||||||
|
p.add_argument("--port", type=int, help="Override auto-picked port")
|
||||||
|
p.add_argument("--output-dir", type=Path, default=RESULTS_DIR)
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def filter_matrix(args: argparse.Namespace) -> list[MatrixRow]:
|
||||||
|
only = set(x.strip() for x in args.only.split(",")) if args.only else None
|
||||||
|
skip = set(x.strip() for x in args.skip.split(",")) if args.skip else set()
|
||||||
|
rows = []
|
||||||
|
for r in MATRIX:
|
||||||
|
if only is not None and r.engine not in only:
|
||||||
|
continue
|
||||||
|
if r.engine in skip:
|
||||||
|
continue
|
||||||
|
rows.append(r)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_reference(args: argparse.Namespace) -> tuple[Path, str]:
|
||||||
|
wav = args.reference_wav
|
||||||
|
if not wav.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Reference WAV not found: {wav}\n"
|
||||||
|
f"Place a sample at {FIXTURES_DIR / 'reference_voice.wav'} or pass --reference-wav.\n"
|
||||||
|
f"See backend/tests/fixtures/README.md."
|
||||||
|
)
|
||||||
|
if args.reference_text:
|
||||||
|
text = args.reference_text
|
||||||
|
else:
|
||||||
|
txt_path = wav.with_suffix(".txt")
|
||||||
|
if not txt_path.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Reference transcription not found: {txt_path}\n"
|
||||||
|
f"Create it next to the WAV, or pass --reference-text."
|
||||||
|
)
|
||||||
|
text = txt_path.read_text().strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("Reference transcription is empty")
|
||||||
|
return wav, text
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
rows = filter_matrix(args)
|
||||||
|
if not rows:
|
||||||
|
print("No rows selected after --only/--skip filtering", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
# Binary
|
||||||
|
binary = args.binary or find_binary()
|
||||||
|
if binary is None:
|
||||||
|
if args.skip_build:
|
||||||
|
print("No frozen binary found and --skip-build set. Run: python backend/build_binary.py", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
binary = build_binary()
|
||||||
|
if not binary.exists():
|
||||||
|
print(f"Binary path does not exist: {binary}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
print(f"[binary] {binary}", flush=True)
|
||||||
|
|
||||||
|
# Reference audio (only required if any cloning row is in the matrix)
|
||||||
|
needs_reference = any(r.profile_kind == "cloned" for r in rows)
|
||||||
|
ref_wav: Optional[Path] = None
|
||||||
|
ref_text: Optional[str] = None
|
||||||
|
if needs_reference:
|
||||||
|
try:
|
||||||
|
ref_wav, ref_text = resolve_reference(args)
|
||||||
|
except (FileNotFoundError, ValueError) as e:
|
||||||
|
print(f"[fixture] {e}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
print(f"[fixture] reference WAV: {ref_wav}", flush=True)
|
||||||
|
print(f"[fixture] reference text: {ref_text!r}", flush=True)
|
||||||
|
|
||||||
|
# Tempdir + log path
|
||||||
|
data_dir = Path(tempfile.mkdtemp(prefix="voicebox-e2e-"))
|
||||||
|
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||||
|
log_path = args.output_dir / f"server-{ts}.log"
|
||||||
|
|
||||||
|
port = args.port or pick_free_port()
|
||||||
|
base_url = f"http://127.0.0.1:{port}"
|
||||||
|
|
||||||
|
server = ServerProcess(binary=binary, port=port, data_dir=data_dir, log_path=log_path)
|
||||||
|
started_at = datetime.now(timezone.utc)
|
||||||
|
results: list[ModelResult] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
server.start()
|
||||||
|
print(f"[health] waiting for {base_url}/health ...", flush=True)
|
||||||
|
wait_for_health(base_url, server, HEALTH_TIMEOUT)
|
||||||
|
print("[health] ready", flush=True)
|
||||||
|
|
||||||
|
with httpx.Client(timeout=30.0) as client:
|
||||||
|
# Profile setup (only create what's needed)
|
||||||
|
cloned_profile_id: Optional[str] = None
|
||||||
|
kokoro_profile_id: Optional[str] = None
|
||||||
|
qwen_cv_profile_id: Optional[str] = None
|
||||||
|
needed_kinds = {r.profile_kind for r in rows}
|
||||||
|
if "cloned" in needed_kinds:
|
||||||
|
assert ref_wav is not None and ref_text is not None
|
||||||
|
print("[profile] creating cloned profile...", flush=True)
|
||||||
|
cloned_profile_id = create_cloned_profile(client, base_url, ref_wav, ref_text)
|
||||||
|
if "preset_kokoro" in needed_kinds:
|
||||||
|
print("[profile] creating kokoro preset...", flush=True)
|
||||||
|
kokoro_profile_id = create_preset_profile(client, base_url, "e2e-kokoro", "kokoro", "af_heart")
|
||||||
|
if "preset_qwen_cv" in needed_kinds:
|
||||||
|
print("[profile] creating qwen_custom_voice preset...", flush=True)
|
||||||
|
qwen_cv_profile_id = create_preset_profile(client, base_url, "e2e-qwen-cv", "qwen_custom_voice", "Ryan")
|
||||||
|
|
||||||
|
profile_lookup = {
|
||||||
|
"cloned": cloned_profile_id,
|
||||||
|
"preset_kokoro": kokoro_profile_id,
|
||||||
|
"preset_qwen_cv": qwen_cv_profile_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Matrix loop
|
||||||
|
for row in rows:
|
||||||
|
print(f"\n[run] {row.label} (engine={row.engine}, size={row.model_size})", flush=True)
|
||||||
|
profile_id = profile_lookup[row.profile_kind]
|
||||||
|
assert profile_id is not None
|
||||||
|
was_cached = get_model_cached(client, base_url, row.model_name)
|
||||||
|
timeout_s = args.timeout_cached if was_cached else args.timeout_download
|
||||||
|
print(f"[run] cached={was_cached} timeout={timeout_s}s", flush=True)
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
result = ModelResult(
|
||||||
|
label=row.label,
|
||||||
|
engine=row.engine,
|
||||||
|
model_size=row.model_size,
|
||||||
|
status="failed",
|
||||||
|
was_cached=was_cached,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
status, payload = run_one_generation(client, base_url, row, profile_id, timeout_s)
|
||||||
|
result.status = "passed" if status == "completed" else status
|
||||||
|
result.generation_id = payload.get("id")
|
||||||
|
result.audio_duration = payload.get("duration")
|
||||||
|
result.error = payload.get("error")
|
||||||
|
if status == "completed" and result.generation_id:
|
||||||
|
audio_path, audio_bytes = fetch_audio_info(
|
||||||
|
client, base_url, result.generation_id, data_dir
|
||||||
|
)
|
||||||
|
result.audio_path = audio_path
|
||||||
|
result.audio_bytes = audio_bytes
|
||||||
|
if audio_bytes is not None and audio_bytes == 0:
|
||||||
|
result.status = "failed"
|
||||||
|
result.error = (result.error or "") + " (audio file is empty)"
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
result.status = "failed"
|
||||||
|
result.http_status = e.response.status_code
|
||||||
|
try:
|
||||||
|
detail = e.response.json().get("detail")
|
||||||
|
except Exception:
|
||||||
|
detail = e.response.text
|
||||||
|
result.error = f"HTTP {e.response.status_code}: {detail}"
|
||||||
|
except Exception as e:
|
||||||
|
result.status = "failed"
|
||||||
|
result.error = f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
result.elapsed_seconds = round(time.time() - t0, 2)
|
||||||
|
if result.status != "passed":
|
||||||
|
result.server_log_tail = server.log_tail(100)
|
||||||
|
print(f"[run] {row.label} → {result.status} in {result.elapsed_seconds}s"
|
||||||
|
+ (f" ({result.error})" if result.error else ""), flush=True)
|
||||||
|
results.append(result)
|
||||||
|
finally:
|
||||||
|
finished_at = datetime.now(timezone.utc)
|
||||||
|
server.stop()
|
||||||
|
if not args.keep_data_dir:
|
||||||
|
shutil.rmtree(data_dir, ignore_errors=True)
|
||||||
|
else:
|
||||||
|
print(f"[cleanup] keeping data dir: {data_dir}", flush=True)
|
||||||
|
|
||||||
|
json_path, md_path = write_reports(args.output_dir, binary, started_at, finished_at, results)
|
||||||
|
print(f"\n[report] {json_path}")
|
||||||
|
print(f"[report] {md_path}")
|
||||||
|
print(f"[report] server log: {log_path}")
|
||||||
|
|
||||||
|
passed = sum(1 for r in results if r.status == "passed")
|
||||||
|
failed = len(results) - passed
|
||||||
|
print(f"\n== {passed} passed, {failed} failed ==")
|
||||||
|
return 0 if failed == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -295,6 +295,15 @@ fix-python: _ensure-venv
|
|||||||
test: _ensure-venv
|
test: _ensure-venv
|
||||||
{{ venv_bin }}/python -m pytest {{ backend_dir }}/tests -v
|
{{ venv_bin }}/python -m pytest {{ backend_dir }}/tests -v
|
||||||
|
|
||||||
|
# E2E: generate with every TTS model against the frozen binary (pass extra flags like --only kokoro)
|
||||||
|
[unix]
|
||||||
|
test-models *ARGS: _ensure-venv
|
||||||
|
{{ venv_bin }}/python {{ backend_dir }}/tests/test_all_models_e2e.py {{ ARGS }}
|
||||||
|
|
||||||
|
[windows]
|
||||||
|
test-models *ARGS: _ensure-venv
|
||||||
|
& "{{ python }}" {{ backend_dir }}/tests/test_all_models_e2e.py {{ ARGS }}
|
||||||
|
|
||||||
# ─── Database ─────────────────────────────────────────────────────────
|
# ─── Database ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Initialize SQLite database
|
# Initialize SQLite database
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
+451
-392
@@ -1,422 +1,481 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { Github, Globe, Languages, MessageSquare, SlidersHorizontal, Zap } from 'lucide-react';
|
import {
|
||||||
import { useEffect, useState } from 'react';
|
Github,
|
||||||
import { ControlUI } from '@/components/ControlUI';
|
Globe,
|
||||||
import { Features } from '@/components/Features';
|
Languages,
|
||||||
import { Footer } from '@/components/Footer';
|
MessageSquare,
|
||||||
import { Navbar } from '@/components/Navbar';
|
SlidersHorizontal,
|
||||||
import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons';
|
Zap,
|
||||||
import { VoiceCreator } from '@/components/VoiceCreator';
|
} from "lucide-react";
|
||||||
import { DOWNLOAD_LINKS, GITHUB_REPO } from '@/lib/constants';
|
import {useEffect, useState} from "react";
|
||||||
import type { DownloadLinks } from '@/lib/releases';
|
import {ApiSection} from "@/components/ApiSection";
|
||||||
|
import {ControlUI} from "@/components/ControlUI";
|
||||||
|
import {Features} from "@/components/Features";
|
||||||
|
import {Footer} from "@/components/Footer";
|
||||||
|
import {Navbar} from "@/components/Navbar";
|
||||||
|
import {AppleIcon, LinuxIcon, WindowsIcon} from "@/components/PlatformIcons";
|
||||||
|
import {TutorialsSection} from "@/components/TutorialsSection";
|
||||||
|
import {VoiceCreator} from "@/components/VoiceCreator";
|
||||||
|
import {DOWNLOAD_LINKS, GITHUB_REPO} from "@/lib/constants";
|
||||||
|
import type {DownloadLinks} from "@/lib/releases";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [downloadLinks, setDownloadLinks] = useState<DownloadLinks>(DOWNLOAD_LINKS);
|
const [downloadLinks, setDownloadLinks] =
|
||||||
const [version, setVersion] = useState<string | null>(null);
|
useState<DownloadLinks>(DOWNLOAD_LINKS);
|
||||||
const [totalDownloads, setTotalDownloads] = useState<number | null>(null);
|
const [version, setVersion] = useState<string | null>(null);
|
||||||
|
const [totalDownloads, setTotalDownloads] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/releases')
|
fetch("/api/releases")
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res.ok) throw new Error('Failed to fetch releases');
|
if (!res.ok) throw new Error("Failed to fetch releases");
|
||||||
return res.json();
|
return res.json();
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.downloadLinks) setDownloadLinks(data.downloadLinks);
|
if (data.downloadLinks) setDownloadLinks(data.downloadLinks);
|
||||||
if (data.version) setVersion(data.version);
|
if (data.version) setVersion(data.version);
|
||||||
if (data.totalDownloads != null) setTotalDownloads(data.totalDownloads);
|
if (data.totalDownloads != null) setTotalDownloads(data.totalDownloads);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to fetch release info:', error);
|
console.error("Failed to fetch release info:", error);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
{/* ── Hero Section ─────────────────────────────────────────────── */}
|
{/* ── Hero Section ─────────────────────────────────────────────── */}
|
||||||
<section className="relative pt-32 pb-16">
|
<section className="relative pt-32 pb-16">
|
||||||
{/* Background glow */}
|
{/* Background glow */}
|
||||||
<div className="hero-glow hero-glow-fade pointer-events-none absolute inset-0 -top-32">
|
<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-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 className="absolute left-1/2 top-12 -translate-x-1/2 w-[500px] h-[400px] rounded-full bg-accent/10 blur-[80px]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mx-auto max-w-7xl px-6 text-center">
|
<div className="relative mx-auto max-w-7xl px-6 text-center">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div
|
<div
|
||||||
className="fade-in mx-auto mb-8 h-[120px] w-[120px] md:h-[160px] md:w-[160px]"
|
className="fade-in mx-auto mb-8 h-[120px] w-[120px] md:h-[160px] md:w-[160px]"
|
||||||
style={{ animationDelay: '0ms' }}
|
style={{animationDelay: "0ms"}}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src="/voicebox-logo-app.webp"
|
src="/voicebox-logo-app.webp"
|
||||||
alt="Voicebox"
|
alt="Voicebox"
|
||||||
className="h-full w-full object-contain"
|
className="h-full w-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Headline */}
|
{/* Headline */}
|
||||||
<div className="fade-in relative" style={{ animationDelay: '100ms' }}>
|
<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">
|
<h1 className="text-5xl font-bold tracking-tighter leading-[0.9] text-foreground md:text-7xl lg:text-8xl">
|
||||||
Your voice, your machine.
|
Clone any voice, in seconds.
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subtitle */}
|
{/* Subtitle */}
|
||||||
<p
|
<p
|
||||||
className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl"
|
className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl"
|
||||||
style={{ animationDelay: '200ms' }}
|
style={{animationDelay: "200ms"}}
|
||||||
>
|
>
|
||||||
Open source voice cloning studio with support for multiple TTS engines. Clone any voice,
|
Open source voice cloning studio with support for multiple TTS
|
||||||
generate natural speech, and compose multi-voice projects — all running locally.
|
engines. Clone any voice, generate natural speech, and compose
|
||||||
</p>
|
multi-voice projects. All running{" "}
|
||||||
|
<b className="text-white">locally on your machine.</b>
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* CTAs */}
|
{/* CTAs */}
|
||||||
<div
|
<div
|
||||||
className="fade-in mt-10 flex flex-col sm:flex-row items-center justify-center gap-4"
|
className="fade-in mt-10 flex flex-col sm:flex-row items-center justify-center gap-4"
|
||||||
style={{ animationDelay: '300ms' }}
|
style={{animationDelay: "300ms"}}
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="#download"
|
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)]"
|
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
|
Download
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={GITHUB_REPO}
|
href={GITHUB_REPO}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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" />
|
<Github className="h-4 w-4" />
|
||||||
View on GitHub
|
View on GitHub
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Version + downloads */}
|
{/* Version + downloads */}
|
||||||
<p
|
<p
|
||||||
className="fade-in mt-4 text-xs text-muted-foreground/50"
|
className="fade-in mt-4 text-xs text-muted-foreground/50"
|
||||||
style={{ animationDelay: '400ms' }}
|
style={{animationDelay: "400ms"}}
|
||||||
>
|
>
|
||||||
{version ?? ''}
|
{version ?? ""}
|
||||||
{version && totalDownloads != null ? ' \u00b7 ' : ''}
|
{version && totalDownloads != null ? " \u00b7 " : ""}
|
||||||
{totalDownloads != null ? `${totalDownloads.toLocaleString()} downloads` : ''}
|
{totalDownloads != null
|
||||||
{version || totalDownloads != null ? ' \u00b7 ' : ''}
|
? `${totalDownloads.toLocaleString()} downloads`
|
||||||
macOS, Windows, Linux
|
: ""}
|
||||||
</p>
|
{version || totalDownloads != null ? " \u00b7 " : ""}
|
||||||
</div>
|
macOS, Windows, Linux
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── ControlUI mockup ─────────────────────────────────────── */}
|
{/* ── ControlUI mockup ─────────────────────────────────────── */}
|
||||||
<div className="mt-16">
|
<div className="mt-16">
|
||||||
<ControlUI />
|
<ControlUI />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Features ─────────────────────────────────────────────── */}
|
{/* ── Features ─────────────────────────────────────────────── */}
|
||||||
<Features />
|
<Features />
|
||||||
|
|
||||||
{/* ── Voice Creator ────────────────────────────────────────── */}
|
{/* ── Voice Creator ────────────────────────────────────────── */}
|
||||||
<VoiceCreator />
|
<VoiceCreator />
|
||||||
|
|
||||||
{/* ── Models ─────────────────────────────────────────────────── */}
|
{/* ── Tutorials ────────────────────────────────────────────── */}
|
||||||
<section id="about" className="border-t border-border py-24">
|
<TutorialsSection />
|
||||||
<div className="mx-auto max-w-5xl px-6">
|
|
||||||
<div className="text-center mb-14">
|
|
||||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
|
||||||
Multi-Engine Architecture
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
|
||||||
Choose the right model for every job. All models run locally on your hardware —
|
|
||||||
download once, use forever.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
{/* ── API Section ──────────────────────────────────────────── */}
|
||||||
{/* Qwen3-TTS */}
|
<ApiSection />
|
||||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-base font-semibold text-foreground">Qwen3-TTS</h3>
|
|
||||||
<span className="text-xs text-muted-foreground/60">by Alibaba</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
|
||||||
1.7B
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
|
||||||
0.6B
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
|
||||||
High-quality multilingual voice cloning with natural prosody. The only engine with
|
|
||||||
delivery instructions — control tone, pace, and emotion with natural language.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
|
||||||
<Globe className="h-3 w-3" />
|
|
||||||
10 languages
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
|
||||||
<MessageSquare className="h-3 w-3" />
|
|
||||||
Delivery instructions
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chatterbox */}
|
{/* ── Models ─────────────────────────────────────────────────── */}
|
||||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
<section id="about" className="border-t border-border py-24">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="mx-auto max-w-5xl px-6">
|
||||||
<div>
|
<div className="text-center mb-14">
|
||||||
<h3 className="text-base font-semibold text-foreground">Chatterbox</h3>
|
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||||
<span className="text-xs text-muted-foreground/60">by Resemble AI</span>
|
Multi-Engine Architecture
|
||||||
</div>
|
</h2>
|
||||||
</div>
|
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
Choose the right model for every job. All models run locally on
|
||||||
Production-grade voice cloning with the broadest language support. 23 languages with
|
your hardware — download once, use forever.
|
||||||
zero-shot cloning and emotion exaggeration control.
|
</p>
|
||||||
</p>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
|
||||||
<Languages className="h-3 w-3" />
|
|
||||||
23 languages
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chatterbox Turbo */}
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
{/* Qwen3-TTS */}
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||||
<div>
|
<div className="flex items-start justify-between mb-3">
|
||||||
<h3 className="text-base font-semibold text-foreground">Chatterbox Turbo</h3>
|
<div>
|
||||||
<span className="text-xs text-muted-foreground/60">by Resemble AI</span>
|
<h3 className="text-base font-semibold text-foreground">
|
||||||
</div>
|
Qwen3-TTS
|
||||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
</h3>
|
||||||
350M
|
<span className="text-xs text-muted-foreground/60">
|
||||||
</span>
|
by Alibaba
|
||||||
</div>
|
</span>
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
</div>
|
||||||
Lightweight and fast. Supports paralinguistic tags — embed [laugh], [sigh], [gasp]
|
<div className="flex gap-1.5">
|
||||||
and more directly in your text for expressive, natural speech.
|
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||||
</p>
|
1.7B
|
||||||
<div className="flex flex-wrap gap-2">
|
</span>
|
||||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||||
<Zap className="h-3 w-3" />
|
0.6B
|
||||||
350M params
|
</span>
|
||||||
</span>
|
</div>
|
||||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
</div>
|
||||||
<MessageSquare className="h-3 w-3" />
|
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||||
[laugh] [sigh] tags
|
High-quality multilingual voice cloning with natural prosody.
|
||||||
</span>
|
The only engine with delivery instructions — control tone, pace,
|
||||||
</div>
|
and emotion with natural language.
|
||||||
</div>
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||||
|
<Globe className="h-3 w-3" />
|
||||||
|
10 languages
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||||
|
<MessageSquare className="h-3 w-3" />
|
||||||
|
Delivery instructions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* LuxTTS */}
|
{/* Chatterbox */}
|
||||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold text-foreground">LuxTTS</h3>
|
<h3 className="text-base font-semibold text-foreground">
|
||||||
<span className="text-xs text-muted-foreground/60">by ZipVoice</span>
|
Chatterbox
|
||||||
</div>
|
</h3>
|
||||||
</div>
|
<span className="text-xs text-muted-foreground/60">
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
by Resemble AI
|
||||||
Ultra-fast, CPU-friendly voice cloning at 48kHz. Exceeds 150x realtime on CPU with
|
</span>
|
||||||
~1GB VRAM. The fastest engine for quick iterations.
|
</div>
|
||||||
</p>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
Production-grade voice cloning with the broadest language
|
||||||
<Zap className="h-3 w-3" />
|
support. 23 languages with zero-shot cloning and emotion
|
||||||
150x realtime
|
exaggeration control.
|
||||||
</span>
|
</p>
|
||||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
<div className="flex flex-wrap gap-2">
|
||||||
48kHz output
|
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||||
</span>
|
<Languages className="h-3 w-3" />
|
||||||
</div>
|
23 languages
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Qwen CustomVoice */}
|
{/* Chatterbox Turbo */}
|
||||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold text-foreground">Qwen CustomVoice</h3>
|
<h3 className="text-base font-semibold text-foreground">
|
||||||
<span className="text-xs text-muted-foreground/60">by Alibaba</span>
|
Chatterbox Turbo
|
||||||
</div>
|
</h3>
|
||||||
<div className="flex gap-1.5">
|
<span className="text-xs text-muted-foreground/60">
|
||||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
by Resemble AI
|
||||||
1.7B
|
</span>
|
||||||
</span>
|
</div>
|
||||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||||
0.6B
|
350M
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
Lightweight and fast. Supports paralinguistic tags — embed
|
||||||
Nine premium preset speakers with natural-language style control. Tell the model how
|
[laugh], [sigh], [gasp] and more directly in your text for
|
||||||
to deliver — "speak slowly with warmth", "authoritative and clear" — and it adapts
|
expressive, natural speech.
|
||||||
tone, emotion, and pace.
|
</p>
|
||||||
</p>
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="flex flex-wrap gap-2">
|
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
<Zap className="h-3 w-3" />
|
||||||
<SlidersHorizontal className="h-3 w-3" />
|
350M params
|
||||||
Instruct control
|
</span>
|
||||||
</span>
|
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
<MessageSquare className="h-3 w-3" />
|
||||||
<Globe className="h-3 w-3" />
|
[laugh] [sigh] tags
|
||||||
10 languages
|
</span>
|
||||||
</span>
|
</div>
|
||||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
</div>
|
||||||
9 preset voices
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* HumeAI TADA */}
|
{/* LuxTTS */}
|
||||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold text-foreground">TADA</h3>
|
<h3 className="text-base font-semibold text-foreground">
|
||||||
<span className="text-xs text-muted-foreground/60">by Hume AI</span>
|
LuxTTS
|
||||||
</div>
|
</h3>
|
||||||
<div className="flex gap-1.5">
|
<span className="text-xs text-muted-foreground/60">
|
||||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
by ZipVoice
|
||||||
3B
|
</span>
|
||||||
</span>
|
</div>
|
||||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
</div>
|
||||||
1B
|
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||||
</span>
|
Ultra-fast, CPU-friendly voice cloning at 48kHz. Exceeds 150x
|
||||||
</div>
|
realtime on CPU with ~1GB VRAM. The fastest engine for quick
|
||||||
</div>
|
iterations.
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
</p>
|
||||||
Speech-language model with text-acoustic dual alignment. Built for long-form
|
<div className="flex flex-wrap gap-2">
|
||||||
generation — produces 700s+ of coherent audio without drift. Multilingual at 3B,
|
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||||
English-focused at 1B.
|
<Zap className="h-3 w-3" />
|
||||||
</p>
|
150x realtime
|
||||||
<div className="flex flex-wrap gap-2">
|
</span>
|
||||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||||
<Globe className="h-3 w-3" />
|
48kHz output
|
||||||
10 languages
|
</span>
|
||||||
</span>
|
</div>
|
||||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
</div>
|
||||||
Long-form coherent
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Kokoro 82M */}
|
{/* Qwen CustomVoice */}
|
||||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold text-foreground">Kokoro</h3>
|
<h3 className="text-base font-semibold text-foreground">
|
||||||
<span className="text-xs text-muted-foreground/60">by hexgrad · Apache 2.0</span>
|
Qwen CustomVoice
|
||||||
</div>
|
</h3>
|
||||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
<span className="text-xs text-muted-foreground/60">
|
||||||
82M
|
by Alibaba
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
<div className="flex gap-1.5">
|
||||||
Tiny 82M-parameter TTS that runs at CPU realtime with negligible VRAM. Pre-built
|
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||||
voice styles instead of cloning — pick a voice, type, generate. Smallest footprint
|
1.7B
|
||||||
of any engine.
|
</span>
|
||||||
</p>
|
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||||
<div className="flex flex-wrap gap-2">
|
0.6B
|
||||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
</span>
|
||||||
<Zap className="h-3 w-3" />
|
</div>
|
||||||
CPU realtime
|
</div>
|
||||||
</span>
|
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
Nine premium preset speakers with natural-language style
|
||||||
Preset voices
|
control. Tell the model how to deliver — "speak slowly with
|
||||||
</span>
|
warmth", "authoritative and clear" — and it adapts tone,
|
||||||
</div>
|
emotion, and pace.
|
||||||
</div>
|
</p>
|
||||||
</div>
|
<div className="flex flex-wrap gap-2">
|
||||||
</div>
|
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||||
</section>
|
<SlidersHorizontal className="h-3 w-3" />
|
||||||
|
Instruct control
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||||
|
<Globe className="h-3 w-3" />
|
||||||
|
10 languages
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||||
|
9 preset voices
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Download Section ─────────────────────────────────────── */}
|
{/* HumeAI TADA */}
|
||||||
<section id="download" className="border-t border-border py-24">
|
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||||
<div className="mx-auto max-w-4xl px-6">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div className="text-center mb-12">
|
<div>
|
||||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
<h3 className="text-base font-semibold text-foreground">
|
||||||
Download Voicebox
|
TADA
|
||||||
</h2>
|
</h3>
|
||||||
<p className="text-muted-foreground">
|
<span className="text-xs text-muted-foreground/60">
|
||||||
Available for macOS, Windows, and Linux. No dependencies required.
|
by Hume AI
|
||||||
</p>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||||
|
3B
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||||
|
1B
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||||
|
Speech-language model with text-acoustic dual alignment. Built
|
||||||
|
for long-form generation — produces 700s+ of coherent audio
|
||||||
|
without drift. Multilingual at 3B, English-focused at 1B.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||||
|
<Globe className="h-3 w-3" />
|
||||||
|
10 languages
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||||
|
Long-form coherent
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl mx-auto">
|
{/* Kokoro 82M */}
|
||||||
{/* macOS ARM */}
|
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||||
<a
|
<div className="flex items-start justify-between mb-3">
|
||||||
href={downloadLinks.macArm}
|
<div>
|
||||||
download
|
<h3 className="text-base font-semibold text-foreground">
|
||||||
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"
|
Kokoro
|
||||||
>
|
</h3>
|
||||||
<AppleIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
<span className="text-xs text-muted-foreground/60">
|
||||||
<div className="ml-4">
|
by hexgrad · Apache 2.0
|
||||||
<div className="text-sm font-medium">macOS</div>
|
</span>
|
||||||
<div className="text-xs text-muted-foreground">Apple Silicon (ARM)</div>
|
</div>
|
||||||
</div>
|
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||||
</a>
|
82M
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||||
|
Tiny 82M-parameter TTS that runs at CPU realtime with negligible
|
||||||
|
VRAM. Pre-built voice styles instead of cloning — pick a voice,
|
||||||
|
type, generate. Smallest footprint of any engine.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||||
|
<Zap className="h-3 w-3" />
|
||||||
|
CPU realtime
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||||
|
Preset voices
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* macOS Intel */}
|
{/* ── Download Section ─────────────────────────────────────── */}
|
||||||
<a
|
<section id="download" className="border-t border-border py-24">
|
||||||
href={downloadLinks.macIntel}
|
<div className="mx-auto max-w-4xl px-6">
|
||||||
download
|
<div className="text-center mb-12">
|
||||||
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"
|
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||||
>
|
Download Voicebox
|
||||||
<AppleIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
</h2>
|
||||||
<div className="ml-4">
|
<p className="text-muted-foreground">
|
||||||
<div className="text-sm font-medium">macOS</div>
|
Available for macOS, Windows, and Linux. No dependencies required.
|
||||||
<div className="text-xs text-muted-foreground">Intel (x64)</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
|
||||||
|
|
||||||
{/* Windows */}
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl mx-auto">
|
||||||
<a
|
{/* macOS ARM */}
|
||||||
href={downloadLinks.windows}
|
<a
|
||||||
download
|
href={downloadLinks.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"
|
download
|
||||||
>
|
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group"
|
||||||
<WindowsIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
>
|
||||||
<div className="ml-4">
|
<AppleIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||||
<div className="text-sm font-medium">Windows</div>
|
<div className="ml-4">
|
||||||
<div className="text-xs text-muted-foreground">64-bit (MSI)</div>
|
<div className="text-sm font-medium">macOS</div>
|
||||||
</div>
|
<div className="text-xs text-muted-foreground">
|
||||||
</a>
|
Apple Silicon (ARM)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
{/* Linux */}
|
{/* macOS Intel */}
|
||||||
<a
|
<a
|
||||||
href="/linux-install"
|
href={downloadLinks.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"
|
download
|
||||||
>
|
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group"
|
||||||
<LinuxIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
>
|
||||||
<div className="ml-4">
|
<AppleIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||||
<div className="text-sm font-medium">Linux</div>
|
<div className="ml-4">
|
||||||
<div className="text-xs text-muted-foreground">Build from source</div>
|
<div className="text-sm font-medium">macOS</div>
|
||||||
</div>
|
<div className="text-xs text-muted-foreground">Intel (x64)</div>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
|
|
||||||
{/* GitHub link */}
|
{/* Windows */}
|
||||||
<div className="mt-6 text-center">
|
<a
|
||||||
<a
|
href={downloadLinks.windows}
|
||||||
href={`${GITHUB_REPO}/releases`}
|
download
|
||||||
target="_blank"
|
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"
|
||||||
rel="noopener noreferrer"
|
>
|
||||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
<WindowsIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||||
>
|
<div className="ml-4">
|
||||||
<Github className="h-4 w-4" />
|
<div className="text-sm font-medium">Windows</div>
|
||||||
View all releases on GitHub
|
<div className="text-xs text-muted-foreground">
|
||||||
</a>
|
64-bit (MSI)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</a>
|
||||||
|
|
||||||
{/* ── Footer ───────────────────────────────────────────────── */}
|
{/* Linux */}
|
||||||
<Footer />
|
<a
|
||||||
</>
|
href="/linux-install"
|
||||||
);
|
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group"
|
||||||
|
>
|
||||||
|
<LinuxIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||||
|
<div className="ml-4">
|
||||||
|
<div className="text-sm font-medium">Linux</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Build from source
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GitHub link */}
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<a
|
||||||
|
href={`${GITHUB_REPO}/releases`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Github className="h-4 w-4" />
|
||||||
|
View all releases on GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Footer ───────────────────────────────────────────────── */}
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {AppWindow, Code2, Gamepad2, Terminal, Wrench} from "lucide-react";
|
||||||
|
|
||||||
|
type Endpoint = {
|
||||||
|
method: "POST" | "GET" | "DELETE" | "PATCH";
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENDPOINTS: Endpoint[] = [
|
||||||
|
{method: "POST", path: "/generate", label: "Generate speech"},
|
||||||
|
{method: "POST", path: "/generate/{id}/cancel", label: "Cancel a generation"},
|
||||||
|
{method: "GET", path: "/profiles", label: "List voice profiles"},
|
||||||
|
{method: "POST", path: "/profiles", label: "Create a new profile"},
|
||||||
|
{method: "GET", path: "/models/status", label: "Model catalog & state"},
|
||||||
|
{method: "GET", path: "/history", label: "Past generations"},
|
||||||
|
{method: "GET", path: "/health", label: "Server health"},
|
||||||
|
];
|
||||||
|
|
||||||
|
const METHOD_STYLES: Record<Endpoint["method"], string> = {
|
||||||
|
POST: "bg-accent/10 text-accent border-accent/20",
|
||||||
|
GET: "bg-muted text-muted-foreground border-border",
|
||||||
|
DELETE: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||||
|
PATCH: "bg-blue-500/10 text-blue-400 border-blue-500/20",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CURL_SNIPPET = `curl -X POST http://127.0.0.1:17493/generate \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"text": "Welcome to the game, player one.",
|
||||||
|
"profile_id": "morgan-freeman",
|
||||||
|
"engine": "qwen",
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -59,6 +59,12 @@ export function Navbar() {
|
|||||||
>
|
>
|
||||||
About
|
About
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href="#api"
|
||||||
|
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
API
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
href="#download"
|
href="#download"
|
||||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"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",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user