Merge pull request #1 from JezzWTF/copilot/create-vibepod-tts-podcast-generator

Add VibePod — Next.js 15 TTS podcast generator GUI backed by VibeVoice 0.5B
This commit is contained in:
2026-04-28 00:33:32 +01:00
committed by GitHub
37 changed files with 6825 additions and 2 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
Podcast Generator using VibeVoice 0.5
# VibePod
A text-to-speech podcast generator powered by [VibeVoice 0.5B](https://huggingface.co/microsoft/VibeVoice-Realtime-0.5B). Paste a script, tune a couple of sliders, and get a WAV back.
## Architecture
```
VibePod/
├── web/ Next.js 15 frontend (React 19, Tailwind CSS 4, TypeScript)
└── server/ FastAPI TTS backend (Python 3.10+, VibeVoice, UV)
```
The Next.js app proxies audio generation requests to the FastAPI server, keeping CORS out of the picture and the Python model off the browser.
## Prerequisites
| Tool | Install |
|------|---------|
| [Node.js 20+](https://nodejs.org) | `winget install OpenJS.NodeJS.LTS` |
| [pnpm](https://pnpm.io) | `npm i -g pnpm` |
| [Python 3.10+](https://python.org) | `winget install Python.Python.3.13` |
| [uv](https://docs.astral.sh/uv/) | `winget install astral-sh.uv` |
## Getting started
```bash
# 1. Clone
git clone https://github.com/LyAhn/VibePod.git
cd VibePod
# 2. Install Node dependencies (root + web workspace)
pnpm install
# 3. Copy env file and fill in values
cp .env.example .env.local
# 4. Start everything
pnpm dev
```
`pnpm dev` starts both services concurrently:
- **SERVER** — `http://localhost:8000` — on first run `uv sync` creates the Python venv and downloads the ~1 GB VibeVoice model from HuggingFace
- **WEB** — `http://localhost:3000` — Next.js dev server with Turbopack
The frontend shows a loading indicator while the model downloads. Once the server reports `status: online`, generation is available.
## Individual commands
```bash
pnpm dev:web # Next.js only
pnpm dev:server # Python server only
pnpm build # Production build of the frontend
```
## Environment variables
Copy `.env.example` to `.env.local` and set:
| Variable | Default | Description |
|----------|---------|-------------|
| `VIBEVOICE_SERVER_URL` | `http://localhost:8000` | URL the Next.js API routes use to reach the Python server |
| `HF_TOKEN` | — | HuggingFace token (required if the model repo is gated) |
| `HF_HOME` | — | Override the HuggingFace model cache directory |
## Project structure
```
web/
├── app/
│ ├── api/generate/ Proxies POST requests to the Python server
│ ├── api/health/ Proxies health checks (status: loading | online | error)
│ ├── page.tsx Main UI — script input, controls, audio player
│ └── layout.tsx
├── components/
│ ├── Header.tsx
│ ├── TextInputPanel.tsx
│ ├── GenerationControls.tsx cfg_scale and inference_steps sliders
│ ├── AudioPlayer.tsx
│ └── StatusLog.tsx
└── hooks/
└── useAudioPlayer.ts
server/
├── vibevoice_server.py FastAPI app — /health and /generate endpoints
├── download_model.py One-shot HuggingFace model prefetch
├── start.sh Entry point: uv sync → model check → uvicorn
└── pyproject.toml Python deps managed by uv
```
## Generation parameters
| Parameter | Range | Default | Effect |
|-----------|-------|---------|--------|
| `speaker` | `carter`, `davis`, `emma`, `frank`, `grace`, `mike` | `carter` | Voice preset used for the generated audio |
| `cfg_scale` | 0.5 - 4.0 | 1.5 | Higher = more expressive guidance |
| `inference_steps` | 5 - 20 | 10 | More steps = higher quality, slower generation |
## How it works
1. The user pastes a script and hits **Generate**
2. The Next.js `/api/generate` route forwards the request to FastAPI on port 8000
3. FastAPI runs the text through the VibeVoice streaming processor and inference model
4. Audio chunks stream back to the browser as SSE events containing base64 float32 PCM
5. The browser plays the chunks live, assembles a WAV Blob, and loads it into the audio player
## Python dependencies
Managed by [uv](https://docs.astral.sh/uv/). The `server/uv.lock` is committed so installs are fully reproducible.
```bash
# Add a package
cd server && uv add <package>
# Upgrade all dependencies
cd server && uv lock --upgrade
```
+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'
+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.
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""
Download microsoft/VibeVoice-Realtime-0.5B to the local HuggingFace cache.
Run once before starting the server:
python download_model.py
Set HF_HOME or HUGGINGFACE_HUB_CACHE to control where the model is stored.
Set HF_TOKEN (or HUGGINGFACE_TOKEN) if you need an access token.
"""
import os
import sys
import time
MODEL_ID = "microsoft/VibeVoice-Realtime-0.5B"
# Patterns that are not needed for PyTorch inference
_IGNORE = [
"*.msgpack",
"flax_model*",
"tf_model*",
"rust_model*",
"*.ot",
]
def download() -> str:
try:
from huggingface_hub import snapshot_download
except ImportError:
print(
"ERROR: huggingface_hub is not installed.\n"
"Run: pip install huggingface_hub",
file=sys.stderr,
)
sys.exit(1)
token: str | None = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_TOKEN")
print(f"Checking / downloading model: {MODEL_ID}")
print("(This may take several minutes on first run — the model is ~1 GB)")
start = time.time()
cache_path = snapshot_download(
repo_id=MODEL_ID,
ignore_patterns=_IGNORE,
token=token or None,
)
elapsed = time.time() - start
print(f"Model ready in {elapsed:.1f}s -> {cache_path}")
return cache_path
if __name__ == "__main__":
download()
+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"},
)
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+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 });
}
}
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
export async function GET() {
const pythonServerUrl =
process.env.VIBEVOICE_SERVER_URL ?? "http://localhost:8000";
try {
const res = await fetch(`${pythonServerUrl}/health`, {
method: "GET",
signal: AbortSignal.timeout(4000),
// Don't cache health checks
cache: "no-store",
});
if (res.ok) {
const data = await res.json().catch(() => ({}));
// Pass through the exact status the Python server reports:
// "online" | "loading" | "error"
const status: string = data.status ?? "online";
return NextResponse.json(
{
status,
message: data.message,
progress: data.progress ?? null,
voices: data.voices ?? [],
},
{ headers: { "Cache-Control": "no-store" } }
);
}
return NextResponse.json(
{ status: "offline" },
{ headers: { "Cache-Control": "no-store" } }
);
} catch {
return NextResponse.json(
{ status: "offline" },
{ headers: { "Cache-Control": "no-store" } }
);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+87
View File
@@ -0,0 +1,87 @@
@import "tailwindcss";
:root {
--background: #0d1117;
--foreground: #e2e8f0;
--card-bg: #161b22;
--border: #21262d;
--accent-teal: #2dd4bf;
--accent-violet: #a78bfa;
--accent-teal-dim: #0d9488;
--accent-violet-dim: #7c3aed;
--muted: #64748b;
--success: #22c55e;
--error: #ef4444;
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
min-height: 100vh;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--card-bg);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted);
}
/* Range input styling */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
}
input[type="range"]::-webkit-slider-runnable-track {
background: var(--border);
height: 4px;
border-radius: 2px;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent-teal);
margin-top: -6px;
box-shadow: 0 0 6px rgba(45, 212, 191, 0.4);
transition: box-shadow 0.15s ease;
}
input[type="range"]:hover::-webkit-slider-thumb {
box-shadow: 0 0 10px rgba(45, 212, 191, 0.7);
}
input[type="range"]::-moz-range-track {
background: var(--border);
height: 4px;
border-radius: 2px;
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent-teal);
border: none;
box-shadow: 0 0 6px rgba(45, 212, 191, 0.4);
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "VibePod — TTS Podcast Generator",
description: "Generate podcast audio using Microsoft VibeVoice 0.5B",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body style={{ background: "var(--background)", color: "var(--foreground)" }}>
{children}
</body>
</html>
);
}
+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>
);
}
+195
View File
@@ -0,0 +1,195 @@
"use client";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
interface AudioPlayerProps {
audioUrl: string | null;
}
function formatTime(seconds: number): string {
if (!isFinite(seconds) || isNaN(seconds)) return "0:00";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
export default function AudioPlayer({ audioUrl }: AudioPlayerProps) {
const {
isPlaying,
currentTime,
duration,
volume,
toggle,
seek,
setVolume,
} = useAudioPlayer(audioUrl);
if (!audioUrl) return null;
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
const handleDownload = () => {
const a = document.createElement("a");
a.href = audioUrl;
a.download = "vibepod-output.wav";
a.click();
};
return (
<div
className="rounded-xl border p-5 flex flex-col gap-4"
style={{ background: "var(--card-bg)", borderColor: "var(--border)" }}
>
<div className="flex items-center justify-between">
<h2
className="text-sm font-semibold uppercase tracking-wider"
style={{ color: "var(--accent-teal)" }}
>
Audio Player
</h2>
<button
onClick={handleDownload}
className="flex items-center gap-2 text-xs px-3 py-1.5 rounded-lg border transition-colors cursor-pointer"
style={{
borderColor: "var(--accent-teal-dim)",
color: "var(--accent-teal)",
background: "rgba(45, 212, 191, 0.05)",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background =
"rgba(45, 212, 191, 0.15)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background =
"rgba(45, 212, 191, 0.05)";
}}
>
<svg
className="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Download WAV
</button>
</div>
{/* Waveform / progress bar */}
<div className="flex flex-col gap-2">
<div
className="relative h-2 rounded-full cursor-pointer overflow-hidden"
style={{ background: "var(--border)" }}
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
seek(ratio * duration);
}}
>
<div
className="absolute inset-y-0 left-0 rounded-full transition-all"
style={{
width: `${progress}%`,
background:
"linear-gradient(90deg, var(--accent-teal-dim), var(--accent-violet-dim))",
}}
/>
</div>
<div
className="flex items-center justify-between text-xs font-mono"
style={{ color: "var(--muted)" }}
>
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
{/* Controls row */}
<div className="flex items-center gap-4">
{/* Play/Pause */}
<button
onClick={toggle}
className="w-10 h-10 rounded-full flex items-center justify-center transition-transform active:scale-95 cursor-pointer"
style={{
background:
"linear-gradient(135deg, var(--accent-teal-dim), var(--accent-violet-dim))",
boxShadow: "0 4px 12px rgba(45, 212, 191, 0.3)",
}}
aria-label={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? (
<svg
className="w-4 h-4 text-white"
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>
) : (
<svg
className="w-4 h-4 text-white"
viewBox="0 0 24 24"
fill="currentColor"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
)}
</button>
{/* Duration info */}
<div className="flex-1 flex items-center gap-1 text-sm">
<span style={{ color: "var(--foreground)" }}>
{formatTime(currentTime)}
</span>
<span style={{ color: "var(--muted)" }}>/</span>
<span style={{ color: "var(--muted)" }}>{formatTime(duration)}</span>
</div>
{/* Volume control */}
<div className="flex items-center gap-2">
<svg
className="w-4 h-4 flex-shrink-0"
style={{ color: "var(--muted)" }}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
{volume === 0 ? (
<>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<line x1="23" y1="9" x2="17" y2="15" />
<line x1="17" y1="9" x2="23" y2="15" />
</>
) : volume < 0.5 ? (
<>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M15.54 8.46a5 5 0 010 7.07" />
</>
) : (
<>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07" />
</>
)}
</svg>
<input
type="range"
min={0}
max={1}
step={0.05}
value={volume}
onChange={(e) => setVolume(parseFloat(e.target.value))}
className="w-20"
aria-label="Volume"
/>
</div>
</div>
</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>
);
}
+154
View File
@@ -0,0 +1,154 @@
"use client";
import { useEffect, useRef, useState } from "react";
type ServerStatus = "checking" | "downloading" | "loading" | "online" | "error" | "offline";
// Polling intervals: poll quickly until the server is online, then slow down.
const FAST_INTERVAL_MS = 3000; // while checking / loading
const SLOW_INTERVAL_MS = 30000; // once online
export default function Header() {
const [status, setStatus] = useState<ServerStatus>("checking");
const [message, setMessage] = useState<string | undefined>();
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
const checkHealth = async () => {
try {
const res = await fetch("/api/health", { cache: "no-store" });
const data = await res.json();
const newStatus: ServerStatus = (data.status as ServerStatus) ?? "offline";
setStatus(newStatus);
setMessage(data.message);
// Switch to slow polling once we know the server is online
if (newStatus === "online" && intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = setInterval(checkHealth, SLOW_INTERVAL_MS);
}
// Switch to fast polling if we detect the server went offline/loading
if ((newStatus === "offline" || newStatus === "downloading" || newStatus === "loading") && intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = setInterval(checkHealth, FAST_INTERVAL_MS);
}
} catch {
setStatus("offline");
setMessage(undefined);
}
};
// Start with a fast poll — the server may still be loading the model
checkHealth();
intervalRef.current = setInterval(checkHealth, FAST_INTERVAL_MS);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);
const statusConfig: Record<
ServerStatus,
{ color: string; label: string; pulse: boolean; ring: string }
> = {
checking: {
color: "bg-yellow-500",
label: "Checking…",
pulse: true,
ring: "border-yellow-500/30",
},
loading: {
color: "bg-blue-400",
label: "Loading model…",
pulse: true,
ring: "border-blue-400/30",
},
downloading: {
color: "bg-sky-400",
label: "Downloading model…",
pulse: true,
ring: "border-sky-400/30",
},
online: {
color: "bg-green-500",
label: "Server Online",
pulse: false,
ring: "border-green-500/30",
},
error: {
color: "bg-orange-500",
label: "Model Error",
pulse: false,
ring: "border-orange-500/30",
},
offline: {
color: "bg-red-500",
label: "Server Offline",
pulse: false,
ring: "border-red-500/30",
},
};
const cfg = statusConfig[status];
return (
<header
className="border-b px-6 py-4 flex items-center justify-between"
style={{
background: "var(--card-bg)",
borderColor: "var(--border)",
}}
>
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<div
className="w-9 h-9 rounded-xl flex items-center justify-center text-lg font-bold"
style={{
background:
"linear-gradient(135deg, var(--accent-teal-dim), var(--accent-violet-dim))",
}}
>
🎙
</div>
<div>
<h1
className="text-xl font-bold tracking-tight"
style={{
background:
"linear-gradient(135deg, var(--accent-teal), var(--accent-violet))",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
VibePod
</h1>
<p className="text-xs" style={{ color: "var(--muted)" }}>
Powered by VibeVoice 0.5B
</p>
</div>
</div>
</div>
<div
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium border ${cfg.ring}`}
style={{
background: "var(--background)",
borderColor: "var(--border)",
}}
title={message}
>
<span className="relative flex h-2 w-2">
{cfg.pulse && (
<span
className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${cfg.color}`}
/>
)}
<span
className={`relative inline-flex rounded-full h-2 w-2 ${cfg.color}`}
/>
</span>
<span style={{ color: "var(--foreground)" }}>{cfg.label}</span>
</div>
</header>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useEffect, useRef } from "react";
interface StatusLogProps {
messages: string[];
}
export default function StatusLog({ messages }: StatusLogProps) {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
return (
<div
className="rounded-xl border p-5 flex flex-col gap-3"
style={{ background: "var(--card-bg)", borderColor: "var(--border)" }}
>
<div className="flex items-center gap-2">
<h2
className="text-sm font-semibold uppercase tracking-wider"
style={{ color: "var(--accent-teal)" }}
>
Status Log
</h2>
<div className="flex gap-1 ml-auto">
<span className="w-2.5 h-2.5 rounded-full bg-red-500 opacity-70" />
<span className="w-2.5 h-2.5 rounded-full bg-yellow-500 opacity-70" />
<span className="w-2.5 h-2.5 rounded-full bg-green-500 opacity-70" />
</div>
</div>
<div
className="rounded-lg p-4 h-40 overflow-y-auto font-mono text-xs leading-relaxed"
style={{
background: "var(--background)",
border: "1px solid var(--border)",
}}
>
{messages.length === 0 ? (
<p style={{ color: "var(--muted)" }}>
Waiting for input...
<span className="animate-pulse"></span>
</p>
) : (
messages.map((msg, i) => {
const isError =
msg.toLowerCase().includes("error") ||
msg.toLowerCase().includes("failed");
const isSuccess =
msg.toLowerCase().includes("done") ||
msg.toLowerCase().includes("complete") ||
msg.toLowerCase().includes("ready");
const color = isError
? "var(--error)"
: isSuccess
? "var(--success)"
: "var(--foreground)";
return (
<div key={i} className="flex items-start gap-2">
<span style={{ color: "var(--muted)" }} className="select-none">
{String(i + 1).padStart(2, "0")}
</span>
<span style={{ color }}>{msg}</span>
</div>
);
})
)}
<div ref={bottomRef} />
</div>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
"use client";
const SAMPLE_SCRIPT = `Welcome to VibePod, your gateway to the future of audio content creation. Today, we're diving deep into the world of artificial intelligence and how it's transforming the way we produce and consume podcasts.
Imagine being able to transform any written article, blog post, or essay into a professional-sounding audio experience in just seconds. That's exactly what VibeVoice 0.5B brings to the table — a compact yet powerful text-to-speech model that delivers remarkably natural-sounding voices.
The technology behind modern TTS systems has evolved dramatically over the past few years. We've moved from robotic, stilted speech synthesis to voices that carry real emotional nuance and natural prosody. VibeVoice represents Microsoft's latest contribution to this rapidly advancing field.
Whether you're a content creator looking to repurpose written material, an educator who wants to make content more accessible, or a developer building the next generation of audio applications, VibePod provides the tools you need.
In today's episode, we'll explore the key features that make VibeVoice unique, discuss practical use cases across different industries, and look ahead to what the next generation of voice AI might bring. Let's get started.`;
interface TextInputPanelProps {
value: string;
onChange: (text: string) => void;
}
export default function TextInputPanel({
value,
onChange,
}: TextInputPanelProps) {
const charCount = value.length;
const wordCount = value.trim() === "" ? 0 : value.trim().split(/\s+/).length;
return (
<div
className="rounded-xl border p-5 flex flex-col gap-4"
style={{ background: "var(--card-bg)", borderColor: "var(--border)" }}
>
<div className="flex items-center justify-between">
<h2
className="text-sm font-semibold uppercase tracking-wider"
style={{ color: "var(--accent-teal)" }}
>
Podcast Script
</h2>
<div className="flex items-center gap-2">
<button
onClick={() => onChange(SAMPLE_SCRIPT)}
className="text-xs px-3 py-1.5 rounded-lg border transition-colors cursor-pointer"
style={{
borderColor: "var(--border)",
color: "var(--muted)",
}}
onMouseEnter={(e) => {
(e.target as HTMLButtonElement).style.color =
"var(--accent-violet)";
(e.target as HTMLButtonElement).style.borderColor =
"var(--accent-violet)";
}}
onMouseLeave={(e) => {
(e.target as HTMLButtonElement).style.color = "var(--muted)";
(e.target as HTMLButtonElement).style.borderColor =
"var(--border)";
}}
>
Load sample script
</button>
<button
onClick={() => onChange("")}
className="text-xs px-3 py-1.5 rounded-lg border transition-colors cursor-pointer"
style={{
borderColor: "var(--border)",
color: "var(--muted)",
}}
onMouseEnter={(e) => {
(e.target as HTMLButtonElement).style.color = "var(--error)";
(e.target as HTMLButtonElement).style.borderColor = "var(--error)";
}}
onMouseLeave={(e) => {
(e.target as HTMLButtonElement).style.color = "var(--muted)";
(e.target as HTMLButtonElement).style.borderColor =
"var(--border)";
}}
>
Clear
</button>
</div>
</div>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Paste or type your podcast script here..."
rows={12}
className="w-full rounded-lg p-4 text-sm resize-y outline-none transition-colors font-sans leading-relaxed"
style={{
background: "var(--background)",
border: "1px solid var(--border)",
color: "var(--foreground)",
minHeight: "200px",
}}
onFocus={(e) => {
e.target.style.borderColor = "var(--accent-teal)";
}}
onBlur={(e) => {
e.target.style.borderColor = "var(--border)";
}}
/>
<div
className="flex items-center justify-between text-xs"
style={{ color: "var(--muted)" }}
>
<span>
{wordCount} word{wordCount !== 1 ? "s" : ""}
</span>
<span>{charCount.toLocaleString()} characters</span>
</div>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
interface AudioPlayerState {
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
}
export function useAudioPlayer(audioUrl: string | null) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [state, setState] = useState<AudioPlayerState>({
isPlaying: false,
currentTime: 0,
duration: 0,
volume: 1,
});
// Create/replace the Audio element whenever the URL changes
useEffect(() => {
if (!audioUrl) {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
setState({ isPlaying: false, currentTime: 0, duration: 0, volume: 1 });
return;
}
const audio = new Audio(audioUrl);
audioRef.current = audio;
const onTimeUpdate = () =>
setState((prev) => ({ ...prev, currentTime: audio.currentTime }));
const onDurationChange = () =>
setState((prev) => ({ ...prev, duration: audio.duration }));
const onEnded = () =>
setState((prev) => ({ ...prev, isPlaying: false, currentTime: 0 }));
const onPlay = () => setState((prev) => ({ ...prev, isPlaying: true }));
const onPause = () => setState((prev) => ({ ...prev, isPlaying: false }));
audio.addEventListener("timeupdate", onTimeUpdate);
audio.addEventListener("durationchange", onDurationChange);
audio.addEventListener("loadedmetadata", onDurationChange);
audio.addEventListener("ended", onEnded);
audio.addEventListener("play", onPlay);
audio.addEventListener("pause", onPause);
return () => {
audio.pause();
audio.removeEventListener("timeupdate", onTimeUpdate);
audio.removeEventListener("durationchange", onDurationChange);
audio.removeEventListener("loadedmetadata", onDurationChange);
audio.removeEventListener("ended", onEnded);
audio.removeEventListener("play", onPlay);
audio.removeEventListener("pause", onPause);
};
}, [audioUrl]);
const toggle = useCallback(() => {
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) {
audio.play();
} else {
audio.pause();
}
}, []);
const seek = useCallback((time: number) => {
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = Math.max(0, Math.min(time, audio.duration));
}, []);
const setVolume = useCallback((v: number) => {
const audio = audioRef.current;
if (!audio) return;
audio.volume = Math.max(0, Math.min(1, v));
setState((prev) => ({ ...prev, volume: v }));
}, []);
return {
isPlaying: state.isPlaying,
currentTime: state.currentTime,
duration: state.duration,
volume: state.volume,
toggle,
seek,
setVolume,
};
}
+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,
};
}
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+24
View File
@@ -0,0 +1,24 @@
{
"name": "vibepod-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start"
},
"dependencies": {
"next": "15.5.15",
"react": "19.1.0",
"react-dom": "19.1.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"typescript": "^5"
},
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8"
}
+5
View File
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}