feat: add studio roadmap and streaming cleanup

This commit is contained in:
2026-04-28 00:09:15 +01:00
parent 11ffc7df7c
commit 34ec879cdb
45 changed files with 5899 additions and 2659 deletions
+20
View File
@@ -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)"
]
}
}
+10
View File
@@ -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
+24
View File
@@ -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
+117 -2
View File
@@ -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
```
+25
View File
@@ -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
+12
View File
@@ -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"
}
+979
View File
@@ -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: {}
+2
View File
@@ -0,0 +1,2 @@
packages:
- 'web'
-36
View File
@@ -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.
-55
View File
@@ -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 });
}
}
-168
View File
@@ -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>
);
}
-1959
View File
File diff suppressed because it is too large Load Diff
-12
View File
@@ -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
-35
View File
@@ -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 \
"$@"
-189
View File
@@ -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
+48
View File
@@ -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
+34
View File
@@ -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" }
+40
View File
@@ -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 \
"$@"
+3321
View File
File diff suppressed because it is too large Load Diff
+370
View File
@@ -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"},
)
+48
View File
@@ -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

+221
View File
@@ -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>
);
}
+312
View File
@@ -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",
+297
View File
@@ -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