feat: add studio roadmap and streaming cleanup
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(mv podcast-forge/pnpm-lock.yaml /tmp/vibepod-pnpm-lock.yaml)",
|
||||
"Bash(git mv *)",
|
||||
"Bash(mv /tmp/vibepod-pnpm-lock.yaml web/pnpm-lock.yaml)",
|
||||
"Bash(git rm *)",
|
||||
"Bash(uv lock *)",
|
||||
"Bash(pnpm install *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(command -v uv)",
|
||||
"Bash(uv --version)",
|
||||
"Bash(uv sync *)",
|
||||
"Bash(pnpm --filter vibepod-web exec tsc --noEmit)",
|
||||
"Bash(xargs cat *)",
|
||||
"Bash(.venv/Scripts/python.exe -c \"import torch; print\\('torch:', torch.__version__\\); print\\('CUDA available:', torch.cuda.is_available\\(\\)\\); print\\('CUDA version:', torch.version.cuda\\)\")",
|
||||
"Bash(nvidia-smi)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# Copy to .env.local and fill in values
|
||||
|
||||
# URL of the Python TTS server (used by Next.js API routes)
|
||||
VIBEVOICE_SERVER_URL=http://localhost:8000
|
||||
|
||||
# HuggingFace token — required if the model repo is private or gated
|
||||
HF_TOKEN=
|
||||
|
||||
# Override the HuggingFace model cache directory (optional)
|
||||
# HF_HOME=/path/to/hf-cache
|
||||
@@ -0,0 +1,24 @@
|
||||
# Python
|
||||
server/.venv/
|
||||
server/voices/
|
||||
server/__pycache__/
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
**/*.pyo
|
||||
*.egg-info/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Node (root-level)
|
||||
node_modules/
|
||||
web/.next/
|
||||
web/tsconfig.tsbuildinfo
|
||||
web/next-env.d.ts
|
||||
web/node_modules/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -1,2 +1,117 @@
|
||||
# vibepod
|
||||
Podcast Generator using VibeVoice 0.5
|
||||
# VibePod
|
||||
|
||||
A text-to-speech podcast generator powered by [VibeVoice 0.5B](https://huggingface.co/microsoft/VibeVoice-Realtime-0.5B). Paste a script, tune a couple of sliders, and get a WAV back.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
VibePod/
|
||||
├── web/ Next.js 15 frontend (React 19, Tailwind CSS 4, TypeScript)
|
||||
└── server/ FastAPI TTS backend (Python 3.10+, VibeVoice, UV)
|
||||
```
|
||||
|
||||
The Next.js app proxies audio generation requests to the FastAPI server, keeping CORS out of the picture and the Python model off the browser.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Tool | Install |
|
||||
|------|---------|
|
||||
| [Node.js 20+](https://nodejs.org) | `winget install OpenJS.NodeJS.LTS` |
|
||||
| [pnpm](https://pnpm.io) | `npm i -g pnpm` |
|
||||
| [Python 3.10+](https://python.org) | `winget install Python.Python.3.13` |
|
||||
| [uv](https://docs.astral.sh/uv/) | `winget install astral-sh.uv` |
|
||||
|
||||
## Getting started
|
||||
|
||||
```bash
|
||||
# 1. Clone
|
||||
git clone https://github.com/LyAhn/VibePod.git
|
||||
cd VibePod
|
||||
|
||||
# 2. Install Node dependencies (root + web workspace)
|
||||
pnpm install
|
||||
|
||||
# 3. Copy env file and fill in values
|
||||
cp .env.example .env.local
|
||||
|
||||
# 4. Start everything
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
`pnpm dev` starts both services concurrently:
|
||||
|
||||
- **SERVER** — `http://localhost:8000` — on first run `uv sync` creates the Python venv and downloads the ~1 GB VibeVoice model from HuggingFace
|
||||
- **WEB** — `http://localhost:3000` — Next.js dev server with Turbopack
|
||||
|
||||
The frontend shows a loading indicator while the model downloads. Once the server reports `status: online`, generation is available.
|
||||
|
||||
## Individual commands
|
||||
|
||||
```bash
|
||||
pnpm dev:web # Next.js only
|
||||
pnpm dev:server # Python server only
|
||||
pnpm build # Production build of the frontend
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
Copy `.env.example` to `.env.local` and set:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `VIBEVOICE_SERVER_URL` | `http://localhost:8000` | URL the Next.js API routes use to reach the Python server |
|
||||
| `HF_TOKEN` | — | HuggingFace token (required if the model repo is gated) |
|
||||
| `HF_HOME` | — | Override the HuggingFace model cache directory |
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
web/
|
||||
├── app/
|
||||
│ ├── api/generate/ Proxies POST requests to the Python server
|
||||
│ ├── api/health/ Proxies health checks (status: loading | online | error)
|
||||
│ ├── page.tsx Main UI — script input, controls, audio player
|
||||
│ └── layout.tsx
|
||||
├── components/
|
||||
│ ├── Header.tsx
|
||||
│ ├── TextInputPanel.tsx
|
||||
│ ├── GenerationControls.tsx cfg_scale and inference_steps sliders
|
||||
│ ├── AudioPlayer.tsx
|
||||
│ └── StatusLog.tsx
|
||||
└── hooks/
|
||||
└── useAudioPlayer.ts
|
||||
|
||||
server/
|
||||
├── vibevoice_server.py FastAPI app — /health and /generate endpoints
|
||||
├── download_model.py One-shot HuggingFace model prefetch
|
||||
├── start.sh Entry point: uv sync → model check → uvicorn
|
||||
└── pyproject.toml Python deps managed by uv
|
||||
```
|
||||
|
||||
## Generation parameters
|
||||
|
||||
| Parameter | Range | Default | Effect |
|
||||
|-----------|-------|---------|--------|
|
||||
| `speaker` | `carter`, `davis`, `emma`, `frank`, `grace`, `mike` | `carter` | Voice preset used for the generated audio |
|
||||
| `cfg_scale` | 0.5 - 4.0 | 1.5 | Higher = more expressive guidance |
|
||||
| `inference_steps` | 5 - 20 | 10 | More steps = higher quality, slower generation |
|
||||
|
||||
## How it works
|
||||
|
||||
1. The user pastes a script and hits **Generate**
|
||||
2. The Next.js `/api/generate` route forwards the request to FastAPI on port 8000
|
||||
3. FastAPI runs the text through the VibeVoice streaming processor and inference model
|
||||
4. Audio chunks stream back to the browser as SSE events containing base64 float32 PCM
|
||||
5. The browser plays the chunks live, assembles a WAV Blob, and loads it into the audio player
|
||||
|
||||
## Python dependencies
|
||||
|
||||
Managed by [uv](https://docs.astral.sh/uv/). The `server/uv.lock` is committed so installs are fully reproducible.
|
||||
|
||||
```bash
|
||||
# Add a package
|
||||
cd server && uv add <package>
|
||||
|
||||
# Upgrade all dependencies
|
||||
cd server && uv lock --upgrade
|
||||
```
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# VibePod dev launcher
|
||||
# Starts all services and kills the entire process group cleanly on Ctrl+C.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
CY=$'\e[36m'; MG=$'\e[35m'; RS=$'\e[0m'
|
||||
|
||||
# On any exit (including Ctrl+C) kill every process in this group.
|
||||
trap 'kill 0' EXIT
|
||||
|
||||
prefix() {
|
||||
local label=$1 color=$2
|
||||
while IFS= read -r line; do
|
||||
printf '%s%-10s%s %s\n' "$color" "$label" "$RS" "$line"
|
||||
done
|
||||
}
|
||||
|
||||
echo "Starting VibePod — Ctrl+C to stop all"
|
||||
echo ""
|
||||
|
||||
bash server/start.sh 2>&1 | prefix "[SERVER]" "$CY" &
|
||||
pnpm --filter vibepod-web dev 2>&1 | prefix "[WEB]" "$MG" &
|
||||
|
||||
wait
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "vibepod",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bash dev.sh",
|
||||
"dev:server": "bash server/start.sh",
|
||||
"dev:web": "pnpm --filter vibepod-web dev",
|
||||
"build": "pnpm --filter vibepod-web build"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8"
|
||||
}
|
||||
@@ -0,0 +1,979 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.: {}
|
||||
|
||||
web:
|
||||
dependencies:
|
||||
next:
|
||||
specifier: 15.5.15
|
||||
version: 15.5.15(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react:
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0
|
||||
react-dom:
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0(react@19.1.0)
|
||||
devDependencies:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4
|
||||
version: 4.2.4
|
||||
'@types/node':
|
||||
specifier: ^20
|
||||
version: 20.19.39
|
||||
'@types/react':
|
||||
specifier: ^19
|
||||
version: 19.2.14
|
||||
'@types/react-dom':
|
||||
specifier: ^19
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
tailwindcss:
|
||||
specifier: ^4
|
||||
version: 4.2.4
|
||||
typescript:
|
||||
specifier: ^5
|
||||
version: 5.9.3
|
||||
|
||||
packages:
|
||||
|
||||
'@alloc/quick-lru@5.2.0':
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@emnapi/runtime@1.10.0':
|
||||
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
|
||||
|
||||
'@img/colour@1.1.0':
|
||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-darwin-x64@0.34.5':
|
||||
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-ia32@0.34.5':
|
||||
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
'@jridgewell/remapping@2.3.5':
|
||||
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2':
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@next/env@15.5.15':
|
||||
resolution: {integrity: sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==}
|
||||
|
||||
'@next/swc-darwin-arm64@15.5.15':
|
||||
resolution: {integrity: sha512-6PvFO2Tzt10GFK2Ro9tAVEtacMqRmTarYMFKAnV2vYMdwWc73xzmDQyAV7SwEdMhzmiRoo7+m88DuiXlJlGeaw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-darwin-x64@15.5.15':
|
||||
resolution: {integrity: sha512-G+YNV+z6FDZTp/+IdGyIMFqalBTaQSnvAA+X/hrt+eaTRFSznRMz9K7rTmzvM6tDmKegNtyzgufZW0HwVzEqaQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-linux-arm64-gnu@15.5.15':
|
||||
resolution: {integrity: sha512-eVkrMcVIBqGfXB+QUC7jjZ94Z6uX/dNStbQFabewAnk13Uy18Igd1YZ/GtPRzdhtm7QwC0e6o7zOQecul4iC1w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@15.5.15':
|
||||
resolution: {integrity: sha512-RwSHKMQ7InLy5GfkY2/n5PcFycKA08qI1VST78n09nN36nUPqCvGSMiLXlfUmzmpQpF6XeBYP2KRWHi0UW3uNg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@15.5.15':
|
||||
resolution: {integrity: sha512-nplqvY86LakS+eeiuWsNWvfmK8pFcOEW7ZtVRt4QH70lL+0x6LG/m1OpJ/tvrbwjmR8HH9/fH2jzW1GlL03TIg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@15.5.15':
|
||||
resolution: {integrity: sha512-eAgl9NKQ84/sww0v81DQINl/vL2IBxD7sMybd0cWRw6wqgouVI53brVRBrggqBRP/NWeIAE1dm5cbKYoiMlqDQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@15.5.15':
|
||||
resolution: {integrity: sha512-GJVZC86lzSquh0MtvZT+L7G8+jMnJcldloOjA8Kf3wXvBrvb6OGe2MzPuALxFshSm/IpwUtD2mIoof39ymf52A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-x64-msvc@15.5.15':
|
||||
resolution: {integrity: sha512-nFucjVdwlFqxh/JG3hWSJ4p8+YJV7Ii8aPDuBQULB6DzUF4UNZETXLfEUk+oI2zEznWWULPt7MeuTE6xtK1HSA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
'@tailwindcss/node@4.2.4':
|
||||
resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==}
|
||||
|
||||
'@tailwindcss/oxide-android-arm64@4.2.4':
|
||||
resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@tailwindcss/oxide-darwin-arm64@4.2.4':
|
||||
resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@tailwindcss/oxide-darwin-x64@4.2.4':
|
||||
resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@tailwindcss/oxide-freebsd-x64@4.2.4':
|
||||
resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4':
|
||||
resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-gnu@4.2.4':
|
||||
resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.2.4':
|
||||
resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.2.4':
|
||||
resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.2.4':
|
||||
resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.2.4':
|
||||
resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
bundledDependencies:
|
||||
- '@napi-rs/wasm-runtime'
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
- '@tybys/wasm-util'
|
||||
- '@emnapi/wasi-threads'
|
||||
- tslib
|
||||
|
||||
'@tailwindcss/oxide-win32-arm64-msvc@4.2.4':
|
||||
resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/oxide-win32-x64-msvc@4.2.4':
|
||||
resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/oxide@4.2.4':
|
||||
resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@tailwindcss/postcss@4.2.4':
|
||||
resolution: {integrity: sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==}
|
||||
|
||||
'@types/node@20.19.39':
|
||||
resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==}
|
||||
|
||||
'@types/react-dom@19.2.3':
|
||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
|
||||
'@types/react@19.2.14':
|
||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||
|
||||
caniuse-lite@1.0.30001791:
|
||||
resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==}
|
||||
|
||||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
enhanced-resolve@5.21.0:
|
||||
resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
jiti@2.6.1:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
lightningcss-darwin-arm64@1.32.0:
|
||||
resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
lightningcss-darwin-x64@1.32.0:
|
||||
resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
lightningcss-freebsd-x64@1.32.0:
|
||||
resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
lightningcss-linux-arm-gnueabihf@1.32.0:
|
||||
resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-arm64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
lightningcss-win32-x64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
lightningcss@1.32.0:
|
||||
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
next@15.5.15:
|
||||
resolution: {integrity: sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==}
|
||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
||||
hasBin: true
|
||||
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
|
||||
peerDependenciesMeta:
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@playwright/test':
|
||||
optional: true
|
||||
babel-plugin-react-compiler:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
postcss@8.4.31:
|
||||
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.12:
|
||||
resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
react-dom@19.1.0:
|
||||
resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==}
|
||||
peerDependencies:
|
||||
react: ^19.1.0
|
||||
|
||||
react@19.1.0:
|
||||
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
scheduler@0.26.0:
|
||||
resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==}
|
||||
|
||||
semver@7.7.4:
|
||||
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
sharp@0.34.5:
|
||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
styled-jsx@5.1.6:
|
||||
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': '*'
|
||||
babel-plugin-macros: '*'
|
||||
react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
|
||||
peerDependenciesMeta:
|
||||
'@babel/core':
|
||||
optional: true
|
||||
babel-plugin-macros:
|
||||
optional: true
|
||||
|
||||
tailwindcss@4.2.4:
|
||||
resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==}
|
||||
|
||||
tapable@2.3.3:
|
||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@emnapi/runtime@1.10.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@img/colour@1.1.0':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
dependencies:
|
||||
'@emnapi/runtime': 1.10.0
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-ia32@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/remapping@2.3.5':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@next/env@15.5.15': {}
|
||||
|
||||
'@next/swc-darwin-arm64@15.5.15':
|
||||
optional: true
|
||||
|
||||
'@next/swc-darwin-x64@15.5.15':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-gnu@15.5.15':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-musl@15.5.15':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-gnu@15.5.15':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-musl@15.5.15':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-arm64-msvc@15.5.15':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-x64-msvc@15.5.15':
|
||||
optional: true
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@tailwindcss/node@4.2.4':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
enhanced-resolve: 5.21.0
|
||||
jiti: 2.6.1
|
||||
lightningcss: 1.32.0
|
||||
magic-string: 0.30.21
|
||||
source-map-js: 1.2.1
|
||||
tailwindcss: 4.2.4
|
||||
|
||||
'@tailwindcss/oxide-android-arm64@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-darwin-arm64@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-darwin-x64@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-freebsd-x64@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-gnu@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-win32-arm64-msvc@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-win32-x64-msvc@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide@4.2.4':
|
||||
optionalDependencies:
|
||||
'@tailwindcss/oxide-android-arm64': 4.2.4
|
||||
'@tailwindcss/oxide-darwin-arm64': 4.2.4
|
||||
'@tailwindcss/oxide-darwin-x64': 4.2.4
|
||||
'@tailwindcss/oxide-freebsd-x64': 4.2.4
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4
|
||||
'@tailwindcss/oxide-linux-arm64-gnu': 4.2.4
|
||||
'@tailwindcss/oxide-linux-arm64-musl': 4.2.4
|
||||
'@tailwindcss/oxide-linux-x64-gnu': 4.2.4
|
||||
'@tailwindcss/oxide-linux-x64-musl': 4.2.4
|
||||
'@tailwindcss/oxide-wasm32-wasi': 4.2.4
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.4
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.2.4
|
||||
|
||||
'@tailwindcss/postcss@4.2.4':
|
||||
dependencies:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
'@tailwindcss/node': 4.2.4
|
||||
'@tailwindcss/oxide': 4.2.4
|
||||
postcss: 8.5.12
|
||||
tailwindcss: 4.2.4
|
||||
|
||||
'@types/node@20.19.39':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@types/react@19.2.14':
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
caniuse-lite@1.0.30001791: {}
|
||||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
enhanced-resolve@5.21.0:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.3
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-darwin-arm64@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-darwin-x64@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-freebsd-x64@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-arm-gnueabihf@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-arm64-gnu@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-win32-x64-msvc@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss@1.32.0:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
lightningcss-android-arm64: 1.32.0
|
||||
lightningcss-darwin-arm64: 1.32.0
|
||||
lightningcss-darwin-x64: 1.32.0
|
||||
lightningcss-freebsd-x64: 1.32.0
|
||||
lightningcss-linux-arm-gnueabihf: 1.32.0
|
||||
lightningcss-linux-arm64-gnu: 1.32.0
|
||||
lightningcss-linux-arm64-musl: 1.32.0
|
||||
lightningcss-linux-x64-gnu: 1.32.0
|
||||
lightningcss-linux-x64-musl: 1.32.0
|
||||
lightningcss-win32-arm64-msvc: 1.32.0
|
||||
lightningcss-win32-x64-msvc: 1.32.0
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
next@15.5.15(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
'@next/env': 15.5.15
|
||||
'@swc/helpers': 0.5.15
|
||||
caniuse-lite: 1.0.30001791
|
||||
postcss: 8.4.31
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
styled-jsx: 5.1.6(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 15.5.15
|
||||
'@next/swc-darwin-x64': 15.5.15
|
||||
'@next/swc-linux-arm64-gnu': 15.5.15
|
||||
'@next/swc-linux-arm64-musl': 15.5.15
|
||||
'@next/swc-linux-x64-gnu': 15.5.15
|
||||
'@next/swc-linux-x64-musl': 15.5.15
|
||||
'@next/swc-win32-arm64-msvc': 15.5.15
|
||||
'@next/swc-win32-x64-msvc': 15.5.15
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
postcss@8.4.31:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.12:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
react-dom@19.1.0(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
scheduler: 0.26.0
|
||||
|
||||
react@19.1.0: {}
|
||||
|
||||
scheduler@0.26.0: {}
|
||||
|
||||
semver@7.7.4:
|
||||
optional: true
|
||||
|
||||
sharp@0.34.5:
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
detect-libc: 2.1.2
|
||||
semver: 7.7.4
|
||||
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
|
||||
optional: true
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
styled-jsx@5.1.6(react@19.1.0):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.1.0
|
||||
|
||||
tailwindcss@4.2.4: {}
|
||||
|
||||
tapable@2.3.3: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- 'web'
|
||||
@@ -1,36 +0,0 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -1,55 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { text, cfg_scale, inference_steps } = body as {
|
||||
text: string;
|
||||
cfg_scale: number;
|
||||
inference_steps: number;
|
||||
};
|
||||
|
||||
if (!text || typeof text !== "string" || text.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing or empty text field" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const pythonServerUrl =
|
||||
process.env.VIBEVOICE_SERVER_URL ?? "http://localhost:8000";
|
||||
|
||||
const upstream = await fetch(`${pythonServerUrl}/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
text: text.trim(),
|
||||
cfg_scale: cfg_scale ?? 2.5,
|
||||
inference_steps: inference_steps ?? 20,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!upstream.ok) {
|
||||
const errorText = await upstream.text().catch(() => "Unknown error");
|
||||
return NextResponse.json(
|
||||
{ error: `VibeVoice server error: ${errorText}` },
|
||||
{ status: upstream.status }
|
||||
);
|
||||
}
|
||||
|
||||
const audioBuffer = await upstream.arrayBuffer();
|
||||
|
||||
return new NextResponse(audioBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "audio/wav",
|
||||
"Content-Disposition": 'attachment; filename="vibepod-output.wav"',
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to connect to VibeVoice server";
|
||||
return NextResponse.json({ error: message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useReducer, useCallback } from "react";
|
||||
import Header from "@/components/Header";
|
||||
import TextInputPanel from "@/components/TextInputPanel";
|
||||
import GenerationControls from "@/components/GenerationControls";
|
||||
import AudioPlayer from "@/components/AudioPlayer";
|
||||
import StatusLog from "@/components/StatusLog";
|
||||
|
||||
interface AppState {
|
||||
script: string;
|
||||
cfgScale: number;
|
||||
inferenceSteps: number;
|
||||
isGenerating: boolean;
|
||||
audioUrl: string | null;
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
type AppAction =
|
||||
| { type: "SET_SCRIPT"; payload: string }
|
||||
| { type: "SET_CFG_SCALE"; payload: number }
|
||||
| { type: "SET_INFERENCE_STEPS"; payload: number }
|
||||
| { type: "START_GENERATION" }
|
||||
| { type: "GENERATION_SUCCESS"; payload: string }
|
||||
| { type: "GENERATION_ERROR"; payload: string }
|
||||
| { type: "ADD_LOG"; payload: string };
|
||||
|
||||
function appReducer(state: AppState, action: AppAction): AppState {
|
||||
switch (action.type) {
|
||||
case "SET_SCRIPT":
|
||||
return { ...state, script: action.payload };
|
||||
case "SET_CFG_SCALE":
|
||||
return { ...state, cfgScale: action.payload };
|
||||
case "SET_INFERENCE_STEPS":
|
||||
return { ...state, inferenceSteps: action.payload };
|
||||
case "START_GENERATION":
|
||||
return {
|
||||
...state,
|
||||
isGenerating: true,
|
||||
audioUrl: null,
|
||||
logs: [],
|
||||
};
|
||||
case "GENERATION_SUCCESS":
|
||||
return {
|
||||
...state,
|
||||
isGenerating: false,
|
||||
audioUrl: action.payload,
|
||||
};
|
||||
case "GENERATION_ERROR":
|
||||
return {
|
||||
...state,
|
||||
isGenerating: false,
|
||||
};
|
||||
case "ADD_LOG":
|
||||
return { ...state, logs: [...state.logs, action.payload] };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const initialState: AppState = {
|
||||
script: "",
|
||||
cfgScale: 2.5,
|
||||
inferenceSteps: 20,
|
||||
isGenerating: false,
|
||||
audioUrl: null,
|
||||
logs: [],
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
const [state, dispatch] = useReducer(appReducer, initialState);
|
||||
|
||||
const wordCount =
|
||||
state.script.trim() === ""
|
||||
? 0
|
||||
: state.script.trim().split(/\s+/).length;
|
||||
|
||||
const addLog = useCallback((msg: string) => {
|
||||
dispatch({ type: "ADD_LOG", payload: msg });
|
||||
}, []);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!state.script.trim() || state.isGenerating) return;
|
||||
|
||||
dispatch({ type: "START_GENERATION" });
|
||||
addLog("Connecting to VibeVoice server...");
|
||||
|
||||
try {
|
||||
addLog(`Sending script (${wordCount} words) for synthesis...`);
|
||||
addLog(
|
||||
`Settings: CFG=${state.cfgScale.toFixed(1)}, Steps=${state.inferenceSteps}`
|
||||
);
|
||||
|
||||
const res = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
text: state.script,
|
||||
cfg_scale: state.cfgScale,
|
||||
inference_steps: state.inferenceSteps,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
addLog("Generating audio...");
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const sizeMB = (blob.size / 1024 / 1024).toFixed(2);
|
||||
addLog(`Audio received — ${sizeMB} MB`);
|
||||
addLog("Done — audio ready for playback.");
|
||||
|
||||
dispatch({ type: "GENERATION_SUCCESS", payload: url });
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Unknown error occurred";
|
||||
addLog(`Error: ${message}`);
|
||||
dispatch({ type: "GENERATION_ERROR", payload: message });
|
||||
}
|
||||
}, [state.script, state.cfgScale, state.inferenceSteps, state.isGenerating, wordCount, addLog]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex flex-col"
|
||||
style={{ background: "var(--background)" }}
|
||||
>
|
||||
<Header />
|
||||
|
||||
<main className="flex-1 container mx-auto px-4 py-6 max-w-6xl">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left column: script input */}
|
||||
<div className="lg:col-span-2 flex flex-col gap-6">
|
||||
<TextInputPanel
|
||||
value={state.script}
|
||||
onChange={(text) =>
|
||||
dispatch({ type: "SET_SCRIPT", payload: text })
|
||||
}
|
||||
/>
|
||||
{state.audioUrl && <AudioPlayer audioUrl={state.audioUrl} />}
|
||||
</div>
|
||||
|
||||
{/* Right column: controls + log */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<GenerationControls
|
||||
cfgScale={state.cfgScale}
|
||||
onCfgScaleChange={(v) =>
|
||||
dispatch({ type: "SET_CFG_SCALE", payload: v })
|
||||
}
|
||||
inferenceSteps={state.inferenceSteps}
|
||||
onInferenceStepsChange={(v) =>
|
||||
dispatch({ type: "SET_INFERENCE_STEPS", payload: v })
|
||||
}
|
||||
onGenerate={handleGenerate}
|
||||
isGenerating={state.isGenerating}
|
||||
wordCount={wordCount}
|
||||
/>
|
||||
<StatusLog messages={state.logs} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
"use client";
|
||||
|
||||
interface GenerationControlsProps {
|
||||
cfgScale: number;
|
||||
onCfgScaleChange: (v: number) => void;
|
||||
inferenceSteps: number;
|
||||
onInferenceStepsChange: (v: number) => void;
|
||||
onGenerate: () => void;
|
||||
isGenerating: boolean;
|
||||
wordCount: number;
|
||||
}
|
||||
|
||||
export default function GenerationControls({
|
||||
cfgScale,
|
||||
onCfgScaleChange,
|
||||
inferenceSteps,
|
||||
onInferenceStepsChange,
|
||||
onGenerate,
|
||||
isGenerating,
|
||||
wordCount,
|
||||
}: GenerationControlsProps) {
|
||||
const estimatedSeconds = Math.ceil(wordCount / 50);
|
||||
const estimatedDisplay =
|
||||
wordCount === 0
|
||||
? "—"
|
||||
: estimatedSeconds < 60
|
||||
? `~${estimatedSeconds}s`
|
||||
: `~${Math.floor(estimatedSeconds / 60)}m ${estimatedSeconds % 60}s`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border p-5 flex flex-col gap-5"
|
||||
style={{ background: "var(--card-bg)", borderColor: "var(--border)" }}
|
||||
>
|
||||
<h2
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--accent-teal)" }}
|
||||
>
|
||||
Generation Settings
|
||||
</h2>
|
||||
|
||||
{/* CFG Scale slider */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium" style={{ color: "var(--foreground)" }}>
|
||||
Voice Expressiveness
|
||||
</label>
|
||||
<span
|
||||
className="text-sm font-mono px-2 py-0.5 rounded"
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
color: "var(--accent-teal)",
|
||||
}}
|
||||
>
|
||||
{cfgScale.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1.0}
|
||||
max={3.0}
|
||||
step={0.1}
|
||||
value={cfgScale}
|
||||
onChange={(e) => onCfgScaleChange(parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div
|
||||
className="flex items-center justify-between text-xs"
|
||||
style={{ color: "var(--muted)" }}
|
||||
>
|
||||
<span>Flat (1.0)</span>
|
||||
<span>CFG Scale</span>
|
||||
<span>Expressive (3.0)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inference Steps slider */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium" style={{ color: "var(--foreground)" }}>
|
||||
Quality vs Speed
|
||||
</label>
|
||||
<span
|
||||
className="text-sm font-mono px-2 py-0.5 rounded"
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
color: "var(--accent-violet)",
|
||||
}}
|
||||
>
|
||||
{inferenceSteps}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={30}
|
||||
step={1}
|
||||
value={inferenceSteps}
|
||||
onChange={(e) => onInferenceStepsChange(parseInt(e.target.value, 10))}
|
||||
className="w-full"
|
||||
style={
|
||||
{
|
||||
"--thumb-color": "var(--accent-violet)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="flex items-center justify-between text-xs"
|
||||
style={{ color: "var(--muted)" }}
|
||||
>
|
||||
<span>Faster (10)</span>
|
||||
<span>Inference Steps</span>
|
||||
<span>Higher quality (30)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Estimated time */}
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2 rounded-lg text-sm"
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--muted)" }}>Estimated generation time</span>
|
||||
<span
|
||||
className="font-mono font-medium"
|
||||
style={{ color: "var(--accent-teal)" }}
|
||||
>
|
||||
{estimatedDisplay}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Generate button */}
|
||||
<button
|
||||
onClick={onGenerate}
|
||||
disabled={isGenerating || wordCount === 0}
|
||||
className="w-full py-3 rounded-xl font-semibold text-sm transition-all cursor-pointer disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
style={
|
||||
isGenerating || wordCount === 0
|
||||
? {
|
||||
background: "var(--border)",
|
||||
color: "var(--muted)",
|
||||
}
|
||||
: {
|
||||
background:
|
||||
"linear-gradient(135deg, var(--accent-teal-dim), var(--accent-violet-dim))",
|
||||
color: "#fff",
|
||||
boxShadow: "0 4px 15px rgba(45, 212, 191, 0.2)",
|
||||
}
|
||||
}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
Generating audio...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
Generate Podcast Audio
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
# VibePod TTS Server dependencies
|
||||
# Install with: pip install -r requirements.txt
|
||||
|
||||
fastapi>=0.111.0
|
||||
uvicorn[standard]>=0.29.0
|
||||
transformers>=4.40.0
|
||||
torch>=2.2.0
|
||||
soundfile>=0.12.1
|
||||
scipy>=1.13.0
|
||||
numpy>=1.26.0
|
||||
pydantic>=2.7.0
|
||||
huggingface_hub>=0.23.0
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# VibePod TTS server startup script
|
||||
# Usage: ./start.sh [uvicorn options]
|
||||
#
|
||||
# Downloads the model on first run, then starts the FastAPI server.
|
||||
# Set HF_TOKEN env var if a HuggingFace access token is required.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "================================================"
|
||||
echo " VibePod TTS Server"
|
||||
echo "================================================"
|
||||
|
||||
# 1. Ensure Python deps are available
|
||||
if ! python -c "import fastapi" &>/dev/null; then
|
||||
echo "Installing Python dependencies..."
|
||||
pip install -r requirements.txt
|
||||
fi
|
||||
|
||||
# 2. Download model if not already cached
|
||||
echo ""
|
||||
echo "--> Checking model cache..."
|
||||
python download_model.py
|
||||
|
||||
# 3. Start the server
|
||||
echo ""
|
||||
echo "--> Starting uvicorn on http://0.0.0.0:8000"
|
||||
exec uvicorn vibevoice_server:app \
|
||||
--host 0.0.0.0 \
|
||||
--port 8000 \
|
||||
--log-level info \
|
||||
"$@"
|
||||
@@ -1,189 +0,0 @@
|
||||
"""
|
||||
VibePod — VibeVoice FastAPI TTS Server
|
||||
|
||||
Loads microsoft/VibeVoice-Realtime-0.5B via HuggingFace transformers and
|
||||
exposes a POST /generate endpoint that accepts { text, cfg_scale, inference_steps }
|
||||
and returns a WAV audio blob.
|
||||
|
||||
Start with:
|
||||
./start.sh
|
||||
or directly:
|
||||
uvicorn vibevoice_server:app --host 0.0.0.0 --port 8000
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator, Literal, Optional
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import torch
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from transformers import AutoProcessor, AutoModel
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MODEL_ID = "microsoft/VibeVoice-Realtime-0.5B"
|
||||
DEFAULT_SAMPLE_RATE = 24_000 # fallback sample rate when not specified by model config
|
||||
|
||||
# ─── Global model state ────────────────────────────────────────────────────────
|
||||
ModelStatus = Literal["loading", "online", "error"]
|
||||
|
||||
_processor: Optional[object] = None
|
||||
_model: Optional[object] = None
|
||||
_device: str = "cpu"
|
||||
_model_status: ModelStatus = "loading"
|
||||
_model_error: Optional[str] = None
|
||||
_load_lock = threading.Lock()
|
||||
|
||||
|
||||
def _load_model_sync() -> None:
|
||||
"""Load the model synchronously. Called from a background thread at startup."""
|
||||
global _processor, _model, _device, _model_status, _model_error
|
||||
|
||||
with _load_lock:
|
||||
if _model is not None:
|
||||
return
|
||||
|
||||
_device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
logger.info("Loading %s on %s …", MODEL_ID, _device)
|
||||
|
||||
try:
|
||||
_processor = AutoProcessor.from_pretrained(MODEL_ID)
|
||||
_model = AutoModel.from_pretrained(
|
||||
MODEL_ID,
|
||||
torch_dtype=torch.float16 if _device == "cuda" else torch.float32,
|
||||
)
|
||||
_model = _model.to(_device)
|
||||
_model.eval()
|
||||
|
||||
_model_status = "online"
|
||||
logger.info("Model loaded successfully on %s.", _device)
|
||||
except Exception as exc:
|
||||
_model_status = "error"
|
||||
_model_error = str(exc)
|
||||
logger.exception("Failed to load model: %s", exc)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
# Start model loading in a background thread so the server answers
|
||||
# health-check requests immediately (status="loading") rather than
|
||||
# blocking startup for the full model download/load time.
|
||||
thread = threading.Thread(target=_load_model_sync, daemon=True, name="model-loader")
|
||||
thread.start()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="VibePod TTS Server", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
|
||||
# ─── Request / response schemas ────────────────────────────────────────────────
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=10_000)
|
||||
cfg_scale: float = Field(default=2.5, ge=1.0, le=3.0)
|
||||
inference_steps: int = Field(default=20, ge=10, le=30)
|
||||
|
||||
@field_validator("text")
|
||||
@classmethod
|
||||
def text_not_blank(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("text must not be blank")
|
||||
return v.strip()
|
||||
|
||||
|
||||
# ─── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
"""
|
||||
Liveness / readiness probe used by the Next.js /api/health route.
|
||||
|
||||
Returns:
|
||||
{ status: "loading" | "online" | "error", model: str, message?: str }
|
||||
"""
|
||||
body: dict = {"status": _model_status, "model": MODEL_ID}
|
||||
if _model_error:
|
||||
body["message"] = _model_error
|
||||
return body
|
||||
|
||||
|
||||
@app.post("/generate")
|
||||
async def generate(req: GenerateRequest) -> StreamingResponse:
|
||||
"""
|
||||
Generate speech from text and return a WAV audio stream.
|
||||
"""
|
||||
if _model_status == "loading":
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Model is still loading — please retry in a moment.",
|
||||
)
|
||||
if _model_status == "error" or _model is None or _processor is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Model failed to load: {_model_error or 'unknown error'}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Generating audio for %d chars (cfg=%.1f, steps=%d)",
|
||||
len(req.text),
|
||||
req.cfg_scale,
|
||||
req.inference_steps,
|
||||
)
|
||||
|
||||
try:
|
||||
inputs = _processor(text=req.text, return_tensors="pt").to(_device)
|
||||
|
||||
with torch.no_grad():
|
||||
output = _model.generate(
|
||||
**inputs,
|
||||
guidance_scale=req.cfg_scale,
|
||||
num_inference_steps=req.inference_steps,
|
||||
)
|
||||
|
||||
# output is typically a tensor of shape (1, num_samples) or (num_samples,)
|
||||
audio_array = output.squeeze().cpu().numpy()
|
||||
|
||||
# Normalise to [-1, 1] float32 for WAV.
|
||||
# astype() may copy the array, but we need float32 for soundfile — this is intentional.
|
||||
if audio_array.dtype != np.float32:
|
||||
audio_array = audio_array.astype(np.float32)
|
||||
peak = np.abs(audio_array).max()
|
||||
if peak > 0:
|
||||
audio_array = audio_array / peak
|
||||
|
||||
# Determine sample rate — try common attribute names
|
||||
sample_rate: int = (
|
||||
getattr(_model.config, "sampling_rate", None)
|
||||
or getattr(_model.config, "sample_rate", None)
|
||||
or DEFAULT_SAMPLE_RATE
|
||||
)
|
||||
|
||||
buf = io.BytesIO()
|
||||
sf.write(buf, audio_array, sample_rate, format="WAV", subtype="FLOAT")
|
||||
buf.seek(0)
|
||||
|
||||
logger.info(
|
||||
"Audio generated: %.2f s at %d Hz (%d bytes)",
|
||||
len(audio_array) / sample_rate,
|
||||
sample_rate,
|
||||
buf.getbuffer().nbytes,
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="audio/wav",
|
||||
headers={"Content-Disposition": 'attachment; filename="vibepod-output.wav"'},
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Generation failed: %s", exc)
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# VibePod Roadmap
|
||||
|
||||
## Studio Vision
|
||||
|
||||
VibePod Studio will turn generated audio from a one-shot download into a reusable editing workspace. The core idea is to persist each generation as a project artifact with the source script, voice, generation settings, audio file, waveform peaks, and edit history, then expose those artifacts in a timeline editor.
|
||||
|
||||
## Phase 1: Generation Artifacts
|
||||
|
||||
- Store generated audio as server-side jobs instead of browser-only object URLs.
|
||||
- Save job metadata: script, speaker, cfg scale, inference steps, duration, sample rate, created date, and generation status.
|
||||
- Generate waveform peak data for fast timeline rendering.
|
||||
- Add a library view for previous generations.
|
||||
|
||||
## Phase 2: Basic Studio Editor
|
||||
|
||||
- Add a Studio route with waveform timeline playback.
|
||||
- Support trim start/end, split, delete range, silence insertion, fade in/out, and clip gain.
|
||||
- Keep edits non-destructive by storing an edit decision list instead of rewriting the original audio immediately.
|
||||
- Export edited audio as WAV first, then add compressed formats later.
|
||||
|
||||
## Phase 3: Regeneration Workflow
|
||||
|
||||
- Link script text ranges to generated audio ranges.
|
||||
- Allow users to select a clip and regenerate just that segment.
|
||||
- Support voice/settings changes per regenerated segment.
|
||||
- Add replace, insert, and compare-take workflows.
|
||||
|
||||
## Phase 4: Multi-Speaker Projects
|
||||
|
||||
- Support script blocks with per-speaker assignment.
|
||||
- Render speakers into separate timeline lanes.
|
||||
- Add voice presets, reusable show templates, and episode-level settings.
|
||||
- Support intro/outro/music beds once the audio engine can mix multiple lanes.
|
||||
|
||||
## Phase 5: Production Export
|
||||
|
||||
- Add loudness normalization, silence cleanup, and final mastering presets.
|
||||
- Export MP3, WAV, and podcast-ready metadata.
|
||||
- Add project save/load, autosave, and recoverable render jobs.
|
||||
- Prepare the audio pipeline for queueing longer renders outside the request lifecycle.
|
||||
|
||||
## Foundation Work Needed First
|
||||
|
||||
- Persist generated outputs with stable IDs.
|
||||
- Move waveform and WAV assembly into reusable modules.
|
||||
- Add cancellation-aware generation jobs.
|
||||
- Add a backend audio processing layer for edits and exports.
|
||||
- Keep the current generate screen as the fast path while Studio grows beside it.
|
||||
@@ -49,7 +49,7 @@ def download() -> str:
|
||||
)
|
||||
|
||||
elapsed = time.time() - start
|
||||
print(f"Model ready in {elapsed:.1f}s → {cache_path}")
|
||||
print(f"Model ready in {elapsed:.1f}s -> {cache_path}")
|
||||
return cache_path
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
[project]
|
||||
name = "vibepod-server"
|
||||
version = "0.1.0"
|
||||
description = "VibePod TTS Server — VibeVoice FastAPI backend"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
# torch is listed explicitly so uv pulls the CUDA wheel (see [tool.uv.sources]).
|
||||
# To switch back to CPU-only, remove the [tool.uv.sources] torch entry below.
|
||||
"torch>=2.0.0",
|
||||
# VibeVoice custom model + processor classes (not yet in upstream transformers)
|
||||
"vibevoice @ git+https://github.com/microsoft/VibeVoice.git",
|
||||
# Exact version required by vibevoice's streaming TTS module
|
||||
"transformers==4.51.3",
|
||||
"fastapi>=0.111.0",
|
||||
"uvicorn[standard]>=0.29.0",
|
||||
"soundfile>=0.12.1",
|
||||
"pydantic>=2.7.0",
|
||||
"huggingface_hub>=0.23.0",
|
||||
]
|
||||
|
||||
# No build-system — this is a scripts project, not an installable package.
|
||||
# Lock file is committed so installs are reproducible.
|
||||
# Run `uv lock --upgrade` to bump dependencies.
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch-cu124"
|
||||
url = "https://download.pytorch.org/whl/cu124"
|
||||
explicit = true
|
||||
|
||||
[tool.uv.sources]
|
||||
# Pull torch from the PyTorch CUDA 12.4 index instead of PyPI's CPU-only wheel.
|
||||
# CUDA 12.4 runs on any driver >= 525.60 (RTX 30/40 series all qualify).
|
||||
# To use CPU instead: remove this block and run `uv sync --reinstall-package torch`.
|
||||
torch = { index = "pytorch-cu124" }
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# VibePod TTS server — start script
|
||||
# Syncs the uv environment, downloads the model on first run, then launches uvicorn.
|
||||
# Prerequisite: uv must be installed (https://docs.astral.sh/uv/getting-started/installation/)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "================================================"
|
||||
echo " VibePod TTS Server"
|
||||
echo "================================================"
|
||||
|
||||
# 1. Check uv is available
|
||||
if ! command -v uv &>/dev/null; then
|
||||
echo ""
|
||||
echo "ERROR: uv is not installed."
|
||||
echo "Install it first:"
|
||||
echo " Windows: winget install astral-sh.uv"
|
||||
echo " macOS/Linux: curl -LsSf https://astral.sh/uv/install.sh | sh"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Sync Python environment (creates .venv on first run, no-op afterwards)
|
||||
echo ""
|
||||
echo "--> Syncing Python environment..."
|
||||
uv sync
|
||||
|
||||
# 3. Start the server — model download + load happens inside the server process
|
||||
# so the /health endpoint is reachable immediately and can report progress.
|
||||
echo ""
|
||||
echo "--> Starting uvicorn on http://0.0.0.0:8000"
|
||||
export PYTHONUTF8=1
|
||||
exec uv run uvicorn vibevoice_server:app \
|
||||
--host 0.0.0.0 \
|
||||
--port 8000 \
|
||||
--log-level info \
|
||||
"$@"
|
||||
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
VibePod — VibeVoice FastAPI TTS Server
|
||||
|
||||
Startup sequence (background thread):
|
||||
1. Download model weights if not cached -> status: downloading
|
||||
2. Download voice preset .pt files -> status: loading
|
||||
3. Load processor + model into memory -> status: loading
|
||||
4. Pre-load all voice tensors -> status: loading
|
||||
-> Server ready -> status: online
|
||||
|
||||
Generation flow:
|
||||
POST /generate -> SSE stream of audio_chunk events (base64 float32 PCM),
|
||||
ends with {type:"complete"}
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import copy
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator, Literal, Optional
|
||||
|
||||
import torch
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from tqdm import tqdm as _BaseTqdm
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MODEL_ID = "microsoft/VibeVoice-Realtime-0.5B"
|
||||
SAMPLE_RATE = 24_000
|
||||
|
||||
VOICES_DIR = Path(__file__).parent / "voices" / "streaming_model"
|
||||
VOICE_BASE_URL = (
|
||||
"https://raw.githubusercontent.com/microsoft/VibeVoice/main"
|
||||
"/demo/voices/streaming_model"
|
||||
)
|
||||
|
||||
EN_VOICES: dict[str, str] = {
|
||||
"carter": "en-Carter_man.pt",
|
||||
"davis": "en-Davis_man.pt",
|
||||
"emma": "en-Emma_woman.pt",
|
||||
"frank": "en-Frank_man.pt",
|
||||
"grace": "en-Grace_woman.pt",
|
||||
"mike": "en-Mike_man.pt",
|
||||
}
|
||||
DEFAULT_SPEAKER = "carter"
|
||||
|
||||
_IGNORE_PATTERNS = ["*.msgpack", "flax_model*", "tf_model*", "rust_model*", "*.ot"]
|
||||
|
||||
# ── Global state ────────────────────────────────────────────────────────────────
|
||||
|
||||
ModelStatus = Literal["downloading", "loading", "online", "error"]
|
||||
|
||||
_processor = None
|
||||
_model = None
|
||||
_device: str = "cpu"
|
||||
_model_status: ModelStatus = "loading"
|
||||
_model_error: Optional[str] = None
|
||||
_voice_presets: dict[str, object] = {}
|
||||
_load_lock = threading.Lock()
|
||||
_generation_lock = asyncio.Lock()
|
||||
|
||||
# Download progress (files downloaded so far)
|
||||
_dl_progress: dict[str, int] = {"done": 0, "total": 0}
|
||||
|
||||
|
||||
|
||||
# ── Progress-tracking tqdm (for model file downloads) ──────────────────────────
|
||||
|
||||
def _make_dl_tqdm() -> type:
|
||||
class _DlTqdm(_BaseTqdm):
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
if isinstance(self.total, (int, float)) and 0 < self.total < 10_000:
|
||||
_dl_progress["total"] = int(self.total)
|
||||
_dl_progress["done"] = 0
|
||||
|
||||
def update(self, n: int = 1) -> "bool | None":
|
||||
result = super().update(n)
|
||||
if isinstance(self.total, (int, float)) and 0 < self.total < 10_000:
|
||||
_dl_progress["done"] = int(self.n)
|
||||
return result
|
||||
|
||||
return _DlTqdm
|
||||
|
||||
|
||||
# ── Model / voice helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def _is_model_cached() -> bool:
|
||||
try:
|
||||
from huggingface_hub import snapshot_download
|
||||
snapshot_download(MODEL_ID, local_files_only=True, ignore_patterns=_IGNORE_PATTERNS)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _download_model() -> None:
|
||||
from huggingface_hub import snapshot_download
|
||||
token: Optional[str] = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_TOKEN")
|
||||
DlTqdm = _make_dl_tqdm()
|
||||
logger.info("Model not cached — downloading %s...", MODEL_ID)
|
||||
snapshot_download(
|
||||
repo_id=MODEL_ID,
|
||||
ignore_patterns=_IGNORE_PATTERNS,
|
||||
token=token or None,
|
||||
tqdm_class=DlTqdm,
|
||||
)
|
||||
logger.info("Model download complete.")
|
||||
|
||||
|
||||
def _download_voices() -> None:
|
||||
VOICES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for name, filename in EN_VOICES.items():
|
||||
dest = VOICES_DIR / filename
|
||||
if not dest.exists():
|
||||
url = f"{VOICE_BASE_URL}/{filename}"
|
||||
logger.info("Downloading voice preset: %s", filename)
|
||||
urllib.request.urlretrieve(url, dest)
|
||||
logger.info("Voice presets ready.")
|
||||
|
||||
|
||||
# ── Background model loader ─────────────────────────────────────────────────────
|
||||
|
||||
def _load_model_sync() -> None:
|
||||
global _processor, _model, _device, _model_status, _model_error, _voice_presets
|
||||
|
||||
with _load_lock:
|
||||
if _model is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
if not _is_model_cached():
|
||||
_model_status = "downloading"
|
||||
_download_model()
|
||||
|
||||
_model_status = "loading"
|
||||
_download_voices()
|
||||
|
||||
_device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
load_dtype = torch.bfloat16 if _device == "cuda" else torch.float32
|
||||
attn_impl = "flash_attention_2" if _device == "cuda" else "sdpa"
|
||||
|
||||
logger.info("Loading processor...")
|
||||
from vibevoice.processor.vibevoice_streaming_processor import (
|
||||
VibeVoiceStreamingProcessor,
|
||||
)
|
||||
_processor = VibeVoiceStreamingProcessor.from_pretrained(MODEL_ID)
|
||||
|
||||
logger.info("Loading model on %s...", _device)
|
||||
from vibevoice.modular.modeling_vibevoice_streaming_inference import (
|
||||
VibeVoiceStreamingForConditionalGenerationInference,
|
||||
)
|
||||
try:
|
||||
_model = VibeVoiceStreamingForConditionalGenerationInference.from_pretrained(
|
||||
MODEL_ID,
|
||||
torch_dtype=load_dtype,
|
||||
device_map=_device,
|
||||
attn_implementation=attn_impl,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("flash_attention_2 unavailable, falling back to sdpa")
|
||||
_model = VibeVoiceStreamingForConditionalGenerationInference.from_pretrained(
|
||||
MODEL_ID,
|
||||
torch_dtype=load_dtype,
|
||||
device_map=_device,
|
||||
attn_implementation="sdpa",
|
||||
)
|
||||
|
||||
_model.eval()
|
||||
_model.set_ddpm_inference_steps(num_steps=10)
|
||||
|
||||
for name, filename in EN_VOICES.items():
|
||||
path = VOICES_DIR / filename
|
||||
if path.exists():
|
||||
_voice_presets[name] = torch.load(
|
||||
path, map_location=_device, weights_only=False
|
||||
)
|
||||
|
||||
_model_status = "online"
|
||||
logger.info("Model ready on %s. Voices: %s", _device, list(_voice_presets.keys()))
|
||||
|
||||
except Exception as exc:
|
||||
_model_status = "error"
|
||||
_model_error = str(exc)
|
||||
logger.exception("Failed to initialise model: %s", exc)
|
||||
|
||||
|
||||
# ── FastAPI app ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
thread = threading.Thread(target=_load_model_sync, daemon=True, name="model-loader")
|
||||
thread.start()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="VibePod TTS Server", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
|
||||
# ── Schemas ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=10_000)
|
||||
speaker: str = Field(default=DEFAULT_SPEAKER)
|
||||
cfg_scale: float = Field(default=1.5, ge=0.5, le=4.0)
|
||||
inference_steps: int = Field(default=10, ge=5, le=20)
|
||||
|
||||
@field_validator("text")
|
||||
@classmethod
|
||||
def text_not_blank(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("text must not be blank")
|
||||
return v.strip()
|
||||
|
||||
@field_validator("speaker")
|
||||
@classmethod
|
||||
def normalise_speaker(cls, v: str) -> str:
|
||||
return v.lower().strip()
|
||||
|
||||
|
||||
# ── Endpoints ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
body: dict = {
|
||||
"status": _model_status,
|
||||
"model": MODEL_ID,
|
||||
"voices": list(_voice_presets.keys()),
|
||||
}
|
||||
if _model_status == "downloading":
|
||||
body["progress"] = {"done": _dl_progress["done"], "total": _dl_progress["total"]}
|
||||
if _model_error:
|
||||
body["message"] = _model_error
|
||||
return body
|
||||
|
||||
|
||||
def _sync_generate(
|
||||
req: GenerateRequest,
|
||||
streamer: Optional[object] = None,
|
||||
cancel_event: Optional[threading.Event] = None,
|
||||
) -> str:
|
||||
"""Blocking inference. Returns the speaker used.
|
||||
Runs in a thread-pool executor — do not call from the event loop directly.
|
||||
Pass an AsyncAudioStreamer to receive audio chunks in real time.
|
||||
"""
|
||||
if cancel_event and cancel_event.is_set():
|
||||
raise RuntimeError("Generation cancelled.")
|
||||
|
||||
speaker = req.speaker if req.speaker in _voice_presets else DEFAULT_SPEAKER
|
||||
voice_preset = copy.deepcopy(_voice_presets[speaker])
|
||||
|
||||
_model.set_ddpm_inference_steps(num_steps=req.inference_steps)
|
||||
|
||||
inputs = _processor.process_input_with_cached_prompt(
|
||||
text=req.text,
|
||||
cached_prompt=voice_preset,
|
||||
padding=True,
|
||||
return_tensors="pt",
|
||||
return_attention_mask=True,
|
||||
)
|
||||
for k, v in inputs.items():
|
||||
if torch.is_tensor(v):
|
||||
inputs[k] = v.to(_device)
|
||||
|
||||
outputs = _model.generate(
|
||||
**inputs,
|
||||
max_new_tokens=None,
|
||||
cfg_scale=req.cfg_scale,
|
||||
tokenizer=_processor.tokenizer,
|
||||
generation_config={"do_sample": False},
|
||||
verbose=True,
|
||||
all_prefilled_outputs=copy.deepcopy(voice_preset),
|
||||
audio_streamer=streamer,
|
||||
)
|
||||
|
||||
if not outputs.speech_outputs or outputs.speech_outputs[0] is None:
|
||||
raise ValueError("Model returned no audio output.")
|
||||
|
||||
return speaker
|
||||
|
||||
|
||||
def _sse(event: dict) -> str:
|
||||
return f"data: {json.dumps(event)}\n\n"
|
||||
|
||||
|
||||
@app.post("/generate")
|
||||
async def generate(req: GenerateRequest, request: Request) -> StreamingResponse:
|
||||
if _model_status != "online":
|
||||
detail = {
|
||||
"downloading": "Model is downloading — please wait.",
|
||||
"loading": "Model is loading into memory — please wait.",
|
||||
"error": f"Model failed to load: {_model_error or 'unknown error'}",
|
||||
}.get(_model_status, "Server not ready.")
|
||||
raise HTTPException(status_code=503, detail=detail)
|
||||
|
||||
if _generation_lock.locked():
|
||||
raise HTTPException(status_code=503, detail="Server is already generating audio. Please wait.")
|
||||
|
||||
async def event_stream() -> AsyncGenerator[str, None]:
|
||||
from vibevoice.modular.streamer import AsyncAudioStreamer
|
||||
|
||||
start = time.monotonic()
|
||||
streamer = AsyncAudioStreamer(batch_size=1)
|
||||
cancel_event = threading.Event()
|
||||
|
||||
async with _generation_lock:
|
||||
loop = asyncio.get_event_loop()
|
||||
future = loop.run_in_executor(
|
||||
None, functools.partial(_sync_generate, req, streamer, cancel_event)
|
||||
)
|
||||
|
||||
# Drain audio chunks as they arrive from the diffusion head.
|
||||
# stop_signal=None is the default sentinel that ends the queue.
|
||||
while True:
|
||||
try:
|
||||
chunk = await asyncio.wait_for(
|
||||
streamer.audio_queues[0].get(), timeout=120.0
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
cancel_event.set()
|
||||
future.cancel()
|
||||
yield _sse({"type": "error", "message": "Generation timed out"})
|
||||
return
|
||||
|
||||
if await request.is_disconnected():
|
||||
cancel_event.set()
|
||||
future.cancel()
|
||||
logger.info("Generation client disconnected; stream cancelled.")
|
||||
return
|
||||
|
||||
if chunk is None: # stop signal
|
||||
break
|
||||
|
||||
pcm_b64 = base64.b64encode(
|
||||
chunk.detach().cpu().float().numpy().tobytes()
|
||||
).decode()
|
||||
yield _sse({"type": "audio_chunk", "data": pcm_b64})
|
||||
|
||||
try:
|
||||
speaker = await future
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Generation cancelled.")
|
||||
yield _sse({"type": "cancelled"})
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.exception("Generation failed: %s", exc)
|
||||
yield _sse({"type": "error", "message": str(exc)})
|
||||
return
|
||||
|
||||
elapsed = round(time.monotonic() - start, 1)
|
||||
logger.info("Generation complete in %.1fs", elapsed)
|
||||
yield _sse({"type": "complete", "elapsed": elapsed, "speaker": speaker})
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const pythonServerUrl = process.env.VIBEVOICE_SERVER_URL ?? "http://localhost:8000";
|
||||
|
||||
try {
|
||||
const body = await request.json() as {
|
||||
text: string;
|
||||
speaker?: string;
|
||||
cfg_scale?: number;
|
||||
inference_steps?: number;
|
||||
};
|
||||
|
||||
if (!body.text?.trim()) {
|
||||
return NextResponse.json({ error: "Missing or empty text field" }, { status: 400 });
|
||||
}
|
||||
|
||||
const upstream = await fetch(`${pythonServerUrl}/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
text: body.text.trim(),
|
||||
speaker: body.speaker ?? "carter",
|
||||
cfg_scale: body.cfg_scale ?? 1.5,
|
||||
inference_steps: body.inference_steps ?? 10,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!upstream.ok) {
|
||||
const text = await upstream.text().catch(() => "Unknown error");
|
||||
return NextResponse.json({ error: text }, { status: upstream.status });
|
||||
}
|
||||
|
||||
// Proxy the SSE stream through to the browser
|
||||
return new NextResponse(upstream.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to connect to VibeVoice server";
|
||||
return NextResponse.json({ error: message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,12 @@ export async function GET() {
|
||||
// "online" | "loading" | "error"
|
||||
const status: string = data.status ?? "online";
|
||||
return NextResponse.json(
|
||||
{ status, message: data.message },
|
||||
{
|
||||
status,
|
||||
message: data.message,
|
||||
progress: data.progress ?? null,
|
||||
voices: data.voices ?? [],
|
||||
},
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { useReducer, useCallback, useEffect } from "react";
|
||||
import Header from "@/components/Header";
|
||||
import TextInputPanel from "@/components/TextInputPanel";
|
||||
import GenerationControls from "@/components/GenerationControls";
|
||||
import AudioPlayer from "@/components/AudioPlayer";
|
||||
import StatusLog from "@/components/StatusLog";
|
||||
import { useStreamingGeneration } from "@/hooks/useStreamingGeneration";
|
||||
|
||||
export type ServerStatus = "offline" | "downloading" | "loading" | "online" | "error";
|
||||
|
||||
export interface DownloadProgress {
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
script: string;
|
||||
speaker: string;
|
||||
cfgScale: number;
|
||||
inferenceSteps: number;
|
||||
isGenerating: boolean;
|
||||
genElapsed: number;
|
||||
genPct: number | null;
|
||||
audioUrl: string | null;
|
||||
logs: string[];
|
||||
serverStatus: ServerStatus;
|
||||
downloadProgress: DownloadProgress | null;
|
||||
availableVoices: string[];
|
||||
}
|
||||
|
||||
type AppAction =
|
||||
| { type: "SET_SCRIPT"; payload: string }
|
||||
| { type: "SET_SPEAKER"; payload: string }
|
||||
| { type: "SET_CFG_SCALE"; payload: number }
|
||||
| { type: "SET_INFERENCE_STEPS"; payload: number }
|
||||
| { type: "START_GENERATION" }
|
||||
| { type: "GEN_PROGRESS"; elapsed: number; pct: number | null }
|
||||
| { type: "GENERATION_SUCCESS"; payload: string }
|
||||
| { type: "GENERATION_CANCELLED" }
|
||||
| { type: "GENERATION_ERROR" }
|
||||
| { type: "ADD_LOG"; payload: string }
|
||||
| {
|
||||
type: "SET_SERVER_STATUS";
|
||||
payload: { status: ServerStatus; progress?: DownloadProgress | null; voices?: string[] };
|
||||
};
|
||||
|
||||
function reducer(state: AppState, action: AppAction): AppState {
|
||||
switch (action.type) {
|
||||
case "SET_SCRIPT": return { ...state, script: action.payload };
|
||||
case "SET_SPEAKER": return { ...state, speaker: action.payload };
|
||||
case "SET_CFG_SCALE": return { ...state, cfgScale: action.payload };
|
||||
case "SET_INFERENCE_STEPS": return { ...state, inferenceSteps: action.payload };
|
||||
case "START_GENERATION":
|
||||
return { ...state, isGenerating: true, audioUrl: null, logs: [], genElapsed: 0, genPct: null };
|
||||
case "GEN_PROGRESS":
|
||||
return { ...state, genElapsed: action.elapsed, genPct: action.pct };
|
||||
case "GENERATION_SUCCESS":
|
||||
return { ...state, isGenerating: false, genElapsed: 0, genPct: null, audioUrl: action.payload };
|
||||
case "GENERATION_CANCELLED":
|
||||
case "GENERATION_ERROR":
|
||||
return { ...state, isGenerating: false, genElapsed: 0, genPct: null };
|
||||
case "ADD_LOG":
|
||||
return { ...state, logs: [...state.logs, action.payload] };
|
||||
case "SET_SERVER_STATUS":
|
||||
return {
|
||||
...state,
|
||||
serverStatus: action.payload.status,
|
||||
downloadProgress: action.payload.progress ?? null,
|
||||
availableVoices:
|
||||
action.payload.voices?.length ? action.payload.voices : state.availableVoices,
|
||||
};
|
||||
default: return state;
|
||||
}
|
||||
}
|
||||
|
||||
const initialState: AppState = {
|
||||
script: "",
|
||||
speaker: "carter",
|
||||
cfgScale: 1.5,
|
||||
inferenceSteps: 10,
|
||||
isGenerating: false,
|
||||
genElapsed: 0,
|
||||
genPct: null,
|
||||
audioUrl: null,
|
||||
logs: [],
|
||||
serverStatus: "offline",
|
||||
downloadProgress: null,
|
||||
availableVoices: [],
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const wordCount = state.script.trim() === "" ? 0 : state.script.trim().split(/\s+/).length;
|
||||
|
||||
const addLog = useCallback((msg: string) => dispatch({ type: "ADD_LOG", payload: msg }), []);
|
||||
const handleGenerationStart = useCallback(() => dispatch({ type: "START_GENERATION" }), []);
|
||||
const handleGenerationProgress = useCallback((elapsed: number, pct: number | null) => {
|
||||
dispatch({ type: "GEN_PROGRESS", elapsed, pct });
|
||||
}, []);
|
||||
const handleGenerationSuccess = useCallback((audioUrl: string) => {
|
||||
dispatch({ type: "GENERATION_SUCCESS", payload: audioUrl });
|
||||
}, []);
|
||||
const handleGenerationCancel = useCallback(() => dispatch({ type: "GENERATION_CANCELLED" }), []);
|
||||
const handleGenerationError = useCallback(() => dispatch({ type: "GENERATION_ERROR" }), []);
|
||||
|
||||
const {
|
||||
generate,
|
||||
pauseStream,
|
||||
resumeStream,
|
||||
stop,
|
||||
isStreamPaused,
|
||||
} = useStreamingGeneration({
|
||||
onLog: addLog,
|
||||
onStart: handleGenerationStart,
|
||||
onProgress: handleGenerationProgress,
|
||||
onSuccess: handleGenerationSuccess,
|
||||
onCancel: handleGenerationCancel,
|
||||
onError: handleGenerationError,
|
||||
});
|
||||
|
||||
// Server health polling — fast while not ready, slow when online
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
let cancelled = false;
|
||||
|
||||
async function poll() {
|
||||
if (cancelled) return;
|
||||
let nextStatus: ServerStatus = "offline";
|
||||
let nextProgress: DownloadProgress | null = null;
|
||||
let nextVoices: string[] = [];
|
||||
try {
|
||||
const res = await fetch("/api/health", { cache: "no-store" });
|
||||
const data = await res.json() as {
|
||||
status: ServerStatus;
|
||||
progress?: DownloadProgress | null;
|
||||
voices?: string[];
|
||||
};
|
||||
nextStatus = data.status ?? "offline";
|
||||
nextProgress = data.progress ?? null;
|
||||
nextVoices = data.voices ?? [];
|
||||
} catch {
|
||||
nextStatus = "offline";
|
||||
}
|
||||
if (!cancelled) {
|
||||
dispatch({ type: "SET_SERVER_STATUS", payload: { status: nextStatus, progress: nextProgress, voices: nextVoices } });
|
||||
timeoutId = setTimeout(poll, nextStatus === "online" ? 15_000 : 2_000);
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
return () => { cancelled = true; clearTimeout(timeoutId); };
|
||||
}, []);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!state.script.trim() || state.isGenerating) return;
|
||||
addLog(`${wordCount} words queued`);
|
||||
await generate({
|
||||
text: state.script,
|
||||
speaker: state.speaker,
|
||||
cfgScale: state.cfgScale,
|
||||
inferenceSteps: state.inferenceSteps,
|
||||
});
|
||||
}, [
|
||||
addLog,
|
||||
generate,
|
||||
state.cfgScale,
|
||||
state.inferenceSteps,
|
||||
state.isGenerating,
|
||||
state.script,
|
||||
state.speaker,
|
||||
wordCount,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col" style={{ background: "var(--background)" }}>
|
||||
<Header />
|
||||
<main className="flex-1 container mx-auto px-4 py-6 max-w-6xl">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* Left: script + audio player */}
|
||||
<div className="lg:col-span-2 flex flex-col gap-6">
|
||||
<TextInputPanel
|
||||
value={state.script}
|
||||
onChange={(text) => dispatch({ type: "SET_SCRIPT", payload: text })}
|
||||
/>
|
||||
{state.audioUrl && <AudioPlayer audioUrl={state.audioUrl} />}
|
||||
</div>
|
||||
|
||||
{/* Right: controls + log */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<GenerationControls
|
||||
speaker={state.speaker}
|
||||
availableVoices={state.availableVoices}
|
||||
onSpeakerChange={(v) => dispatch({ type: "SET_SPEAKER", payload: v })}
|
||||
cfgScale={state.cfgScale}
|
||||
onCfgScaleChange={(v) => dispatch({ type: "SET_CFG_SCALE", payload: v })}
|
||||
inferenceSteps={state.inferenceSteps}
|
||||
onInferenceStepsChange={(v) => dispatch({ type: "SET_INFERENCE_STEPS", payload: v })}
|
||||
onGenerate={handleGenerate}
|
||||
onStop={stop}
|
||||
onPauseStream={pauseStream}
|
||||
onResumeStream={resumeStream}
|
||||
isStreamPaused={isStreamPaused}
|
||||
isGenerating={state.isGenerating}
|
||||
genElapsed={state.genElapsed}
|
||||
genPct={state.genPct}
|
||||
wordCount={wordCount}
|
||||
serverStatus={state.serverStatus}
|
||||
downloadProgress={state.downloadProgress}
|
||||
/>
|
||||
<StatusLog messages={state.logs} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
"use client";
|
||||
|
||||
import type { ServerStatus, DownloadProgress } from "@/app/page";
|
||||
|
||||
const FALLBACK_VOICES = ["carter", "davis", "emma", "frank", "grace", "mike"];
|
||||
|
||||
interface GenerationControlsProps {
|
||||
speaker: string;
|
||||
availableVoices: string[];
|
||||
onSpeakerChange: (v: string) => void;
|
||||
cfgScale: number;
|
||||
onCfgScaleChange: (v: number) => void;
|
||||
inferenceSteps: number;
|
||||
onInferenceStepsChange: (v: number) => void;
|
||||
onGenerate: () => void;
|
||||
onStop: () => void;
|
||||
onPauseStream: () => void;
|
||||
onResumeStream: () => void;
|
||||
isStreamPaused: boolean;
|
||||
isGenerating: boolean;
|
||||
genElapsed: number;
|
||||
genPct: number | null;
|
||||
wordCount: number;
|
||||
serverStatus: ServerStatus;
|
||||
downloadProgress: DownloadProgress | null;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
Exclude<ServerStatus, "online">,
|
||||
{ color: string; label: (p: DownloadProgress | null) => string }
|
||||
> = {
|
||||
offline: { color: "var(--error)", label: () => "Server offline — waiting for connection..." },
|
||||
downloading: { color: "#60a5fa", label: (p) => p && p.total > 0 ? `Downloading model... (${p.done} / ${p.total} files)` : "Downloading model (~1 GB)..." },
|
||||
loading: { color: "#fbbf24", label: () => "Loading model into memory..." },
|
||||
error: { color: "var(--error)", label: () => "Server error — check the terminal for details." },
|
||||
};
|
||||
|
||||
export default function GenerationControls({
|
||||
speaker,
|
||||
availableVoices,
|
||||
onSpeakerChange,
|
||||
cfgScale,
|
||||
onCfgScaleChange,
|
||||
inferenceSteps,
|
||||
onInferenceStepsChange,
|
||||
onGenerate,
|
||||
onStop,
|
||||
onPauseStream,
|
||||
onResumeStream,
|
||||
isStreamPaused,
|
||||
isGenerating,
|
||||
genElapsed,
|
||||
genPct,
|
||||
wordCount,
|
||||
serverStatus,
|
||||
downloadProgress,
|
||||
}: GenerationControlsProps) {
|
||||
const voices = availableVoices.length > 0 ? availableVoices : FALLBACK_VOICES;
|
||||
const serverReady = serverStatus === "online";
|
||||
const buttonDisabled = isGenerating || wordCount === 0 || !serverReady;
|
||||
|
||||
const downloadPct =
|
||||
downloadProgress && downloadProgress.total > 0
|
||||
? Math.round((downloadProgress.done / downloadProgress.total) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border p-5 flex flex-col gap-5"
|
||||
style={{ background: "var(--card-bg)", borderColor: "var(--border)" }}
|
||||
>
|
||||
<h2
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--accent-teal)" }}
|
||||
>
|
||||
Generation Settings
|
||||
</h2>
|
||||
|
||||
{/* Voice selector */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium" style={{ color: "var(--foreground)" }}>
|
||||
Voice
|
||||
</label>
|
||||
<select
|
||||
value={speaker}
|
||||
onChange={(e) => onSpeakerChange(e.target.value)}
|
||||
disabled={!serverReady}
|
||||
className="w-full px-3 py-2 rounded-lg text-sm font-medium appearance-none cursor-pointer disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
border: "1px solid var(--border)",
|
||||
color: serverReady ? "var(--foreground)" : "var(--muted)",
|
||||
}}
|
||||
>
|
||||
{voices.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v.charAt(0).toUpperCase() + v.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* CFG Scale slider */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium" style={{ color: "var(--foreground)" }}>
|
||||
Voice Expressiveness
|
||||
</label>
|
||||
<span
|
||||
className="text-sm font-mono px-2 py-0.5 rounded"
|
||||
style={{ background: "var(--background)", color: "var(--accent-teal)" }}
|
||||
>
|
||||
{cfgScale.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={4.0}
|
||||
step={0.1}
|
||||
value={cfgScale}
|
||||
onChange={(e) => onCfgScaleChange(parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs" style={{ color: "var(--muted)" }}>
|
||||
<span>Flat (0.5)</span>
|
||||
<span>CFG Scale</span>
|
||||
<span>Expressive (4.0)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inference Steps slider */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium" style={{ color: "var(--foreground)" }}>
|
||||
Quality vs Speed
|
||||
</label>
|
||||
<span
|
||||
className="text-sm font-mono px-2 py-0.5 rounded"
|
||||
style={{ background: "var(--background)", color: "var(--accent-violet)" }}
|
||||
>
|
||||
{inferenceSteps}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={5}
|
||||
max={20}
|
||||
step={1}
|
||||
value={inferenceSteps}
|
||||
onChange={(e) => onInferenceStepsChange(parseInt(e.target.value, 10))}
|
||||
className="w-full"
|
||||
style={{ "--thumb-color": "var(--accent-violet)" } as React.CSSProperties}
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs" style={{ color: "var(--muted)" }}>
|
||||
<span>Faster (5)</span>
|
||||
<span>Diffusion Steps</span>
|
||||
<span>Better (20)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server status banner */}
|
||||
{!serverReady && (
|
||||
<div
|
||||
className="flex flex-col gap-2 px-3 py-3 rounded-lg text-sm"
|
||||
style={{ background: "var(--background)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full flex-shrink-0 ${serverStatus === "offline" || serverStatus === "error" ? "" : "animate-pulse"}`}
|
||||
style={{ background: STATUS_CONFIG[serverStatus].color }}
|
||||
/>
|
||||
<span style={{ color: STATUS_CONFIG[serverStatus].color }}>
|
||||
{STATUS_CONFIG[serverStatus].label(downloadProgress)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{serverStatus === "downloading" && (
|
||||
<div className="w-full rounded-full h-1.5 overflow-hidden" style={{ background: "var(--border)" }}>
|
||||
<div
|
||||
className="h-1.5 rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${downloadPct}%`,
|
||||
background: "linear-gradient(90deg, #60a5fa, var(--accent-teal))",
|
||||
minWidth: downloadPct > 0 ? "4px" : "0",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{serverStatus === "loading" && (
|
||||
<div className="w-full rounded-full h-1.5 overflow-hidden" style={{ background: "var(--border)" }}>
|
||||
<div
|
||||
className="h-1.5 rounded-full animate-pulse"
|
||||
style={{ width: "60%", background: "linear-gradient(90deg, #fbbf24, var(--accent-teal))" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generation progress bar */}
|
||||
{isGenerating && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between text-xs" style={{ color: "var(--muted)" }}>
|
||||
<span>{genElapsed}s elapsed</span>
|
||||
<span>{genPct !== null ? `${genPct}%` : "starting..."}</span>
|
||||
</div>
|
||||
<div className="w-full rounded-full h-1.5 overflow-hidden" style={{ background: "var(--border)" }}>
|
||||
<div
|
||||
className="h-1.5 rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: genPct !== null ? `${genPct}%` : "0%",
|
||||
background: "linear-gradient(90deg, var(--accent-teal), var(--accent-violet))",
|
||||
minWidth: genPct !== null && genPct > 0 ? "4px" : "0",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate / Stop buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onGenerate}
|
||||
disabled={buttonDisabled}
|
||||
className="flex-1 py-3 rounded-xl font-semibold text-sm transition-all cursor-pointer disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
style={
|
||||
buttonDisabled
|
||||
? { background: "var(--border)", color: "var(--muted)" }
|
||||
: {
|
||||
background: "linear-gradient(135deg, var(--accent-teal-dim), var(--accent-violet-dim))",
|
||||
color: "#fff",
|
||||
boxShadow: "0 4px 15px rgba(45, 212, 191, 0.2)",
|
||||
}
|
||||
}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Generating...
|
||||
</>
|
||||
) : !serverReady ? (
|
||||
<>
|
||||
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{serverStatus === "downloading" ? "Downloading model..." : "Waiting for server..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
Generate Audio
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isGenerating && (
|
||||
<>
|
||||
<button
|
||||
onClick={isStreamPaused ? onResumeStream : onPauseStream}
|
||||
className="px-4 py-3 rounded-xl font-semibold text-sm transition-all cursor-pointer flex items-center justify-center gap-1.5"
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
border: `1px solid ${isStreamPaused ? "var(--accent-teal)" : "#fbbf24"}`,
|
||||
color: isStreamPaused ? "var(--accent-teal)" : "#fbbf24",
|
||||
}}
|
||||
>
|
||||
{isStreamPaused ? (
|
||||
<>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
Resume
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="6" y="4" width="4" height="16" />
|
||||
<rect x="14" y="4" width="4" height="16" />
|
||||
</svg>
|
||||
Pause
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onStop}
|
||||
className="px-4 py-3 rounded-xl font-semibold text-sm transition-all cursor-pointer flex items-center justify-center gap-1.5"
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
border: "1px solid var(--error)",
|
||||
color: "var(--error)",
|
||||
}}
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
</svg>
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type ServerStatus = "checking" | "loading" | "online" | "error" | "offline";
|
||||
type ServerStatus = "checking" | "downloading" | "loading" | "online" | "error" | "offline";
|
||||
|
||||
// Polling intervals: poll quickly until the server is online, then slow down.
|
||||
const FAST_INTERVAL_MS = 3000; // while checking / loading
|
||||
@@ -28,7 +28,7 @@ export default function Header() {
|
||||
intervalRef.current = setInterval(checkHealth, SLOW_INTERVAL_MS);
|
||||
}
|
||||
// Switch to fast polling if we detect the server went offline/loading
|
||||
if ((newStatus === "offline" || newStatus === "loading") && intervalRef.current) {
|
||||
if ((newStatus === "offline" || newStatus === "downloading" || newStatus === "loading") && intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = setInterval(checkHealth, FAST_INTERVAL_MS);
|
||||
}
|
||||
@@ -62,6 +62,12 @@ export default function Header() {
|
||||
pulse: true,
|
||||
ring: "border-blue-400/30",
|
||||
},
|
||||
downloading: {
|
||||
color: "bg-sky-400",
|
||||
label: "Downloading model…",
|
||||
pulse: true,
|
||||
ring: "border-sky-400/30",
|
||||
},
|
||||
online: {
|
||||
color: "bg-green-500",
|
||||
label: "Server Online",
|
||||
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const SAMPLE_RATE = 24_000;
|
||||
const PREBUFFER_SECS = 2.0;
|
||||
const REBUFFER_THRESHOLD_SECS = 0.4;
|
||||
const RESUME_THRESHOLD_SECS = 1.5;
|
||||
|
||||
interface GenerateOptions {
|
||||
text: string;
|
||||
speaker: string;
|
||||
cfgScale: number;
|
||||
inferenceSteps: number;
|
||||
}
|
||||
|
||||
interface UseStreamingGenerationOptions {
|
||||
onLog: (message: string) => void;
|
||||
onStart: () => void;
|
||||
onProgress: (elapsed: number, pct: number | null) => void;
|
||||
onSuccess: (audioUrl: string) => void;
|
||||
onCancel: () => void;
|
||||
onError: () => void;
|
||||
}
|
||||
|
||||
function mergeFloat32Arrays(chunks: Float32Array<ArrayBuffer>[]): Float32Array<ArrayBuffer> {
|
||||
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const out = new Float32Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildWav(samples: Float32Array<ArrayBuffer>, sampleRate: number): Blob {
|
||||
const dataSize = samples.length * 4;
|
||||
const buffer = new ArrayBuffer(44 + dataSize);
|
||||
const view = new DataView(buffer);
|
||||
const writeString = (offset: number, value: string) => {
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
view.setUint8(offset + i, value.charCodeAt(i));
|
||||
}
|
||||
};
|
||||
|
||||
writeString(0, "RIFF");
|
||||
view.setUint32(4, 36 + dataSize, true);
|
||||
writeString(8, "WAVE");
|
||||
writeString(12, "fmt ");
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 3, true);
|
||||
view.setUint16(22, 1, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * 4, true);
|
||||
view.setUint16(32, 4, true);
|
||||
view.setUint16(34, 32, true);
|
||||
writeString(36, "data");
|
||||
view.setUint32(40, dataSize, true);
|
||||
new Float32Array(buffer, 44).set(samples);
|
||||
return new Blob([buffer], { type: "audio/wav" });
|
||||
}
|
||||
|
||||
function decodeFloat32Chunk(data: string): Float32Array<ArrayBuffer> {
|
||||
const raw = atob(data);
|
||||
const bytes = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i += 1) {
|
||||
bytes[i] = raw.charCodeAt(i);
|
||||
}
|
||||
return new Float32Array(bytes.buffer as ArrayBuffer);
|
||||
}
|
||||
|
||||
export function useStreamingGeneration({
|
||||
onLog,
|
||||
onStart,
|
||||
onProgress,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
onError,
|
||||
}: UseStreamingGenerationOptions) {
|
||||
const [isStreamPaused, setIsStreamPaused] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const nextStartTimeRef = useRef(0);
|
||||
const chunksRef = useRef<Float32Array<ArrayBuffer>[]>([]);
|
||||
const hasStartedPlaybackRef = useRef(false);
|
||||
const isAutoBufferingRef = useRef(false);
|
||||
const isUserPausedRef = useRef(false);
|
||||
const audioUrlRef = useRef<string | null>(null);
|
||||
|
||||
const revokeCurrentUrl = useCallback(() => {
|
||||
if (audioUrlRef.current) {
|
||||
URL.revokeObjectURL(audioUrlRef.current);
|
||||
audioUrlRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetPlayback = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
audioCtxRef.current?.close().catch(() => {});
|
||||
audioCtxRef.current = null;
|
||||
nextStartTimeRef.current = 0;
|
||||
chunksRef.current = [];
|
||||
hasStartedPlaybackRef.current = false;
|
||||
isAutoBufferingRef.current = false;
|
||||
isUserPausedRef.current = false;
|
||||
setIsStreamPaused(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
resetPlayback();
|
||||
revokeCurrentUrl();
|
||||
};
|
||||
}, [resetPlayback, revokeCurrentUrl]);
|
||||
|
||||
const enqueue = useCallback((ctx: AudioContext, chunk: Float32Array<ArrayBuffer>) => {
|
||||
const audioBuffer = ctx.createBuffer(1, chunk.length, SAMPLE_RATE);
|
||||
audioBuffer.copyToChannel(chunk, 0);
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(ctx.destination);
|
||||
const startAt = Math.max(nextStartTimeRef.current, ctx.currentTime + 0.05);
|
||||
source.start(startAt);
|
||||
nextStartTimeRef.current = startAt + audioBuffer.duration;
|
||||
}, []);
|
||||
|
||||
const flushBufferedAudio = useCallback(() => {
|
||||
const ctx = audioCtxRef.current;
|
||||
if (!ctx || chunksRef.current.length === 0) return;
|
||||
nextStartTimeRef.current = ctx.currentTime + 0.1;
|
||||
for (const chunk of chunksRef.current) {
|
||||
enqueue(ctx, chunk);
|
||||
}
|
||||
hasStartedPlaybackRef.current = true;
|
||||
}, [enqueue]);
|
||||
|
||||
const handleAudioChunk = useCallback((chunk: Float32Array<ArrayBuffer>) => {
|
||||
const ctx = audioCtxRef.current;
|
||||
if (!ctx) return;
|
||||
|
||||
chunksRef.current.push(chunk);
|
||||
|
||||
if (!hasStartedPlaybackRef.current) {
|
||||
const bufferedSecs = chunksRef.current.reduce((sum, c) => sum + c.length, 0) / SAMPLE_RATE;
|
||||
if (bufferedSecs >= PREBUFFER_SECS) {
|
||||
flushBufferedAudio();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
enqueue(ctx, chunk);
|
||||
if (isUserPausedRef.current) return;
|
||||
|
||||
const ahead = nextStartTimeRef.current - ctx.currentTime;
|
||||
if (ctx.state === "running" && ahead < REBUFFER_THRESHOLD_SECS) {
|
||||
ctx.suspend().catch(() => {});
|
||||
isAutoBufferingRef.current = true;
|
||||
} else if (
|
||||
ctx.state === "suspended" &&
|
||||
isAutoBufferingRef.current &&
|
||||
ahead >= RESUME_THRESHOLD_SECS
|
||||
) {
|
||||
ctx.resume().catch(() => {});
|
||||
isAutoBufferingRef.current = false;
|
||||
}
|
||||
}, [enqueue, flushBufferedAudio]);
|
||||
|
||||
const generate = useCallback(async (options: GenerateOptions) => {
|
||||
if (!options.text.trim()) return;
|
||||
|
||||
resetPlayback();
|
||||
revokeCurrentUrl();
|
||||
audioCtxRef.current = new AudioContext({ sampleRate: SAMPLE_RATE });
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
onStart();
|
||||
onLog(`Voice: ${options.speaker}`);
|
||||
onLog(`CFG ${options.cfgScale.toFixed(1)}, steps ${options.inferenceSteps}`);
|
||||
|
||||
const startedAt = Date.now();
|
||||
const timerId = window.setInterval(() => {
|
||||
onProgress((Date.now() - startedAt) / 1000, null);
|
||||
}, 500);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
text: options.text,
|
||||
speaker: options.speaker,
|
||||
cfg_scale: options.cfgScale,
|
||||
inference_steps: options.inferenceSteps,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
const err = await res.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const event = JSON.parse(line.slice(6)) as {
|
||||
type: "audio_chunk" | "complete" | "error" | "cancelled";
|
||||
data?: string;
|
||||
elapsed?: number;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (event.type === "audio_chunk" && event.data) {
|
||||
handleAudioChunk(decodeFloat32Chunk(event.data));
|
||||
} else if (event.type === "complete") {
|
||||
if (!hasStartedPlaybackRef.current) {
|
||||
flushBufferedAudio();
|
||||
}
|
||||
const wavBlob = buildWav(mergeFloat32Arrays(chunksRef.current), SAMPLE_RATE);
|
||||
const audioUrl = URL.createObjectURL(wavBlob);
|
||||
audioUrlRef.current = audioUrl;
|
||||
const kb = (wavBlob.size / 1024).toFixed(0);
|
||||
onLog(`Done in ${event.elapsed}s - ${kb} KB`);
|
||||
onSuccess(audioUrl);
|
||||
} else if (event.type === "cancelled") {
|
||||
throw new DOMException("Generation cancelled", "AbortError");
|
||||
} else if (event.type === "error") {
|
||||
throw new Error(event.message ?? "Generation failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
onLog("Cancelled.");
|
||||
onCancel();
|
||||
} else {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
onLog(`Error: ${message}`);
|
||||
onError();
|
||||
}
|
||||
} finally {
|
||||
window.clearInterval(timerId);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [
|
||||
flushBufferedAudio,
|
||||
handleAudioChunk,
|
||||
onCancel,
|
||||
onError,
|
||||
onLog,
|
||||
onProgress,
|
||||
onStart,
|
||||
onSuccess,
|
||||
resetPlayback,
|
||||
revokeCurrentUrl,
|
||||
]);
|
||||
|
||||
const pauseStream = useCallback(() => {
|
||||
isUserPausedRef.current = true;
|
||||
audioCtxRef.current?.suspend().catch(() => {});
|
||||
setIsStreamPaused(true);
|
||||
}, []);
|
||||
|
||||
const resumeStream = useCallback(() => {
|
||||
isUserPausedRef.current = false;
|
||||
isAutoBufferingRef.current = false;
|
||||
audioCtxRef.current?.resume().catch(() => {});
|
||||
setIsStreamPaused(false);
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
resetPlayback();
|
||||
}, [resetPlayback]);
|
||||
|
||||
return {
|
||||
generate,
|
||||
pauseStream,
|
||||
resumeStream,
|
||||
stop,
|
||||
isStreamPaused,
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
{
|
||||
"name": "podcast-forge",
|
||||
"name": "vibepod-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev:all": "concurrently --names \"TTS,NEXT\" --prefix-colors \"cyan,magenta\" \"cd server && bash start.sh\" \"next dev --turbopack\"",
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"server": "cd server && bash start.sh"
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "15.5.15",
|
||||
@@ -19,8 +17,8 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"concurrently": "^9.2.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 391 B After Width: | Height: | Size: 391 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 128 B After Width: | Height: | Size: 128 B |
|
Before Width: | Height: | Size: 385 B After Width: | Height: | Size: 385 B |