mirror of
https://github.com/JezzWTF/vibepod.git
synced 2026-06-01 15:22:14 +00:00
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:
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(mv podcast-forge/pnpm-lock.yaml /tmp/vibepod-pnpm-lock.yaml)",
|
||||
"Bash(git mv *)",
|
||||
"Bash(mv /tmp/vibepod-pnpm-lock.yaml web/pnpm-lock.yaml)",
|
||||
"Bash(git rm *)",
|
||||
"Bash(uv lock *)",
|
||||
"Bash(pnpm install *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(command -v uv)",
|
||||
"Bash(uv --version)",
|
||||
"Bash(uv sync *)",
|
||||
"Bash(pnpm --filter vibepod-web exec tsc --noEmit)",
|
||||
"Bash(xargs cat *)",
|
||||
"Bash(.venv/Scripts/python.exe -c \"import torch; print\\('torch:', torch.__version__\\); print\\('CUDA available:', torch.cuda.is_available\\(\\)\\); print\\('CUDA version:', torch.version.cuda\\)\")",
|
||||
"Bash(nvidia-smi)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# Copy to .env.local and fill in values
|
||||
|
||||
# URL of the Python TTS server (used by Next.js API routes)
|
||||
VIBEVOICE_SERVER_URL=http://localhost:8000
|
||||
|
||||
# HuggingFace token — required if the model repo is private or gated
|
||||
HF_TOKEN=
|
||||
|
||||
# Override the HuggingFace model cache directory (optional)
|
||||
# HF_HOME=/path/to/hf-cache
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Python
|
||||
server/.venv/
|
||||
server/voices/
|
||||
server/__pycache__/
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
**/*.pyo
|
||||
*.egg-info/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Node (root-level)
|
||||
node_modules/
|
||||
web/.next/
|
||||
web/tsconfig.tsbuildinfo
|
||||
web/next-env.d.ts
|
||||
web/node_modules/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -1,2 +1,117 @@
|
||||
# vibepod
|
||||
Podcast Generator using VibeVoice 0.5
|
||||
# VibePod
|
||||
|
||||
A text-to-speech podcast generator powered by [VibeVoice 0.5B](https://huggingface.co/microsoft/VibeVoice-Realtime-0.5B). Paste a script, tune a couple of sliders, and get a WAV back.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
VibePod/
|
||||
├── web/ Next.js 15 frontend (React 19, Tailwind CSS 4, TypeScript)
|
||||
└── server/ FastAPI TTS backend (Python 3.10+, VibeVoice, UV)
|
||||
```
|
||||
|
||||
The Next.js app proxies audio generation requests to the FastAPI server, keeping CORS out of the picture and the Python model off the browser.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Tool | Install |
|
||||
|------|---------|
|
||||
| [Node.js 20+](https://nodejs.org) | `winget install OpenJS.NodeJS.LTS` |
|
||||
| [pnpm](https://pnpm.io) | `npm i -g pnpm` |
|
||||
| [Python 3.10+](https://python.org) | `winget install Python.Python.3.13` |
|
||||
| [uv](https://docs.astral.sh/uv/) | `winget install astral-sh.uv` |
|
||||
|
||||
## Getting started
|
||||
|
||||
```bash
|
||||
# 1. Clone
|
||||
git clone https://github.com/LyAhn/VibePod.git
|
||||
cd VibePod
|
||||
|
||||
# 2. Install Node dependencies (root + web workspace)
|
||||
pnpm install
|
||||
|
||||
# 3. Copy env file and fill in values
|
||||
cp .env.example .env.local
|
||||
|
||||
# 4. Start everything
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
`pnpm dev` starts both services concurrently:
|
||||
|
||||
- **SERVER** — `http://localhost:8000` — on first run `uv sync` creates the Python venv and downloads the ~1 GB VibeVoice model from HuggingFace
|
||||
- **WEB** — `http://localhost:3000` — Next.js dev server with Turbopack
|
||||
|
||||
The frontend shows a loading indicator while the model downloads. Once the server reports `status: online`, generation is available.
|
||||
|
||||
## Individual commands
|
||||
|
||||
```bash
|
||||
pnpm dev:web # Next.js only
|
||||
pnpm dev:server # Python server only
|
||||
pnpm build # Production build of the frontend
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
Copy `.env.example` to `.env.local` and set:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `VIBEVOICE_SERVER_URL` | `http://localhost:8000` | URL the Next.js API routes use to reach the Python server |
|
||||
| `HF_TOKEN` | — | HuggingFace token (required if the model repo is gated) |
|
||||
| `HF_HOME` | — | Override the HuggingFace model cache directory |
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
web/
|
||||
├── app/
|
||||
│ ├── api/generate/ Proxies POST requests to the Python server
|
||||
│ ├── api/health/ Proxies health checks (status: loading | online | error)
|
||||
│ ├── page.tsx Main UI — script input, controls, audio player
|
||||
│ └── layout.tsx
|
||||
├── components/
|
||||
│ ├── Header.tsx
|
||||
│ ├── TextInputPanel.tsx
|
||||
│ ├── GenerationControls.tsx cfg_scale and inference_steps sliders
|
||||
│ ├── AudioPlayer.tsx
|
||||
│ └── StatusLog.tsx
|
||||
└── hooks/
|
||||
└── useAudioPlayer.ts
|
||||
|
||||
server/
|
||||
├── vibevoice_server.py FastAPI app — /health and /generate endpoints
|
||||
├── download_model.py One-shot HuggingFace model prefetch
|
||||
├── start.sh Entry point: uv sync → model check → uvicorn
|
||||
└── pyproject.toml Python deps managed by uv
|
||||
```
|
||||
|
||||
## Generation parameters
|
||||
|
||||
| Parameter | Range | Default | Effect |
|
||||
|-----------|-------|---------|--------|
|
||||
| `speaker` | `carter`, `davis`, `emma`, `frank`, `grace`, `mike` | `carter` | Voice preset used for the generated audio |
|
||||
| `cfg_scale` | 0.5 - 4.0 | 1.5 | Higher = more expressive guidance |
|
||||
| `inference_steps` | 5 - 20 | 10 | More steps = higher quality, slower generation |
|
||||
|
||||
## How it works
|
||||
|
||||
1. The user pastes a script and hits **Generate**
|
||||
2. The Next.js `/api/generate` route forwards the request to FastAPI on port 8000
|
||||
3. FastAPI runs the text through the VibeVoice streaming processor and inference model
|
||||
4. Audio chunks stream back to the browser as SSE events containing base64 float32 PCM
|
||||
5. The browser plays the chunks live, assembles a WAV Blob, and loads it into the audio player
|
||||
|
||||
## Python dependencies
|
||||
|
||||
Managed by [uv](https://docs.astral.sh/uv/). The `server/uv.lock` is committed so installs are fully reproducible.
|
||||
|
||||
```bash
|
||||
# Add a package
|
||||
cd server && uv add <package>
|
||||
|
||||
# Upgrade all dependencies
|
||||
cd server && uv lock --upgrade
|
||||
```
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# VibePod dev launcher
|
||||
# Starts all services and kills the entire process group cleanly on Ctrl+C.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
CY=$'\e[36m'; MG=$'\e[35m'; RS=$'\e[0m'
|
||||
|
||||
# On any exit (including Ctrl+C) kill every process in this group.
|
||||
trap 'kill 0' EXIT
|
||||
|
||||
prefix() {
|
||||
local label=$1 color=$2
|
||||
while IFS= read -r line; do
|
||||
printf '%s%-10s%s %s\n' "$color" "$label" "$RS" "$line"
|
||||
done
|
||||
}
|
||||
|
||||
echo "Starting VibePod — Ctrl+C to stop all"
|
||||
echo ""
|
||||
|
||||
bash server/start.sh 2>&1 | prefix "[SERVER]" "$CY" &
|
||||
pnpm --filter vibepod-web dev 2>&1 | prefix "[WEB]" "$MG" &
|
||||
|
||||
wait
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "vibepod",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bash dev.sh",
|
||||
"dev:server": "bash server/start.sh",
|
||||
"dev:web": "pnpm --filter vibepod-web dev",
|
||||
"build": "pnpm --filter vibepod-web build"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8"
|
||||
}
|
||||
Generated
+979
@@ -0,0 +1,979 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.: {}
|
||||
|
||||
web:
|
||||
dependencies:
|
||||
next:
|
||||
specifier: 15.5.15
|
||||
version: 15.5.15(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react:
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0
|
||||
react-dom:
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0(react@19.1.0)
|
||||
devDependencies:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4
|
||||
version: 4.2.4
|
||||
'@types/node':
|
||||
specifier: ^20
|
||||
version: 20.19.39
|
||||
'@types/react':
|
||||
specifier: ^19
|
||||
version: 19.2.14
|
||||
'@types/react-dom':
|
||||
specifier: ^19
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
tailwindcss:
|
||||
specifier: ^4
|
||||
version: 4.2.4
|
||||
typescript:
|
||||
specifier: ^5
|
||||
version: 5.9.3
|
||||
|
||||
packages:
|
||||
|
||||
'@alloc/quick-lru@5.2.0':
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@emnapi/runtime@1.10.0':
|
||||
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
|
||||
|
||||
'@img/colour@1.1.0':
|
||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-darwin-x64@0.34.5':
|
||||
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-ia32@0.34.5':
|
||||
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
'@jridgewell/remapping@2.3.5':
|
||||
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2':
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@next/env@15.5.15':
|
||||
resolution: {integrity: sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==}
|
||||
|
||||
'@next/swc-darwin-arm64@15.5.15':
|
||||
resolution: {integrity: sha512-6PvFO2Tzt10GFK2Ro9tAVEtacMqRmTarYMFKAnV2vYMdwWc73xzmDQyAV7SwEdMhzmiRoo7+m88DuiXlJlGeaw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-darwin-x64@15.5.15':
|
||||
resolution: {integrity: sha512-G+YNV+z6FDZTp/+IdGyIMFqalBTaQSnvAA+X/hrt+eaTRFSznRMz9K7rTmzvM6tDmKegNtyzgufZW0HwVzEqaQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-linux-arm64-gnu@15.5.15':
|
||||
resolution: {integrity: sha512-eVkrMcVIBqGfXB+QUC7jjZ94Z6uX/dNStbQFabewAnk13Uy18Igd1YZ/GtPRzdhtm7QwC0e6o7zOQecul4iC1w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@15.5.15':
|
||||
resolution: {integrity: sha512-RwSHKMQ7InLy5GfkY2/n5PcFycKA08qI1VST78n09nN36nUPqCvGSMiLXlfUmzmpQpF6XeBYP2KRWHi0UW3uNg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@15.5.15':
|
||||
resolution: {integrity: sha512-nplqvY86LakS+eeiuWsNWvfmK8pFcOEW7ZtVRt4QH70lL+0x6LG/m1OpJ/tvrbwjmR8HH9/fH2jzW1GlL03TIg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@15.5.15':
|
||||
resolution: {integrity: sha512-eAgl9NKQ84/sww0v81DQINl/vL2IBxD7sMybd0cWRw6wqgouVI53brVRBrggqBRP/NWeIAE1dm5cbKYoiMlqDQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@15.5.15':
|
||||
resolution: {integrity: sha512-GJVZC86lzSquh0MtvZT+L7G8+jMnJcldloOjA8Kf3wXvBrvb6OGe2MzPuALxFshSm/IpwUtD2mIoof39ymf52A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-x64-msvc@15.5.15':
|
||||
resolution: {integrity: sha512-nFucjVdwlFqxh/JG3hWSJ4p8+YJV7Ii8aPDuBQULB6DzUF4UNZETXLfEUk+oI2zEznWWULPt7MeuTE6xtK1HSA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
'@tailwindcss/node@4.2.4':
|
||||
resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==}
|
||||
|
||||
'@tailwindcss/oxide-android-arm64@4.2.4':
|
||||
resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@tailwindcss/oxide-darwin-arm64@4.2.4':
|
||||
resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@tailwindcss/oxide-darwin-x64@4.2.4':
|
||||
resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@tailwindcss/oxide-freebsd-x64@4.2.4':
|
||||
resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4':
|
||||
resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-gnu@4.2.4':
|
||||
resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.2.4':
|
||||
resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.2.4':
|
||||
resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.2.4':
|
||||
resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.2.4':
|
||||
resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
bundledDependencies:
|
||||
- '@napi-rs/wasm-runtime'
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
- '@tybys/wasm-util'
|
||||
- '@emnapi/wasi-threads'
|
||||
- tslib
|
||||
|
||||
'@tailwindcss/oxide-win32-arm64-msvc@4.2.4':
|
||||
resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/oxide-win32-x64-msvc@4.2.4':
|
||||
resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/oxide@4.2.4':
|
||||
resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@tailwindcss/postcss@4.2.4':
|
||||
resolution: {integrity: sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==}
|
||||
|
||||
'@types/node@20.19.39':
|
||||
resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==}
|
||||
|
||||
'@types/react-dom@19.2.3':
|
||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
|
||||
'@types/react@19.2.14':
|
||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||
|
||||
caniuse-lite@1.0.30001791:
|
||||
resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==}
|
||||
|
||||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
enhanced-resolve@5.21.0:
|
||||
resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
jiti@2.6.1:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
lightningcss-darwin-arm64@1.32.0:
|
||||
resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
lightningcss-darwin-x64@1.32.0:
|
||||
resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
lightningcss-freebsd-x64@1.32.0:
|
||||
resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
lightningcss-linux-arm-gnueabihf@1.32.0:
|
||||
resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-arm64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
lightningcss-win32-x64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
lightningcss@1.32.0:
|
||||
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
next@15.5.15:
|
||||
resolution: {integrity: sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==}
|
||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.1.0
|
||||
'@playwright/test': ^1.51.1
|
||||
babel-plugin-react-compiler: '*'
|
||||
react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
|
||||
react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
|
||||
sass: ^1.3.0
|
||||
peerDependenciesMeta:
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@playwright/test':
|
||||
optional: true
|
||||
babel-plugin-react-compiler:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
postcss@8.4.31:
|
||||
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.12:
|
||||
resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
react-dom@19.1.0:
|
||||
resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==}
|
||||
peerDependencies:
|
||||
react: ^19.1.0
|
||||
|
||||
react@19.1.0:
|
||||
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
scheduler@0.26.0:
|
||||
resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==}
|
||||
|
||||
semver@7.7.4:
|
||||
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
sharp@0.34.5:
|
||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
styled-jsx@5.1.6:
|
||||
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': '*'
|
||||
babel-plugin-macros: '*'
|
||||
react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
|
||||
peerDependenciesMeta:
|
||||
'@babel/core':
|
||||
optional: true
|
||||
babel-plugin-macros:
|
||||
optional: true
|
||||
|
||||
tailwindcss@4.2.4:
|
||||
resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==}
|
||||
|
||||
tapable@2.3.3:
|
||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@emnapi/runtime@1.10.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@img/colour@1.1.0':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
dependencies:
|
||||
'@emnapi/runtime': 1.10.0
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-ia32@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/remapping@2.3.5':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@next/env@15.5.15': {}
|
||||
|
||||
'@next/swc-darwin-arm64@15.5.15':
|
||||
optional: true
|
||||
|
||||
'@next/swc-darwin-x64@15.5.15':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-gnu@15.5.15':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-musl@15.5.15':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-gnu@15.5.15':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-musl@15.5.15':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-arm64-msvc@15.5.15':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-x64-msvc@15.5.15':
|
||||
optional: true
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@tailwindcss/node@4.2.4':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
enhanced-resolve: 5.21.0
|
||||
jiti: 2.6.1
|
||||
lightningcss: 1.32.0
|
||||
magic-string: 0.30.21
|
||||
source-map-js: 1.2.1
|
||||
tailwindcss: 4.2.4
|
||||
|
||||
'@tailwindcss/oxide-android-arm64@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-darwin-arm64@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-darwin-x64@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-freebsd-x64@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-gnu@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-win32-arm64-msvc@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-win32-x64-msvc@4.2.4':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide@4.2.4':
|
||||
optionalDependencies:
|
||||
'@tailwindcss/oxide-android-arm64': 4.2.4
|
||||
'@tailwindcss/oxide-darwin-arm64': 4.2.4
|
||||
'@tailwindcss/oxide-darwin-x64': 4.2.4
|
||||
'@tailwindcss/oxide-freebsd-x64': 4.2.4
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4
|
||||
'@tailwindcss/oxide-linux-arm64-gnu': 4.2.4
|
||||
'@tailwindcss/oxide-linux-arm64-musl': 4.2.4
|
||||
'@tailwindcss/oxide-linux-x64-gnu': 4.2.4
|
||||
'@tailwindcss/oxide-linux-x64-musl': 4.2.4
|
||||
'@tailwindcss/oxide-wasm32-wasi': 4.2.4
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.4
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.2.4
|
||||
|
||||
'@tailwindcss/postcss@4.2.4':
|
||||
dependencies:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
'@tailwindcss/node': 4.2.4
|
||||
'@tailwindcss/oxide': 4.2.4
|
||||
postcss: 8.5.12
|
||||
tailwindcss: 4.2.4
|
||||
|
||||
'@types/node@20.19.39':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@types/react@19.2.14':
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
caniuse-lite@1.0.30001791: {}
|
||||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
enhanced-resolve@5.21.0:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.3
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-darwin-arm64@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-darwin-x64@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-freebsd-x64@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-arm-gnueabihf@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-arm64-gnu@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-win32-x64-msvc@1.32.0:
|
||||
optional: true
|
||||
|
||||
lightningcss@1.32.0:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
lightningcss-android-arm64: 1.32.0
|
||||
lightningcss-darwin-arm64: 1.32.0
|
||||
lightningcss-darwin-x64: 1.32.0
|
||||
lightningcss-freebsd-x64: 1.32.0
|
||||
lightningcss-linux-arm-gnueabihf: 1.32.0
|
||||
lightningcss-linux-arm64-gnu: 1.32.0
|
||||
lightningcss-linux-arm64-musl: 1.32.0
|
||||
lightningcss-linux-x64-gnu: 1.32.0
|
||||
lightningcss-linux-x64-musl: 1.32.0
|
||||
lightningcss-win32-arm64-msvc: 1.32.0
|
||||
lightningcss-win32-x64-msvc: 1.32.0
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
next@15.5.15(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
'@next/env': 15.5.15
|
||||
'@swc/helpers': 0.5.15
|
||||
caniuse-lite: 1.0.30001791
|
||||
postcss: 8.4.31
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
styled-jsx: 5.1.6(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 15.5.15
|
||||
'@next/swc-darwin-x64': 15.5.15
|
||||
'@next/swc-linux-arm64-gnu': 15.5.15
|
||||
'@next/swc-linux-arm64-musl': 15.5.15
|
||||
'@next/swc-linux-x64-gnu': 15.5.15
|
||||
'@next/swc-linux-x64-musl': 15.5.15
|
||||
'@next/swc-win32-arm64-msvc': 15.5.15
|
||||
'@next/swc-win32-x64-msvc': 15.5.15
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
postcss@8.4.31:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.12:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
react-dom@19.1.0(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
scheduler: 0.26.0
|
||||
|
||||
react@19.1.0: {}
|
||||
|
||||
scheduler@0.26.0: {}
|
||||
|
||||
semver@7.7.4:
|
||||
optional: true
|
||||
|
||||
sharp@0.34.5:
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
detect-libc: 2.1.2
|
||||
semver: 7.7.4
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.34.5
|
||||
'@img/sharp-darwin-x64': 0.34.5
|
||||
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||
'@img/sharp-linux-arm': 0.34.5
|
||||
'@img/sharp-linux-arm64': 0.34.5
|
||||
'@img/sharp-linux-ppc64': 0.34.5
|
||||
'@img/sharp-linux-riscv64': 0.34.5
|
||||
'@img/sharp-linux-s390x': 0.34.5
|
||||
'@img/sharp-linux-x64': 0.34.5
|
||||
'@img/sharp-linuxmusl-arm64': 0.34.5
|
||||
'@img/sharp-linuxmusl-x64': 0.34.5
|
||||
'@img/sharp-wasm32': 0.34.5
|
||||
'@img/sharp-win32-arm64': 0.34.5
|
||||
'@img/sharp-win32-ia32': 0.34.5
|
||||
'@img/sharp-win32-x64': 0.34.5
|
||||
optional: true
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
styled-jsx@5.1.6(react@19.1.0):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.1.0
|
||||
|
||||
tailwindcss@4.2.4: {}
|
||||
|
||||
tapable@2.3.3: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- 'web'
|
||||
+48
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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" }
|
||||
Executable
+40
@@ -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 \
|
||||
"$@"
|
||||
Generated
+3321
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
VibePod — VibeVoice FastAPI TTS Server
|
||||
|
||||
Startup sequence (background thread):
|
||||
1. Download model weights if not cached -> status: downloading
|
||||
2. Download voice preset .pt files -> status: loading
|
||||
3. Load processor + model into memory -> status: loading
|
||||
4. Pre-load all voice tensors -> status: loading
|
||||
-> Server ready -> status: online
|
||||
|
||||
Generation flow:
|
||||
POST /generate -> SSE stream of audio_chunk events (base64 float32 PCM),
|
||||
ends with {type:"complete"}
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import copy
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator, Literal, Optional
|
||||
|
||||
import torch
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from tqdm import tqdm as _BaseTqdm
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MODEL_ID = "microsoft/VibeVoice-Realtime-0.5B"
|
||||
SAMPLE_RATE = 24_000
|
||||
|
||||
VOICES_DIR = Path(__file__).parent / "voices" / "streaming_model"
|
||||
VOICE_BASE_URL = (
|
||||
"https://raw.githubusercontent.com/microsoft/VibeVoice/main"
|
||||
"/demo/voices/streaming_model"
|
||||
)
|
||||
|
||||
EN_VOICES: dict[str, str] = {
|
||||
"carter": "en-Carter_man.pt",
|
||||
"davis": "en-Davis_man.pt",
|
||||
"emma": "en-Emma_woman.pt",
|
||||
"frank": "en-Frank_man.pt",
|
||||
"grace": "en-Grace_woman.pt",
|
||||
"mike": "en-Mike_man.pt",
|
||||
}
|
||||
DEFAULT_SPEAKER = "carter"
|
||||
|
||||
_IGNORE_PATTERNS = ["*.msgpack", "flax_model*", "tf_model*", "rust_model*", "*.ot"]
|
||||
|
||||
# ── Global state ────────────────────────────────────────────────────────────────
|
||||
|
||||
ModelStatus = Literal["downloading", "loading", "online", "error"]
|
||||
|
||||
_processor = None
|
||||
_model = None
|
||||
_device: str = "cpu"
|
||||
_model_status: ModelStatus = "loading"
|
||||
_model_error: Optional[str] = None
|
||||
_voice_presets: dict[str, object] = {}
|
||||
_load_lock = threading.Lock()
|
||||
_generation_lock = asyncio.Lock()
|
||||
|
||||
# Download progress (files downloaded so far)
|
||||
_dl_progress: dict[str, int] = {"done": 0, "total": 0}
|
||||
|
||||
|
||||
|
||||
# ── Progress-tracking tqdm (for model file downloads) ──────────────────────────
|
||||
|
||||
def _make_dl_tqdm() -> type:
|
||||
class _DlTqdm(_BaseTqdm):
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
if isinstance(self.total, (int, float)) and 0 < self.total < 10_000:
|
||||
_dl_progress["total"] = int(self.total)
|
||||
_dl_progress["done"] = 0
|
||||
|
||||
def update(self, n: int = 1) -> "bool | None":
|
||||
result = super().update(n)
|
||||
if isinstance(self.total, (int, float)) and 0 < self.total < 10_000:
|
||||
_dl_progress["done"] = int(self.n)
|
||||
return result
|
||||
|
||||
return _DlTqdm
|
||||
|
||||
|
||||
# ── Model / voice helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def _is_model_cached() -> bool:
|
||||
try:
|
||||
from huggingface_hub import snapshot_download
|
||||
snapshot_download(MODEL_ID, local_files_only=True, ignore_patterns=_IGNORE_PATTERNS)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _download_model() -> None:
|
||||
from huggingface_hub import snapshot_download
|
||||
token: Optional[str] = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_TOKEN")
|
||||
DlTqdm = _make_dl_tqdm()
|
||||
logger.info("Model not cached — downloading %s...", MODEL_ID)
|
||||
snapshot_download(
|
||||
repo_id=MODEL_ID,
|
||||
ignore_patterns=_IGNORE_PATTERNS,
|
||||
token=token or None,
|
||||
tqdm_class=DlTqdm,
|
||||
)
|
||||
logger.info("Model download complete.")
|
||||
|
||||
|
||||
def _download_voices() -> None:
|
||||
VOICES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for name, filename in EN_VOICES.items():
|
||||
dest = VOICES_DIR / filename
|
||||
if not dest.exists():
|
||||
url = f"{VOICE_BASE_URL}/{filename}"
|
||||
logger.info("Downloading voice preset: %s", filename)
|
||||
urllib.request.urlretrieve(url, dest)
|
||||
logger.info("Voice presets ready.")
|
||||
|
||||
|
||||
# ── Background model loader ─────────────────────────────────────────────────────
|
||||
|
||||
def _load_model_sync() -> None:
|
||||
global _processor, _model, _device, _model_status, _model_error, _voice_presets
|
||||
|
||||
with _load_lock:
|
||||
if _model is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
if not _is_model_cached():
|
||||
_model_status = "downloading"
|
||||
_download_model()
|
||||
|
||||
_model_status = "loading"
|
||||
_download_voices()
|
||||
|
||||
_device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
load_dtype = torch.bfloat16 if _device == "cuda" else torch.float32
|
||||
attn_impl = "flash_attention_2" if _device == "cuda" else "sdpa"
|
||||
|
||||
logger.info("Loading processor...")
|
||||
from vibevoice.processor.vibevoice_streaming_processor import (
|
||||
VibeVoiceStreamingProcessor,
|
||||
)
|
||||
_processor = VibeVoiceStreamingProcessor.from_pretrained(MODEL_ID)
|
||||
|
||||
logger.info("Loading model on %s...", _device)
|
||||
from vibevoice.modular.modeling_vibevoice_streaming_inference import (
|
||||
VibeVoiceStreamingForConditionalGenerationInference,
|
||||
)
|
||||
try:
|
||||
_model = VibeVoiceStreamingForConditionalGenerationInference.from_pretrained(
|
||||
MODEL_ID,
|
||||
torch_dtype=load_dtype,
|
||||
device_map=_device,
|
||||
attn_implementation=attn_impl,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("flash_attention_2 unavailable, falling back to sdpa")
|
||||
_model = VibeVoiceStreamingForConditionalGenerationInference.from_pretrained(
|
||||
MODEL_ID,
|
||||
torch_dtype=load_dtype,
|
||||
device_map=_device,
|
||||
attn_implementation="sdpa",
|
||||
)
|
||||
|
||||
_model.eval()
|
||||
_model.set_ddpm_inference_steps(num_steps=10)
|
||||
|
||||
for name, filename in EN_VOICES.items():
|
||||
path = VOICES_DIR / filename
|
||||
if path.exists():
|
||||
_voice_presets[name] = torch.load(
|
||||
path, map_location=_device, weights_only=False
|
||||
)
|
||||
|
||||
_model_status = "online"
|
||||
logger.info("Model ready on %s. Voices: %s", _device, list(_voice_presets.keys()))
|
||||
|
||||
except Exception as exc:
|
||||
_model_status = "error"
|
||||
_model_error = str(exc)
|
||||
logger.exception("Failed to initialise model: %s", exc)
|
||||
|
||||
|
||||
# ── FastAPI app ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
thread = threading.Thread(target=_load_model_sync, daemon=True, name="model-loader")
|
||||
thread.start()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="VibePod TTS Server", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
|
||||
# ── Schemas ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=10_000)
|
||||
speaker: str = Field(default=DEFAULT_SPEAKER)
|
||||
cfg_scale: float = Field(default=1.5, ge=0.5, le=4.0)
|
||||
inference_steps: int = Field(default=10, ge=5, le=20)
|
||||
|
||||
@field_validator("text")
|
||||
@classmethod
|
||||
def text_not_blank(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("text must not be blank")
|
||||
return v.strip()
|
||||
|
||||
@field_validator("speaker")
|
||||
@classmethod
|
||||
def normalise_speaker(cls, v: str) -> str:
|
||||
return v.lower().strip()
|
||||
|
||||
|
||||
# ── Endpoints ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
body: dict = {
|
||||
"status": _model_status,
|
||||
"model": MODEL_ID,
|
||||
"voices": list(_voice_presets.keys()),
|
||||
}
|
||||
if _model_status == "downloading":
|
||||
body["progress"] = {"done": _dl_progress["done"], "total": _dl_progress["total"]}
|
||||
if _model_error:
|
||||
body["message"] = _model_error
|
||||
return body
|
||||
|
||||
|
||||
def _sync_generate(
|
||||
req: GenerateRequest,
|
||||
streamer: Optional[object] = None,
|
||||
cancel_event: Optional[threading.Event] = None,
|
||||
) -> str:
|
||||
"""Blocking inference. Returns the speaker used.
|
||||
Runs in a thread-pool executor — do not call from the event loop directly.
|
||||
Pass an AsyncAudioStreamer to receive audio chunks in real time.
|
||||
"""
|
||||
if cancel_event and cancel_event.is_set():
|
||||
raise RuntimeError("Generation cancelled.")
|
||||
|
||||
speaker = req.speaker if req.speaker in _voice_presets else DEFAULT_SPEAKER
|
||||
voice_preset = copy.deepcopy(_voice_presets[speaker])
|
||||
|
||||
_model.set_ddpm_inference_steps(num_steps=req.inference_steps)
|
||||
|
||||
inputs = _processor.process_input_with_cached_prompt(
|
||||
text=req.text,
|
||||
cached_prompt=voice_preset,
|
||||
padding=True,
|
||||
return_tensors="pt",
|
||||
return_attention_mask=True,
|
||||
)
|
||||
for k, v in inputs.items():
|
||||
if torch.is_tensor(v):
|
||||
inputs[k] = v.to(_device)
|
||||
|
||||
outputs = _model.generate(
|
||||
**inputs,
|
||||
max_new_tokens=None,
|
||||
cfg_scale=req.cfg_scale,
|
||||
tokenizer=_processor.tokenizer,
|
||||
generation_config={"do_sample": False},
|
||||
verbose=True,
|
||||
all_prefilled_outputs=copy.deepcopy(voice_preset),
|
||||
audio_streamer=streamer,
|
||||
)
|
||||
|
||||
if not outputs.speech_outputs or outputs.speech_outputs[0] is None:
|
||||
raise ValueError("Model returned no audio output.")
|
||||
|
||||
return speaker
|
||||
|
||||
|
||||
def _sse(event: dict) -> str:
|
||||
return f"data: {json.dumps(event)}\n\n"
|
||||
|
||||
|
||||
@app.post("/generate")
|
||||
async def generate(req: GenerateRequest, request: Request) -> StreamingResponse:
|
||||
if _model_status != "online":
|
||||
detail = {
|
||||
"downloading": "Model is downloading — please wait.",
|
||||
"loading": "Model is loading into memory — please wait.",
|
||||
"error": f"Model failed to load: {_model_error or 'unknown error'}",
|
||||
}.get(_model_status, "Server not ready.")
|
||||
raise HTTPException(status_code=503, detail=detail)
|
||||
|
||||
if _generation_lock.locked():
|
||||
raise HTTPException(status_code=503, detail="Server is already generating audio. Please wait.")
|
||||
|
||||
async def event_stream() -> AsyncGenerator[str, None]:
|
||||
from vibevoice.modular.streamer import AsyncAudioStreamer
|
||||
|
||||
start = time.monotonic()
|
||||
streamer = AsyncAudioStreamer(batch_size=1)
|
||||
cancel_event = threading.Event()
|
||||
|
||||
async with _generation_lock:
|
||||
loop = asyncio.get_event_loop()
|
||||
future = loop.run_in_executor(
|
||||
None, functools.partial(_sync_generate, req, streamer, cancel_event)
|
||||
)
|
||||
|
||||
# Drain audio chunks as they arrive from the diffusion head.
|
||||
# stop_signal=None is the default sentinel that ends the queue.
|
||||
while True:
|
||||
try:
|
||||
chunk = await asyncio.wait_for(
|
||||
streamer.audio_queues[0].get(), timeout=120.0
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
cancel_event.set()
|
||||
future.cancel()
|
||||
yield _sse({"type": "error", "message": "Generation timed out"})
|
||||
return
|
||||
|
||||
if await request.is_disconnected():
|
||||
cancel_event.set()
|
||||
future.cancel()
|
||||
logger.info("Generation client disconnected; stream cancelled.")
|
||||
return
|
||||
|
||||
if chunk is None: # stop signal
|
||||
break
|
||||
|
||||
pcm_b64 = base64.b64encode(
|
||||
chunk.detach().cpu().float().numpy().tobytes()
|
||||
).decode()
|
||||
yield _sse({"type": "audio_chunk", "data": pcm_b64})
|
||||
|
||||
try:
|
||||
speaker = await future
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Generation cancelled.")
|
||||
yield _sse({"type": "cancelled"})
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.exception("Generation failed: %s", exc)
|
||||
yield _sse({"type": "error", "message": str(exc)})
|
||||
return
|
||||
|
||||
elapsed = round(time.monotonic() - start, 1)
|
||||
logger.info("Generation complete in %.1fs", elapsed)
|
||||
yield _sse({"type": "complete", "elapsed": elapsed, "speaker": speaker})
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,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
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { useReducer, useCallback, useEffect } from "react";
|
||||
import Header from "@/components/Header";
|
||||
import TextInputPanel from "@/components/TextInputPanel";
|
||||
import GenerationControls from "@/components/GenerationControls";
|
||||
import AudioPlayer from "@/components/AudioPlayer";
|
||||
import StatusLog from "@/components/StatusLog";
|
||||
import { useStreamingGeneration } from "@/hooks/useStreamingGeneration";
|
||||
|
||||
export type ServerStatus = "offline" | "downloading" | "loading" | "online" | "error";
|
||||
|
||||
export interface DownloadProgress {
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
script: string;
|
||||
speaker: string;
|
||||
cfgScale: number;
|
||||
inferenceSteps: number;
|
||||
isGenerating: boolean;
|
||||
genElapsed: number;
|
||||
genPct: number | null;
|
||||
audioUrl: string | null;
|
||||
logs: string[];
|
||||
serverStatus: ServerStatus;
|
||||
downloadProgress: DownloadProgress | null;
|
||||
availableVoices: string[];
|
||||
}
|
||||
|
||||
type AppAction =
|
||||
| { type: "SET_SCRIPT"; payload: string }
|
||||
| { type: "SET_SPEAKER"; payload: string }
|
||||
| { type: "SET_CFG_SCALE"; payload: number }
|
||||
| { type: "SET_INFERENCE_STEPS"; payload: number }
|
||||
| { type: "START_GENERATION" }
|
||||
| { type: "GEN_PROGRESS"; elapsed: number; pct: number | null }
|
||||
| { type: "GENERATION_SUCCESS"; payload: string }
|
||||
| { type: "GENERATION_CANCELLED" }
|
||||
| { type: "GENERATION_ERROR" }
|
||||
| { type: "ADD_LOG"; payload: string }
|
||||
| {
|
||||
type: "SET_SERVER_STATUS";
|
||||
payload: { status: ServerStatus; progress?: DownloadProgress | null; voices?: string[] };
|
||||
};
|
||||
|
||||
function reducer(state: AppState, action: AppAction): AppState {
|
||||
switch (action.type) {
|
||||
case "SET_SCRIPT": return { ...state, script: action.payload };
|
||||
case "SET_SPEAKER": return { ...state, speaker: action.payload };
|
||||
case "SET_CFG_SCALE": return { ...state, cfgScale: action.payload };
|
||||
case "SET_INFERENCE_STEPS": return { ...state, inferenceSteps: action.payload };
|
||||
case "START_GENERATION":
|
||||
return { ...state, isGenerating: true, audioUrl: null, logs: [], genElapsed: 0, genPct: null };
|
||||
case "GEN_PROGRESS":
|
||||
return { ...state, genElapsed: action.elapsed, genPct: action.pct };
|
||||
case "GENERATION_SUCCESS":
|
||||
return { ...state, isGenerating: false, genElapsed: 0, genPct: null, audioUrl: action.payload };
|
||||
case "GENERATION_CANCELLED":
|
||||
case "GENERATION_ERROR":
|
||||
return { ...state, isGenerating: false, genElapsed: 0, genPct: null };
|
||||
case "ADD_LOG":
|
||||
return { ...state, logs: [...state.logs, action.payload] };
|
||||
case "SET_SERVER_STATUS":
|
||||
return {
|
||||
...state,
|
||||
serverStatus: action.payload.status,
|
||||
downloadProgress: action.payload.progress ?? null,
|
||||
availableVoices:
|
||||
action.payload.voices?.length ? action.payload.voices : state.availableVoices,
|
||||
};
|
||||
default: return state;
|
||||
}
|
||||
}
|
||||
|
||||
const initialState: AppState = {
|
||||
script: "",
|
||||
speaker: "carter",
|
||||
cfgScale: 1.5,
|
||||
inferenceSteps: 10,
|
||||
isGenerating: false,
|
||||
genElapsed: 0,
|
||||
genPct: null,
|
||||
audioUrl: null,
|
||||
logs: [],
|
||||
serverStatus: "offline",
|
||||
downloadProgress: null,
|
||||
availableVoices: [],
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const wordCount = state.script.trim() === "" ? 0 : state.script.trim().split(/\s+/).length;
|
||||
|
||||
const addLog = useCallback((msg: string) => dispatch({ type: "ADD_LOG", payload: msg }), []);
|
||||
const handleGenerationStart = useCallback(() => dispatch({ type: "START_GENERATION" }), []);
|
||||
const handleGenerationProgress = useCallback((elapsed: number, pct: number | null) => {
|
||||
dispatch({ type: "GEN_PROGRESS", elapsed, pct });
|
||||
}, []);
|
||||
const handleGenerationSuccess = useCallback((audioUrl: string) => {
|
||||
dispatch({ type: "GENERATION_SUCCESS", payload: audioUrl });
|
||||
}, []);
|
||||
const handleGenerationCancel = useCallback(() => dispatch({ type: "GENERATION_CANCELLED" }), []);
|
||||
const handleGenerationError = useCallback(() => dispatch({ type: "GENERATION_ERROR" }), []);
|
||||
|
||||
const {
|
||||
generate,
|
||||
pauseStream,
|
||||
resumeStream,
|
||||
stop,
|
||||
isStreamPaused,
|
||||
} = useStreamingGeneration({
|
||||
onLog: addLog,
|
||||
onStart: handleGenerationStart,
|
||||
onProgress: handleGenerationProgress,
|
||||
onSuccess: handleGenerationSuccess,
|
||||
onCancel: handleGenerationCancel,
|
||||
onError: handleGenerationError,
|
||||
});
|
||||
|
||||
// Server health polling — fast while not ready, slow when online
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
let cancelled = false;
|
||||
|
||||
async function poll() {
|
||||
if (cancelled) return;
|
||||
let nextStatus: ServerStatus = "offline";
|
||||
let nextProgress: DownloadProgress | null = null;
|
||||
let nextVoices: string[] = [];
|
||||
try {
|
||||
const res = await fetch("/api/health", { cache: "no-store" });
|
||||
const data = await res.json() as {
|
||||
status: ServerStatus;
|
||||
progress?: DownloadProgress | null;
|
||||
voices?: string[];
|
||||
};
|
||||
nextStatus = data.status ?? "offline";
|
||||
nextProgress = data.progress ?? null;
|
||||
nextVoices = data.voices ?? [];
|
||||
} catch {
|
||||
nextStatus = "offline";
|
||||
}
|
||||
if (!cancelled) {
|
||||
dispatch({ type: "SET_SERVER_STATUS", payload: { status: nextStatus, progress: nextProgress, voices: nextVoices } });
|
||||
timeoutId = setTimeout(poll, nextStatus === "online" ? 15_000 : 2_000);
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
return () => { cancelled = true; clearTimeout(timeoutId); };
|
||||
}, []);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!state.script.trim() || state.isGenerating) return;
|
||||
addLog(`${wordCount} words queued`);
|
||||
await generate({
|
||||
text: state.script,
|
||||
speaker: state.speaker,
|
||||
cfgScale: state.cfgScale,
|
||||
inferenceSteps: state.inferenceSteps,
|
||||
});
|
||||
}, [
|
||||
addLog,
|
||||
generate,
|
||||
state.cfgScale,
|
||||
state.inferenceSteps,
|
||||
state.isGenerating,
|
||||
state.script,
|
||||
state.speaker,
|
||||
wordCount,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col" style={{ background: "var(--background)" }}>
|
||||
<Header />
|
||||
<main className="flex-1 container mx-auto px-4 py-6 max-w-6xl">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* Left: script + audio player */}
|
||||
<div className="lg:col-span-2 flex flex-col gap-6">
|
||||
<TextInputPanel
|
||||
value={state.script}
|
||||
onChange={(text) => dispatch({ type: "SET_SCRIPT", payload: text })}
|
||||
/>
|
||||
{state.audioUrl && <AudioPlayer audioUrl={state.audioUrl} />}
|
||||
</div>
|
||||
|
||||
{/* Right: controls + log */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<GenerationControls
|
||||
speaker={state.speaker}
|
||||
availableVoices={state.availableVoices}
|
||||
onSpeakerChange={(v) => dispatch({ type: "SET_SPEAKER", payload: v })}
|
||||
cfgScale={state.cfgScale}
|
||||
onCfgScaleChange={(v) => dispatch({ type: "SET_CFG_SCALE", payload: v })}
|
||||
inferenceSteps={state.inferenceSteps}
|
||||
onInferenceStepsChange={(v) => dispatch({ type: "SET_INFERENCE_STEPS", payload: v })}
|
||||
onGenerate={handleGenerate}
|
||||
onStop={stop}
|
||||
onPauseStream={pauseStream}
|
||||
onResumeStream={resumeStream}
|
||||
isStreamPaused={isStreamPaused}
|
||||
isGenerating={state.isGenerating}
|
||||
genElapsed={state.genElapsed}
|
||||
genPct={state.genPct}
|
||||
wordCount={wordCount}
|
||||
serverStatus={state.serverStatus}
|
||||
downloadProgress={state.downloadProgress}
|
||||
/>
|
||||
<StatusLog messages={state.logs} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user