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
|
# VibePod
|
||||||
Podcast Generator using VibeVoice 0.5
|
|
||||||
|
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
|
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
|
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"
|
// "online" | "loading" | "error"
|
||||||
const status: string = data.status ?? "online";
|
const status: string = data.status ?? "online";
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ status, message: data.message },
|
{
|
||||||
|
status,
|
||||||
|
message: data.message,
|
||||||
|
progress: data.progress ?? null,
|
||||||
|
voices: data.voices ?? [],
|
||||||
|
},
|
||||||
{ headers: { "Cache-Control": "no-store" } }
|
{ 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";
|
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.
|
// Polling intervals: poll quickly until the server is online, then slow down.
|
||||||
const FAST_INTERVAL_MS = 3000; // while checking / loading
|
const FAST_INTERVAL_MS = 3000; // while checking / loading
|
||||||
@@ -28,7 +28,7 @@ export default function Header() {
|
|||||||
intervalRef.current = setInterval(checkHealth, SLOW_INTERVAL_MS);
|
intervalRef.current = setInterval(checkHealth, SLOW_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
// Switch to fast polling if we detect the server went offline/loading
|
// 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);
|
clearInterval(intervalRef.current);
|
||||||
intervalRef.current = setInterval(checkHealth, FAST_INTERVAL_MS);
|
intervalRef.current = setInterval(checkHealth, FAST_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
@@ -62,6 +62,12 @@ export default function Header() {
|
|||||||
pulse: true,
|
pulse: true,
|
||||||
ring: "border-blue-400/30",
|
ring: "border-blue-400/30",
|
||||||
},
|
},
|
||||||
|
downloading: {
|
||||||
|
color: "bg-sky-400",
|
||||||
|
label: "Downloading model…",
|
||||||
|
pulse: true,
|
||||||
|
ring: "border-sky-400/30",
|
||||||
|
},
|
||||||
online: {
|
online: {
|
||||||
color: "bg-green-500",
|
color: "bg-green-500",
|
||||||
label: "Server Online",
|
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",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"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",
|
"build": "next build --turbopack",
|
||||||
"start": "next start",
|
"start": "next start"
|
||||||
"server": "cd server && bash start.sh"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "15.5.15",
|
"next": "15.5.15",
|
||||||
@@ -19,8 +17,8 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"concurrently": "^9.2.1",
|
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"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 |