Commit ·
cd5198d
0
Parent(s):
Initial VibeVoice Studio commit
Browse files- .gitignore +29 -0
- Dockerfile +61 -0
- app.py +908 -0
- frontend/.gitignore +24 -0
- frontend/README.md +16 -0
- frontend/eslint.config.js +21 -0
- frontend/index.html +30 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +32 -0
- frontend/public/favicon.svg +1 -0
- frontend/public/icons.svg +24 -0
- frontend/src/App.css +1 -0
- frontend/src/App.jsx +2184 -0
- frontend/src/LandingPage.jsx +1148 -0
- frontend/src/assets/hero.png +0 -0
- frontend/src/assets/react.svg +1 -0
- frontend/src/assets/vite.svg +1 -0
- frontend/src/index.css +140 -0
- frontend/src/main.jsx +10 -0
- frontend/src/supabaseClient.js +6 -0
- frontend/vite.config.js +26 -0
- requirements.txt +10 -0
- run.bat +21 -0
- static/app.js +729 -0
- static/index.css +1067 -0
- static/index.html +252 -0
.gitignore
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Node packages
|
| 2 |
+
node_modules/
|
| 3 |
+
frontend/node_modules/
|
| 4 |
+
frontend/dist/
|
| 5 |
+
|
| 6 |
+
# Python caching
|
| 7 |
+
__pycache__/
|
| 8 |
+
*.py[cod]
|
| 9 |
+
*$py.class
|
| 10 |
+
.venv/
|
| 11 |
+
env/
|
| 12 |
+
venv/
|
| 13 |
+
ENV/
|
| 14 |
+
|
| 15 |
+
# OS files
|
| 16 |
+
.DS_Store
|
| 17 |
+
Thumbs.db
|
| 18 |
+
|
| 19 |
+
# Audio artifacts and temporary caches
|
| 20 |
+
static/temp/*
|
| 21 |
+
static/cloned_voices/*
|
| 22 |
+
!static/temp/.gitkeep
|
| 23 |
+
!static/cloned_voices/.gitkeep
|
| 24 |
+
combined_test.wav
|
| 25 |
+
|
| 26 |
+
# Local environment / API keys
|
| 27 |
+
.env
|
| 28 |
+
.gemini/
|
| 29 |
+
.git/
|
Dockerfile
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ==========================================
|
| 2 |
+
# Stage 1: Build the React + Vite Frontend
|
| 3 |
+
# ==========================================
|
| 4 |
+
FROM node:20-slim AS frontend-builder
|
| 5 |
+
WORKDIR /app/frontend
|
| 6 |
+
|
| 7 |
+
# Copy frontend source files
|
| 8 |
+
COPY frontend/package*.json ./
|
| 9 |
+
RUN npm ci
|
| 10 |
+
|
| 11 |
+
COPY frontend/ ./
|
| 12 |
+
RUN npm run build
|
| 13 |
+
|
| 14 |
+
# ==========================================
|
| 15 |
+
# Stage 2: Build the FastAPI + PyTorch Backend
|
| 16 |
+
# ==========================================
|
| 17 |
+
FROM python:3.10-slim
|
| 18 |
+
|
| 19 |
+
# Install system dependencies (libsndfile1 is required by soundfile, espeak for pyttsx3 Linux speech)
|
| 20 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 21 |
+
libsndfile1 \
|
| 22 |
+
espeak \
|
| 23 |
+
ffmpeg \
|
| 24 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 25 |
+
|
| 26 |
+
WORKDIR /app
|
| 27 |
+
|
| 28 |
+
# Create a non-root user with UID 1000 (Hugging Face Spaces requirement)
|
| 29 |
+
RUN useradd -m -u 1000 user
|
| 30 |
+
ENV HOME=/home/user \
|
| 31 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 32 |
+
PYTHONUNBUFFERED=1
|
| 33 |
+
|
| 34 |
+
# Pre-create writable directories for audio caches and temp audio files
|
| 35 |
+
RUN mkdir -p /app/static/temp && \
|
| 36 |
+
mkdir -p /home/user/.cache/huggingface && \
|
| 37 |
+
chown -R user:user /app /home/user
|
| 38 |
+
|
| 39 |
+
# Switch to the non-root user
|
| 40 |
+
USER user
|
| 41 |
+
|
| 42 |
+
# Install CPU-only PyTorch first to keep the container extremely lightweight (~1.5GB instead of 5GB)
|
| 43 |
+
RUN pip3 install --no-cache-dir --user torch torchaudio --index-url https://download.pytorch.org/whl/cpu
|
| 44 |
+
|
| 45 |
+
# Copy backend requirements and install dependencies
|
| 46 |
+
COPY --chown=user:user requirements.txt .
|
| 47 |
+
RUN pip3 install --no-cache-dir --user -r requirements.txt
|
| 48 |
+
|
| 49 |
+
# Copy FastAPI backend code and assets
|
| 50 |
+
COPY --chown=user:user app.py .
|
| 51 |
+
COPY --chown=user:user static/ static/
|
| 52 |
+
|
| 53 |
+
# Copy the built React static bundles from Stage 1
|
| 54 |
+
COPY --chown=user:user --from=frontend-builder /app/frontend/dist ./frontend/dist
|
| 55 |
+
|
| 56 |
+
# Expose Hugging Face Space port
|
| 57 |
+
EXPOSE 7860
|
| 58 |
+
ENV PORT=7860
|
| 59 |
+
|
| 60 |
+
# Start the FastAPI server dynamically binding to port 7860
|
| 61 |
+
CMD ["python3", "app.py"]
|
app.py
ADDED
|
@@ -0,0 +1,908 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import math
|
| 4 |
+
import time
|
| 5 |
+
import asyncio
|
| 6 |
+
import threading
|
| 7 |
+
import traceback
|
| 8 |
+
import numpy as np
|
| 9 |
+
import soundfile as sf
|
| 10 |
+
import re
|
| 11 |
+
import pyttsx3
|
| 12 |
+
try:
|
| 13 |
+
import pythoncom
|
| 14 |
+
except ImportError:
|
| 15 |
+
pythoncom = None
|
| 16 |
+
import urllib.request
|
| 17 |
+
import hashlib
|
| 18 |
+
from typing import List, Optional
|
| 19 |
+
|
| 20 |
+
import torch
|
| 21 |
+
|
| 22 |
+
def parse_script_dialogue(script_text: str):
|
| 23 |
+
"""
|
| 24 |
+
Parses script dialogue like:
|
| 25 |
+
Speaker 0: Hello there!
|
| 26 |
+
Speaker 1: Hi Sarah.
|
| 27 |
+
"""
|
| 28 |
+
lines = []
|
| 29 |
+
current_speaker = 0
|
| 30 |
+
|
| 31 |
+
pattern = re.compile(r'^(Speaker\s*(\d+)|([A-Za-z0-9_\s]+))\s*:\s*(.*)$', re.IGNORECASE)
|
| 32 |
+
|
| 33 |
+
for line in script_text.strip().split('\n'):
|
| 34 |
+
line = line.strip()
|
| 35 |
+
if not line:
|
| 36 |
+
continue
|
| 37 |
+
|
| 38 |
+
match = pattern.match(line)
|
| 39 |
+
if match:
|
| 40 |
+
if match.group(2) is not None:
|
| 41 |
+
speaker_id = int(match.group(2))
|
| 42 |
+
else:
|
| 43 |
+
name = match.group(3).strip().lower()
|
| 44 |
+
if 'sarah' in name or 'dialogue' in name or 'female' in name or 'partner' in name:
|
| 45 |
+
speaker_id = 1
|
| 46 |
+
else:
|
| 47 |
+
speaker_id = 0
|
| 48 |
+
text = match.group(4).strip()
|
| 49 |
+
lines.append((speaker_id, text))
|
| 50 |
+
current_speaker = speaker_id
|
| 51 |
+
else:
|
| 52 |
+
lines.append((current_speaker, line))
|
| 53 |
+
|
| 54 |
+
return lines
|
| 55 |
+
|
| 56 |
+
def sapi5_generate_wav(text: str, speaker_id: int, output_path: str):
|
| 57 |
+
"""
|
| 58 |
+
Generate high-fidelity speech WAV file cross-platform.
|
| 59 |
+
Uses Microsoft SAPI5 natively on Windows, and eSpeak fallback on Linux/macOS.
|
| 60 |
+
"""
|
| 61 |
+
use_com = (os.name == 'nt' and pythoncom is not None)
|
| 62 |
+
if use_com:
|
| 63 |
+
try:
|
| 64 |
+
pythoncom.CoInitialize()
|
| 65 |
+
except Exception as e:
|
| 66 |
+
print(f"[Speech-Engine] Warning: CoInitialize failed: {e}")
|
| 67 |
+
use_com = False
|
| 68 |
+
|
| 69 |
+
try:
|
| 70 |
+
engine = pyttsx3.init()
|
| 71 |
+
voices = engine.getProperty('voices')
|
| 72 |
+
if len(voices) > 0:
|
| 73 |
+
voice_index = speaker_id % len(voices)
|
| 74 |
+
engine.setProperty('voice', voices[voice_index].id)
|
| 75 |
+
|
| 76 |
+
engine.setProperty('rate', 155) # Natural speech speed
|
| 77 |
+
engine.save_to_file(text, output_path)
|
| 78 |
+
engine.runAndWait()
|
| 79 |
+
time.sleep(0.1) # Let the file output settle
|
| 80 |
+
except Exception as e:
|
| 81 |
+
print(f"[Speech-Engine] Warning: pyttsx3 generation failed ({e}). Generating robust silent fallback.")
|
| 82 |
+
# If pyttsx3 fails in a headless cloud container, write a safe silent WAV so the app keeps running
|
| 83 |
+
sr = 24000
|
| 84 |
+
duration = max(1.0, len(text.split()) * 0.35)
|
| 85 |
+
dummy = np.zeros(int(sr * duration))
|
| 86 |
+
sf.write(output_path, dummy, sr)
|
| 87 |
+
finally:
|
| 88 |
+
if use_com:
|
| 89 |
+
try:
|
| 90 |
+
pythoncom.CoUninitialize()
|
| 91 |
+
except:
|
| 92 |
+
pass
|
| 93 |
+
|
| 94 |
+
def estimate_pitch_autocorrelation(audio_data, sr):
|
| 95 |
+
"""
|
| 96 |
+
Estimate fundamental frequency F0 of mono audio data using a robust autocorrelation method.
|
| 97 |
+
Applies a low-pass filter to eliminate high-frequency noise/sibilants and restricts
|
| 98 |
+
the pitch search strictly to human conversational speech ranges (75Hz to 260Hz).
|
| 99 |
+
"""
|
| 100 |
+
max_samples = min(len(audio_data), sr * 5)
|
| 101 |
+
signal = audio_data[:max_samples]
|
| 102 |
+
|
| 103 |
+
# 1. Apply a rolling average low-pass filter to smooth out high-frequency noise
|
| 104 |
+
# (Window of 5 samples acts as a fast, zero-dependency low-pass filter at 24kHz)
|
| 105 |
+
if len(signal) > 5:
|
| 106 |
+
signal = np.convolve(signal, np.ones(5)/5.0, mode='same')
|
| 107 |
+
|
| 108 |
+
signal = signal - np.mean(signal)
|
| 109 |
+
|
| 110 |
+
# 2. Restrict human speech F0 lag parameters:
|
| 111 |
+
# Deep male base (75Hz) to high female base (260Hz)
|
| 112 |
+
min_lag = int(sr / 260)
|
| 113 |
+
max_lag = int(sr / 75)
|
| 114 |
+
|
| 115 |
+
corr = np.correlate(signal, signal, mode='full')
|
| 116 |
+
corr = corr[len(corr)//2:]
|
| 117 |
+
|
| 118 |
+
if len(corr) > max_lag:
|
| 119 |
+
# Find peak lag within conversational bounds
|
| 120 |
+
peak_lag = np.argmax(corr[min_lag:max_lag]) + min_lag
|
| 121 |
+
f0 = sr / peak_lag
|
| 122 |
+
|
| 123 |
+
# Guard against zero lag or out of bound values
|
| 124 |
+
if 70.0 <= f0 <= 280.0:
|
| 125 |
+
return f0
|
| 126 |
+
|
| 127 |
+
return 150.0 # Safe conversational default pitch
|
| 128 |
+
|
| 129 |
+
def phase_vocoder_pitch_shift(audio, sr, shift_factor):
|
| 130 |
+
"""
|
| 131 |
+
Shifts the pitch of an audio array by shift_factor using a phase vocoder
|
| 132 |
+
to keep duration/speed completely unchanged and avoid chipmunk artifacts.
|
| 133 |
+
"""
|
| 134 |
+
if abs(shift_factor - 1.0) < 0.05:
|
| 135 |
+
return audio
|
| 136 |
+
|
| 137 |
+
n_fft = 1024
|
| 138 |
+
hop_length = 256
|
| 139 |
+
pad_len = n_fft
|
| 140 |
+
padded_audio = np.pad(audio, pad_len, mode='reflect')
|
| 141 |
+
window = np.hanning(n_fft)
|
| 142 |
+
|
| 143 |
+
frames = []
|
| 144 |
+
for i in range(0, len(audio) + pad_len, hop_length):
|
| 145 |
+
frame = padded_audio[i : i + n_fft]
|
| 146 |
+
if len(frame) < n_fft:
|
| 147 |
+
frame = np.pad(frame, (0, n_fft - len(frame)), mode='constant')
|
| 148 |
+
frames.append(frame * window)
|
| 149 |
+
|
| 150 |
+
frames = np.array(frames)
|
| 151 |
+
stft = np.fft.rfft(frames, axis=-1)
|
| 152 |
+
|
| 153 |
+
num_frames = len(stft)
|
| 154 |
+
new_num_frames = int(num_frames / shift_factor)
|
| 155 |
+
|
| 156 |
+
time_steps = np.linspace(0, num_frames - 1, new_num_frames)
|
| 157 |
+
new_stft = np.zeros((new_num_frames, stft.shape[1]), dtype=np.complex64)
|
| 158 |
+
|
| 159 |
+
phase_acc = np.angle(stft[0])
|
| 160 |
+
new_stft[0] = stft[0]
|
| 161 |
+
omega = 2 * np.pi * hop_length * np.arange(stft.shape[1]) / n_fft
|
| 162 |
+
|
| 163 |
+
for i in range(1, new_num_frames):
|
| 164 |
+
t = time_steps[i]
|
| 165 |
+
t_floor = int(np.floor(t))
|
| 166 |
+
t_ceil = min(t_floor + 1, num_frames - 1)
|
| 167 |
+
alpha = t - t_floor
|
| 168 |
+
|
| 169 |
+
mag = (1 - alpha) * np.abs(stft[t_floor]) + alpha * np.abs(stft[t_ceil])
|
| 170 |
+
phase_diff = np.angle(stft[t_ceil]) - np.angle(stft[t_floor])
|
| 171 |
+
phase_diff_corrected = phase_diff - omega
|
| 172 |
+
phase_diff_corrected = np.mod(phase_diff_corrected + np.pi, 2 * np.pi) - np.pi
|
| 173 |
+
phase_acc += omega + phase_diff_corrected
|
| 174 |
+
new_stft[i] = mag * np.exp(1j * phase_acc)
|
| 175 |
+
|
| 176 |
+
stretched_len = (new_num_frames - 1) * hop_length + n_fft
|
| 177 |
+
stretched_audio = np.zeros(stretched_len)
|
| 178 |
+
window_sum = np.zeros(stretched_len)
|
| 179 |
+
|
| 180 |
+
for i in range(new_num_frames):
|
| 181 |
+
frame = np.fft.irfft(new_stft[i])
|
| 182 |
+
idx = i * hop_length
|
| 183 |
+
if idx + n_fft <= stretched_len:
|
| 184 |
+
stretched_audio[idx : idx + n_fft] += frame * window
|
| 185 |
+
window_sum[idx : idx + n_fft] += window ** 2
|
| 186 |
+
|
| 187 |
+
window_sum[window_sum < 1e-4] = 1.0
|
| 188 |
+
stretched_audio /= window_sum
|
| 189 |
+
|
| 190 |
+
trim_start = n_fft // 2
|
| 191 |
+
trim_end = len(stretched_audio) - (n_fft // 2)
|
| 192 |
+
if trim_start < trim_end:
|
| 193 |
+
stretched_audio = stretched_audio[trim_start : trim_end]
|
| 194 |
+
|
| 195 |
+
target_len = len(audio)
|
| 196 |
+
resampled_audio = np.interp(
|
| 197 |
+
np.linspace(0, len(stretched_audio) - 1, target_len),
|
| 198 |
+
np.arange(len(stretched_audio)),
|
| 199 |
+
stretched_audio
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
return resampled_audio
|
| 203 |
+
|
| 204 |
+
def clone_voice_dsp(sapi5_wav_path: str, user_voice_path: str, output_path: str):
|
| 205 |
+
"""
|
| 206 |
+
Analyzes the user's uploaded voice profile, extracts its characteristic pitch (F0),
|
| 207 |
+
and modulates/pitch-shifts the high-fidelity SAPI5 speech audio to clone the user's voice pitch.
|
| 208 |
+
"""
|
| 209 |
+
try:
|
| 210 |
+
if not os.path.exists(user_voice_path) or os.path.getsize(user_voice_path) < 100:
|
| 211 |
+
return False
|
| 212 |
+
|
| 213 |
+
user_audio, user_sr = sf.read(user_voice_path)
|
| 214 |
+
if len(user_audio.shape) > 1:
|
| 215 |
+
user_audio = user_audio.mean(axis=1)
|
| 216 |
+
user_f0 = estimate_pitch_autocorrelation(user_audio, user_sr)
|
| 217 |
+
|
| 218 |
+
sapi5_audio, sapi5_sr = sf.read(sapi5_wav_path)
|
| 219 |
+
if len(sapi5_audio.shape) > 1:
|
| 220 |
+
sapi5_audio = sapi5_audio.mean(axis=1)
|
| 221 |
+
sapi5_f0 = estimate_pitch_autocorrelation(sapi5_audio, sapi5_sr)
|
| 222 |
+
|
| 223 |
+
print(f"[VoiceCloning-DSP] User pitch F0: {user_f0:.1f}Hz | SAPI5 pitch F0: {sapi5_f0:.1f}Hz")
|
| 224 |
+
|
| 225 |
+
# Calculate optimal pitch shift
|
| 226 |
+
shift_factor = user_f0 / sapi5_f0
|
| 227 |
+
|
| 228 |
+
# Cap the shift factor tightly to human conversational bounds
|
| 229 |
+
# (This prevents squeaky robotic cat formant warping!)
|
| 230 |
+
shift_factor = max(0.80, min(shift_factor, 1.35))
|
| 231 |
+
|
| 232 |
+
print(f"[VoiceCloning-DSP] Modulating synthesis pitch by shift factor: {shift_factor:.3f}")
|
| 233 |
+
cloned_audio = phase_vocoder_pitch_shift(sapi5_audio, sapi5_sr, shift_factor)
|
| 234 |
+
|
| 235 |
+
max_val = np.max(np.abs(cloned_audio))
|
| 236 |
+
if max_val > 0:
|
| 237 |
+
cloned_audio = cloned_audio / max_val * 0.8
|
| 238 |
+
|
| 239 |
+
sf.write(output_path, cloned_audio, sapi5_sr)
|
| 240 |
+
return True
|
| 241 |
+
except Exception as ex:
|
| 242 |
+
print(f"[VoiceCloning-DSP] Error during cloning: {ex}")
|
| 243 |
+
traceback.print_exc()
|
| 244 |
+
return False
|
| 245 |
+
|
| 246 |
+
def download_remote_voice(url: str) -> str:
|
| 247 |
+
"""Helper to download Supabase/HTTP voice urls to a temp local file."""
|
| 248 |
+
if not url or not url.startswith("http"):
|
| 249 |
+
return url
|
| 250 |
+
|
| 251 |
+
url_hash = hashlib.md5(url.encode()).hexdigest()
|
| 252 |
+
local_path = os.path.join("static/temp", f"dl_{url_hash}.wav")
|
| 253 |
+
|
| 254 |
+
if os.path.exists(local_path):
|
| 255 |
+
return local_path
|
| 256 |
+
|
| 257 |
+
try:
|
| 258 |
+
print(f"[VibeVoice] Downloading remote voice profile from {url}...")
|
| 259 |
+
urllib.request.urlretrieve(url, local_path)
|
| 260 |
+
return local_path
|
| 261 |
+
except Exception as e:
|
| 262 |
+
print(f"[VibeVoice] Error downloading remote voice: {e}")
|
| 263 |
+
return url
|
| 264 |
+
|
| 265 |
+
def get_speaker_voices_list(text_script: str, voice_sample_path: Optional[str], speaker_voices: dict):
|
| 266 |
+
"""
|
| 267 |
+
Parses speaker IDs from the text script and maps each speaker index to its respective downloaded voice sample path.
|
| 268 |
+
Falls back to the primary voice sample if no speaker-specific profile is uploaded.
|
| 269 |
+
"""
|
| 270 |
+
dialogue_lines = parse_script_dialogue(text_script)
|
| 271 |
+
present_speakers = list(set(spk_id for spk_id, _ in dialogue_lines))
|
| 272 |
+
if not present_speakers:
|
| 273 |
+
present_speakers = [0]
|
| 274 |
+
max_spk = max(present_speakers)
|
| 275 |
+
|
| 276 |
+
voice_samples_list = []
|
| 277 |
+
for spk_idx in range(max_spk + 1):
|
| 278 |
+
path = speaker_voices.get(str(spk_idx)) or speaker_voices.get(spk_idx)
|
| 279 |
+
if not path or not os.path.exists(path):
|
| 280 |
+
path = voice_sample_path
|
| 281 |
+
if not path or not os.path.exists(path):
|
| 282 |
+
# Safe boundary fallback using existing audio
|
| 283 |
+
path = "combined_test.wav"
|
| 284 |
+
voice_samples_list.append(path)
|
| 285 |
+
return voice_samples_list
|
| 286 |
+
|
| 287 |
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, Form, HTTPException
|
| 288 |
+
from fastapi.responses import FileResponse, JSONResponse, HTMLResponse
|
| 289 |
+
from fastapi.staticfiles import StaticFiles
|
| 290 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 291 |
+
from pydantic import BaseModel
|
| 292 |
+
|
| 293 |
+
# Initialize FastAPI App
|
| 294 |
+
app = FastAPI(
|
| 295 |
+
title="VibeVoice Agent Platform",
|
| 296 |
+
description="Full-stack real-time voice synthesis and agent cloning interface utilizing Microsoft VibeVoice.",
|
| 297 |
+
version="1.0.0"
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
# Enable CORS for direct local frontend connections
|
| 301 |
+
app.add_middleware(
|
| 302 |
+
CORSMiddleware,
|
| 303 |
+
allow_origins=["*"],
|
| 304 |
+
allow_credentials=True,
|
| 305 |
+
allow_methods=["*"],
|
| 306 |
+
allow_headers=["*"],
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
# Global Configuration
|
| 311 |
+
USE_REAL_MODEL = True # Set to True to load actual VibeVoice weights from Hugging Face
|
| 312 |
+
MODEL_ID = "microsoft/VibeVoice-Realtime-0.5B"
|
| 313 |
+
SAMPLING_RATE = 24000 # VibeVoice standard sampling rate
|
| 314 |
+
|
| 315 |
+
# Global references for the real VibeVoice model
|
| 316 |
+
processor = None
|
| 317 |
+
model = None
|
| 318 |
+
model_loading = False
|
| 319 |
+
model_error = None
|
| 320 |
+
|
| 321 |
+
def get_model():
|
| 322 |
+
"""
|
| 323 |
+
Load the real VibeVoice models in a thread-safe way.
|
| 324 |
+
Uses CPU optimization and float32 since CUDA is not available.
|
| 325 |
+
"""
|
| 326 |
+
global processor, model, model_loading, model_error
|
| 327 |
+
if USE_REAL_MODEL and model is None and not model_loading:
|
| 328 |
+
model_loading = True
|
| 329 |
+
try:
|
| 330 |
+
# Set optimal thread count to prevent resource contention and logical core thrashing
|
| 331 |
+
torch.set_num_threads(4)
|
| 332 |
+
|
| 333 |
+
print(f"[VibeVoice] Loading model '{MODEL_ID}' on CPU... This may take a while on first run.", flush=True)
|
| 334 |
+
from vibevoice.processor.vibevoice_processor import VibeVoiceProcessor
|
| 335 |
+
from vibevoice.modular.modeling_vibevoice_inference import VibeVoiceForConditionalGenerationInference
|
| 336 |
+
|
| 337 |
+
processor = VibeVoiceProcessor.from_pretrained(MODEL_ID)
|
| 338 |
+
model = VibeVoiceForConditionalGenerationInference.from_pretrained(
|
| 339 |
+
MODEL_ID,
|
| 340 |
+
torch_dtype=torch.float32,
|
| 341 |
+
device_map="cpu",
|
| 342 |
+
attn_implementation="eager"
|
| 343 |
+
)
|
| 344 |
+
print("[VibeVoice] Model loaded successfully!", flush=True)
|
| 345 |
+
|
| 346 |
+
# Initialize speech scaling and bias buffers if they are NaN
|
| 347 |
+
if torch.isnan(model.model.speech_scaling_factor) or torch.isnan(model.model.speech_bias_factor):
|
| 348 |
+
model.model.speech_scaling_factor.copy_(torch.tensor(1.0))
|
| 349 |
+
model.model.speech_bias_factor.copy_(torch.tensor(0.0))
|
| 350 |
+
print("[VibeVoice] Initialized scaling factor buffers to default 1.0 and 0.0.", flush=True)
|
| 351 |
+
|
| 352 |
+
model_loading = False
|
| 353 |
+
model_error = None
|
| 354 |
+
except Exception as e:
|
| 355 |
+
model_error = str(e)
|
| 356 |
+
model_loading = False
|
| 357 |
+
print(f"[VibeVoice] ERROR loading model: {e}", file=sys.stderr, flush=True)
|
| 358 |
+
traceback.print_exc()
|
| 359 |
+
return processor, model
|
| 360 |
+
|
| 361 |
+
# Create static directory and temp outputs folder if they don't exist
|
| 362 |
+
os.makedirs("static", exist_ok=True)
|
| 363 |
+
os.makedirs("static/temp", exist_ok=True)
|
| 364 |
+
os.makedirs("static/cloned_voices", exist_ok=True)
|
| 365 |
+
|
| 366 |
+
class GenerateRequest(BaseModel):
|
| 367 |
+
text: str
|
| 368 |
+
voice_sample_path: Optional[str] = None
|
| 369 |
+
speaker_id: Optional[int] = 0
|
| 370 |
+
speaker_voices: Optional[dict] = None
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
@app.get("/api/status")
|
| 374 |
+
async def get_status():
|
| 375 |
+
"""Returns the current state of the VibeVoice model loading."""
|
| 376 |
+
global model, model_loading, model_error
|
| 377 |
+
|
| 378 |
+
# Trigger load in background if setting is active
|
| 379 |
+
if USE_REAL_MODEL and model is None and not model_loading:
|
| 380 |
+
threading.Thread(target=get_model, daemon=True).start()
|
| 381 |
+
|
| 382 |
+
return {
|
| 383 |
+
"use_real_model": USE_REAL_MODEL,
|
| 384 |
+
"model_id": MODEL_ID,
|
| 385 |
+
"loaded": model is not None,
|
| 386 |
+
"loading": model_loading,
|
| 387 |
+
"error": model_error,
|
| 388 |
+
"device": "CPU"
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
@app.post("/api/toggle-model")
|
| 392 |
+
async def toggle_model(enable: bool = True):
|
| 393 |
+
"""Force enable real AI model execution as requested by the user."""
|
| 394 |
+
global USE_REAL_MODEL
|
| 395 |
+
USE_REAL_MODEL = True
|
| 396 |
+
threading.Thread(target=get_model, daemon=True).start()
|
| 397 |
+
return {
|
| 398 |
+
"status": "success",
|
| 399 |
+
"use_real_model": True,
|
| 400 |
+
"message": "Real VibeVoice model mode is locked to ENABLED."
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
@app.post("/api/upload-voice")
|
| 404 |
+
async def upload_voice(file: UploadFile = File(...), speaker_name: str = Form("Cloned Voice")):
|
| 405 |
+
"""Uploads a voice reference file (WAV/MP3) for zero-shot speaker cloning."""
|
| 406 |
+
try:
|
| 407 |
+
filename = f"cloned_{int(time.time())}_{file.filename}"
|
| 408 |
+
save_path = os.path.join("static/cloned_voices", filename)
|
| 409 |
+
|
| 410 |
+
with open(save_path, "wb") as buffer:
|
| 411 |
+
content = await file.read()
|
| 412 |
+
buffer.write(content)
|
| 413 |
+
|
| 414 |
+
return {
|
| 415 |
+
"status": "success",
|
| 416 |
+
"voice_path": save_path,
|
| 417 |
+
"filename": filename,
|
| 418 |
+
"speaker_name": speaker_name,
|
| 419 |
+
"message": "Voice profile uploaded and prepared successfully!"
|
| 420 |
+
}
|
| 421 |
+
except Exception as e:
|
| 422 |
+
raise HTTPException(status_code=500, detail=f"Failed to upload voice: {str(e)}")
|
| 423 |
+
|
| 424 |
+
@app.delete("/api/delete-voice/{filename}")
|
| 425 |
+
async def delete_voice(filename: str):
|
| 426 |
+
"""Deletes a voice reference file from local server storage."""
|
| 427 |
+
try:
|
| 428 |
+
# Prevent directory traversal attacks
|
| 429 |
+
safe_name = os.path.basename(filename)
|
| 430 |
+
save_path = os.path.join("static/cloned_voices", safe_name)
|
| 431 |
+
if os.path.exists(save_path):
|
| 432 |
+
os.remove(save_path)
|
| 433 |
+
return {
|
| 434 |
+
"status": "success",
|
| 435 |
+
"message": f"Successfully deleted voice profile file '{safe_name}'"
|
| 436 |
+
}
|
| 437 |
+
return {
|
| 438 |
+
"status": "error",
|
| 439 |
+
"message": f"File '{safe_name}' not found."
|
| 440 |
+
}
|
| 441 |
+
except Exception as e:
|
| 442 |
+
raise HTTPException(status_code=500, detail=f"Failed to delete voice: {str(e)}")
|
| 443 |
+
|
| 444 |
+
@app.post("/api/generate")
|
| 445 |
+
async def generate_speech(req: GenerateRequest):
|
| 446 |
+
"""
|
| 447 |
+
Generates a full audio script using VibeVoice.
|
| 448 |
+
Supports multi-speaker formatting (Speaker 0: Text, Speaker 1: Text).
|
| 449 |
+
"""
|
| 450 |
+
try:
|
| 451 |
+
# Standardize script format
|
| 452 |
+
text_script = req.text
|
| 453 |
+
if ":" not in text_script and "Speaker" not in text_script:
|
| 454 |
+
text_script = f"Speaker {req.speaker_id}: {text_script}"
|
| 455 |
+
|
| 456 |
+
# Resolve remote URLs to local paths if needed
|
| 457 |
+
voice_sample_path = download_remote_voice(req.voice_sample_path)
|
| 458 |
+
speaker_id = req.speaker_id
|
| 459 |
+
|
| 460 |
+
speaker_voices = req.speaker_voices or {}
|
| 461 |
+
for k, v in speaker_voices.items():
|
| 462 |
+
speaker_voices[k] = download_remote_voice(v)
|
| 463 |
+
|
| 464 |
+
output_filename = f"output_{int(time.time())}.wav"
|
| 465 |
+
output_path = os.path.join("static/temp", output_filename)
|
| 466 |
+
|
| 467 |
+
# Real AI Model Inference Path
|
| 468 |
+
if USE_REAL_MODEL:
|
| 469 |
+
proc, loaded_model = get_model()
|
| 470 |
+
if loaded_model is None:
|
| 471 |
+
raise HTTPException(
|
| 472 |
+
status_code=503,
|
| 473 |
+
detail="VibeVoice AI Model is currently loading or failed to load. Please try again or switch to Demo Mode."
|
| 474 |
+
)
|
| 475 |
+
|
| 476 |
+
# Load reference voices for all active speakers in order
|
| 477 |
+
voice_samples = get_speaker_voices_list(text_script, voice_sample_path, speaker_voices)
|
| 478 |
+
|
| 479 |
+
inputs = proc(text=text_script, voice_samples=voice_samples, return_tensors="pt")
|
| 480 |
+
|
| 481 |
+
print(f"[VibeVoice] Generating speech script: {text_script}")
|
| 482 |
+
with torch.no_grad():
|
| 483 |
+
outputs = loaded_model.generate(
|
| 484 |
+
**inputs,
|
| 485 |
+
tokenizer=proc.tokenizer,
|
| 486 |
+
max_new_tokens=1000,
|
| 487 |
+
do_sample=True,
|
| 488 |
+
cfg_scale=1.0
|
| 489 |
+
)
|
| 490 |
+
|
| 491 |
+
# Save generated audio
|
| 492 |
+
if outputs.speech_outputs and outputs.speech_outputs[0] is not None:
|
| 493 |
+
audio_tensor = outputs.speech_outputs[0]
|
| 494 |
+
proc.save_audio(audio_tensor, output_path=output_path, sampling_rate=SAMPLING_RATE)
|
| 495 |
+
else:
|
| 496 |
+
raise Exception("Generation succeeded but no audio output was generated.")
|
| 497 |
+
|
| 498 |
+
# High-Fidelity Demo/Simulated Mode Path (Runs instantly on CPU without GPU)
|
| 499 |
+
else:
|
| 500 |
+
print(f"[Demo] Compiling high-fidelity speech script: {text_script}")
|
| 501 |
+
try:
|
| 502 |
+
# 1. Parse dialogue into lines and speaker ids
|
| 503 |
+
dialogue_lines = parse_script_dialogue(text_script)
|
| 504 |
+
|
| 505 |
+
temp_files = []
|
| 506 |
+
concatenated_audio = []
|
| 507 |
+
last_sr = SAMPLING_RATE
|
| 508 |
+
|
| 509 |
+
# 2. Synthesize each line using local SAPI5 voices
|
| 510 |
+
for i, (spk_id, line_text) in enumerate(dialogue_lines):
|
| 511 |
+
# Check if custom voice profile path exists for this speaker ID
|
| 512 |
+
custom_voice = None
|
| 513 |
+
if speaker_voices:
|
| 514 |
+
custom_voice = speaker_voices.get(str(spk_id)) or speaker_voices.get(spk_id)
|
| 515 |
+
if not custom_voice and spk_id == 0:
|
| 516 |
+
custom_voice = voice_sample_path
|
| 517 |
+
|
| 518 |
+
temp_file = os.path.join("static/temp", f"temp_dialog_part_{int(time.time())}_{i}.wav")
|
| 519 |
+
sapi5_generate_wav(line_text, spk_id, temp_file)
|
| 520 |
+
|
| 521 |
+
# Apply DSP Voice Cloning if a custom voice sample is present
|
| 522 |
+
if custom_voice and os.path.exists(custom_voice) and os.path.getsize(custom_voice) > 100:
|
| 523 |
+
print(f"[Demo] DSP-Cloning Speaker {spk_id} using custom voice profile: {custom_voice}")
|
| 524 |
+
cloned_file = os.path.join("static/temp", f"cloned_part_{int(time.time())}_{i}.wav")
|
| 525 |
+
success = clone_voice_dsp(temp_file, custom_voice, cloned_file)
|
| 526 |
+
if success and os.path.exists(cloned_file):
|
| 527 |
+
try: os.remove(temp_file)
|
| 528 |
+
except: pass
|
| 529 |
+
temp_file = cloned_file
|
| 530 |
+
|
| 531 |
+
temp_files.append(temp_file)
|
| 532 |
+
|
| 533 |
+
if os.path.exists(temp_file) and os.path.getsize(temp_file) > 44:
|
| 534 |
+
audio_data, sr = sf.read(temp_file)
|
| 535 |
+
last_sr = sr
|
| 536 |
+
# Convert stereo to mono
|
| 537 |
+
if len(audio_data.shape) > 1:
|
| 538 |
+
audio_data = audio_data.mean(axis=1)
|
| 539 |
+
|
| 540 |
+
concatenated_audio.append(audio_data)
|
| 541 |
+
|
| 542 |
+
# Add natural breathing pause (0.3 seconds) between speakers
|
| 543 |
+
pause_samples = int(sr * 0.3)
|
| 544 |
+
concatenated_audio.append(np.zeros(pause_samples))
|
| 545 |
+
|
| 546 |
+
if len(concatenated_audio) > 0:
|
| 547 |
+
# Concatenate dialogue, excluding the trailing pause
|
| 548 |
+
final_audio = np.concatenate(concatenated_audio[:-1])
|
| 549 |
+
|
| 550 |
+
# Normalize audio amplitude safely
|
| 551 |
+
max_val = np.max(np.abs(final_audio))
|
| 552 |
+
if max_val > 0:
|
| 553 |
+
final_audio = final_audio / max_val * 0.8
|
| 554 |
+
|
| 555 |
+
# Save compiled high-fidelity speech script WAV file
|
| 556 |
+
sf.write(output_path, final_audio, last_sr)
|
| 557 |
+
|
| 558 |
+
# Clean up temporary parts
|
| 559 |
+
for temp_f in temp_files:
|
| 560 |
+
try:
|
| 561 |
+
os.remove(temp_f)
|
| 562 |
+
except:
|
| 563 |
+
pass
|
| 564 |
+
else:
|
| 565 |
+
raise Exception("SAPI5 compiled audio empty or missing parts")
|
| 566 |
+
|
| 567 |
+
except Exception as e:
|
| 568 |
+
print(f"[Demo] Fallback to simple synthesizer wave due to: {e}")
|
| 569 |
+
traceback.print_exc()
|
| 570 |
+
|
| 571 |
+
# Clean up any temp files created
|
| 572 |
+
for temp_f in temp_files:
|
| 573 |
+
try: os.remove(temp_f)
|
| 574 |
+
except: pass
|
| 575 |
+
|
| 576 |
+
# Generate simple visual waveform sweep as fallback
|
| 577 |
+
words = text_script.split()
|
| 578 |
+
duration = len(words) * 0.45
|
| 579 |
+
t = np.linspace(0, duration, int(SAMPLING_RATE * duration), endpoint=False)
|
| 580 |
+
carrier = np.sin(2 * np.pi * 120 * t)
|
| 581 |
+
if "Speaker 1" in text_script or req.speaker_id == 1:
|
| 582 |
+
carrier = np.sin(2 * np.pi * 220 * t)
|
| 583 |
+
harmonic1 = 0.5 * np.sin(2 * np.pi * 240 * t)
|
| 584 |
+
harmonic2 = 0.25 * np.sin(2 * np.pi * 360 * t)
|
| 585 |
+
|
| 586 |
+
envelope = np.zeros_like(t)
|
| 587 |
+
word_samples = len(t) // len(words)
|
| 588 |
+
for i in range(len(words)):
|
| 589 |
+
start = i * word_samples
|
| 590 |
+
end = min((i + 1) * word_samples, len(t))
|
| 591 |
+
w_t = np.linspace(0, np.pi, end - start)
|
| 592 |
+
envelope[start:end] = np.sin(w_t) ** 2
|
| 593 |
+
|
| 594 |
+
raw_audio = (carrier + harmonic1 + harmonic2) * envelope
|
| 595 |
+
raw_audio = raw_audio / np.max(np.abs(raw_audio)) * 0.8
|
| 596 |
+
sf.write(output_path, raw_audio, SAMPLING_RATE)
|
| 597 |
+
|
| 598 |
+
return {
|
| 599 |
+
"status": "success",
|
| 600 |
+
"audio_url": f"/static/temp/{output_filename}",
|
| 601 |
+
"text": req.text,
|
| 602 |
+
"mode": "AI Model" if USE_REAL_MODEL else "Demo Synthesizer"
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
except Exception as e:
|
| 606 |
+
traceback.print_exc()
|
| 607 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 608 |
+
|
| 609 |
+
@app.websocket("/api/stream")
|
| 610 |
+
async def websocket_endpoint(websocket: WebSocket):
|
| 611 |
+
"""
|
| 612 |
+
WebSocket endpoint for real-time, low-latency streaming text-to-speech.
|
| 613 |
+
Receives text streams from client, generates audio chunks, and streams them back instantly.
|
| 614 |
+
"""
|
| 615 |
+
await websocket.accept()
|
| 616 |
+
print("[WebSocket] Connected to streaming audio client.")
|
| 617 |
+
|
| 618 |
+
try:
|
| 619 |
+
while True:
|
| 620 |
+
# Receive JSON instruction from client
|
| 621 |
+
data = await websocket.receive_json()
|
| 622 |
+
text = data.get("text", "")
|
| 623 |
+
|
| 624 |
+
# Resolve remote URLs to local paths if needed
|
| 625 |
+
voice_sample_path = download_remote_voice(data.get("voice_sample_path", None))
|
| 626 |
+
speaker_id = data.get("speaker_id", 0)
|
| 627 |
+
|
| 628 |
+
speaker_voices = data.get("speaker_voices", {})
|
| 629 |
+
for k, v in speaker_voices.items():
|
| 630 |
+
speaker_voices[k] = download_remote_voice(v)
|
| 631 |
+
data["speaker_voices"] = speaker_voices
|
| 632 |
+
|
| 633 |
+
if not text:
|
| 634 |
+
continue
|
| 635 |
+
|
| 636 |
+
print(f"[WebSocket] Streaming request: '{text}' (Speaker: {speaker_id})")
|
| 637 |
+
|
| 638 |
+
# Real AI Model Streaming Path
|
| 639 |
+
if USE_REAL_MODEL:
|
| 640 |
+
proc, loaded_model = get_model()
|
| 641 |
+
if loaded_model is None:
|
| 642 |
+
await websocket.send_json({
|
| 643 |
+
"type": "error",
|
| 644 |
+
"message": "AI Model is loading. Streaming unavailable."
|
| 645 |
+
})
|
| 646 |
+
continue
|
| 647 |
+
|
| 648 |
+
# Setup streamer
|
| 649 |
+
from vibevoice.modular.streamer import AsyncAudioStreamer
|
| 650 |
+
streamer = AsyncAudioStreamer(batch_size=1)
|
| 651 |
+
|
| 652 |
+
# Format prompt
|
| 653 |
+
text_prompt = text
|
| 654 |
+
if ":" not in text_prompt:
|
| 655 |
+
text_prompt = f"Speaker {speaker_id}: {text_prompt}"
|
| 656 |
+
|
| 657 |
+
# Load reference voices for all active speakers in order
|
| 658 |
+
voice_samples = get_speaker_voices_list(text_prompt, voice_sample_path, speaker_voices)
|
| 659 |
+
|
| 660 |
+
inputs = proc(text=text_prompt, voice_samples=voice_samples, return_tensors="pt")
|
| 661 |
+
|
| 662 |
+
# Run generation in a separate background thread
|
| 663 |
+
def run_inference():
|
| 664 |
+
try:
|
| 665 |
+
with torch.no_grad():
|
| 666 |
+
loaded_model.generate(
|
| 667 |
+
**inputs,
|
| 668 |
+
tokenizer=proc.tokenizer,
|
| 669 |
+
audio_streamer=streamer,
|
| 670 |
+
max_new_tokens=400,
|
| 671 |
+
do_sample=True,
|
| 672 |
+
show_progress_bar=False
|
| 673 |
+
)
|
| 674 |
+
except Exception as ex:
|
| 675 |
+
print(f"[VibeVoice] Streaming generation exception: {ex}")
|
| 676 |
+
finally:
|
| 677 |
+
streamer.end()
|
| 678 |
+
|
| 679 |
+
threading.Thread(target=run_inference, daemon=True).start()
|
| 680 |
+
|
| 681 |
+
# Fetch audio chunks asynchronously from queue and stream to client
|
| 682 |
+
chunk_index = 0
|
| 683 |
+
async for audio_tensor in streamer.get_stream(0):
|
| 684 |
+
audio_numpy = audio_tensor.numpy()
|
| 685 |
+
|
| 686 |
+
# Convert Float32 audio values to 16-bit PCM integer bytes
|
| 687 |
+
audio_pcm = (audio_numpy * 32767.0).astype(np.int16)
|
| 688 |
+
|
| 689 |
+
# Send raw audio binary buffer
|
| 690 |
+
await websocket.send_bytes(audio_pcm.tobytes())
|
| 691 |
+
chunk_index += 1
|
| 692 |
+
|
| 693 |
+
# Send stream boundary signal
|
| 694 |
+
await websocket.send_json({"type": "done"})
|
| 695 |
+
|
| 696 |
+
# High-Fidelity Demo/Simulated Streaming Path
|
| 697 |
+
else:
|
| 698 |
+
print(f"[WebSocket-Demo] Synthesizing real-time high-fidelity streaming speech for script: {text}")
|
| 699 |
+
try:
|
| 700 |
+
# 1. Parse dialogue lines
|
| 701 |
+
dialogue_lines = parse_script_dialogue(text)
|
| 702 |
+
|
| 703 |
+
# 2. Loop through each line and stream it
|
| 704 |
+
for spk_id, line_text in dialogue_lines:
|
| 705 |
+
custom_voice = None
|
| 706 |
+
if speaker_voices:
|
| 707 |
+
custom_voice = speaker_voices.get(str(spk_id)) or speaker_voices.get(spk_id)
|
| 708 |
+
if not custom_voice and spk_id == 0:
|
| 709 |
+
custom_voice = voice_sample_path
|
| 710 |
+
|
| 711 |
+
temp_wav = os.path.join("static/temp", f"stream_temp_{int(time.time())}_{spk_id}.wav")
|
| 712 |
+
sapi5_generate_wav(line_text, spk_id, temp_wav)
|
| 713 |
+
|
| 714 |
+
# Apply voice cloning DSP if custom voice profile is present!
|
| 715 |
+
if custom_voice and os.path.exists(custom_voice) and os.path.getsize(custom_voice) > 100:
|
| 716 |
+
print(f"[WebSocket-Demo] DSP-Cloning Speaker {spk_id} using custom voice profile: {custom_voice}")
|
| 717 |
+
cloned_wav = os.path.join("static/temp", f"cloned_stream_{int(time.time())}_{spk_id}.wav")
|
| 718 |
+
success = clone_voice_dsp(temp_wav, custom_voice, cloned_wav)
|
| 719 |
+
if success and os.path.exists(cloned_wav):
|
| 720 |
+
try: os.remove(temp_wav)
|
| 721 |
+
except: pass
|
| 722 |
+
temp_wav = cloned_wav
|
| 723 |
+
|
| 724 |
+
if os.path.exists(temp_wav) and os.path.getsize(temp_wav) > 44:
|
| 725 |
+
audio_data, sr = sf.read(temp_wav)
|
| 726 |
+
|
| 727 |
+
# Convert stereo to mono
|
| 728 |
+
if len(audio_data.shape) > 1:
|
| 729 |
+
audio_data = audio_data.mean(axis=1)
|
| 730 |
+
|
| 731 |
+
# Resample to the standard SAMPLING_RATE (24000Hz) if needed
|
| 732 |
+
if sr != SAMPLING_RATE:
|
| 733 |
+
num_target = int(len(audio_data) * SAMPLING_RATE / sr)
|
| 734 |
+
audio_data = np.interp(
|
| 735 |
+
np.linspace(0, len(audio_data), num_target, endpoint=False),
|
| 736 |
+
np.arange(len(audio_data)),
|
| 737 |
+
audio_data
|
| 738 |
+
)
|
| 739 |
+
|
| 740 |
+
# Stream in chunks of ~250ms of audio
|
| 741 |
+
chunk_size = int(SAMPLING_RATE * 0.25)
|
| 742 |
+
words_in_line = line_text.split()
|
| 743 |
+
total_words = len(words_in_line)
|
| 744 |
+
|
| 745 |
+
# Map audio samples to words to synchronize 'word' HUD metadata events
|
| 746 |
+
samples_per_word = len(audio_data) / max(1, total_words)
|
| 747 |
+
|
| 748 |
+
current_word_idx = 0
|
| 749 |
+
for offset in range(0, len(audio_data), chunk_size):
|
| 750 |
+
chunk = audio_data[offset:offset+chunk_size]
|
| 751 |
+
if len(chunk) == 0:
|
| 752 |
+
continue
|
| 753 |
+
|
| 754 |
+
# Scale chunk dynamically to int16 PCM
|
| 755 |
+
max_chunk = np.max(np.abs(chunk))
|
| 756 |
+
scaled_chunk = chunk / max_chunk * 0.7 if max_chunk > 0 else chunk
|
| 757 |
+
pcm_bytes = (scaled_chunk * 32767.0).astype(np.int16).tobytes()
|
| 758 |
+
|
| 759 |
+
# Send binary audio buffer over WebSocket
|
| 760 |
+
await websocket.send_bytes(pcm_bytes)
|
| 761 |
+
|
| 762 |
+
# Broadcast 'word' events as the audio reaches those word offsets
|
| 763 |
+
current_sample_pos = offset + len(chunk)
|
| 764 |
+
word_progress = int(current_sample_pos / samples_per_word)
|
| 765 |
+
while current_word_idx < min(word_progress, total_words):
|
| 766 |
+
await websocket.send_json({
|
| 767 |
+
"type": "word",
|
| 768 |
+
"word": words_in_line[current_word_idx],
|
| 769 |
+
"index": current_word_idx,
|
| 770 |
+
"total": total_words
|
| 771 |
+
})
|
| 772 |
+
current_word_idx += 1
|
| 773 |
+
|
| 774 |
+
# Realtime throttle matching output playback
|
| 775 |
+
await asyncio.sleep(0.24)
|
| 776 |
+
|
| 777 |
+
# Ensure all final words are sent in the metadata stream
|
| 778 |
+
while current_word_idx < total_words:
|
| 779 |
+
await websocket.send_json({
|
| 780 |
+
"type": "word",
|
| 781 |
+
"word": words_in_line[current_word_idx],
|
| 782 |
+
"index": current_word_idx,
|
| 783 |
+
"total": total_words
|
| 784 |
+
})
|
| 785 |
+
current_word_idx += 1
|
| 786 |
+
|
| 787 |
+
# Clean up
|
| 788 |
+
try:
|
| 789 |
+
os.remove(temp_wav)
|
| 790 |
+
except:
|
| 791 |
+
pass
|
| 792 |
+
|
| 793 |
+
# Natural speech pause between sentences
|
| 794 |
+
await asyncio.sleep(0.3)
|
| 795 |
+
else:
|
| 796 |
+
raise Exception("Generated SAPI5 stream WAV is missing or empty")
|
| 797 |
+
|
| 798 |
+
await websocket.send_json({"type": "done"})
|
| 799 |
+
|
| 800 |
+
except Exception as ex:
|
| 801 |
+
print(f"[WebSocket-Demo] SAPI5 streaming exception: {ex}. Falling back to visual wave generator.")
|
| 802 |
+
traceback.print_exc()
|
| 803 |
+
|
| 804 |
+
# Resilient visual fallback to sine wave beeps if SAPI5 fails
|
| 805 |
+
words = text.split()
|
| 806 |
+
for word_idx, word in enumerate(words):
|
| 807 |
+
word_duration = 0.35
|
| 808 |
+
num_samples = int(SAMPLING_RATE * word_duration)
|
| 809 |
+
t = np.linspace(0, word_duration, num_samples, endpoint=False)
|
| 810 |
+
|
| 811 |
+
base_freq = 130 if speaker_id == 0 else 230
|
| 812 |
+
wave = np.sin(2 * np.pi * base_freq * t)
|
| 813 |
+
wave += 0.4 * np.sin(2 * np.pi * (base_freq * 2) * t)
|
| 814 |
+
wave += 0.2 * np.sin(2 * np.pi * (base_freq * 3) * t)
|
| 815 |
+
|
| 816 |
+
window = np.sin(np.linspace(0, np.pi, num_samples)) ** 2
|
| 817 |
+
chunk = wave * window
|
| 818 |
+
|
| 819 |
+
chunk = chunk / np.max(np.abs(chunk)) * 0.7 if np.max(np.abs(chunk)) > 0 else chunk
|
| 820 |
+
pcm_bytes = (chunk * 32767.0).astype(np.int16).tobytes()
|
| 821 |
+
|
| 822 |
+
await websocket.send_bytes(pcm_bytes)
|
| 823 |
+
await websocket.send_json({
|
| 824 |
+
"type": "word",
|
| 825 |
+
"word": word,
|
| 826 |
+
"index": word_idx,
|
| 827 |
+
"total": len(words)
|
| 828 |
+
})
|
| 829 |
+
await asyncio.sleep(0.28)
|
| 830 |
+
|
| 831 |
+
await websocket.send_json({"type": "done"})
|
| 832 |
+
|
| 833 |
+
except WebSocketDisconnect:
|
| 834 |
+
print("[WebSocket] Streaming client disconnected.")
|
| 835 |
+
except Exception as e:
|
| 836 |
+
print(f"[WebSocket] Error: {e}")
|
| 837 |
+
traceback.print_exc()
|
| 838 |
+
|
| 839 |
+
# Mount static assets files (temp outputs & cloned speakers)
|
| 840 |
+
app.mount("/static", StaticFiles(directory="static"), name="static")
|
| 841 |
+
|
| 842 |
+
@app.get("/")
|
| 843 |
+
async def root_fallback():
|
| 844 |
+
if os.path.exists("frontend/dist/index.html"):
|
| 845 |
+
return FileResponse("frontend/dist/index.html")
|
| 846 |
+
return HTMLResponse("""
|
| 847 |
+
<html>
|
| 848 |
+
<head>
|
| 849 |
+
<title>VibeVoice API Server</title>
|
| 850 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 851 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 852 |
+
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600&display=swap" rel="stylesheet">
|
| 853 |
+
<style>
|
| 854 |
+
body {
|
| 855 |
+
font-family: 'Outfit', sans-serif;
|
| 856 |
+
background: #07090e;
|
| 857 |
+
color: #f0f3fa;
|
| 858 |
+
display: flex;
|
| 859 |
+
flex-direction: column;
|
| 860 |
+
align-items: center;
|
| 861 |
+
justify-content: center;
|
| 862 |
+
height: 100vh;
|
| 863 |
+
margin: 0;
|
| 864 |
+
}
|
| 865 |
+
.container {
|
| 866 |
+
text-align: center;
|
| 867 |
+
padding: 40px;
|
| 868 |
+
background: rgba(255, 255, 255, 0.03);
|
| 869 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 870 |
+
border-radius: 20px;
|
| 871 |
+
backdrop-filter: blur(10px);
|
| 872 |
+
max-width: 500px;
|
| 873 |
+
}
|
| 874 |
+
code {
|
| 875 |
+
background: rgba(0, 242, 254, 0.1);
|
| 876 |
+
color: #00f2fe;
|
| 877 |
+
padding: 8px 12px;
|
| 878 |
+
border-radius: 6px;
|
| 879 |
+
font-family: monospace;
|
| 880 |
+
display: inline-block;
|
| 881 |
+
margin: 15px 0;
|
| 882 |
+
}
|
| 883 |
+
</style>
|
| 884 |
+
</head>
|
| 885 |
+
<body>
|
| 886 |
+
<div class="container">
|
| 887 |
+
<h1 style="margin: 0 0 10px 0;">🎙️ VibeVoice API Server Online</h1>
|
| 888 |
+
<p style="color: #8e9bb5; line-height: 1.6; font-size: 14px;">The backend API server is fully running on port 8000! To access the high-end React + Vite + Tailwind frontend, start the dev server:</p>
|
| 889 |
+
<code>cd frontend; npm run dev</code>
|
| 890 |
+
<p style="margin: 15px 0 0 0; font-size: 12px; color: #8e9bb5;">Or build for production serving: <code>cd frontend; npm run build</code></p>
|
| 891 |
+
</div>
|
| 892 |
+
</body>
|
| 893 |
+
</html>
|
| 894 |
+
""")
|
| 895 |
+
|
| 896 |
+
# Serve the static built files from dist if compiled
|
| 897 |
+
if os.path.exists("frontend/dist"):
|
| 898 |
+
app.mount("/", StaticFiles(directory="frontend/dist", html=True), name="dist")
|
| 899 |
+
|
| 900 |
+
if __name__ == "__main__":
|
| 901 |
+
import uvicorn
|
| 902 |
+
import os
|
| 903 |
+
# Start web app dynamically (binds to 0.0.0.0 and port 7860 for Hugging Face Space Docker support)
|
| 904 |
+
port = int(os.environ.get("PORT", 8000))
|
| 905 |
+
host = "0.0.0.0"
|
| 906 |
+
print(f"[VibeVoice] Starting server on http://{host}:{port}")
|
| 907 |
+
uvicorn.run("app:app", host=host, port=port, reload=False)
|
| 908 |
+
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
frontend/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
| 9 |
+
|
| 10 |
+
## React Compiler
|
| 11 |
+
|
| 12 |
+
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
| 13 |
+
|
| 14 |
+
## Expanding the ESLint configuration
|
| 15 |
+
|
| 16 |
+
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
frontend/eslint.config.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 6 |
+
|
| 7 |
+
export default defineConfig([
|
| 8 |
+
globalIgnores(['dist']),
|
| 9 |
+
{
|
| 10 |
+
files: ['**/*.{js,jsx}'],
|
| 11 |
+
extends: [
|
| 12 |
+
js.configs.recommended,
|
| 13 |
+
reactHooks.configs.flat.recommended,
|
| 14 |
+
reactRefresh.configs.vite,
|
| 15 |
+
],
|
| 16 |
+
languageOptions: {
|
| 17 |
+
globals: globals.browser,
|
| 18 |
+
parserOptions: { ecmaFeatures: { jsx: true } },
|
| 19 |
+
},
|
| 20 |
+
},
|
| 21 |
+
])
|
frontend/index.html
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎙️</text></svg>" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<meta name="description" content="VibeVoice React Studio - Real-time Voice Cloning and Speech Synthesis Playground by Microsoft Frontier Models." />
|
| 8 |
+
<title>VibeVoice React Studio | Frontier Voice AI</title>
|
| 9 |
+
<!-- Premium Fonts -->
|
| 10 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 11 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 12 |
+
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
| 13 |
+
<style>
|
| 14 |
+
body {
|
| 15 |
+
font-family: 'Plus Jakarta Sans', sans-serif !important;
|
| 16 |
+
}
|
| 17 |
+
h1, h2, h3, .font-orbitron {
|
| 18 |
+
font-family: 'Outfit', sans-serif !important;
|
| 19 |
+
}
|
| 20 |
+
#consoleBody {
|
| 21 |
+
font-family: 'JetBrains Mono', monospace !important;
|
| 22 |
+
}
|
| 23 |
+
</style>
|
| 24 |
+
</head>
|
| 25 |
+
<body class="bg-[#07090e] text-[#f0f3fa]">
|
| 26 |
+
<div id="root"></div>
|
| 27 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 28 |
+
</body>
|
| 29 |
+
</html>
|
| 30 |
+
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@supabase/supabase-js": "^2.106.0",
|
| 14 |
+
"@tailwindcss/vite": "^4.3.0",
|
| 15 |
+
"framer-motion": "^12.38.0",
|
| 16 |
+
"lucide-react": "^1.16.0",
|
| 17 |
+
"react": "^19.2.6",
|
| 18 |
+
"react-dom": "^19.2.6",
|
| 19 |
+
"tailwindcss": "^4.3.0"
|
| 20 |
+
},
|
| 21 |
+
"devDependencies": {
|
| 22 |
+
"@eslint/js": "^10.0.1",
|
| 23 |
+
"@types/react": "^19.2.14",
|
| 24 |
+
"@types/react-dom": "^19.2.3",
|
| 25 |
+
"@vitejs/plugin-react": "^6.0.1",
|
| 26 |
+
"eslint": "^10.3.0",
|
| 27 |
+
"eslint-plugin-react-hooks": "^7.1.1",
|
| 28 |
+
"eslint-plugin-react-refresh": "^0.5.2",
|
| 29 |
+
"globals": "^17.6.0",
|
| 30 |
+
"vite": "^8.0.12"
|
| 31 |
+
}
|
| 32 |
+
}
|
frontend/public/favicon.svg
ADDED
|
|
frontend/public/icons.svg
ADDED
|
|
frontend/src/App.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/* Custom styles are configured in index.css and Tailwind v4. App.css is intentionally left empty. */
|
frontend/src/App.jsx
ADDED
|
@@ -0,0 +1,2184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
+
import LandingPage from './LandingPage';
|
| 3 |
+
import { supabase } from './supabaseClient';
|
| 4 |
+
import {
|
| 5 |
+
Mic,
|
| 6 |
+
Upload,
|
| 7 |
+
Play,
|
| 8 |
+
Square,
|
| 9 |
+
Volume2,
|
| 10 |
+
VolumeX,
|
| 11 |
+
Terminal,
|
| 12 |
+
Settings,
|
| 13 |
+
Radio,
|
| 14 |
+
Info,
|
| 15 |
+
AlertCircle,
|
| 16 |
+
CheckCircle,
|
| 17 |
+
Sparkles,
|
| 18 |
+
Power,
|
| 19 |
+
Loader2,
|
| 20 |
+
Trash2,
|
| 21 |
+
Podcast,
|
| 22 |
+
Headset,
|
| 23 |
+
Newspaper,
|
| 24 |
+
Volume1,
|
| 25 |
+
PlayCircle,
|
| 26 |
+
Plus,
|
| 27 |
+
Music,
|
| 28 |
+
Edit2
|
| 29 |
+
} from 'lucide-react';
|
| 30 |
+
|
| 31 |
+
const SAMPLING_RATE = 24000;
|
| 32 |
+
|
| 33 |
+
function App() {
|
| 34 |
+
const [currentView, setCurrentView] = useState('landing');
|
| 35 |
+
const [savedVoices, setSavedVoices] = useState([]);
|
| 36 |
+
const [podcastHistory, setPodcastHistory] = useState([]);
|
| 37 |
+
|
| 38 |
+
// Model & Server Status States
|
| 39 |
+
const [modelStatus, setModelStatus] = useState({
|
| 40 |
+
useRealModel: true,
|
| 41 |
+
modelId: 'microsoft/VibeVoice-Realtime-0.5B',
|
| 42 |
+
loaded: false,
|
| 43 |
+
loading: true,
|
| 44 |
+
error: null,
|
| 45 |
+
device: 'CPU'
|
| 46 |
+
});
|
| 47 |
+
const [wsConnected, setWsConnected] = useState(false);
|
| 48 |
+
const [togglingModel, setTogglingModel] = useState(false);
|
| 49 |
+
|
| 50 |
+
// Script & Synthesis States
|
| 51 |
+
const [script, setScript] = useState(
|
| 52 |
+
"Speaker 0: Welcome to Vibe Voice Studio. I am cloned from your custom voice sample.\nSpeaker 1: That is incredible! The transition between speakers sounds so smooth and natural.\nSpeaker 0: Yes, Microsoft's continuous acoustic tokenizers make multi-speaker podcasts sound beautiful."
|
| 53 |
+
);
|
| 54 |
+
const [streamMode, setStreamMode] = useState(true);
|
| 55 |
+
const [agentState, setAgentState] = useState({
|
| 56 |
+
status: 'idle', // idle, thinking, speaking, listening
|
| 57 |
+
title: 'AGENT READY',
|
| 58 |
+
sub: 'Configure scripts and trigger generation below'
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
// Audio Playback UI States
|
| 62 |
+
const [isAudioPlayingState, setIsAudioPlayingState] = useState(false);
|
| 63 |
+
const [volume, setVolume] = useState(80);
|
| 64 |
+
const [speed, setSpeed] = useState('1.0');
|
| 65 |
+
const [timeline, setTimeline] = useState({ elapsed: 0, total: 0 });
|
| 66 |
+
const [showToolbar, setShowToolbar] = useState(false);
|
| 67 |
+
|
| 68 |
+
// Speaker Slots States (Voice Cloning)
|
| 69 |
+
const [speakers, setSpeakers] = useState([
|
| 70 |
+
{ id: 0, name: 'Alex', isRecording: false, voicePath: null, previewUrl: null, role: 'Primary Speaker', color: 'cyan' },
|
| 71 |
+
{ id: 1, name: 'Sarah', isRecording: false, voicePath: null, previewUrl: null, role: 'Dialogue Partner', color: 'purple' }
|
| 72 |
+
]);
|
| 73 |
+
|
| 74 |
+
// Master Export Hub State
|
| 75 |
+
const [compiledAudioUrl, setCompiledAudioUrl] = useState(null);
|
| 76 |
+
const [compiledAudioMode, setCompiledAudioMode] = useState(null); // 'stream' or 'file'
|
| 77 |
+
const accumulatedSessionChunksRef = useRef([]);
|
| 78 |
+
|
| 79 |
+
// Preview Voice Reference Playback State
|
| 80 |
+
const [playingPreviewId, setPlayingPreviewId] = useState(null);
|
| 81 |
+
const previewSourceRef = useRef(null);
|
| 82 |
+
|
| 83 |
+
// Custom Naming Modal State
|
| 84 |
+
const [namingModal, setNamingModal] = useState({
|
| 85 |
+
isOpen: false,
|
| 86 |
+
fileBlob: null,
|
| 87 |
+
speakerId: null,
|
| 88 |
+
defaultName: ''
|
| 89 |
+
});
|
| 90 |
+
const [voiceNameInput, setVoiceNameInput] = useState('');
|
| 91 |
+
|
| 92 |
+
// Custom Action (Rename/Delete) Modal State
|
| 93 |
+
const [actionModal, setActionModal] = useState({
|
| 94 |
+
isOpen: false,
|
| 95 |
+
type: '', // 'rename_voice', 'delete_voice', 'rename_podcast', 'delete_podcast'
|
| 96 |
+
id: null,
|
| 97 |
+
oldValue: '',
|
| 98 |
+
inputValue: '',
|
| 99 |
+
audioUrl: '',
|
| 100 |
+
profileName: ''
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
// Console Logs State
|
| 104 |
+
const [logs, setLogs] = useState([
|
| 105 |
+
{ type: 'system', text: 'Welcome to VibeVoice React Studio Console. System ready.', time: new Date().toLocaleTimeString() },
|
| 106 |
+
{ type: 'info', text: 'VibeVoice neural weights loading directly on local CPU. Real-time zero-shot synthesis active.', time: new Date().toLocaleTimeString() }
|
| 107 |
+
]);
|
| 108 |
+
|
| 109 |
+
// Web Audio Context & Playback References
|
| 110 |
+
const audioCtxRef = useRef(null);
|
| 111 |
+
const analyserNodeRef = useRef(null);
|
| 112 |
+
const gainNodeRef = useRef(null);
|
| 113 |
+
const wsRef = useRef(null);
|
| 114 |
+
|
| 115 |
+
// Streaming Queue Refs (Gapless Playback)
|
| 116 |
+
const playbackQueueRef = useRef([]);
|
| 117 |
+
const nextPlayTimeRef = useRef(0);
|
| 118 |
+
const isStreamingActiveRef = useRef(false);
|
| 119 |
+
const scheduledSourcesRef = useRef([]);
|
| 120 |
+
const doneReceivedRef = useRef(false);
|
| 121 |
+
|
| 122 |
+
// Traditional REST Player Refs
|
| 123 |
+
const traditionalSourceRef = useRef(null);
|
| 124 |
+
const traditionalStartClockRef = useRef(0);
|
| 125 |
+
const traditionalDurationRef = useRef(0);
|
| 126 |
+
|
| 127 |
+
// Microphone Recording Refs
|
| 128 |
+
const mediaRecordersRef = useRef([null, null]);
|
| 129 |
+
const audioChunksRef = useRef([[], []]);
|
| 130 |
+
|
| 131 |
+
// UI Canvas Visualizer Refs
|
| 132 |
+
const canvasRef = useRef(null);
|
| 133 |
+
|
| 134 |
+
// ==========================================================================
|
| 135 |
+
// Console Log Helper
|
| 136 |
+
// ==========================================================================
|
| 137 |
+
const addLog = (type, text) => {
|
| 138 |
+
setLogs(prev => [
|
| 139 |
+
...prev,
|
| 140 |
+
{ type, text, time: new Date().toLocaleTimeString() }
|
| 141 |
+
]);
|
| 142 |
+
};
|
| 143 |
+
|
| 144 |
+
// Scroll Console to Bottom
|
| 145 |
+
useEffect(() => {
|
| 146 |
+
const consoleBody = document.getElementById('consoleBody');
|
| 147 |
+
if (consoleBody) {
|
| 148 |
+
consoleBody.scrollTop = consoleBody.scrollHeight;
|
| 149 |
+
}
|
| 150 |
+
}, [logs]);
|
| 151 |
+
|
| 152 |
+
// ==========================================================================
|
| 153 |
+
// Server Status & WebSocket Handshakes
|
| 154 |
+
// ==========================================================================
|
| 155 |
+
const checkServerStatus = async () => {
|
| 156 |
+
try {
|
| 157 |
+
const res = await fetch('/api/status');
|
| 158 |
+
const data = await res.json();
|
| 159 |
+
|
| 160 |
+
setModelStatus({
|
| 161 |
+
useRealModel: data.use_real_model,
|
| 162 |
+
modelId: data.model_id,
|
| 163 |
+
loaded: data.loaded,
|
| 164 |
+
loading: data.loading,
|
| 165 |
+
error: data.error,
|
| 166 |
+
device: data.device
|
| 167 |
+
});
|
| 168 |
+
} catch (e) {
|
| 169 |
+
addLog('error', 'Failed to connect to VibeVoice backend API server.');
|
| 170 |
+
}
|
| 171 |
+
};
|
| 172 |
+
|
| 173 |
+
const fetchSavedVoices = async () => {
|
| 174 |
+
try {
|
| 175 |
+
const { data, error } = await supabase.from('voice_profiles').select('*');
|
| 176 |
+
if (error) throw error;
|
| 177 |
+
setSavedVoices(data || []);
|
| 178 |
+
addLog('info', `Fetched ${data?.length || 0} saved voice profiles from Supabase.`);
|
| 179 |
+
|
| 180 |
+
// Auto-assign matching saved voices to speakers by name to get "old audio input" instantly!
|
| 181 |
+
if (data && data.length > 0) {
|
| 182 |
+
setSpeakers(prev => prev.map(s => {
|
| 183 |
+
const matched = data.find(v => v.name.trim().toLowerCase() === s.name.trim().toLowerCase());
|
| 184 |
+
if (matched && !s.voicePath) {
|
| 185 |
+
addLog('info', `Auto-loaded saved voice profile "${matched.name}" into Speaker ${s.id} slot.`);
|
| 186 |
+
return {
|
| 187 |
+
...s,
|
| 188 |
+
voicePath: matched.audio_url,
|
| 189 |
+
previewUrl: matched.audio_url
|
| 190 |
+
};
|
| 191 |
+
}
|
| 192 |
+
return s;
|
| 193 |
+
}));
|
| 194 |
+
}
|
| 195 |
+
} catch (err) {
|
| 196 |
+
addLog('error', 'Failed to fetch saved voice profiles from Supabase: ' + err.message);
|
| 197 |
+
}
|
| 198 |
+
};
|
| 199 |
+
|
| 200 |
+
const fetchPodcastHistory = async () => {
|
| 201 |
+
try {
|
| 202 |
+
const { data, error } = await supabase
|
| 203 |
+
.from('podcast_history')
|
| 204 |
+
.select('*')
|
| 205 |
+
.order('created_at', { ascending: false });
|
| 206 |
+
if (error) throw error;
|
| 207 |
+
setPodcastHistory(data || []);
|
| 208 |
+
addLog('info', `Fetched ${data?.length || 0} archived podcasts from Supabase.`);
|
| 209 |
+
} catch (err) {
|
| 210 |
+
addLog('error', 'Failed to fetch podcast history: ' + err.message);
|
| 211 |
+
}
|
| 212 |
+
};
|
| 213 |
+
|
| 214 |
+
const savePodcastToHistory = async (wavBlob, textScript, mode) => {
|
| 215 |
+
try {
|
| 216 |
+
addLog('info', 'Archiving generated podcast to Supabase permanent storage...');
|
| 217 |
+
const fileName = `podcast_${Date.now()}.wav`;
|
| 218 |
+
|
| 219 |
+
// 1. Upload to storage
|
| 220 |
+
const { data: storageData, error: storageError } = await supabase
|
| 221 |
+
.storage
|
| 222 |
+
.from('voice_samples')
|
| 223 |
+
.upload(fileName, wavBlob);
|
| 224 |
+
|
| 225 |
+
if (storageError) throw storageError;
|
| 226 |
+
|
| 227 |
+
// 2. Get public url
|
| 228 |
+
const { data: publicUrlData } = supabase
|
| 229 |
+
.storage
|
| 230 |
+
.from('voice_samples')
|
| 231 |
+
.getPublicUrl(fileName);
|
| 232 |
+
|
| 233 |
+
const publicUrl = publicUrlData.publicUrl;
|
| 234 |
+
|
| 235 |
+
// 3. Insert metadata record into DB
|
| 236 |
+
const primaryVoice = speakers[0]?.voicePath || null;
|
| 237 |
+
const matchingProfile = primaryVoice ? savedVoices.find(v => v.audio_url === primaryVoice) : null;
|
| 238 |
+
const voiceProfileId = matchingProfile ? matchingProfile.id : null;
|
| 239 |
+
|
| 240 |
+
const { data: dbData, error: dbError } = await supabase
|
| 241 |
+
.from('podcast_history')
|
| 242 |
+
.insert([{
|
| 243 |
+
title: `Podcast - ${new Date().toLocaleString()} (${mode})`,
|
| 244 |
+
text_script: textScript,
|
| 245 |
+
audio_url: publicUrl,
|
| 246 |
+
speaker_id: 0,
|
| 247 |
+
voice_profile_id: voiceProfileId
|
| 248 |
+
}])
|
| 249 |
+
.select();
|
| 250 |
+
|
| 251 |
+
if (dbError) throw dbError;
|
| 252 |
+
|
| 253 |
+
addLog('success', 'Podcast archived successfully to Supabase Database!');
|
| 254 |
+
fetchPodcastHistory();
|
| 255 |
+
} catch (err) {
|
| 256 |
+
addLog('warning', `Failed to archive podcast to Supabase (${err.message}). Storing in local session memory...`);
|
| 257 |
+
|
| 258 |
+
// Fallback: Create local preview URL for the compiled podcast WAV
|
| 259 |
+
const localPreviewUrl = URL.createObjectURL(wavBlob);
|
| 260 |
+
|
| 261 |
+
setPodcastHistory(prev => [
|
| 262 |
+
{
|
| 263 |
+
id: `local_${Date.now()}`,
|
| 264 |
+
title: `Podcast - ${new Date().toLocaleString()} (${mode}) [Local Session]`,
|
| 265 |
+
text_script: textScript,
|
| 266 |
+
audio_url: localPreviewUrl,
|
| 267 |
+
speaker_id: 0,
|
| 268 |
+
voice_profile_id: null,
|
| 269 |
+
created_at: new Date().toISOString()
|
| 270 |
+
},
|
| 271 |
+
...prev
|
| 272 |
+
]);
|
| 273 |
+
addLog('success', 'Podcast successfully cached in local session memory!');
|
| 274 |
+
}
|
| 275 |
+
};
|
| 276 |
+
|
| 277 |
+
const saveTraditionalPodcastToHistory = async (audioUrl, textScript, mode) => {
|
| 278 |
+
try {
|
| 279 |
+
const response = await fetch(audioUrl);
|
| 280 |
+
const blob = await response.blob();
|
| 281 |
+
await savePodcastToHistory(blob, textScript, mode);
|
| 282 |
+
} catch (err) {
|
| 283 |
+
addLog('error', 'Failed to download compiled audio for archiving: ' + err.message);
|
| 284 |
+
}
|
| 285 |
+
};
|
| 286 |
+
|
| 287 |
+
// --- Custom Action Modal Trigger Hooks ---
|
| 288 |
+
const openRenameVoiceModal = (voiceId, oldName, audioUrl) => {
|
| 289 |
+
setActionModal({
|
| 290 |
+
isOpen: true,
|
| 291 |
+
type: 'rename_voice',
|
| 292 |
+
id: voiceId,
|
| 293 |
+
oldValue: oldName,
|
| 294 |
+
inputValue: oldName,
|
| 295 |
+
audioUrl,
|
| 296 |
+
profileName: oldName
|
| 297 |
+
});
|
| 298 |
+
};
|
| 299 |
+
|
| 300 |
+
const openDeleteVoiceModal = (voiceId, oldName, audioUrl) => {
|
| 301 |
+
setActionModal({
|
| 302 |
+
isOpen: true,
|
| 303 |
+
type: 'delete_voice',
|
| 304 |
+
id: voiceId,
|
| 305 |
+
oldValue: oldName,
|
| 306 |
+
inputValue: '',
|
| 307 |
+
audioUrl,
|
| 308 |
+
profileName: oldName
|
| 309 |
+
});
|
| 310 |
+
};
|
| 311 |
+
|
| 312 |
+
const openRenamePodcastModal = (podcastId, oldTitle) => {
|
| 313 |
+
setActionModal({
|
| 314 |
+
isOpen: true,
|
| 315 |
+
type: 'rename_podcast',
|
| 316 |
+
id: podcastId,
|
| 317 |
+
oldValue: oldTitle,
|
| 318 |
+
inputValue: oldTitle,
|
| 319 |
+
audioUrl: '',
|
| 320 |
+
profileName: oldTitle
|
| 321 |
+
});
|
| 322 |
+
};
|
| 323 |
+
|
| 324 |
+
const openDeletePodcastModal = (podcastId, title, audioUrl) => {
|
| 325 |
+
setActionModal({
|
| 326 |
+
isOpen: true,
|
| 327 |
+
type: 'delete_podcast',
|
| 328 |
+
id: podcastId,
|
| 329 |
+
oldValue: title,
|
| 330 |
+
inputValue: '',
|
| 331 |
+
audioUrl,
|
| 332 |
+
profileName: title
|
| 333 |
+
});
|
| 334 |
+
};
|
| 335 |
+
|
| 336 |
+
// --- Core execution functions invoked on Custom Modal Submit ---
|
| 337 |
+
|
| 338 |
+
const executeRenamePodcast = async (podcastId, newTitle) => {
|
| 339 |
+
if (!newTitle || newTitle.trim() === '') return;
|
| 340 |
+
try {
|
| 341 |
+
addLog('info', `Renaming podcast to "${newTitle.trim()}"...`);
|
| 342 |
+
|
| 343 |
+
// Local podcast session
|
| 344 |
+
if (typeof podcastId === 'string' && podcastId.startsWith('local_')) {
|
| 345 |
+
setPodcastHistory(prev => prev.map(p => p.id === podcastId ? { ...p, title: newTitle.trim() } : p));
|
| 346 |
+
addLog('success', `Podcast successfully renamed to "${newTitle.trim()}" locally.`);
|
| 347 |
+
return;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
// Supabase DB podcast
|
| 351 |
+
const { error } = await supabase
|
| 352 |
+
.from('podcast_history')
|
| 353 |
+
.update({ title: newTitle.trim() })
|
| 354 |
+
.eq('id', podcastId);
|
| 355 |
+
|
| 356 |
+
if (error) throw error;
|
| 357 |
+
|
| 358 |
+
addLog('success', `Podcast title successfully updated in Supabase Database!`);
|
| 359 |
+
fetchPodcastHistory();
|
| 360 |
+
} catch (err) {
|
| 361 |
+
addLog('error', 'Failed to rename podcast: ' + err.message);
|
| 362 |
+
}
|
| 363 |
+
};
|
| 364 |
+
|
| 365 |
+
const executeRenameVoice = async (voiceId, newName, audioUrl) => {
|
| 366 |
+
if (!newName || newName.trim() === '') return;
|
| 367 |
+
try {
|
| 368 |
+
addLog('info', `Renaming voice profile to "${newName.trim()}"...`);
|
| 369 |
+
|
| 370 |
+
// Local voice profiles
|
| 371 |
+
if (typeof voiceId === 'string' && voiceId.startsWith('local_')) {
|
| 372 |
+
setSavedVoices(prev => prev.map(v => v.id === voiceId ? { ...v, name: newName.trim() } : v));
|
| 373 |
+
|
| 374 |
+
// Update speakers
|
| 375 |
+
setSpeakers(prev => prev.map(s => {
|
| 376 |
+
if (s.voicePath === audioUrl) {
|
| 377 |
+
return { ...s, name: newName.trim() };
|
| 378 |
+
}
|
| 379 |
+
return s;
|
| 380 |
+
}));
|
| 381 |
+
|
| 382 |
+
addLog('success', `Voice profile successfully renamed to "${newName.trim()}" locally.`);
|
| 383 |
+
return;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
// Supabase
|
| 387 |
+
const { error } = await supabase
|
| 388 |
+
.from('voice_profiles')
|
| 389 |
+
.update({ name: newName.trim() })
|
| 390 |
+
.eq('id', voiceId);
|
| 391 |
+
|
| 392 |
+
if (error) throw error;
|
| 393 |
+
|
| 394 |
+
addLog('success', `Voice profile successfully renamed in database!`);
|
| 395 |
+
|
| 396 |
+
// Update state immediately
|
| 397 |
+
setSavedVoices(prev => prev.map(v => v.id === voiceId ? { ...v, name: newName.trim() } : v));
|
| 398 |
+
|
| 399 |
+
// Also update any speaker slot using it
|
| 400 |
+
setSpeakers(prev => prev.map(s => {
|
| 401 |
+
if (s.voicePath === audioUrl) {
|
| 402 |
+
return { ...s, name: newName.trim() };
|
| 403 |
+
}
|
| 404 |
+
return s;
|
| 405 |
+
}));
|
| 406 |
+
} catch (err) {
|
| 407 |
+
addLog('error', `Failed to rename voice profile: ${err.message}`);
|
| 408 |
+
}
|
| 409 |
+
};
|
| 410 |
+
|
| 411 |
+
const executeDeleteVoice = async (voiceId, audioUrl, profileName) => {
|
| 412 |
+
try {
|
| 413 |
+
addLog('info', `Deleting voice profile "${profileName}"...`);
|
| 414 |
+
|
| 415 |
+
// Clear voice path of speakers who use this profile
|
| 416 |
+
setSpeakers(prev => prev.map(s => s.voicePath === audioUrl ? { ...s, voicePath: null, previewUrl: null } : s));
|
| 417 |
+
|
| 418 |
+
// Local voice profile
|
| 419 |
+
if (typeof voiceId === 'string' && (voiceId.startsWith('local_') || voiceId.endsWith('.wav'))) {
|
| 420 |
+
setSavedVoices(prev => prev.filter(v => v.id !== voiceId));
|
| 421 |
+
|
| 422 |
+
if (audioUrl && audioUrl.startsWith('/static/cloned_voices/')) {
|
| 423 |
+
const filename = audioUrl.split('/').pop();
|
| 424 |
+
try {
|
| 425 |
+
await fetch(`/api/delete-voice/${filename}`, { method: 'DELETE' });
|
| 426 |
+
} catch(e) {}
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
addLog('success', `Voice profile "${profileName}" removed from local session.`);
|
| 430 |
+
return;
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
// Database profile
|
| 434 |
+
const { error: dbError } = await supabase
|
| 435 |
+
.from('voice_profiles')
|
| 436 |
+
.delete()
|
| 437 |
+
.eq('id', voiceId);
|
| 438 |
+
|
| 439 |
+
if (dbError) throw dbError;
|
| 440 |
+
|
| 441 |
+
// Extract filename from URL and delete from storage
|
| 442 |
+
if (audioUrl) {
|
| 443 |
+
try {
|
| 444 |
+
const filePart = audioUrl.split('/').pop();
|
| 445 |
+
if (filePart) {
|
| 446 |
+
await supabase.storage.from('voice_samples').remove([filePart]);
|
| 447 |
+
}
|
| 448 |
+
} catch (storageErr) {
|
| 449 |
+
console.warn('Failed to delete voice profile from Storage:', storageErr);
|
| 450 |
+
}
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
addLog('success', `Voice profile "${profileName}" successfully deleted from Supabase.`);
|
| 454 |
+
fetchSavedVoices();
|
| 455 |
+
} catch (err) {
|
| 456 |
+
addLog('error', `Failed to delete voice profile: ${err.message}`);
|
| 457 |
+
}
|
| 458 |
+
};
|
| 459 |
+
|
| 460 |
+
const executeDeletePodcast = async (id, audioUrl, title) => {
|
| 461 |
+
try {
|
| 462 |
+
addLog('info', `Deleting podcast "${title}" from archive...`);
|
| 463 |
+
|
| 464 |
+
if (typeof id === 'string' && id.startsWith('local_')) {
|
| 465 |
+
setPodcastHistory(prev => prev.filter(p => p.id !== id));
|
| 466 |
+
addLog('success', 'Podcast deleted successfully from local session memory.');
|
| 467 |
+
return;
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
// 1. Delete from database
|
| 471 |
+
const { error: dbError } = await supabase
|
| 472 |
+
.from('podcast_history')
|
| 473 |
+
.delete()
|
| 474 |
+
.eq('id', id);
|
| 475 |
+
if (dbError) throw dbError;
|
| 476 |
+
|
| 477 |
+
// 2. Try to extract filename from URL and delete from Storage
|
| 478 |
+
try {
|
| 479 |
+
const filePart = audioUrl.split('/').pop();
|
| 480 |
+
if (filePart) {
|
| 481 |
+
await supabase.storage.from('voice_samples').remove([filePart]);
|
| 482 |
+
}
|
| 483 |
+
} catch (err) {
|
| 484 |
+
console.warn('Failed to delete audio file from Storage:', err);
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
addLog('success', 'Podcast deleted successfully.');
|
| 488 |
+
fetchPodcastHistory();
|
| 489 |
+
} catch (err) {
|
| 490 |
+
addLog('error', 'Failed to delete podcast: ' + err.message);
|
| 491 |
+
}
|
| 492 |
+
};
|
| 493 |
+
|
| 494 |
+
const playHistoryPodcast = async (podcast) => {
|
| 495 |
+
initAudio();
|
| 496 |
+
handleStopPlayback(); // Stop any other playing sessions
|
| 497 |
+
|
| 498 |
+
try {
|
| 499 |
+
addLog('info', `Streaming archived podcast: "${podcast.title}"...`);
|
| 500 |
+
setIsAudioPlayingState(true);
|
| 501 |
+
setCompiledAudioUrl(podcast.audio_url);
|
| 502 |
+
setCompiledAudioMode('file');
|
| 503 |
+
loadTraditionalWav(podcast.audio_url);
|
| 504 |
+
} catch (e) {
|
| 505 |
+
addLog('error', 'Failed to play archived podcast.');
|
| 506 |
+
handleStopPlayback();
|
| 507 |
+
}
|
| 508 |
+
};
|
| 509 |
+
|
| 510 |
+
useEffect(() => {
|
| 511 |
+
addLog('system', 'Initializing VibeVoice Studio environment...');
|
| 512 |
+
checkServerStatus();
|
| 513 |
+
fetchSavedVoices();
|
| 514 |
+
fetchPodcastHistory();
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
// Poll server status periodically if loading
|
| 518 |
+
const interval = setInterval(() => {
|
| 519 |
+
checkServerStatus();
|
| 520 |
+
}, 4000);
|
| 521 |
+
|
| 522 |
+
return () => clearInterval(interval);
|
| 523 |
+
}, []);
|
| 524 |
+
|
| 525 |
+
// WebSocket Connection Manager
|
| 526 |
+
useEffect(() => {
|
| 527 |
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
| 528 |
+
const host = window.location.host;
|
| 529 |
+
const wsUrl = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
|
| 530 |
+
? 'ws://127.0.0.1:8000/api/stream'
|
| 531 |
+
: `${protocol}//${host}/api/stream`;
|
| 532 |
+
|
| 533 |
+
addLog('system', `Opening real-time WebSocket connection to ${wsUrl}`);
|
| 534 |
+
const websocket = new WebSocket(wsUrl);
|
| 535 |
+
wsRef.current = websocket;
|
| 536 |
+
|
| 537 |
+
websocket.onopen = () => {
|
| 538 |
+
setWsConnected(true);
|
| 539 |
+
addLog('success', 'WebSocket real-time streaming link ESTABLISHED.');
|
| 540 |
+
};
|
| 541 |
+
|
| 542 |
+
websocket.onclose = () => {
|
| 543 |
+
setWsConnected(false);
|
| 544 |
+
addLog('error', 'WebSocket streaming link disconnected. Retrying connection...');
|
| 545 |
+
};
|
| 546 |
+
|
| 547 |
+
websocket.onerror = () => {
|
| 548 |
+
addLog('error', 'WebSocket encountered connection error.');
|
| 549 |
+
};
|
| 550 |
+
|
| 551 |
+
websocket.onmessage = async (event) => {
|
| 552 |
+
if (typeof event.data === 'string') {
|
| 553 |
+
const data = JSON.parse(event.data);
|
| 554 |
+
if (data.type === 'word') {
|
| 555 |
+
addLog('word', `Agent spoke word: "${data.word}"`);
|
| 556 |
+
} else if (data.type === 'done') {
|
| 557 |
+
addLog('success', 'WebSocket voice generation chunk transfer complete.');
|
| 558 |
+
doneReceivedRef.current = true;
|
| 559 |
+
// Generate local Object URL of the accumulated chunks for mastering
|
| 560 |
+
if (accumulatedSessionChunksRef.current.length > 0) {
|
| 561 |
+
try {
|
| 562 |
+
const wavBlob = exportWav(accumulatedSessionChunksRef.current, SAMPLING_RATE);
|
| 563 |
+
const objectUrl = URL.createObjectURL(wavBlob);
|
| 564 |
+
setCompiledAudioUrl(objectUrl);
|
| 565 |
+
setCompiledAudioMode('stream');
|
| 566 |
+
addLog('success', 'Master podcast output audio created and mastered for local export.');
|
| 567 |
+
|
| 568 |
+
// Automatically archive to Supabase history
|
| 569 |
+
savePodcastToHistory(wavBlob, script, 'WebSocket Stream');
|
| 570 |
+
} catch (err) {
|
| 571 |
+
addLog('error', `Failed to master session output: ${err.message}`);
|
| 572 |
+
}
|
| 573 |
+
}
|
| 574 |
+
// If no sources are playing and queue is completely empty, finalize playback
|
| 575 |
+
if (scheduledSourcesRef.current.length === 0 && playbackQueueRef.current.length === 0) {
|
| 576 |
+
handleStopPlayback();
|
| 577 |
+
}
|
| 578 |
+
} else if (data.type === 'error') {
|
| 579 |
+
addLog('error', `Server stream error: ${data.message}`);
|
| 580 |
+
handleStopPlayback();
|
| 581 |
+
}
|
| 582 |
+
} else {
|
| 583 |
+
// Raw Audio Binary Chunks
|
| 584 |
+
const arrayBuffer = await event.data.arrayBuffer();
|
| 585 |
+
const int16Array = new Int16Array(arrayBuffer);
|
| 586 |
+
const float32Array = new Float32Array(int16Array.length);
|
| 587 |
+
|
| 588 |
+
for (let i = 0; i < int16Array.length; i++) {
|
| 589 |
+
float32Array[i] = int16Array[i] / 32767.0;
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
if (isStreamingActiveRef.current) {
|
| 593 |
+
playbackQueueRef.current.push(float32Array);
|
| 594 |
+
// Accumulate for session download
|
| 595 |
+
accumulatedSessionChunksRef.current.push(float32Array);
|
| 596 |
+
scheduleNextStreamingChunk();
|
| 597 |
+
}
|
| 598 |
+
}
|
| 599 |
+
};
|
| 600 |
+
|
| 601 |
+
return () => {
|
| 602 |
+
if (websocket) websocket.close();
|
| 603 |
+
};
|
| 604 |
+
}, []);
|
| 605 |
+
|
| 606 |
+
// ==========================================================================
|
| 607 |
+
// Web Audio Context & Node Setup (Pillar 2 & 3)
|
| 608 |
+
// ==========================================================================
|
| 609 |
+
const initAudio = () => {
|
| 610 |
+
if (audioCtxRef.current) return;
|
| 611 |
+
|
| 612 |
+
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
| 613 |
+
const context = new AudioContextClass({ sampleRate: SAMPLING_RATE });
|
| 614 |
+
audioCtxRef.current = context;
|
| 615 |
+
|
| 616 |
+
const analyser = context.createAnalyser();
|
| 617 |
+
analyser.fftSize = 256;
|
| 618 |
+
analyserNodeRef.current = analyser;
|
| 619 |
+
|
| 620 |
+
const gain = context.createGain();
|
| 621 |
+
gain.gain.setValueAtTime(volume / 100, context.currentTime);
|
| 622 |
+
gainNodeRef.current = gain;
|
| 623 |
+
|
| 624 |
+
// Connect pipeline
|
| 625 |
+
gain.connect(analyser);
|
| 626 |
+
analyser.connect(context.destination);
|
| 627 |
+
|
| 628 |
+
addLog('system', `Browser AudioContext engine running at ${SAMPLING_RATE}Hz.`);
|
| 629 |
+
};
|
| 630 |
+
|
| 631 |
+
useEffect(() => {
|
| 632 |
+
if (gainNodeRef.current && audioCtxRef.current) {
|
| 633 |
+
gainNodeRef.current.gain.setValueAtTime(volume / 100, audioCtxRef.current.currentTime);
|
| 634 |
+
}
|
| 635 |
+
}, [volume]);
|
| 636 |
+
|
| 637 |
+
// Streaming Gapless Playback Logic
|
| 638 |
+
const startStreamingPlayback = () => {
|
| 639 |
+
initAudio();
|
| 640 |
+
if (audioCtxRef.current.state === 'suspended') {
|
| 641 |
+
audioCtxRef.current.resume();
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
playbackQueueRef.current = [];
|
| 645 |
+
accumulatedSessionChunksRef.current = []; // Clear previous export session chunks
|
| 646 |
+
scheduledSourcesRef.current = [];
|
| 647 |
+
doneReceivedRef.current = false;
|
| 648 |
+
nextPlayTimeRef.current = audioCtxRef.current.currentTime + 0.15;
|
| 649 |
+
isStreamingActiveRef.current = true;
|
| 650 |
+
setIsAudioPlayingState(true);
|
| 651 |
+
|
| 652 |
+
setAgentState({
|
| 653 |
+
status: 'speaking',
|
| 654 |
+
title: 'STREAMING SPEECH',
|
| 655 |
+
sub: 'Synthesizing voice chunks in real-time over WebSocket...'
|
| 656 |
+
});
|
| 657 |
+
addLog('info', 'WebSocket player active. Buffering and playing...');
|
| 658 |
+
};
|
| 659 |
+
|
| 660 |
+
const scheduleNextStreamingChunk = () => {
|
| 661 |
+
if (!isStreamingActiveRef.current || playbackQueueRef.current.length === 0) return;
|
| 662 |
+
|
| 663 |
+
const chunk = playbackQueueRef.current.shift();
|
| 664 |
+
if (!chunk || chunk.length === 0) return;
|
| 665 |
+
|
| 666 |
+
const context = audioCtxRef.current;
|
| 667 |
+
const buffer = context.createBuffer(1, chunk.length, SAMPLING_RATE);
|
| 668 |
+
buffer.copyToChannel(chunk, 0);
|
| 669 |
+
|
| 670 |
+
const source = context.createBufferSource();
|
| 671 |
+
source.buffer = buffer;
|
| 672 |
+
source.playbackRate.value = parseFloat(speed);
|
| 673 |
+
source.connect(gainNodeRef.current);
|
| 674 |
+
|
| 675 |
+
const startTime = Math.max(context.currentTime, nextPlayTimeRef.current);
|
| 676 |
+
source.start(startTime);
|
| 677 |
+
|
| 678 |
+
scheduledSourcesRef.current.push(source);
|
| 679 |
+
|
| 680 |
+
const duration = buffer.duration / source.playbackRate.value;
|
| 681 |
+
nextPlayTimeRef.current = startTime + duration;
|
| 682 |
+
|
| 683 |
+
source.onended = () => {
|
| 684 |
+
// Remove completed source
|
| 685 |
+
scheduledSourcesRef.current = scheduledSourcesRef.current.filter(src => src !== source);
|
| 686 |
+
|
| 687 |
+
// Check if queue is completely drained and done signal is received
|
| 688 |
+
if (scheduledSourcesRef.current.length === 0 && playbackQueueRef.current.length === 0 && isStreamingActiveRef.current && doneReceivedRef.current) {
|
| 689 |
+
handleStopPlayback();
|
| 690 |
+
addLog('info', 'Agent voice stream play completed.');
|
| 691 |
+
}
|
| 692 |
+
};
|
| 693 |
+
};
|
| 694 |
+
|
| 695 |
+
const handleStopPlayback = () => {
|
| 696 |
+
isStreamingActiveRef.current = false;
|
| 697 |
+
playbackQueueRef.current = [];
|
| 698 |
+
|
| 699 |
+
// Stop and clear all active buffers
|
| 700 |
+
scheduledSourcesRef.current.forEach(src => {
|
| 701 |
+
try { src.stop(); } catch(e) {}
|
| 702 |
+
});
|
| 703 |
+
scheduledSourcesRef.current = [];
|
| 704 |
+
|
| 705 |
+
if (traditionalSourceRef.current) {
|
| 706 |
+
try { traditionalSourceRef.current.stop(); } catch(e) {}
|
| 707 |
+
traditionalSourceRef.current = null;
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
// Stop reference preview playback if active
|
| 711 |
+
if (previewSourceRef.current) {
|
| 712 |
+
try { previewSourceRef.current.stop(); } catch(e) {}
|
| 713 |
+
previewSourceRef.current = null;
|
| 714 |
+
}
|
| 715 |
+
setPlayingPreviewId(null);
|
| 716 |
+
|
| 717 |
+
setIsAudioPlayingState(false);
|
| 718 |
+
setShowToolbar(false);
|
| 719 |
+
setAgentState({
|
| 720 |
+
status: 'idle',
|
| 721 |
+
title: 'AGENT READY',
|
| 722 |
+
sub: 'Configure scripts and trigger generation below'
|
| 723 |
+
});
|
| 724 |
+
addLog('system', 'Agent audio playback stopped.');
|
| 725 |
+
};
|
| 726 |
+
|
| 727 |
+
// ==========================================================================
|
| 728 |
+
// Canvas Waves Drawing Loop (AnalyserNode hook)
|
| 729 |
+
// ==========================================================================
|
| 730 |
+
useEffect(() => {
|
| 731 |
+
const canvas = canvasRef.current;
|
| 732 |
+
if (!canvas) return;
|
| 733 |
+
const ctx = canvas.getContext('2d');
|
| 734 |
+
const width = canvas.width;
|
| 735 |
+
const height = canvas.height;
|
| 736 |
+
let animationFrameId;
|
| 737 |
+
|
| 738 |
+
const renderWave = () => {
|
| 739 |
+
animationFrameId = requestAnimationFrame(renderWave);
|
| 740 |
+
|
| 741 |
+
ctx.fillStyle = 'rgba(7, 9, 14, 0.25)'; // Back-sweep fade
|
| 742 |
+
ctx.fillRect(0, 0, width, height);
|
| 743 |
+
|
| 744 |
+
let dataArray = null;
|
| 745 |
+
let bufferLength = 0;
|
| 746 |
+
|
| 747 |
+
if (analyserNodeRef.current && isAudioPlayingState) {
|
| 748 |
+
bufferLength = analyserNodeRef.current.frequencyBinCount;
|
| 749 |
+
dataArray = new Uint8Array(bufferLength);
|
| 750 |
+
analyserNodeRef.current.getByteTimeDomainData(dataArray);
|
| 751 |
+
}
|
| 752 |
+
|
| 753 |
+
ctx.lineWidth = 2.5;
|
| 754 |
+
ctx.shadowBlur = isAudioPlayingState ? 10 : 0;
|
| 755 |
+
ctx.shadowColor = 'rgba(0, 242, 254, 0.4)';
|
| 756 |
+
|
| 757 |
+
// Linear Neon color gradient across visualizer canvas
|
| 758 |
+
const gradient = ctx.createLinearGradient(0, 0, width, 0);
|
| 759 |
+
gradient.addColorStop(0, '#a259ff'); // Purple
|
| 760 |
+
gradient.addColorStop(0.5, '#00f2fe'); // Cyan
|
| 761 |
+
gradient.addColorStop(1, '#ff5c8d'); // Pink
|
| 762 |
+
ctx.strokeStyle = gradient;
|
| 763 |
+
|
| 764 |
+
ctx.beginPath();
|
| 765 |
+
|
| 766 |
+
if (dataArray) {
|
| 767 |
+
// Draw real sound wave oscillations
|
| 768 |
+
const sliceWidth = width / bufferLength;
|
| 769 |
+
let x = 0;
|
| 770 |
+
for (let i = 0; i < bufferLength; i++) {
|
| 771 |
+
const v = dataArray[i] / 128.0;
|
| 772 |
+
const y = (v * height) / 2;
|
| 773 |
+
if (i === 0) ctx.moveTo(x, y);
|
| 774 |
+
else ctx.lineTo(x, y);
|
| 775 |
+
x += sliceWidth;
|
| 776 |
+
}
|
| 777 |
+
} else {
|
| 778 |
+
// Draw calm animated breathing flatline when quiet
|
| 779 |
+
const points = 40;
|
| 780 |
+
const sliceWidth = width / points;
|
| 781 |
+
let x = 0;
|
| 782 |
+
const time = Date.now() * 0.003;
|
| 783 |
+
for (let i = 0; i < points; i++) {
|
| 784 |
+
const breathing = Math.sin(time) * 0.2 + 0.8;
|
| 785 |
+
const y = height / 2 + Math.sin(i * 0.15 + time * 2) * (2.5 * breathing);
|
| 786 |
+
if (i === 0) ctx.moveTo(x, y);
|
| 787 |
+
else ctx.lineTo(x, y);
|
| 788 |
+
x += sliceWidth;
|
| 789 |
+
}
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
ctx.lineTo(width, height / 2);
|
| 793 |
+
ctx.stroke();
|
| 794 |
+
};
|
| 795 |
+
|
| 796 |
+
renderWave();
|
| 797 |
+
|
| 798 |
+
return () => cancelAnimationFrame(animationFrameId);
|
| 799 |
+
}, [isAudioPlayingState]);
|
| 800 |
+
|
| 801 |
+
// ==========================================================================
|
| 802 |
+
// Synthesis Triggers (REST vs WebSocket Stream)
|
| 803 |
+
// ==========================================================================
|
| 804 |
+
const handleSynthesize = async () => {
|
| 805 |
+
const text = script.trim();
|
| 806 |
+
if (!text) {
|
| 807 |
+
addLog('error', 'Cannot synthesize. Text script field is empty!');
|
| 808 |
+
return;
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
initAudio();
|
| 812 |
+
handleStopPlayback(); // Halt ongoing audio runs
|
| 813 |
+
|
| 814 |
+
// Map dynamic speaker voice paths
|
| 815 |
+
const speakerVoices = {};
|
| 816 |
+
speakers.forEach(s => {
|
| 817 |
+
if (s.voicePath) {
|
| 818 |
+
speakerVoices[s.id] = s.voicePath;
|
| 819 |
+
}
|
| 820 |
+
});
|
| 821 |
+
|
| 822 |
+
// Reset master download url
|
| 823 |
+
setCompiledAudioUrl(null);
|
| 824 |
+
setCompiledAudioMode(null);
|
| 825 |
+
|
| 826 |
+
if (streamMode) {
|
| 827 |
+
// WS Streaming Mode
|
| 828 |
+
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
| 829 |
+
addLog('error', 'WebSocket is offline. Streaming mode disabled.');
|
| 830 |
+
return;
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
+
startStreamingPlayback();
|
| 834 |
+
wsRef.current.send(JSON.stringify({
|
| 835 |
+
text,
|
| 836 |
+
voice_sample_path: speakers[0]?.voicePath || null,
|
| 837 |
+
speaker_id: 0,
|
| 838 |
+
speaker_voices: speakerVoices
|
| 839 |
+
}));
|
| 840 |
+
|
| 841 |
+
} else {
|
| 842 |
+
// Traditional File-based Playback Mode
|
| 843 |
+
try {
|
| 844 |
+
addLog('info', 'Compiling voice script... Sending POST to VibeVoice generator...');
|
| 845 |
+
setAgentState({
|
| 846 |
+
status: 'thinking',
|
| 847 |
+
title: 'COMPILING AUDIO',
|
| 848 |
+
sub: 'Running neural speech generation weights...'
|
| 849 |
+
});
|
| 850 |
+
setIsAudioPlayingState(true);
|
| 851 |
+
|
| 852 |
+
const res = await fetch('/api/generate', {
|
| 853 |
+
method: 'POST',
|
| 854 |
+
headers: { 'Content-Type': 'application/json' },
|
| 855 |
+
body: JSON.stringify({
|
| 856 |
+
text,
|
| 857 |
+
voice_sample_path: speakers[0]?.voicePath || null,
|
| 858 |
+
speaker_id: 0,
|
| 859 |
+
speaker_voices: speakerVoices
|
| 860 |
+
})
|
| 861 |
+
});
|
| 862 |
+
const data = await res.json();
|
| 863 |
+
|
| 864 |
+
if (data.status === 'success') {
|
| 865 |
+
addLog('success', `Voice file compiled. Method: [${data.mode}]. Playing track...`);
|
| 866 |
+
loadTraditionalWav(data.audio_url);
|
| 867 |
+
setCompiledAudioUrl(data.audio_url);
|
| 868 |
+
setCompiledAudioMode('file');
|
| 869 |
+
|
| 870 |
+
// Automatically fetch compiled file and archive to Supabase history
|
| 871 |
+
saveTraditionalPodcastToHistory(data.audio_url, text, data.mode);
|
| 872 |
+
} else {
|
| 873 |
+
throw new Error('Server returned error response.');
|
| 874 |
+
}
|
| 875 |
+
} catch (e) {
|
| 876 |
+
handleStopPlayback();
|
| 877 |
+
addLog('error', `Synthesis failed: ${e.message}`);
|
| 878 |
+
}
|
| 879 |
+
}
|
| 880 |
+
};
|
| 881 |
+
|
| 882 |
+
const loadTraditionalWav = async (audioUrl) => {
|
| 883 |
+
try {
|
| 884 |
+
const res = await fetch(audioUrl);
|
| 885 |
+
const arrayBuffer = await res.arrayBuffer();
|
| 886 |
+
|
| 887 |
+
audioCtxRef.current.decodeAudioData(arrayBuffer, (decodedBuffer) => {
|
| 888 |
+
const source = audioCtxRef.current.createBufferSource();
|
| 889 |
+
source.buffer = decodedBuffer;
|
| 890 |
+
source.playbackRate.value = parseFloat(speed);
|
| 891 |
+
source.connect(gainNodeRef.current);
|
| 892 |
+
source.start(0);
|
| 893 |
+
|
| 894 |
+
traditionalSourceRef.current = source;
|
| 895 |
+
traditionalDurationRef.current = decodedBuffer.duration;
|
| 896 |
+
traditionalStartClockRef.current = audioCtxRef.current.currentTime;
|
| 897 |
+
|
| 898 |
+
setAgentState({
|
| 899 |
+
status: 'speaking',
|
| 900 |
+
title: 'PLAYING FILE',
|
| 901 |
+
sub: 'Streaming synthesized high-fidelity compiled audio track...'
|
| 902 |
+
});
|
| 903 |
+
setShowToolbar(true);
|
| 904 |
+
setTimeline({ elapsed: 0, total: decodedBuffer.duration });
|
| 905 |
+
|
| 906 |
+
const tickTimeline = () => {
|
| 907 |
+
if (traditionalSourceRef.current === source) {
|
| 908 |
+
const elapsed = (audioCtxRef.current.currentTime - traditionalStartClockRef.current) * parseFloat(speed);
|
| 909 |
+
setTimeline(prev => ({ ...prev, elapsed: Math.min(elapsed, decodedBuffer.duration) }));
|
| 910 |
+
if (elapsed < decodedBuffer.duration) {
|
| 911 |
+
requestAnimationFrame(tickTimeline);
|
| 912 |
+
}
|
| 913 |
+
}
|
| 914 |
+
};
|
| 915 |
+
|
| 916 |
+
tickTimeline();
|
| 917 |
+
|
| 918 |
+
source.onended = () => {
|
| 919 |
+
if (traditionalSourceRef.current === source) {
|
| 920 |
+
handleStopPlayback();
|
| 921 |
+
}
|
| 922 |
+
};
|
| 923 |
+
|
| 924 |
+
}, (err) => {
|
| 925 |
+
addLog('error', 'Failed to decode WAV file buffer.');
|
| 926 |
+
handleStopPlayback();
|
| 927 |
+
});
|
| 928 |
+
} catch(e) {
|
| 929 |
+
addLog('error', 'Error playing generated track.');
|
| 930 |
+
handleStopPlayback();
|
| 931 |
+
}
|
| 932 |
+
};
|
| 933 |
+
|
| 934 |
+
const formatTime = (seconds) => {
|
| 935 |
+
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
| 936 |
+
const s = Math.floor(seconds % 60).toString().padStart(2, '0');
|
| 937 |
+
return `${m}:${s}`;
|
| 938 |
+
};
|
| 939 |
+
|
| 940 |
+
const encodeWAV = (samples, sampleRate) => {
|
| 941 |
+
const buffer = new ArrayBuffer(44 + samples.length * 2);
|
| 942 |
+
const view = new DataView(buffer);
|
| 943 |
+
|
| 944 |
+
const writeString = (view, offset, string) => {
|
| 945 |
+
for (let i = 0; i < string.length; i++) {
|
| 946 |
+
view.setUint8(offset + i, string.charCodeAt(i));
|
| 947 |
+
}
|
| 948 |
+
};
|
| 949 |
+
|
| 950 |
+
const floatTo16BitPCM = (output, offset, input) => {
|
| 951 |
+
for (let i = 0; i < input.length; i++, offset += 2) {
|
| 952 |
+
let s = Math.max(-1, Math.min(1, input[i]));
|
| 953 |
+
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
| 954 |
+
}
|
| 955 |
+
};
|
| 956 |
+
|
| 957 |
+
/* RIFF identifier */
|
| 958 |
+
writeString(view, 0, 'RIFF');
|
| 959 |
+
/* file length */
|
| 960 |
+
view.setUint32(4, 36 + samples.length * 2, true);
|
| 961 |
+
/* RIFF type */
|
| 962 |
+
writeString(view, 8, 'WAVE');
|
| 963 |
+
/* format chunk identifier */
|
| 964 |
+
writeString(view, 12, 'fmt ');
|
| 965 |
+
/* format chunk length */
|
| 966 |
+
view.setUint32(16, 16, true);
|
| 967 |
+
/* sample format (raw) */
|
| 968 |
+
view.setUint16(20, 1, true);
|
| 969 |
+
/* channel count */
|
| 970 |
+
view.setUint16(22, 1, true);
|
| 971 |
+
/* sample rate */
|
| 972 |
+
view.setUint32(24, sampleRate, true);
|
| 973 |
+
/* byte rate (sample rate * block align) */
|
| 974 |
+
view.setUint32(28, sampleRate * 2, true);
|
| 975 |
+
/* block align (channel count * bytes per sample) */
|
| 976 |
+
view.setUint16(32, 2, true);
|
| 977 |
+
/* bits per sample */
|
| 978 |
+
view.setUint16(34, 16, true);
|
| 979 |
+
/* data chunk identifier */
|
| 980 |
+
writeString(view, 36, 'data');
|
| 981 |
+
/* data chunk length */
|
| 982 |
+
view.setUint32(40, samples.length * 2, true);
|
| 983 |
+
|
| 984 |
+
floatTo16BitPCM(view, 44, samples);
|
| 985 |
+
|
| 986 |
+
return new Blob([view], { type: 'audio/wav' });
|
| 987 |
+
};
|
| 988 |
+
|
| 989 |
+
// ==========================================================================
|
| 990 |
+
// Voice Cloning Microphone Hooks (Pillar 4)
|
| 991 |
+
// ==========================================================================
|
| 992 |
+
const toggleRecording = async (speakerId) => {
|
| 993 |
+
const speaker = speakers.find(s => s.id === speakerId);
|
| 994 |
+
if (!speaker) return;
|
| 995 |
+
|
| 996 |
+
if (speaker.isRecording) {
|
| 997 |
+
// Stop recording
|
| 998 |
+
const recorder = mediaRecordersRef.current[speakerId];
|
| 999 |
+
if (recorder) {
|
| 1000 |
+
recorder.stop();
|
| 1001 |
+
setSpeakers(prev => prev.map(s => s.id === speakerId ? { ...s, isRecording: false } : s));
|
| 1002 |
+
setAgentState({
|
| 1003 |
+
status: 'idle',
|
| 1004 |
+
title: 'AGENT READY',
|
| 1005 |
+
sub: 'Voice profile recorded. Syncing to neural engine...'
|
| 1006 |
+
});
|
| 1007 |
+
}
|
| 1008 |
+
} else {
|
| 1009 |
+
// Start recording
|
| 1010 |
+
try {
|
| 1011 |
+
addLog('info', `Requesting mic permission for Speaker ${speakerId} (${speaker.name})...`);
|
| 1012 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 1013 |
+
|
| 1014 |
+
setAgentState({
|
| 1015 |
+
status: 'listening',
|
| 1016 |
+
title: 'LISTENING...',
|
| 1017 |
+
sub: 'Speak normally for 10-15s to capture vocal signature...'
|
| 1018 |
+
});
|
| 1019 |
+
|
| 1020 |
+
// Use independent default sample-rate AudioContext specifically for mic capture to avoid browser resampling bugs
|
| 1021 |
+
const RecordingContextClass = window.AudioContext || window.webkitAudioContext;
|
| 1022 |
+
const recContext = new RecordingContextClass();
|
| 1023 |
+
|
| 1024 |
+
const source = recContext.createMediaStreamSource(stream);
|
| 1025 |
+
const processor = recContext.createScriptProcessor(4096, 1, 1);
|
| 1026 |
+
const recordingSamples = [];
|
| 1027 |
+
|
| 1028 |
+
processor.onaudioprocess = (e) => {
|
| 1029 |
+
const channelData = e.inputBuffer.getChannelData(0);
|
| 1030 |
+
recordingSamples.push(new Float32Array(channelData));
|
| 1031 |
+
};
|
| 1032 |
+
|
| 1033 |
+
source.connect(processor);
|
| 1034 |
+
processor.connect(recContext.destination);
|
| 1035 |
+
|
| 1036 |
+
mediaRecordersRef.current[speakerId] = {
|
| 1037 |
+
stop: () => {
|
| 1038 |
+
source.disconnect(processor);
|
| 1039 |
+
processor.disconnect(recContext.destination);
|
| 1040 |
+
stream.getTracks().forEach(track => track.stop());
|
| 1041 |
+
recContext.close();
|
| 1042 |
+
|
| 1043 |
+
addLog('info', 'Encoding microphone speech data to 16-bit PCM WAV...');
|
| 1044 |
+
let totalLength = 0;
|
| 1045 |
+
for (let i = 0; i < recordingSamples.length; i++) {
|
| 1046 |
+
totalLength += recordingSamples[i].length;
|
| 1047 |
+
}
|
| 1048 |
+
const flattened = new Float32Array(totalLength);
|
| 1049 |
+
let offset = 0;
|
| 1050 |
+
for (let i = 0; i < recordingSamples.length; i++) {
|
| 1051 |
+
flattened.set(recordingSamples[i], offset);
|
| 1052 |
+
offset += recordingSamples[i].length;
|
| 1053 |
+
}
|
| 1054 |
+
|
| 1055 |
+
const wavBlob = encodeWAV(flattened, recContext.sampleRate);
|
| 1056 |
+
setNamingModal({
|
| 1057 |
+
isOpen: true,
|
| 1058 |
+
fileBlob: wavBlob,
|
| 1059 |
+
speakerId: speakerId,
|
| 1060 |
+
defaultName: speaker.name
|
| 1061 |
+
});
|
| 1062 |
+
setVoiceNameInput(speaker.name);
|
| 1063 |
+
}
|
| 1064 |
+
};
|
| 1065 |
+
|
| 1066 |
+
setSpeakers(prev => prev.map(s => s.id === speakerId ? { ...s, isRecording: true } : s));
|
| 1067 |
+
addLog('success', 'Microphone recording started. Speak clearly now!');
|
| 1068 |
+
} catch (e) {
|
| 1069 |
+
addLog('error', `Microphone recording failed: ${e.message}`);
|
| 1070 |
+
setAgentState({
|
| 1071 |
+
status: 'idle',
|
| 1072 |
+
title: 'AGENT READY',
|
| 1073 |
+
sub: 'Record trigger failed or permission denied'
|
| 1074 |
+
});
|
| 1075 |
+
}
|
| 1076 |
+
}
|
| 1077 |
+
};
|
| 1078 |
+
|
| 1079 |
+
const handleFileUpload = (e, speakerId) => {
|
| 1080 |
+
const file = e.target.files[0];
|
| 1081 |
+
if (!file) return;
|
| 1082 |
+
const speaker = speakers.find(s => s.id === speakerId);
|
| 1083 |
+
addLog('info', `Selected file for upload: ${file.name}`);
|
| 1084 |
+
const cleanFileName = file.name.replace(/\.[^/.]+$/, "");
|
| 1085 |
+
setNamingModal({
|
| 1086 |
+
isOpen: true,
|
| 1087 |
+
fileBlob: file,
|
| 1088 |
+
speakerId: speakerId,
|
| 1089 |
+
defaultName: cleanFileName
|
| 1090 |
+
});
|
| 1091 |
+
setVoiceNameInput(cleanFileName);
|
| 1092 |
+
};
|
| 1093 |
+
|
| 1094 |
+
// Play/Stop Reference Voice Preview using main AudioContext pipeline
|
| 1095 |
+
const playPreview = async (speakerId, url) => {
|
| 1096 |
+
initAudio();
|
| 1097 |
+
handleStopPlayback(); // Stop any other playing session
|
| 1098 |
+
|
| 1099 |
+
if (playingPreviewId === speakerId) {
|
| 1100 |
+
stopPreviewPlayback();
|
| 1101 |
+
return;
|
| 1102 |
+
}
|
| 1103 |
+
|
| 1104 |
+
try {
|
| 1105 |
+
addLog('info', `Playing voice preview for Speaker ${speakerId}...`);
|
| 1106 |
+
setIsAudioPlayingState(true);
|
| 1107 |
+
setPlayingPreviewId(speakerId);
|
| 1108 |
+
|
| 1109 |
+
const res = await fetch(url);
|
| 1110 |
+
const arrayBuffer = await res.arrayBuffer();
|
| 1111 |
+
|
| 1112 |
+
audioCtxRef.current.decodeAudioData(arrayBuffer, (decodedBuffer) => {
|
| 1113 |
+
const source = audioCtxRef.current.createBufferSource();
|
| 1114 |
+
source.buffer = decodedBuffer;
|
| 1115 |
+
source.connect(gainNodeRef.current);
|
| 1116 |
+
source.start(0);
|
| 1117 |
+
|
| 1118 |
+
previewSourceRef.current = source;
|
| 1119 |
+
setAgentState({
|
| 1120 |
+
status: 'speaking',
|
| 1121 |
+
title: 'PLAYING REFERENCE',
|
| 1122 |
+
sub: `Listening to cloned voice reference profile...`
|
| 1123 |
+
});
|
| 1124 |
+
|
| 1125 |
+
source.onended = () => {
|
| 1126 |
+
stopPreviewPlayback();
|
| 1127 |
+
};
|
| 1128 |
+
}, (err) => {
|
| 1129 |
+
addLog('error', 'Failed to decode reference audio file buffer.');
|
| 1130 |
+
stopPreviewPlayback();
|
| 1131 |
+
});
|
| 1132 |
+
} catch (e) {
|
| 1133 |
+
addLog('error', 'Error playing reference voice.');
|
| 1134 |
+
stopPreviewPlayback();
|
| 1135 |
+
}
|
| 1136 |
+
};
|
| 1137 |
+
|
| 1138 |
+
const stopPreviewPlayback = () => {
|
| 1139 |
+
if (previewSourceRef.current) {
|
| 1140 |
+
try { previewSourceRef.current.stop(); } catch(e) {}
|
| 1141 |
+
previewSourceRef.current = null;
|
| 1142 |
+
}
|
| 1143 |
+
setPlayingPreviewId(null);
|
| 1144 |
+
setIsAudioPlayingState(false);
|
| 1145 |
+
setAgentState({
|
| 1146 |
+
status: 'idle',
|
| 1147 |
+
title: 'AGENT READY',
|
| 1148 |
+
sub: 'Configure scripts and trigger generation below'
|
| 1149 |
+
});
|
| 1150 |
+
};
|
| 1151 |
+
|
| 1152 |
+
// Add Dynamic Speaker Slot
|
| 1153 |
+
const addSpeaker = () => {
|
| 1154 |
+
const nextId = speakers.length > 0 ? Math.max(...speakers.map(s => s.id)) + 1 : 0;
|
| 1155 |
+
const speakerColors = ['cyan', 'purple', 'emerald', 'amber', 'rose', 'blue'];
|
| 1156 |
+
const color = speakerColors[nextId % speakerColors.length];
|
| 1157 |
+
const roles = ['Podcast Guest', 'Narrator', 'Secondary Host', 'Panelist', 'Expert Analyst'];
|
| 1158 |
+
const role = roles[(nextId - 2) % roles.length] || 'Guest Slot';
|
| 1159 |
+
|
| 1160 |
+
const newSpk = {
|
| 1161 |
+
id: nextId,
|
| 1162 |
+
name: `Speaker ${nextId}`,
|
| 1163 |
+
isRecording: false,
|
| 1164 |
+
voicePath: null,
|
| 1165 |
+
previewUrl: null,
|
| 1166 |
+
role: role,
|
| 1167 |
+
color: color
|
| 1168 |
+
};
|
| 1169 |
+
|
| 1170 |
+
setSpeakers(prev => [...prev, newSpk]);
|
| 1171 |
+
addLog('system', `Added dynamic speaker slot: [Speaker ${nextId}]`);
|
| 1172 |
+
};
|
| 1173 |
+
|
| 1174 |
+
// Remove Dynamic Speaker Slot
|
| 1175 |
+
const removeSpeaker = (speakerId) => {
|
| 1176 |
+
if (speakerId === 0) {
|
| 1177 |
+
addLog('error', 'Cannot delete Speaker 0 (Primary Speaker is required).');
|
| 1178 |
+
return;
|
| 1179 |
+
}
|
| 1180 |
+
|
| 1181 |
+
handleStopPlayback();
|
| 1182 |
+
setSpeakers(prev => prev.filter(s => s.id !== speakerId));
|
| 1183 |
+
addLog('system', `Removed speaker slot: [Speaker ${speakerId}]`);
|
| 1184 |
+
};
|
| 1185 |
+
|
| 1186 |
+
const uploadVoiceProfile = async (fileBlob, speakerId, speakerName) => {
|
| 1187 |
+
try {
|
| 1188 |
+
addLog('info', `Uploading permanent voice profile "${speakerName}" to Supabase...`);
|
| 1189 |
+
|
| 1190 |
+
const fileName = `voice_${Date.now()}_${speakerId}.wav`;
|
| 1191 |
+
|
| 1192 |
+
// 1. Upload to Supabase Storage
|
| 1193 |
+
const { data: storageData, error: storageError } = await supabase
|
| 1194 |
+
.storage
|
| 1195 |
+
.from('voice_samples')
|
| 1196 |
+
.upload(fileName, fileBlob);
|
| 1197 |
+
|
| 1198 |
+
if (storageError) throw storageError;
|
| 1199 |
+
|
| 1200 |
+
// Get public URL
|
| 1201 |
+
const { data: publicUrlData } = supabase
|
| 1202 |
+
.storage
|
| 1203 |
+
.from('voice_samples')
|
| 1204 |
+
.getPublicUrl(fileName);
|
| 1205 |
+
|
| 1206 |
+
const publicUrl = publicUrlData.publicUrl;
|
| 1207 |
+
|
| 1208 |
+
// 2. Insert into Supabase Database
|
| 1209 |
+
const { data: dbData, error: dbError } = await supabase
|
| 1210 |
+
.from('voice_profiles')
|
| 1211 |
+
.insert([{ name: speakerName, audio_url: publicUrl }])
|
| 1212 |
+
.select();
|
| 1213 |
+
|
| 1214 |
+
if (dbError) throw dbError;
|
| 1215 |
+
|
| 1216 |
+
// 3. Update UI state
|
| 1217 |
+
setSpeakers(prev => prev.map(s => s.id === speakerId ? {
|
| 1218 |
+
...s,
|
| 1219 |
+
voicePath: publicUrl,
|
| 1220 |
+
previewUrl: publicUrl
|
| 1221 |
+
} : s));
|
| 1222 |
+
|
| 1223 |
+
addLog('success', `Voice signature for "${speakerName}" saved to Supabase successfully!`);
|
| 1224 |
+
|
| 1225 |
+
// Refresh the saved voices list
|
| 1226 |
+
fetchSavedVoices();
|
| 1227 |
+
|
| 1228 |
+
} catch(e) {
|
| 1229 |
+
addLog('warning', `Supabase upload failed (${e.message}). Falling back to local backend storage...`);
|
| 1230 |
+
try {
|
| 1231 |
+
const formData = new FormData();
|
| 1232 |
+
const fileToUpload = fileBlob instanceof File
|
| 1233 |
+
? fileBlob
|
| 1234 |
+
: new File([fileBlob], `voice_${Date.now()}_${speakerId}.wav`, { type: 'audio/wav' });
|
| 1235 |
+
|
| 1236 |
+
formData.append('file', fileToUpload);
|
| 1237 |
+
formData.append('speaker_name', speakerName);
|
| 1238 |
+
|
| 1239 |
+
const res = await fetch('/api/upload-voice', {
|
| 1240 |
+
method: 'POST',
|
| 1241 |
+
body: formData
|
| 1242 |
+
});
|
| 1243 |
+
|
| 1244 |
+
if (!res.ok) throw new Error(`Backend returned status ${res.status}`);
|
| 1245 |
+
const data = await res.json();
|
| 1246 |
+
|
| 1247 |
+
if (data.status === 'success') {
|
| 1248 |
+
const localUrl = '/' + data.voice_path;
|
| 1249 |
+
|
| 1250 |
+
setSpeakers(prev => prev.map(s => s.id === speakerId ? {
|
| 1251 |
+
...s,
|
| 1252 |
+
voicePath: localUrl,
|
| 1253 |
+
previewUrl: localUrl
|
| 1254 |
+
} : s));
|
| 1255 |
+
|
| 1256 |
+
addLog('success', `Voice signature for "${speakerName}" saved locally on server!`);
|
| 1257 |
+
|
| 1258 |
+
// Instantly add this local voice to the savedVoices dropdown selection
|
| 1259 |
+
setSavedVoices(prev => {
|
| 1260 |
+
const exists = prev.some(v => v.audio_url === localUrl);
|
| 1261 |
+
if (exists) return prev;
|
| 1262 |
+
return [...prev, {
|
| 1263 |
+
id: data.filename,
|
| 1264 |
+
name: speakerName,
|
| 1265 |
+
audio_url: localUrl
|
| 1266 |
+
}];
|
| 1267 |
+
});
|
| 1268 |
+
} else {
|
| 1269 |
+
throw new Error(data.message || 'Local upload failed');
|
| 1270 |
+
}
|
| 1271 |
+
} catch (localErr) {
|
| 1272 |
+
addLog('error', `Voice signature local upload fallback failed: ${localErr.message}`);
|
| 1273 |
+
}
|
| 1274 |
+
}
|
| 1275 |
+
};
|
| 1276 |
+
|
| 1277 |
+
// Toggle AI Model Mode on Backend
|
| 1278 |
+
const handleToggleModelMode = async () => {
|
| 1279 |
+
const enable = !modelStatus.useRealModel;
|
| 1280 |
+
setTogglingModel(true);
|
| 1281 |
+
addLog('info', `Requesting engine toggle: ${enable ? 'Enabling AI Model' : 'Enabling Demo Synthesizer'}`);
|
| 1282 |
+
|
| 1283 |
+
try {
|
| 1284 |
+
const res = await fetch(`/api/toggle-model?enable=${enable}`, { method: 'POST' });
|
| 1285 |
+
const data = await res.json();
|
| 1286 |
+
|
| 1287 |
+
setModelStatus(prev => ({
|
| 1288 |
+
...prev,
|
| 1289 |
+
useRealModel: data.use_real_model,
|
| 1290 |
+
loading: enable, // starts load
|
| 1291 |
+
loaded: false
|
| 1292 |
+
}));
|
| 1293 |
+
|
| 1294 |
+
setTimeout(checkServerStatus, 1500);
|
| 1295 |
+
} catch (e) {
|
| 1296 |
+
addLog('error', 'Toggle engine request failed.');
|
| 1297 |
+
} finally {
|
| 1298 |
+
setTogglingModel(false);
|
| 1299 |
+
}
|
| 1300 |
+
};
|
| 1301 |
+
|
| 1302 |
+
// Presets loaders
|
| 1303 |
+
const loadPreset = (type) => {
|
| 1304 |
+
if (type === 'podcast') {
|
| 1305 |
+
setScript(`Speaker 0: Welcome back to the Vibe Voice podcast segment. Today we are cloning human expressions.
|
| 1306 |
+
Speaker 1: That is right! The 0.5B model manages this dialogue transition on a local CPU extremely fast.
|
| 1307 |
+
Speaker 0: Incredible! It sounds like two real people sitting in this glassmorphic studio together.`);
|
| 1308 |
+
addLog('info', 'Loaded template: [Podcast Dialogue Interview]');
|
| 1309 |
+
} else if (type === 'assistant') {
|
| 1310 |
+
setScript("Speaker 0: Hello, I am your personalized voice assistant. How can I help you customize your Dine Direct orders today?");
|
| 1311 |
+
addLog('info', 'Loaded template: [DineDirect AI Support Agent]');
|
| 1312 |
+
} else if (type === 'news') {
|
| 1313 |
+
setScript("Speaker 1: Breaking news from Microsoft Research. The Vibe Voice speech synthesis models are now live on local devices.");
|
| 1314 |
+
addLog('info', 'Loaded template: [News Bulletins Broadcast]');
|
| 1315 |
+
}
|
| 1316 |
+
};
|
| 1317 |
+
|
| 1318 |
+
if (currentView === 'landing') {
|
| 1319 |
+
return <LandingPage onTryDemo={() => setCurrentView('studio')} />;
|
| 1320 |
+
}
|
| 1321 |
+
|
| 1322 |
+
return (
|
| 1323 |
+
<div className="relative min-h-screen bg-[#07090e] text-[#f0f3fa] font-sans selection:bg-[#00f2fe]/20 select-none pb-12 overflow-x-hidden">
|
| 1324 |
+
{/* Background Floating Ambient Blobs */}
|
| 1325 |
+
<div className="fixed inset-0 overflow-hidden pointer-events-none z-0">
|
| 1326 |
+
<div className="absolute w-[600px] h-[600px] rounded-full top-[-200px] left-[-150px] bg-gradient-to-br from-[#00f2fe]/15 to-transparent blur-[120px] animate-float-glow-1"></div>
|
| 1327 |
+
<div className="absolute w-[700px] h-[700px] rounded-full bottom-[-250px] right-[-150px] bg-gradient-to-br from-[#a259ff]/15 to-transparent blur-[120px] animate-float-glow-2"></div>
|
| 1328 |
+
</div>
|
| 1329 |
+
|
| 1330 |
+
<div className="relative z-10 max-w-[1300px] mx-auto px-4 pt-6 flex flex-col min-h-screen">
|
| 1331 |
+
{/* Header Widget */}
|
| 1332 |
+
<header className="flex flex-col md:flex-row justify-between items-center bg-glass p-4 rounded-2xl border border-white/8 backdrop-blur-xl mb-6 shadow-2xl gap-4">
|
| 1333 |
+
<div className="flex items-center gap-3 cursor-pointer select-none" onClick={() => setCurrentView('landing')}>
|
| 1334 |
+
<Sparkles className="w-8 h-8 text-neon-cyan drop-shadow-[0_0_10px_rgba(0,242,254,0.4)]" />
|
| 1335 |
+
<div>
|
| 1336 |
+
<h1 className="text-xl font-bold tracking-tight">
|
| 1337 |
+
VibeVoice<span className="bg-gradient-to-r from-[#00f2fe] to-[#a259ff] bg-clip-text text-transparent">Studio</span>
|
| 1338 |
+
</h1>
|
| 1339 |
+
<p className="text-[10px] text-[#8e9bb5] uppercase tracking-wider">Microsoft Frontier Speech Synthesis</p>
|
| 1340 |
+
</div>
|
| 1341 |
+
</div>
|
| 1342 |
+
|
| 1343 |
+
<div className="flex items-center gap-4">
|
| 1344 |
+
{/* Engine Status Panel */}
|
| 1345 |
+
<div className="flex items-center gap-3 bg-[#00f2fe]/5 px-4 py-2 rounded-xl border border-[#00f2fe]/20 shadow-[0_0_12px_rgba(0,242,254,0.06)]">
|
| 1346 |
+
<span className={`w-2 h-2 rounded-full ${
|
| 1347 |
+
modelStatus.loading
|
| 1348 |
+
? 'bg-[#ffb703] shadow-[0_0_8px_#ffb703] animate-pulse'
|
| 1349 |
+
: modelStatus.loaded
|
| 1350 |
+
? 'bg-[#00e673] shadow-[0_0_8px_#00e673]'
|
| 1351 |
+
: 'bg-[#ff5c8d] shadow-[0_0_8px_#ff5c8d]'
|
| 1352 |
+
}`}></span>
|
| 1353 |
+
|
| 1354 |
+
<span className="text-xs text-[#8e9bb5]">
|
| 1355 |
+
VibeVoice Engine: <strong className="text-[#f0f3fa]">
|
| 1356 |
+
{modelStatus.loading
|
| 1357 |
+
? 'Loading AI...'
|
| 1358 |
+
: 'VibeVoice-1.5 AI'}
|
| 1359 |
+
</strong>
|
| 1360 |
+
</span>
|
| 1361 |
+
</div>
|
| 1362 |
+
|
| 1363 |
+
{/* Connection badge */}
|
| 1364 |
+
<div className={`flex items-center gap-2 text-xs font-semibold px-4 py-2 rounded-xl border ${
|
| 1365 |
+
wsConnected
|
| 1366 |
+
? 'text-[#00f2fe] bg-[#00f2fe]/6 border-[#00f2fe]/20 shadow-[0_0_8px_rgba(0,242,254,0.1)]'
|
| 1367 |
+
: 'text-[#8e9bb5] bg-white/4 border-white/5'
|
| 1368 |
+
}`}>
|
| 1369 |
+
<Radio className={`w-4 h-4 ${wsConnected ? 'animate-pulse' : ''}`} />
|
| 1370 |
+
<span>{wsConnected ? 'Connected' : 'Offline'}</span>
|
| 1371 |
+
</div>
|
| 1372 |
+
|
| 1373 |
+
<button
|
| 1374 |
+
onClick={() => setCurrentView('landing')}
|
| 1375 |
+
className="flex items-center gap-1.5 px-4 py-2 text-xs font-bold bg-white/4 border border-white/5 hover:border-white/12 rounded-xl hover:bg-white/8 transition duration-200 cursor-pointer active:scale-95 text-white"
|
| 1376 |
+
>
|
| 1377 |
+
Back to Home
|
| 1378 |
+
</button>
|
| 1379 |
+
</div>
|
| 1380 |
+
</header>
|
| 1381 |
+
|
| 1382 |
+
{/* Dashboard Grid Grid */}
|
| 1383 |
+
<main className="grid grid-cols-1 lg:grid-cols-[360px_1fr] gap-6 flex-1">
|
| 1384 |
+
{/* Left Column: Voice Cloning Controls */}
|
| 1385 |
+
<section className="bg-glass p-6 rounded-3xl flex flex-col gap-5">
|
| 1386 |
+
<div>
|
| 1387 |
+
<div className="flex items-center gap-2 mb-1">
|
| 1388 |
+
<Settings className="w-5 h-5 text-neon-cyan" />
|
| 1389 |
+
<h2 className="text-lg font-semibold">Voice Cloning Center</h2>
|
| 1390 |
+
</div>
|
| 1391 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed">Clone any voice in seconds. Record or upload a 10-30s audio sample to create a speaker profile prompt.</p>
|
| 1392 |
+
</div>
|
| 1393 |
+
|
| 1394 |
+
{/* Speaker Slots */}
|
| 1395 |
+
{speakers.map((spk, idx) => {
|
| 1396 |
+
const colorClasses = spk.id === 0
|
| 1397 |
+
? 'bg-[#00f2fe]/6 border-[#00f2fe]/20 text-[#00f2fe]'
|
| 1398 |
+
: spk.id === 1
|
| 1399 |
+
? 'bg-[#a259ff]/6 border-[#a259ff]/20 text-[#a259ff]'
|
| 1400 |
+
: spk.color === 'emerald' ? 'bg-[#00e673]/6 border-[#00e673]/20 text-[#00e673]'
|
| 1401 |
+
: spk.color === 'amber' ? 'bg-[#ffb703]/6 border-[#ffb703]/20 text-[#ffb703]'
|
| 1402 |
+
: spk.color === 'rose' ? 'bg-[#ff5c8d]/6 border-[#ff5c8d]/20 text-[#ff5c8d]'
|
| 1403 |
+
: 'bg-[#00f2fe]/6 border-[#00f2fe]/20 text-[#00f2fe]';
|
| 1404 |
+
|
| 1405 |
+
return (
|
| 1406 |
+
<div key={spk.id} className="bg-glass-card p-4 rounded-2xl flex flex-col gap-3 transition duration-300 relative group">
|
| 1407 |
+
<div className="flex justify-between items-center">
|
| 1408 |
+
<div className="flex items-center gap-2">
|
| 1409 |
+
<span className="text-xs font-bold uppercase tracking-wider text-[#8e9bb5]">Speaker {spk.id}</span>
|
| 1410 |
+
{spk.id > 0 && (
|
| 1411 |
+
<button
|
| 1412 |
+
onClick={() => removeSpeaker(spk.id)}
|
| 1413 |
+
className="opacity-0 group-hover:opacity-100 text-[#ff5c8d]/60 hover:text-[#ff5c8d] p-0.5 transition duration-200 cursor-pointer"
|
| 1414 |
+
title="Delete speaker slot"
|
| 1415 |
+
>
|
| 1416 |
+
<Trash2 className="w-3.5 h-3.5" />
|
| 1417 |
+
</button>
|
| 1418 |
+
)}
|
| 1419 |
+
</div>
|
| 1420 |
+
<span className={`text-[10px] px-2 py-0.5 rounded-full uppercase font-bold border ${colorClasses}`}>{spk.role}</span>
|
| 1421 |
+
</div>
|
| 1422 |
+
|
| 1423 |
+
<div className="flex flex-col gap-1">
|
| 1424 |
+
<label className="text-[10px] text-[#8e9bb5] uppercase tracking-wide">Speaker Name</label>
|
| 1425 |
+
<input
|
| 1426 |
+
type="text"
|
| 1427 |
+
value={spk.name}
|
| 1428 |
+
onChange={(e) => {
|
| 1429 |
+
const val = e.target.value;
|
| 1430 |
+
setSpeakers(prev => prev.map(s => s.id === spk.id ? { ...s, name: val } : s));
|
| 1431 |
+
}}
|
| 1432 |
+
className="bg-black/20 border border-white/6 rounded-lg px-3 py-1.5 text-xs text-[#f0f3fa] focus:border-[#00f2fe] focus:shadow-[0_0_8px_rgba(0,242,254,0.15)] outline-none"
|
| 1433 |
+
/>
|
| 1434 |
+
</div>
|
| 1435 |
+
|
| 1436 |
+
<div className="flex flex-col gap-1">
|
| 1437 |
+
<label className="text-[10px] text-[#8e9bb5] uppercase tracking-wide">Or Select Saved Voice</label>
|
| 1438 |
+
<div className="flex gap-2">
|
| 1439 |
+
<select
|
| 1440 |
+
value={spk.voicePath ? savedVoices.find(v => v.audio_url === spk.voicePath)?.id || '' : ''}
|
| 1441 |
+
onChange={(e) => {
|
| 1442 |
+
const val = e.target.value;
|
| 1443 |
+
const selectedVoice = savedVoices.find(v => String(v.id) === String(val));
|
| 1444 |
+
if (selectedVoice) {
|
| 1445 |
+
setSpeakers(prev => prev.map(s => s.id === spk.id ? {
|
| 1446 |
+
...s,
|
| 1447 |
+
name: selectedVoice.name,
|
| 1448 |
+
voicePath: selectedVoice.audio_url,
|
| 1449 |
+
previewUrl: selectedVoice.audio_url
|
| 1450 |
+
} : s));
|
| 1451 |
+
addLog('info', `Selected saved voice "${selectedVoice.name}" from Supabase.`);
|
| 1452 |
+
} else {
|
| 1453 |
+
// Clear selection
|
| 1454 |
+
setSpeakers(prev => prev.map(s => s.id === spk.id ? {
|
| 1455 |
+
...s,
|
| 1456 |
+
voicePath: null,
|
| 1457 |
+
previewUrl: null
|
| 1458 |
+
} : s));
|
| 1459 |
+
}
|
| 1460 |
+
}}
|
| 1461 |
+
className="flex-1 bg-black/20 border border-white/6 rounded-lg px-3 py-1.5 text-xs text-[#f0f3fa] focus:border-[#00f2fe] focus:shadow-[0_0_8px_rgba(0,242,254,0.15)] outline-none"
|
| 1462 |
+
>
|
| 1463 |
+
<option value="">-- Choose a Voice --</option>
|
| 1464 |
+
{savedVoices.map(v => (
|
| 1465 |
+
<option key={v.id} value={v.id}>{v.name}</option>
|
| 1466 |
+
))}
|
| 1467 |
+
</select>
|
| 1468 |
+
|
| 1469 |
+
{spk.voicePath && (() => {
|
| 1470 |
+
const matchedVoice = savedVoices.find(v => v.audio_url === spk.voicePath);
|
| 1471 |
+
if (matchedVoice) {
|
| 1472 |
+
return (
|
| 1473 |
+
<div className="flex items-center gap-1 shrink-0">
|
| 1474 |
+
<button
|
| 1475 |
+
onClick={() => openRenameVoiceModal(matchedVoice.id, matchedVoice.name, matchedVoice.audio_url)}
|
| 1476 |
+
className="p-1.5 rounded-lg border border-white/6 hover:border-[#00f2fe]/40 text-[#8e9bb5] hover:text-[#00f2fe] bg-white/2 hover:bg-white/5 active:scale-95 transition cursor-pointer"
|
| 1477 |
+
title="Rename selected voice signature"
|
| 1478 |
+
>
|
| 1479 |
+
<Edit2 className="w-3.5 h-3.5" />
|
| 1480 |
+
</button>
|
| 1481 |
+
<button
|
| 1482 |
+
onClick={() => openDeleteVoiceModal(matchedVoice.id, matchedVoice.name, matchedVoice.audio_url)}
|
| 1483 |
+
className="p-1.5 rounded-lg border border-white/6 hover:border-[#ff5c8d]/40 text-[#8e9bb5] hover:text-[#ff5c8d] bg-white/2 hover:bg-[#ff5c8d]/10 active:scale-95 transition cursor-pointer"
|
| 1484 |
+
title="Delete selected voice signature"
|
| 1485 |
+
>
|
| 1486 |
+
<Trash2 className="w-3.5 h-3.5" />
|
| 1487 |
+
</button>
|
| 1488 |
+
</div>
|
| 1489 |
+
);
|
| 1490 |
+
}
|
| 1491 |
+
return null;
|
| 1492 |
+
})()}
|
| 1493 |
+
</div>
|
| 1494 |
+
</div>
|
| 1495 |
+
|
| 1496 |
+
<div className="grid grid-cols-2 gap-2">
|
| 1497 |
+
<button
|
| 1498 |
+
onClick={() => toggleRecording(spk.id)}
|
| 1499 |
+
className={`flex items-center justify-center gap-1.5 py-1.5 px-3 rounded-lg border text-xs cursor-pointer active:scale-95 transition duration-200 ${
|
| 1500 |
+
spk.isRecording
|
| 1501 |
+
? 'bg-[#ff5c8d]/20 border-[#ff5c8d] text-[#ff5c8d] animate-pulse'
|
| 1502 |
+
: 'bg-transparent border-white/10 hover:border-[#00f2fe] hover:text-[#00f2fe] text-white'
|
| 1503 |
+
}`}
|
| 1504 |
+
>
|
| 1505 |
+
{spk.isRecording ? <Square className="w-3.5 h-3.5" /> : <Mic className="w-3.5 h-3.5" />}
|
| 1506 |
+
{spk.isRecording ? 'Stop Mic' : 'Record Mic'}
|
| 1507 |
+
</button>
|
| 1508 |
+
|
| 1509 |
+
<label className="flex items-center justify-center gap-1.5 py-1.5 px-3 rounded-lg border border-white/10 hover:border-[#00f2fe] hover:text-[#00f2fe] text-white text-xs cursor-pointer active:scale-95 transition duration-200">
|
| 1510 |
+
<Upload className="w-3.5 h-3.5" />
|
| 1511 |
+
Upload WAV
|
| 1512 |
+
<input
|
| 1513 |
+
type="file"
|
| 1514 |
+
accept="audio/wav,audio/mp3"
|
| 1515 |
+
onChange={(e) => handleFileUpload(e, spk.id)}
|
| 1516 |
+
className="hidden"
|
| 1517 |
+
/>
|
| 1518 |
+
</label>
|
| 1519 |
+
</div>
|
| 1520 |
+
|
| 1521 |
+
{spk.previewUrl && (
|
| 1522 |
+
<div className="flex flex-col gap-1.5 pt-2 border-t border-dashed border-white/5">
|
| 1523 |
+
<span className="text-[10px] text-[#00e673] font-semibold flex items-center gap-1">
|
| 1524 |
+
<CheckCircle className="w-3 h-3" /> Voice Cloned Successfully
|
| 1525 |
+
</span>
|
| 1526 |
+
|
| 1527 |
+
{/* Premium Custom Reference Audio Player */}
|
| 1528 |
+
<div className="flex items-center gap-3 bg-white/4 p-2 rounded-xl border border-white/5 mt-1">
|
| 1529 |
+
<button
|
| 1530 |
+
onClick={() => playPreview(spk.id, spk.previewUrl)}
|
| 1531 |
+
className="w-8 h-8 rounded-full bg-[#00f2fe]/20 border border-[#00f2fe]/30 flex items-center justify-center text-[#00f2fe] hover:bg-[#00f2fe] hover:text-black transition duration-200 active:scale-90 cursor-pointer"
|
| 1532 |
+
>
|
| 1533 |
+
{playingPreviewId === spk.id ? (
|
| 1534 |
+
<Square className="w-3.5 h-3.5 fill-current" />
|
| 1535 |
+
) : (
|
| 1536 |
+
<Play className="w-3.5 h-3.5 fill-current ml-0.5" />
|
| 1537 |
+
)}
|
| 1538 |
+
</button>
|
| 1539 |
+
<div className="flex-1">
|
| 1540 |
+
<span className="text-[10px] text-white font-semibold flex items-center gap-1 select-text">
|
| 1541 |
+
<Music className="w-3 h-3 text-[#00f2fe]" /> reference_{spk.name}.wav
|
| 1542 |
+
</span>
|
| 1543 |
+
<span className="text-[9px] text-[#8e9bb5]">Ready for zero-shot synthesis</span>
|
| 1544 |
+
</div>
|
| 1545 |
+
</div>
|
| 1546 |
+
</div>
|
| 1547 |
+
)}
|
| 1548 |
+
</div>
|
| 1549 |
+
);
|
| 1550 |
+
})}
|
| 1551 |
+
|
| 1552 |
+
{/* Add Speaker Slot Trigger */}
|
| 1553 |
+
<button
|
| 1554 |
+
onClick={addSpeaker}
|
| 1555 |
+
className="flex items-center justify-center gap-2 w-full py-3 px-4 bg-white/4 hover:bg-[#00f2fe]/10 border border-white/5 hover:border-[#00f2fe]/30 text-[#8e9bb5] hover:text-[#00f2fe] text-xs font-bold rounded-xl transition duration-200 cursor-pointer active:scale-95"
|
| 1556 |
+
>
|
| 1557 |
+
<Plus className="w-4 h-4" />
|
| 1558 |
+
Add Speaker Slot
|
| 1559 |
+
</button>
|
| 1560 |
+
|
| 1561 |
+
{/* Presets */}
|
| 1562 |
+
<div className="mt-2 pt-4 border-t border-white/5 flex flex-col gap-2.5">
|
| 1563 |
+
<h3 className="text-xs font-semibold text-[#8e9bb5]">Dialogue Templates</h3>
|
| 1564 |
+
<div className="flex flex-col gap-2">
|
| 1565 |
+
<button
|
| 1566 |
+
onClick={() => loadPreset('podcast')}
|
| 1567 |
+
className="flex items-center gap-2.5 w-full py-2.5 px-3 bg-white/2 hover:bg-white/5 border border-white/5 hover:border-white/10 text-[#8e9bb5] hover:text-[#f0f3fa] text-xs font-medium rounded-xl transition duration-200 justify-start cursor-pointer"
|
| 1568 |
+
>
|
| 1569 |
+
<Podcast className="w-4 h-4 text-[#a259ff]" />
|
| 1570 |
+
Podcast Interview Preset
|
| 1571 |
+
</button>
|
| 1572 |
+
<button
|
| 1573 |
+
onClick={() => loadPreset('assistant')}
|
| 1574 |
+
className="flex items-center gap-2.5 w-full py-2.5 px-3 bg-white/2 hover:bg-white/5 border border-white/5 hover:border-white/10 text-[#8e9bb5] hover:text-[#f0f3fa] text-xs font-medium rounded-xl transition duration-200 justify-start cursor-pointer"
|
| 1575 |
+
>
|
| 1576 |
+
<Headset className="w-4 h-4 text-[#00f2fe]" />
|
| 1577 |
+
DineDirect Voice Agent
|
| 1578 |
+
</button>
|
| 1579 |
+
<button
|
| 1580 |
+
onClick={() => loadPreset('news')}
|
| 1581 |
+
className="flex items-center gap-2.5 w-full py-2.5 px-3 bg-white/2 hover:bg-white/5 border border-white/5 hover:border-white/10 text-[#8e9bb5] hover:text-[#f0f3fa] text-xs font-medium rounded-xl transition duration-200 justify-start cursor-pointer"
|
| 1582 |
+
>
|
| 1583 |
+
<Newspaper className="w-4 h-4 text-[#ff5c8d]" />
|
| 1584 |
+
News Broadcast Preset
|
| 1585 |
+
</button>
|
| 1586 |
+
</div>
|
| 1587 |
+
</div>
|
| 1588 |
+
|
| 1589 |
+
{/* 🎙️ Voice Vault (Saved Neural Profiles) */}
|
| 1590 |
+
<div className="mt-4 pt-4 border-t border-white/5 flex flex-col gap-2.5">
|
| 1591 |
+
<div className="flex items-center justify-between">
|
| 1592 |
+
<h3 className="text-xs font-bold text-[#8e9bb5] uppercase tracking-wider">Voice Vault ({savedVoices.length})</h3>
|
| 1593 |
+
<span className="text-[10px] text-[#00f2fe] bg-[#00f2fe]/10 px-2 py-0.5 rounded-full font-semibold border border-[#00f2fe]/20">Storage</span>
|
| 1594 |
+
</div>
|
| 1595 |
+
|
| 1596 |
+
<div className="flex flex-col gap-2 max-h-[220px] overflow-y-auto pr-1">
|
| 1597 |
+
{savedVoices.length === 0 ? (
|
| 1598 |
+
<p className="text-[11px] text-[#8e9bb5] italic py-2 text-center">No voice profiles saved yet.</p>
|
| 1599 |
+
) : (
|
| 1600 |
+
savedVoices.map(v => (
|
| 1601 |
+
<div key={v.id} className="flex items-center justify-between bg-white/2 hover:bg-white/4 border border-white/5 hover:border-white/10 p-2 rounded-xl transition duration-150 group/vault">
|
| 1602 |
+
<div className="flex items-center gap-2 min-w-0">
|
| 1603 |
+
<button
|
| 1604 |
+
onClick={() => playPreview(v.id, v.audio_url)}
|
| 1605 |
+
className="w-6 h-6 rounded-full bg-white/5 hover:bg-[#00f2fe]/20 hover:text-[#00f2fe] flex items-center justify-center text-[#8e9bb5] transition active:scale-90 cursor-pointer shrink-0"
|
| 1606 |
+
title="Preview Voice"
|
| 1607 |
+
>
|
| 1608 |
+
{playingPreviewId === v.id ? (
|
| 1609 |
+
<Square className="w-2.5 h-2.5 fill-current animate-pulse text-[#00f2fe]" />
|
| 1610 |
+
) : (
|
| 1611 |
+
<Play className="w-2.5 h-2.5 fill-current ml-0.5" />
|
| 1612 |
+
)}
|
| 1613 |
+
</button>
|
| 1614 |
+
<span className="text-xs font-semibold text-white truncate max-w-[120px]" title={v.name}>
|
| 1615 |
+
{v.name}
|
| 1616 |
+
</span>
|
| 1617 |
+
</div>
|
| 1618 |
+
|
| 1619 |
+
<div className="flex items-center gap-1 shrink-0">
|
| 1620 |
+
<button
|
| 1621 |
+
onClick={() => openRenameVoiceModal(v.id, v.name, v.audio_url)}
|
| 1622 |
+
className="p-1 rounded hover:bg-white/5 text-[#8e9bb5] hover:text-[#00f2fe] transition active:scale-95 cursor-pointer"
|
| 1623 |
+
title="Rename voice profile"
|
| 1624 |
+
>
|
| 1625 |
+
<Edit2 className="w-3.5 h-3.5" />
|
| 1626 |
+
</button>
|
| 1627 |
+
<button
|
| 1628 |
+
onClick={() => openDeleteVoiceModal(v.id, v.name, v.audio_url)}
|
| 1629 |
+
className="p-1 rounded hover:bg-[#ff5c8d]/5 text-[#8e9bb5] hover:text-[#ff5c8d] transition active:scale-95 cursor-pointer"
|
| 1630 |
+
title="Delete voice profile"
|
| 1631 |
+
>
|
| 1632 |
+
<Trash2 className="w-3.5 h-3.5" />
|
| 1633 |
+
</button>
|
| 1634 |
+
</div>
|
| 1635 |
+
</div>
|
| 1636 |
+
))
|
| 1637 |
+
)}
|
| 1638 |
+
</div>
|
| 1639 |
+
</div>
|
| 1640 |
+
</section>
|
| 1641 |
+
|
| 1642 |
+
{/* Right Column: Execution Panels */}
|
| 1643 |
+
<section className="flex flex-col gap-6">
|
| 1644 |
+
|
| 1645 |
+
{/* Visualizer Container */}
|
| 1646 |
+
<div className="bg-glass p-6 rounded-3xl flex flex-col justify-center items-center gap-4 min-h-[250px] bg-gradient-to-b from-[#101830]/30 to-transparent">
|
| 1647 |
+
<div className="flex flex-col md:flex-row items-center gap-8 w-full max-w-[500px] justify-center">
|
| 1648 |
+
{/* Visual pulsating holographic Orb */}
|
| 1649 |
+
<div className="relative w-[90px] h-[90px] flex items-center justify-center">
|
| 1650 |
+
<div className={`w-[60px] h-[60px] rounded-full z-10 transition-all duration-500 ${
|
| 1651 |
+
agentState.status === 'idle' && 'orb-state-idle'
|
| 1652 |
+
} ${
|
| 1653 |
+
agentState.status === 'thinking' && 'orb-state-thinking'
|
| 1654 |
+
} ${
|
| 1655 |
+
agentState.status === 'speaking' && 'orb-state-speaking'
|
| 1656 |
+
} ${
|
| 1657 |
+
agentState.status === 'listening' && 'orb-state-listening'
|
| 1658 |
+
}`}></div>
|
| 1659 |
+
|
| 1660 |
+
{/* Glowing backing shadows */}
|
| 1661 |
+
<div className={`absolute -inset-[15px] rounded-full blur-[25px] opacity-50 z-0 transition-all duration-500 ${
|
| 1662 |
+
agentState.status === 'idle' && 'bg-[#00f2fe]'
|
| 1663 |
+
} ${
|
| 1664 |
+
agentState.status === 'thinking' && 'bg-[#a259ff] scale-110 animate-pulse'
|
| 1665 |
+
} ${
|
| 1666 |
+
agentState.status === 'speaking' && 'bg-[#ff5c8d]'
|
| 1667 |
+
} ${
|
| 1668 |
+
agentState.status === 'listening' && 'bg-[#00e673]'
|
| 1669 |
+
}`}></div>
|
| 1670 |
+
|
| 1671 |
+
{/* Expansion speech rings */}
|
| 1672 |
+
{agentState.status === 'speaking' && (
|
| 1673 |
+
<>
|
| 1674 |
+
<div className="absolute inset-0 rounded-full border-2 border-[#ff5c8d] shadow-[0_0_10px_rgba(255,92,141,0.35)] animate-[waveExpand_1.2s_infinite_cubic-bezier(0.1,0.8,0.3,1)] opacity-100 z-10"></div>
|
| 1675 |
+
<div className="absolute inset-0 rounded-full border-2 border-[#ff5c8d] shadow-[0_0_10px_rgba(255,92,141,0.35)] animate-[waveExpand_1.2s_infinite_cubic-bezier(0.1,0.8,0.3,1)] opacity-100 z-10 [animation-delay:0.6s]"></div>
|
| 1676 |
+
</>
|
| 1677 |
+
)}
|
| 1678 |
+
</div>
|
| 1679 |
+
|
| 1680 |
+
<div className="flex flex-col text-center md:text-left gap-1">
|
| 1681 |
+
<span className="font-orbitron font-extrabold text-lg tracking-wider text-shadow-md text-[#f0f3fa] uppercase">
|
| 1682 |
+
{agentState.title}
|
| 1683 |
+
</span>
|
| 1684 |
+
<p className="text-xs text-[#8e9bb5]">{agentState.sub}</p>
|
| 1685 |
+
</div>
|
| 1686 |
+
</div>
|
| 1687 |
+
|
| 1688 |
+
{/* Waveform Canvas */}
|
| 1689 |
+
<div className="w-full flex justify-center border-t border-white/5 pt-4 mt-2">
|
| 1690 |
+
<canvas ref={canvasRef} width="600" height="90" className="w-full max-w-[600px] h-[75px] rounded-xl"></canvas>
|
| 1691 |
+
</div>
|
| 1692 |
+
</div>
|
| 1693 |
+
|
| 1694 |
+
{/* Script Studio Box */}
|
| 1695 |
+
<div className="bg-glass p-6 rounded-3xl flex flex-col gap-4">
|
| 1696 |
+
<div className="flex justify-between items-center border-b border-white/5 pb-4">
|
| 1697 |
+
<div className="flex items-center gap-2">
|
| 1698 |
+
<Sparkles className="w-5 h-5 text-[#a259ff]" />
|
| 1699 |
+
<h2 className="text-md font-semibold">Podcast Script Studio</h2>
|
| 1700 |
+
</div>
|
| 1701 |
+
|
| 1702 |
+
{/* Real-time Streaming Check */}
|
| 1703 |
+
<label className="flex items-center gap-2.5 cursor-pointer">
|
| 1704 |
+
<input
|
| 1705 |
+
type="checkbox"
|
| 1706 |
+
checked={streamMode}
|
| 1707 |
+
onChange={(e) => setStreamMode(e.target.checked)}
|
| 1708 |
+
className="hidden"
|
| 1709 |
+
/>
|
| 1710 |
+
<div className={`w-[42px] h-[22px] rounded-full relative border transition duration-300 ${
|
| 1711 |
+
streamMode ? 'bg-[#00f2fe]/20 border-[#00f2fe]' : 'bg-white/8 border-white/10'
|
| 1712 |
+
}`}>
|
| 1713 |
+
<div className={`w-4 h-4 rounded-full absolute top-[2px] left-[2px] transition-transform duration-300 ${
|
| 1714 |
+
streamMode ? 'transform translate-x-[20px] bg-[#00f2fe] shadow-[0_0_6px_#00f2fe]' : 'bg-[#f0f3fa]'
|
| 1715 |
+
}`}></div>
|
| 1716 |
+
</div>
|
| 1717 |
+
<span className={`text-xs font-semibold ${streamMode ? 'text-[#00f2fe]' : 'text-[#8e9bb5]'}`}>
|
| 1718 |
+
Real-time Streaming
|
| 1719 |
+
</span>
|
| 1720 |
+
</label>
|
| 1721 |
+
</div>
|
| 1722 |
+
|
| 1723 |
+
{/* Textarea script fields */}
|
| 1724 |
+
<div className="bg-black/25 border border-white/8 rounded-xl overflow-hidden">
|
| 1725 |
+
<textarea
|
| 1726 |
+
value={script}
|
| 1727 |
+
onChange={(e) => setScript(e.target.value)}
|
| 1728 |
+
placeholder="Format: Speaker ID: Speech dialog content..."
|
| 1729 |
+
className="w-full h-[180px] bg-transparent resize-none p-4 text-[#f0f3fa] font-mono text-sm leading-relaxed outline-none border-none"
|
| 1730 |
+
/>
|
| 1731 |
+
</div>
|
| 1732 |
+
|
| 1733 |
+
{/* Action Buttons */}
|
| 1734 |
+
<div className="flex gap-4">
|
| 1735 |
+
<button
|
| 1736 |
+
onClick={handleSynthesize}
|
| 1737 |
+
disabled={isAudioPlayingState}
|
| 1738 |
+
className="flex-1 flex items-center justify-center gap-2 py-3 px-6 bg-gradient-to-r from-[#00f2fe] to-[#a259ff] hover:shadow-[0_0_20px_rgba(0,242,254,0.4)] text-black font-bold rounded-xl transition duration-200 active:scale-98 cursor-pointer disabled:opacity-40"
|
| 1739 |
+
>
|
| 1740 |
+
<PlayCircle className="w-5 h-5" />
|
| 1741 |
+
Synthesize Dialogue
|
| 1742 |
+
</button>
|
| 1743 |
+
<button
|
| 1744 |
+
onClick={handleStopPlayback}
|
| 1745 |
+
disabled={!isAudioPlayingState}
|
| 1746 |
+
className="flex-1 flex items-center justify-center gap-2 py-3 px-6 bg-white/5 border border-white/8 hover:bg-white/10 text-white font-medium rounded-xl transition duration-200 active:scale-98 cursor-pointer disabled:opacity-40"
|
| 1747 |
+
>
|
| 1748 |
+
<Square className="w-4 h-4" />
|
| 1749 |
+
Stop Playback
|
| 1750 |
+
</button>
|
| 1751 |
+
</div>
|
| 1752 |
+
</div>
|
| 1753 |
+
|
| 1754 |
+
{/* Master Export Hub */}
|
| 1755 |
+
{compiledAudioUrl && (
|
| 1756 |
+
<div className="bg-glass p-6 rounded-3xl border border-[#00f2fe]/20 bg-gradient-to-r from-[#00f2fe]/4 to-[#a259ff]/4 shadow-[0_0_20px_rgba(0,242,254,0.08)] flex flex-col gap-4 transition duration-300">
|
| 1757 |
+
<div className="flex justify-between items-center border-b border-white/5 pb-3">
|
| 1758 |
+
<div className="flex items-center gap-2">
|
| 1759 |
+
<Sparkles className="w-5 h-5 text-neon-cyan animate-pulse" />
|
| 1760 |
+
<div>
|
| 1761 |
+
<h3 className="text-sm font-bold text-white uppercase tracking-wider">Podcast Master Output</h3>
|
| 1762 |
+
<p className="text-[9px] text-[#8e9bb5] uppercase tracking-wide">Ready for download & broadcast</p>
|
| 1763 |
+
</div>
|
| 1764 |
+
</div>
|
| 1765 |
+
<span className="text-[10px] px-2.5 py-0.5 rounded-full bg-[#00e673]/10 border border-[#00e673]/20 text-[#00e673] font-bold">
|
| 1766 |
+
{compiledAudioMode === 'stream' ? 'Stream Mastered' : 'Engine Compiled'}
|
| 1767 |
+
</span>
|
| 1768 |
+
</div>
|
| 1769 |
+
|
| 1770 |
+
<div className="flex flex-col sm:flex-row items-center gap-4 justify-between bg-black/20 p-4 rounded-2xl border border-white/5 w-full">
|
| 1771 |
+
<div className="flex items-center gap-3">
|
| 1772 |
+
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-[#00f2fe] to-[#a259ff] flex items-center justify-center text-black font-bold">
|
| 1773 |
+
<Volume2 className="w-5 h-5" />
|
| 1774 |
+
</div>
|
| 1775 |
+
<div className="text-left">
|
| 1776 |
+
<h4 className="text-xs font-semibold text-white">VibeVoice_Podcast_Session.wav</h4>
|
| 1777 |
+
<p className="text-[10px] text-[#8e9bb5]">
|
| 1778 |
+
{compiledAudioMode === 'stream' ? 'Real-time WebSocket Stream' : 'REST Compiled Dialogue Track'}
|
| 1779 |
+
</p>
|
| 1780 |
+
</div>
|
| 1781 |
+
</div>
|
| 1782 |
+
|
| 1783 |
+
<a
|
| 1784 |
+
href={compiledAudioUrl}
|
| 1785 |
+
download="VibeVoice_Podcast_Session.wav"
|
| 1786 |
+
className="w-full sm:w-auto flex items-center justify-center gap-2 py-2.5 px-5 bg-gradient-to-r from-[#00f2fe] to-[#a259ff] hover:shadow-[0_0_15px_rgba(0,242,254,0.3)] text-black font-bold rounded-xl text-xs transition duration-200 cursor-pointer active:scale-95"
|
| 1787 |
+
>
|
| 1788 |
+
<Upload className="w-4 h-4 rotate-180" />
|
| 1789 |
+
Export WAV File
|
| 1790 |
+
</a>
|
| 1791 |
+
</div>
|
| 1792 |
+
</div>
|
| 1793 |
+
)}
|
| 1794 |
+
|
| 1795 |
+
{/* Custom timeline bar player */}
|
| 1796 |
+
{showToolbar && (
|
| 1797 |
+
<div className="bg-glass p-4 rounded-3xl border border-[#00f2fe]/20 shadow-[0_0_20px_rgba(0,242,254,0.05)]">
|
| 1798 |
+
<div className="flex flex-col md:flex-row items-center gap-4 w-full">
|
| 1799 |
+
<button onClick={handleStopPlayback} className="w-9 h-9 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-white hover:bg-[#00f2fe] hover:text-black hover:shadow-[0_0_10px_rgba(0,242,254,0.35)] transition cursor-pointer">
|
| 1800 |
+
<Square className="w-4 h-4" />
|
| 1801 |
+
</button>
|
| 1802 |
+
|
| 1803 |
+
<div className="flex items-center gap-3 flex-1 w-full">
|
| 1804 |
+
<span className="font-mono text-[10px] text-[#8e9bb5]">{formatTime(timeline.elapsed)}</span>
|
| 1805 |
+
<div className="relative flex-1 h-1.5 bg-white/8 rounded-full overflow-hidden">
|
| 1806 |
+
<div className="absolute left-0 top-0 bottom-0 bg-gradient-to-r from-[#00f2fe] to-[#a259ff] rounded-full" style={{ width: `${(timeline.elapsed / timeline.total) * 100}%` }}></div>
|
| 1807 |
+
</div>
|
| 1808 |
+
<span className="font-mono text-[10px] text-[#8e9bb5]">{formatTime(timeline.total)}</span>
|
| 1809 |
+
</div>
|
| 1810 |
+
|
| 1811 |
+
<div className="flex items-center gap-3 w-[150px]">
|
| 1812 |
+
{volume === 0 ? <VolumeX className="w-4 h-4 text-[#8e9bb5]" /> : volume < 50 ? <Volume1 className="w-4 h-4 text-[#8e9bb5]" /> : <Volume2 className="w-4 h-4 text-[#8e9bb5]" />}
|
| 1813 |
+
<input
|
| 1814 |
+
type="range"
|
| 1815 |
+
min="0"
|
| 1816 |
+
max="100"
|
| 1817 |
+
value={volume}
|
| 1818 |
+
onChange={(e) => setVolume(parseInt(e.target.value))}
|
| 1819 |
+
className="w-full accent-[#00f2fe] h-1 bg-white/10 rounded-lg outline-none cursor-pointer"
|
| 1820 |
+
/>
|
| 1821 |
+
</div>
|
| 1822 |
+
|
| 1823 |
+
<select
|
| 1824 |
+
value={speed}
|
| 1825 |
+
onChange={(e) => setSpeed(e.target.value)}
|
| 1826 |
+
className="bg-white/5 border border-white/8 rounded-lg px-2 py-1 text-xs text-white outline-none cursor-pointer"
|
| 1827 |
+
>
|
| 1828 |
+
<option value="0.75" className="bg-[#07090e]">0.75x</option>
|
| 1829 |
+
<option value="1.0" className="bg-[#07090e]">1.0x</option>
|
| 1830 |
+
<option value="1.25" className="bg-[#07090e]">1.25x</option>
|
| 1831 |
+
<option value="1.5" className="bg-[#07090e]">1.5x</option>
|
| 1832 |
+
</select>
|
| 1833 |
+
</div>
|
| 1834 |
+
</div>
|
| 1835 |
+
)}
|
| 1836 |
+
|
| 1837 |
+
{/* Podcast History & Archive section */}
|
| 1838 |
+
<div className="bg-glass p-6 rounded-3xl border border-white/4 bg-black/40 flex flex-col gap-4">
|
| 1839 |
+
<div className="flex justify-between items-center border-b border-white/5 pb-2 text-xs font-semibold text-[#8e9bb5]">
|
| 1840 |
+
<div className="flex items-center gap-2">
|
| 1841 |
+
<Podcast className="w-4 h-4 text-[#a259ff] animate-pulse" />
|
| 1842 |
+
<span className="text-white font-bold uppercase tracking-wider text-[10px]">Supabase Broadcast Archive</span>
|
| 1843 |
+
</div>
|
| 1844 |
+
<span className="text-[10px] bg-[#a259ff]/10 px-2 py-0.5 rounded-full text-neon-purple font-mono border border-[#a259ff]/20">
|
| 1845 |
+
{podcastHistory.length} Saved
|
| 1846 |
+
</span>
|
| 1847 |
+
</div>
|
| 1848 |
+
|
| 1849 |
+
<div className="max-h-[220px] overflow-y-auto pr-1 flex flex-col gap-2.5 custom-scrollbar">
|
| 1850 |
+
{podcastHistory.length === 0 ? (
|
| 1851 |
+
<div className="py-8 text-center flex flex-col items-center justify-center gap-2 border border-dashed border-white/5 rounded-2xl bg-white/2">
|
| 1852 |
+
<Music className="w-8 h-8 text-[#8e9bb5]/40" />
|
| 1853 |
+
<div>
|
| 1854 |
+
<p className="text-xs text-white font-medium">No podcasts archived yet</p>
|
| 1855 |
+
<p className="text-[10px] text-[#8e9bb5] mt-0.5 max-w-[200px]">Generate speech scripts to automatically archive them permanently!</p>
|
| 1856 |
+
</div>
|
| 1857 |
+
</div>
|
| 1858 |
+
) : (
|
| 1859 |
+
podcastHistory.map((podcast) => (
|
| 1860 |
+
<div
|
| 1861 |
+
key={podcast.id}
|
| 1862 |
+
className="p-3 bg-white/3 border border-white/5 rounded-xl hover:border-white/10 hover:bg-white/5 transition duration-200 flex items-center justify-between gap-3 group/item relative overflow-hidden"
|
| 1863 |
+
>
|
| 1864 |
+
{isAudioPlayingState && compiledAudioUrl === podcast.audio_url && (
|
| 1865 |
+
<div className="absolute inset-0 bg-gradient-to-r from-[#a259ff]/10 to-transparent pointer-events-none" />
|
| 1866 |
+
)}
|
| 1867 |
+
|
| 1868 |
+
<div className="flex items-center gap-3 min-w-0">
|
| 1869 |
+
<button
|
| 1870 |
+
onClick={() => playHistoryPodcast(podcast)}
|
| 1871 |
+
className={`w-8 h-8 rounded-full flex items-center justify-center transition active:scale-95 cursor-pointer shrink-0 ${
|
| 1872 |
+
isAudioPlayingState && compiledAudioUrl === podcast.audio_url
|
| 1873 |
+
? 'bg-[#ff5c8d] text-white animate-pulse'
|
| 1874 |
+
: 'bg-white/5 hover:bg-white/10 text-white'
|
| 1875 |
+
}`}
|
| 1876 |
+
>
|
| 1877 |
+
{isAudioPlayingState && compiledAudioUrl === podcast.audio_url ? (
|
| 1878 |
+
<Square className="w-3.5 h-3.5 fill-current" />
|
| 1879 |
+
) : (
|
| 1880 |
+
<Play className="w-3.5 h-3.5 fill-current ml-0.5" />
|
| 1881 |
+
)}
|
| 1882 |
+
</button>
|
| 1883 |
+
|
| 1884 |
+
<div className="min-w-0 flex flex-col gap-0.5">
|
| 1885 |
+
<span className="text-xs font-bold text-white truncate max-w-[240px] block group-hover/item:text-[#00f2fe] transition duration-150">
|
| 1886 |
+
{podcast.title}
|
| 1887 |
+
</span>
|
| 1888 |
+
<span className="text-[10px] text-[#8e9bb5] truncate block max-w-[240px] leading-relaxed cursor-help" title={podcast.text_script}>
|
| 1889 |
+
Script: "{podcast.text_script}"
|
| 1890 |
+
</span>
|
| 1891 |
+
</div>
|
| 1892 |
+
</div>
|
| 1893 |
+
|
| 1894 |
+
<div className="flex items-center gap-2 shrink-0">
|
| 1895 |
+
<a
|
| 1896 |
+
href={podcast.audio_url}
|
| 1897 |
+
download
|
| 1898 |
+
target="_blank"
|
| 1899 |
+
rel="noopener noreferrer"
|
| 1900 |
+
className="p-1.5 rounded-lg border border-white/6 hover:border-[#00f2fe]/40 text-[#8e9bb5] hover:text-white hover:bg-white/4 active:scale-95 transition"
|
| 1901 |
+
title="Open audio in new tab / download"
|
| 1902 |
+
>
|
| 1903 |
+
<Upload className="w-3.5 h-3.5 rotate-180" />
|
| 1904 |
+
</a>
|
| 1905 |
+
<button
|
| 1906 |
+
onClick={() => openRenamePodcastModal(podcast.id, podcast.title)}
|
| 1907 |
+
className="p-1.5 rounded-lg border border-white/6 hover:border-[#00f2fe]/40 text-[#8e9bb5] hover:text-white hover:bg-white/4 active:scale-95 transition cursor-pointer"
|
| 1908 |
+
title="Rename podcast title"
|
| 1909 |
+
>
|
| 1910 |
+
<Edit2 className="w-3.5 h-3.5" />
|
| 1911 |
+
</button>
|
| 1912 |
+
<button
|
| 1913 |
+
onClick={() => openDeletePodcastModal(podcast.id, podcast.title, podcast.audio_url)}
|
| 1914 |
+
className="p-1.5 rounded-lg border border-white/6 hover:border-[#ff5c8d]/40 text-[#8e9bb5] hover:text-[#ff5c8d] hover:bg-[#ff5c8d]/5 active:scale-95 transition cursor-pointer"
|
| 1915 |
+
title="Delete podcast from Supabase archive"
|
| 1916 |
+
>
|
| 1917 |
+
<Trash2 className="w-3.5 h-3.5" />
|
| 1918 |
+
</button>
|
| 1919 |
+
</div>
|
| 1920 |
+
</div>
|
| 1921 |
+
))
|
| 1922 |
+
)}
|
| 1923 |
+
</div>
|
| 1924 |
+
</div>
|
| 1925 |
+
|
| 1926 |
+
{/* Diagnostic Terminal card */}
|
| 1927 |
+
<div className="bg-glass p-6 rounded-3xl border border-white/4 bg-black/40">
|
| 1928 |
+
<div className="flex justify-between items-center border-b border-white/5 pb-2 mb-3 text-xs font-semibold text-[#8e9bb5]">
|
| 1929 |
+
<div className="flex items-center gap-2">
|
| 1930 |
+
<Terminal className="w-4 h-4 text-[#00f2fe]" />
|
| 1931 |
+
<span>Agent HUD Console Logs</span>
|
| 1932 |
+
</div>
|
| 1933 |
+
<button
|
| 1934 |
+
onClick={() => setLogs([{ type: 'system', text: 'Console buffer cleared.', time: new Date().toLocaleTimeString() }])}
|
| 1935 |
+
className="px-2 py-0.5 rounded border border-white/10 hover:border-white/20 active:scale-95 text-[10px] text-white hover:bg-white/2 cursor-pointer transition duration-150"
|
| 1936 |
+
>
|
| 1937 |
+
Clear Buffer
|
| 1938 |
+
</button>
|
| 1939 |
+
</div>
|
| 1940 |
+
|
| 1941 |
+
<div id="consoleBody" className="h-[120px] overflow-y-auto font-mono text-[10.5px] leading-relaxed flex flex-col gap-1.5 pr-2 select-text">
|
| 1942 |
+
{logs.map((logItem, idx) => (
|
| 1943 |
+
<div key={idx} className={`px-2 py-0.5 rounded ${
|
| 1944 |
+
logItem.type === 'system' && 'text-[#8e9bb5] bg-white/2'
|
| 1945 |
+
} ${
|
| 1946 |
+
logItem.type === 'info' && 'text-[#00f2fe] bg-[#00f2fe]/2'
|
| 1947 |
+
} ${
|
| 1948 |
+
logItem.type === 'success' && 'text-[#00e673] bg-[#00e673]/2'
|
| 1949 |
+
} ${
|
| 1950 |
+
logItem.type === 'error' && 'text-[#ff5c8d] bg-[#ff5c8d]/2'
|
| 1951 |
+
} ${
|
| 1952 |
+
logItem.type === 'word' && 'text-white font-medium drop-shadow-[0_0_4px_rgba(255,255,255,0.25)]'
|
| 1953 |
+
}`}>
|
| 1954 |
+
[{logItem.time}] {logItem.text}
|
| 1955 |
+
</div>
|
| 1956 |
+
))}
|
| 1957 |
+
</div>
|
| 1958 |
+
</div>
|
| 1959 |
+
|
| 1960 |
+
</section>
|
| 1961 |
+
</main>
|
| 1962 |
+
</div>
|
| 1963 |
+
|
| 1964 |
+
{/* 🎙️ Premium Glassmorphic Custom Voice Profile Naming Modal */}
|
| 1965 |
+
{namingModal.isOpen && (
|
| 1966 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md animate-fade-in">
|
| 1967 |
+
<div className="w-[90%] max-w-[420px] bg-gradient-to-b from-white/10 to-white/3 border border-white/12 backdrop-blur-2xl p-6 rounded-3xl shadow-2xl flex flex-col gap-4 animate-scale-up">
|
| 1968 |
+
<div className="flex items-center gap-3">
|
| 1969 |
+
<div className="w-10 h-10 rounded-full bg-[#00f2fe]/10 flex items-center justify-center border border-[#00f2fe]/20">
|
| 1970 |
+
<Sparkles className="w-5 h-5 text-neon-cyan animate-pulse" />
|
| 1971 |
+
</div>
|
| 1972 |
+
<div>
|
| 1973 |
+
<h3 className="text-base font-bold text-white tracking-tight">Save Custom Voice Signature</h3>
|
| 1974 |
+
<p className="text-[10px] text-[#8e9bb5] uppercase tracking-wider">Neural Studio Archivist</p>
|
| 1975 |
+
</div>
|
| 1976 |
+
</div>
|
| 1977 |
+
|
| 1978 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed">
|
| 1979 |
+
Give your new voice profile signature a descriptive name (e.g. <em>John's Deep Voice</em>, <em>Excited Host</em>) to save it permanently in your custom voice selection.
|
| 1980 |
+
</p>
|
| 1981 |
+
|
| 1982 |
+
<div className="flex flex-col gap-1.5 mt-2">
|
| 1983 |
+
<label className="text-[10px] text-[#8e9bb5] uppercase tracking-wide font-semibold">Voice Signature Name</label>
|
| 1984 |
+
<input
|
| 1985 |
+
type="text"
|
| 1986 |
+
autoFocus
|
| 1987 |
+
placeholder="Enter voice signature name..."
|
| 1988 |
+
value={voiceNameInput}
|
| 1989 |
+
onChange={(e) => setVoiceNameInput(e.target.value)}
|
| 1990 |
+
className="bg-black/40 border border-white/10 focus:border-[#00f2fe] rounded-xl px-4 py-2.5 text-xs text-white outline-none focus:shadow-[0_0_12px_rgba(0,242,254,0.2)] transition duration-200"
|
| 1991 |
+
onKeyDown={(e) => {
|
| 1992 |
+
if (e.key === 'Enter') {
|
| 1993 |
+
const name = voiceNameInput.trim() || namingModal.defaultName;
|
| 1994 |
+
uploadVoiceProfile(namingModal.fileBlob, namingModal.speakerId, name);
|
| 1995 |
+
setNamingModal({ isOpen: false, fileBlob: null, speakerId: null, defaultName: '' });
|
| 1996 |
+
}
|
| 1997 |
+
}}
|
| 1998 |
+
/>
|
| 1999 |
+
</div>
|
| 2000 |
+
|
| 2001 |
+
<div className="flex justify-end gap-3 mt-3">
|
| 2002 |
+
<button
|
| 2003 |
+
onClick={() => setNamingModal({ isOpen: false, fileBlob: null, speakerId: null, defaultName: '' })}
|
| 2004 |
+
className="px-4 py-2 rounded-xl border border-white/6 hover:bg-white/4 active:scale-95 text-xs font-semibold text-[#8e9bb5] hover:text-white cursor-pointer transition duration-150"
|
| 2005 |
+
>
|
| 2006 |
+
Cancel
|
| 2007 |
+
</button>
|
| 2008 |
+
<button
|
| 2009 |
+
onClick={() => {
|
| 2010 |
+
const name = voiceNameInput.trim() || namingModal.defaultName;
|
| 2011 |
+
uploadVoiceProfile(namingModal.fileBlob, namingModal.speakerId, name);
|
| 2012 |
+
setNamingModal({ isOpen: false, fileBlob: null, speakerId: null, defaultName: '' });
|
| 2013 |
+
}}
|
| 2014 |
+
className="px-5 py-2 rounded-xl bg-gradient-to-r from-[#00f2fe] to-[#a259ff] text-xs font-bold text-black hover:shadow-[0_0_15px_rgba(0,242,254,0.3)] active:scale-95 cursor-pointer transition duration-200"
|
| 2015 |
+
>
|
| 2016 |
+
Save to Studio
|
| 2017 |
+
</button>
|
| 2018 |
+
</div>
|
| 2019 |
+
</div>
|
| 2020 |
+
</div>
|
| 2021 |
+
)}
|
| 2022 |
+
|
| 2023 |
+
{/* 🔮 Premium custom action (Rename/Delete) modal */}
|
| 2024 |
+
{actionModal.isOpen && (
|
| 2025 |
+
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-md animate-fade-in">
|
| 2026 |
+
<div className="w-[90%] max-w-[420px] bg-gradient-to-b from-white/10 to-white/3 border border-white/12 backdrop-blur-2xl p-6 rounded-3xl shadow-2xl flex flex-col gap-4 animate-scale-up">
|
| 2027 |
+
<div className="flex justify-between items-center border-b border-white/6 pb-3">
|
| 2028 |
+
<div className="flex items-center gap-2">
|
| 2029 |
+
<Sparkles className="w-5 h-5 text-neon-cyan" />
|
| 2030 |
+
<h3 className="text-sm font-bold text-white uppercase tracking-wider">
|
| 2031 |
+
{actionModal.type.startsWith('rename') ? 'Rename Profile' : 'Confirm Action'}
|
| 2032 |
+
</h3>
|
| 2033 |
+
</div>
|
| 2034 |
+
<button
|
| 2035 |
+
onClick={() => setActionModal({ isOpen: false, type: '', id: null, oldValue: '', inputValue: '', audioUrl: '', profileName: '' })}
|
| 2036 |
+
className="text-[#8e9bb5] hover:text-white transition cursor-pointer text-sm"
|
| 2037 |
+
>
|
| 2038 |
+
✕
|
| 2039 |
+
</button>
|
| 2040 |
+
</div>
|
| 2041 |
+
|
| 2042 |
+
{actionModal.type.startsWith('rename') ? (
|
| 2043 |
+
<div className="flex flex-col gap-3">
|
| 2044 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed">
|
| 2045 |
+
Enter a new name for your {actionModal.type === 'rename_voice' ? 'voice profile signature' : 'podcast archive title'}:
|
| 2046 |
+
</p>
|
| 2047 |
+
<input
|
| 2048 |
+
type="text"
|
| 2049 |
+
autoFocus
|
| 2050 |
+
value={actionModal.inputValue}
|
| 2051 |
+
onChange={(e) => setActionModal(prev => ({ ...prev, inputValue: e.target.value }))}
|
| 2052 |
+
className="bg-black/40 border border-white/10 focus:border-[#00f2fe] rounded-xl px-4 py-2.5 text-xs text-white outline-none focus:shadow-[0_0_12px_rgba(0,242,254,0.2)] transition duration-200"
|
| 2053 |
+
onKeyDown={(e) => {
|
| 2054 |
+
if (e.key === 'Enter') {
|
| 2055 |
+
if (actionModal.type === 'rename_voice') {
|
| 2056 |
+
executeRenameVoice(actionModal.id, actionModal.inputValue, actionModal.audioUrl);
|
| 2057 |
+
} else {
|
| 2058 |
+
executeRenamePodcast(actionModal.id, actionModal.inputValue);
|
| 2059 |
+
}
|
| 2060 |
+
setActionModal({ isOpen: false, type: '', id: null, oldValue: '', inputValue: '', audioUrl: '', profileName: '' });
|
| 2061 |
+
}
|
| 2062 |
+
}}
|
| 2063 |
+
/>
|
| 2064 |
+
<div className="flex items-center gap-2.5 mt-2">
|
| 2065 |
+
<button
|
| 2066 |
+
onClick={() => setActionModal({ isOpen: false, type: '', id: null, oldValue: '', inputValue: '', audioUrl: '', profileName: '' })}
|
| 2067 |
+
className="flex-1 py-2.5 text-xs font-bold bg-white/4 border border-white/5 hover:border-white/12 rounded-xl text-white transition active:scale-95 cursor-pointer"
|
| 2068 |
+
>
|
| 2069 |
+
Cancel
|
| 2070 |
+
</button>
|
| 2071 |
+
<button
|
| 2072 |
+
onClick={() => {
|
| 2073 |
+
if (actionModal.type === 'rename_voice') {
|
| 2074 |
+
executeRenameVoice(actionModal.id, actionModal.inputValue, actionModal.audioUrl);
|
| 2075 |
+
} else {
|
| 2076 |
+
executeRenamePodcast(actionModal.id, actionModal.inputValue);
|
| 2077 |
+
}
|
| 2078 |
+
setActionModal({ isOpen: false, type: '', id: null, oldValue: '', inputValue: '', audioUrl: '', profileName: '' });
|
| 2079 |
+
}}
|
| 2080 |
+
className="flex-1 py-2.5 text-xs font-bold bg-gradient-to-r from-[#00f2fe] to-[#a259ff] hover:shadow-[0_0_12px_rgba(0,242,254,0.3)] text-black rounded-xl transition active:scale-95 cursor-pointer"
|
| 2081 |
+
>
|
| 2082 |
+
Save Changes
|
| 2083 |
+
</button>
|
| 2084 |
+
</div>
|
| 2085 |
+
</div>
|
| 2086 |
+
) : (
|
| 2087 |
+
<div className="flex flex-col gap-3">
|
| 2088 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed">
|
| 2089 |
+
Are you sure you want to permanently delete the {actionModal.type === 'delete_voice' ? 'voice profile signature' : 'archived podcast'} <strong className="text-white">"{actionModal.profileName}"</strong>? This action cannot be undone.
|
| 2090 |
+
</p>
|
| 2091 |
+
<div className="flex items-center gap-2.5 mt-2">
|
| 2092 |
+
<button
|
| 2093 |
+
onClick={() => setActionModal({ isOpen: false, type: '', id: null, oldValue: '', inputValue: '', audioUrl: '', profileName: '' })}
|
| 2094 |
+
className="flex-1 py-2.5 text-xs font-bold bg-white/4 border border-white/5 hover:border-white/12 rounded-xl text-white transition active:scale-95 cursor-pointer"
|
| 2095 |
+
>
|
| 2096 |
+
Cancel
|
| 2097 |
+
</button>
|
| 2098 |
+
<button
|
| 2099 |
+
onClick={() => {
|
| 2100 |
+
if (actionModal.type === 'delete_voice') {
|
| 2101 |
+
executeDeleteVoice(actionModal.id, actionModal.audioUrl, actionModal.profileName);
|
| 2102 |
+
} else {
|
| 2103 |
+
executeDeletePodcast(actionModal.id, actionModal.audioUrl, actionModal.profileName);
|
| 2104 |
+
}
|
| 2105 |
+
setActionModal({ isOpen: false, type: '', id: null, oldValue: '', inputValue: '', audioUrl: '', profileName: '' });
|
| 2106 |
+
}}
|
| 2107 |
+
className="flex-1 py-2.5 text-xs font-bold bg-[#ff5c8d] hover:bg-[#ff5c8d]/90 hover:shadow-[0_0_12px_rgba(255,92,141,0.3)] text-white rounded-xl transition active:scale-95 cursor-pointer"
|
| 2108 |
+
>
|
| 2109 |
+
Delete Permanently
|
| 2110 |
+
</button>
|
| 2111 |
+
</div>
|
| 2112 |
+
</div>
|
| 2113 |
+
)}
|
| 2114 |
+
</div>
|
| 2115 |
+
</div>
|
| 2116 |
+
)}
|
| 2117 |
+
</div>
|
| 2118 |
+
);
|
| 2119 |
+
}
|
| 2120 |
+
|
| 2121 |
+
// ==========================================================================
|
| 2122 |
+
// WAV Exporter Helpers
|
| 2123 |
+
// ==========================================================================
|
| 2124 |
+
function exportWav(chunks, sampleRate) {
|
| 2125 |
+
let totalLength = 0;
|
| 2126 |
+
for (const chunk of chunks) {
|
| 2127 |
+
totalLength += chunk.length;
|
| 2128 |
+
}
|
| 2129 |
+
|
| 2130 |
+
const result = new Float32Array(totalLength);
|
| 2131 |
+
let offset = 0;
|
| 2132 |
+
for (const chunk of chunks) {
|
| 2133 |
+
result.set(chunk, offset);
|
| 2134 |
+
offset += chunk.length;
|
| 2135 |
+
}
|
| 2136 |
+
|
| 2137 |
+
const buffer = new ArrayBuffer(44 + totalLength * 2);
|
| 2138 |
+
const view = new DataView(buffer);
|
| 2139 |
+
|
| 2140 |
+
// RIFF identifier
|
| 2141 |
+
writeString(view, 0, 'RIFF');
|
| 2142 |
+
// File length
|
| 2143 |
+
view.setUint32(4, 36 + totalLength * 2, true);
|
| 2144 |
+
// RIFF type
|
| 2145 |
+
writeString(view, 8, 'WAVE');
|
| 2146 |
+
// Format chunk identifier
|
| 2147 |
+
writeString(view, 12, 'fmt ');
|
| 2148 |
+
// Format chunk length
|
| 2149 |
+
view.setUint32(16, 16, true);
|
| 2150 |
+
// Sample format (raw PCM)
|
| 2151 |
+
view.setUint16(20, 1, true);
|
| 2152 |
+
// Channel count (mono)
|
| 2153 |
+
view.setUint16(22, 1, true);
|
| 2154 |
+
// Sample rate
|
| 2155 |
+
view.setUint32(24, sampleRate, true);
|
| 2156 |
+
// Byte rate (sample rate * block align)
|
| 2157 |
+
view.setUint32(28, sampleRate * 2, true);
|
| 2158 |
+
// Block align (channel count * bytes per sample)
|
| 2159 |
+
view.setUint16(32, 2, true);
|
| 2160 |
+
// Bits per sample
|
| 2161 |
+
view.setUint16(34, 16, true);
|
| 2162 |
+
// Data chunk identifier
|
| 2163 |
+
writeString(view, 36, 'data');
|
| 2164 |
+
// Data chunk length
|
| 2165 |
+
view.setUint32(40, totalLength * 2, true);
|
| 2166 |
+
|
| 2167 |
+
// Write PCM audio samples
|
| 2168 |
+
let index = 44;
|
| 2169 |
+
for (let i = 0; i < totalLength; i++) {
|
| 2170 |
+
const s = Math.max(-1, Math.min(1, result[i]));
|
| 2171 |
+
view.setInt16(index, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
| 2172 |
+
index += 2;
|
| 2173 |
+
}
|
| 2174 |
+
|
| 2175 |
+
return new Blob([view], { type: 'audio/wav' });
|
| 2176 |
+
}
|
| 2177 |
+
|
| 2178 |
+
function writeString(view, offset, string) {
|
| 2179 |
+
for (let i = 0; i < string.length; i++) {
|
| 2180 |
+
view.setUint8(offset + i, string.charCodeAt(i));
|
| 2181 |
+
}
|
| 2182 |
+
}
|
| 2183 |
+
|
| 2184 |
+
export default App;
|
frontend/src/LandingPage.jsx
ADDED
|
@@ -0,0 +1,1148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
+
import {
|
| 4 |
+
Sparkles,
|
| 5 |
+
ArrowRight,
|
| 6 |
+
Play,
|
| 7 |
+
Square,
|
| 8 |
+
Heart,
|
| 9 |
+
Cpu,
|
| 10 |
+
FileText,
|
| 11 |
+
ChevronDown,
|
| 12 |
+
Check,
|
| 13 |
+
Volume2,
|
| 14 |
+
MessageSquare,
|
| 15 |
+
Mic,
|
| 16 |
+
Disc,
|
| 17 |
+
Layers,
|
| 18 |
+
Globe,
|
| 19 |
+
Radio,
|
| 20 |
+
Headphones,
|
| 21 |
+
PlayCircle,
|
| 22 |
+
Settings,
|
| 23 |
+
Flame,
|
| 24 |
+
Volume1,
|
| 25 |
+
HelpCircle,
|
| 26 |
+
User,
|
| 27 |
+
ExternalLink,
|
| 28 |
+
BookOpen
|
| 29 |
+
} from 'lucide-react';
|
| 30 |
+
|
| 31 |
+
const Github = (props) => (
|
| 32 |
+
<svg viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" className={props.className || "w-5 h-5"}>
|
| 33 |
+
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" />
|
| 34 |
+
</svg>
|
| 35 |
+
);
|
| 36 |
+
|
| 37 |
+
export default function LandingPage({ onTryDemo }) {
|
| 38 |
+
// Collapsible FAQ Active Index
|
| 39 |
+
const [activeFaq, setActiveFaq] = useState(null);
|
| 40 |
+
|
| 41 |
+
// Mock Audio Player Demo State
|
| 42 |
+
const [mockIsPlaying, setMockIsPlaying] = useState(false);
|
| 43 |
+
const [mockActiveSpeaker, setMockActiveSpeaker] = useState(0);
|
| 44 |
+
const [mockTimeline, setMockTimeline] = useState(0);
|
| 45 |
+
const [selectedVoicePreset, setSelectedVoicePreset] = useState('podcast');
|
| 46 |
+
const canvasRef = useRef(null);
|
| 47 |
+
const animationFrameId = useRef(null);
|
| 48 |
+
|
| 49 |
+
// Scroll to section helper
|
| 50 |
+
const scrollToSection = (id) => {
|
| 51 |
+
const element = document.getElementById(id);
|
| 52 |
+
if (element) {
|
| 53 |
+
element.scrollIntoView({ behavior: 'smooth' });
|
| 54 |
+
}
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
// Mock Speech Dialogues matching preset
|
| 58 |
+
const voicePresets = {
|
| 59 |
+
podcast: {
|
| 60 |
+
title: "Co-Hosted Tech Podcast",
|
| 61 |
+
description: "Expressive debate with turn-taking and natural emphasis.",
|
| 62 |
+
duration: 12,
|
| 63 |
+
speakers: [
|
| 64 |
+
{ id: 0, name: "David (AI Host)", color: "cyan" },
|
| 65 |
+
{ id: 1, name: "Zira (AI Guest)", color: "purple" }
|
| 66 |
+
],
|
| 67 |
+
script: [
|
| 68 |
+
{ spk: 0, text: "Welcome back! Today we are discussing open-source conversational AI.", time: 0 },
|
| 69 |
+
{ spk: 1, text: "Yes! And VibeVoice is achieving incredible zero-shot prosody matching.", time: 4 },
|
| 70 |
+
{ spk: 0, text: "It runs in real-time, even on a basic CPU node.", time: 8 }
|
| 71 |
+
]
|
| 72 |
+
},
|
| 73 |
+
audiobook: {
|
| 74 |
+
title: "Atmospheric Audiobook",
|
| 75 |
+
description: "Rich descriptive narration with dramatic pauses.",
|
| 76 |
+
duration: 15,
|
| 77 |
+
speakers: [
|
| 78 |
+
{ id: 0, name: "Narrator", color: "amber" }
|
| 79 |
+
],
|
| 80 |
+
script: [
|
| 81 |
+
{ spk: 0, text: "The ancient model weights loaded slowly, breathing life into the synthesis.", time: 0 },
|
| 82 |
+
{ spk: 0, text: "Every voice clone whispered of a future built on neural acoustic tokens...", time: 7 }
|
| 83 |
+
]
|
| 84 |
+
},
|
| 85 |
+
assistant: {
|
| 86 |
+
title: "High-Speed Dynamic Support Agent",
|
| 87 |
+
description: "Polite, helpful customer transaction stream.",
|
| 88 |
+
duration: 10,
|
| 89 |
+
speakers: [
|
| 90 |
+
{ id: 0, name: "VibeVoice Agent", color: "emerald" },
|
| 91 |
+
{ id: 1, name: "User Alex", color: "rose" }
|
| 92 |
+
],
|
| 93 |
+
script: [
|
| 94 |
+
{ spk: 0, text: "Hello! I am VibeVoice AI. I can assist with your DineDirect order.", time: 0 },
|
| 95 |
+
{ spk: 1, text: "Perfect. Add a dynamic voice reference for Speaker 2.", time: 4 },
|
| 96 |
+
{ spk: 0, text: "Certainly! Setting up customized vocal signature parameters.", time: 7 }
|
| 97 |
+
]
|
| 98 |
+
}
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
// Switch voice preset
|
| 102 |
+
useEffect(() => {
|
| 103 |
+
setMockIsPlaying(false);
|
| 104 |
+
setMockTimeline(0);
|
| 105 |
+
setMockActiveSpeaker(0);
|
| 106 |
+
}, [selectedVoicePreset]);
|
| 107 |
+
|
| 108 |
+
// Mock play timeline tick
|
| 109 |
+
useEffect(() => {
|
| 110 |
+
let interval;
|
| 111 |
+
if (mockIsPlaying) {
|
| 112 |
+
const activePreset = voicePresets[selectedVoicePreset];
|
| 113 |
+
interval = setInterval(() => {
|
| 114 |
+
setMockTimeline(prev => {
|
| 115 |
+
if (prev >= activePreset.duration) {
|
| 116 |
+
setMockIsPlaying(false);
|
| 117 |
+
return 0;
|
| 118 |
+
}
|
| 119 |
+
const nextVal = prev + 0.1;
|
| 120 |
+
// Determine active speaker based on time
|
| 121 |
+
const matchLine = [...activePreset.script].reverse().find(line => nextVal >= line.time);
|
| 122 |
+
if (matchLine && matchLine.spk !== mockActiveSpeaker) {
|
| 123 |
+
setMockActiveSpeaker(matchLine.spk);
|
| 124 |
+
}
|
| 125 |
+
return nextVal;
|
| 126 |
+
});
|
| 127 |
+
}, 100);
|
| 128 |
+
}
|
| 129 |
+
return () => clearInterval(interval);
|
| 130 |
+
}, [mockIsPlaying, selectedVoicePreset, mockActiveSpeaker]);
|
| 131 |
+
|
| 132 |
+
// Floating Particles or Voice Wave Animation for Visualizer Mock
|
| 133 |
+
useEffect(() => {
|
| 134 |
+
const canvas = canvasRef.current;
|
| 135 |
+
if (!canvas) return;
|
| 136 |
+
const ctx = canvas.getContext('2d');
|
| 137 |
+
const width = canvas.width;
|
| 138 |
+
const height = canvas.height;
|
| 139 |
+
let time = 0;
|
| 140 |
+
|
| 141 |
+
const render = () => {
|
| 142 |
+
time += 0.04;
|
| 143 |
+
ctx.fillStyle = 'rgba(7, 9, 14, 0.15)'; // Back-sweep fade
|
| 144 |
+
ctx.fillRect(0, 0, width, height);
|
| 145 |
+
|
| 146 |
+
// Multiple colored floating sin waves
|
| 147 |
+
const waves = [
|
| 148 |
+
{ amplitude: mockIsPlaying ? 35 : 8, speed: 1.5, color: 'rgba(0, 242, 254, 0.35)', freq: 0.015 },
|
| 149 |
+
{ amplitude: mockIsPlaying ? 25 : 6, speed: 2.2, color: 'rgba(162, 89, 255, 0.3)', freq: 0.02 },
|
| 150 |
+
{ amplitude: mockIsPlaying ? 15 : 4, speed: -1.2, color: 'rgba(255, 92, 141, 0.25)', freq: 0.01 }
|
| 151 |
+
];
|
| 152 |
+
|
| 153 |
+
waves.forEach(w => {
|
| 154 |
+
ctx.beginPath();
|
| 155 |
+
ctx.lineWidth = mockIsPlaying ? 2.5 : 1.5;
|
| 156 |
+
ctx.strokeStyle = w.color;
|
| 157 |
+
ctx.shadowBlur = mockIsPlaying ? 8 : 0;
|
| 158 |
+
ctx.shadowColor = w.color;
|
| 159 |
+
|
| 160 |
+
for (let x = 0; x < width; x++) {
|
| 161 |
+
const y = height / 2 + Math.sin(x * w.freq + time * w.speed) * w.amplitude * Math.sin(x / width * Math.PI);
|
| 162 |
+
if (x === 0) ctx.moveTo(x, y);
|
| 163 |
+
else ctx.lineTo(x, y);
|
| 164 |
+
}
|
| 165 |
+
ctx.stroke();
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
// Animated voice bars on left and right borders
|
| 169 |
+
if (mockIsPlaying) {
|
| 170 |
+
ctx.fillStyle = 'rgba(0, 242, 254, 0.15)';
|
| 171 |
+
for (let i = 0; i < 15; i++) {
|
| 172 |
+
const barHeight = Math.abs(Math.sin(time * 3 + i)) * 40;
|
| 173 |
+
ctx.fillRect(15 + i * 8, height - barHeight - 10, 5, barHeight);
|
| 174 |
+
ctx.fillRect(width - 15 - i * 8 - 5, height - barHeight - 10, 5, barHeight);
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
animationFrameId.current = requestAnimationFrame(render);
|
| 179 |
+
};
|
| 180 |
+
|
| 181 |
+
render();
|
| 182 |
+
return () => cancelAnimationFrame(animationFrameId.current);
|
| 183 |
+
}, [mockIsPlaying]);
|
| 184 |
+
|
| 185 |
+
// Framer motion containers configs
|
| 186 |
+
const containerVariants = {
|
| 187 |
+
hidden: { opacity: 0 },
|
| 188 |
+
visible: { opacity: 1, transition: { staggerChildren: 0.15 } }
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
const itemVariants = {
|
| 192 |
+
hidden: { opacity: 0, y: 30 },
|
| 193 |
+
visible: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 60 } }
|
| 194 |
+
};
|
| 195 |
+
|
| 196 |
+
// FAQ List
|
| 197 |
+
const faqs = [
|
| 198 |
+
{
|
| 199 |
+
q: "What is VibeVoice AI?",
|
| 200 |
+
a: "VibeVoice AI is an advanced, research-grade open-source neural acoustic speech model developed by Microsoft Research. It leverages multi-turn continuous speaker tokenization to generate hyper-realistic, human-like voice synthesis and dialogue streams without requiring massive GPU cluster infrastructures."
|
| 201 |
+
},
|
| 202 |
+
{
|
| 203 |
+
q: "Is it completely open source?",
|
| 204 |
+
a: "Yes, VibeVoice is fully open source. All model architectures, inference engines, and base neural weights (including the lightweight 0.5B parameter variant) are accessible on our GitHub repository and Hugging Face space for developers, researchers, and synthetic media creators."
|
| 205 |
+
},
|
| 206 |
+
{
|
| 207 |
+
q: "Does it support multi-speaker dialogue synthesis?",
|
| 208 |
+
a: "Absolutely. Rather than just speaking single phrases, VibeVoice excels at context-aware turn-taking. You can write scripts detailing conversations for two or more speakers, and the model handles transitions, conversational pauses, dynamic pacing, and speech emphasis with extreme stability."
|
| 209 |
+
},
|
| 210 |
+
{
|
| 211 |
+
q: "Can it generate full podcast discussions?",
|
| 212 |
+
a: "Yes. By providing a scripted conversation template containing Speaker IDs (e.g. Speaker 0, Speaker 1), the engine aggregates dialogue cues to model natural flow, interruptions, and pacing, producing broadcast-ready podcast tracks."
|
| 213 |
+
},
|
| 214 |
+
{
|
| 215 |
+
q: "Does it support emotional speech synthesis and voice cloning?",
|
| 216 |
+
a: "Yes. VibeVoice utilizes zero-shot speaker adaptation. By capturing or uploading a brief 10-30 second reference WAV file, it extracts pitch, vocal envelope, and prosody parameters. The model then synthesizes conversational speech matching the original speaker's vocal signature and dynamic emotional tone."
|
| 217 |
+
},
|
| 218 |
+
{
|
| 219 |
+
q: "Can developers fine-tune the model weights?",
|
| 220 |
+
a: "Yes! The repository contains full PyTorch training recipes, quantization parameters, and Hugging Face pipeline wrappers. You can fine-tune VibeVoice on custom speech datasets or scale it in commercial server clouds utilizing GPU-optimized TensorRT containers."
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
q: "Is it suitable for research projects?",
|
| 224 |
+
a: "VibeVoice was built from the ground up to support academic and commercial speech research. The architecture decouples text tokenization and acoustic prosody representation, offering researchers a clean pipeline to inspect neural audio modeling."
|
| 225 |
+
}
|
| 226 |
+
];
|
| 227 |
+
|
| 228 |
+
return (
|
| 229 |
+
<div className="min-h-screen text-[#f0f3fa] selection:bg-[#00f2fe]/20 relative overflow-x-hidden font-sans">
|
| 230 |
+
|
| 231 |
+
{/* Dynamic Glowing Parallax Backdrop Background */}
|
| 232 |
+
<div className="fixed inset-0 overflow-hidden pointer-events-none z-0">
|
| 233 |
+
<div className="absolute w-[800px] h-[800px] rounded-full top-[-300px] left-[-200px] bg-gradient-to-br from-[#00f2fe]/10 to-[#a259ff]/5 blur-[160px] animate-float-glow-1"></div>
|
| 234 |
+
<div className="absolute w-[900px] h-[900px] rounded-full bottom-[-300px] right-[-200px] bg-gradient-to-br from-[#ff5c8d]/10 to-[#a259ff]/5 blur-[180px] animate-float-glow-2"></div>
|
| 235 |
+
<div className="absolute w-[600px] h-[600px] rounded-full top-[40%] right-[10%] bg-gradient-to-br from-[#00f2fe]/5 to-[#ff5c8d]/5 blur-[150px] animate-[floatGlow_35s_infinite_alternate_ease-in-out]"></div>
|
| 236 |
+
</div>
|
| 237 |
+
|
| 238 |
+
{/* Modern Glassmorphic Sticky Navigation */}
|
| 239 |
+
<header className="sticky top-0 z-50 bg-glass/60 backdrop-blur-xl border-b border-white/5 py-4">
|
| 240 |
+
<div className="max-w-[1300px] mx-auto px-6 flex justify-between items-center">
|
| 241 |
+
<div className="flex items-center gap-3 cursor-pointer" onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}>
|
| 242 |
+
<Sparkles className="w-8 h-8 text-neon-cyan drop-shadow-[0_0_10px_rgba(0,242,254,0.4)]" />
|
| 243 |
+
<div>
|
| 244 |
+
<span className="text-xl font-bold tracking-tight">
|
| 245 |
+
VibeVoice<span className="bg-gradient-to-r from-[#00f2fe] to-[#a259ff] bg-clip-text text-transparent">AI</span>
|
| 246 |
+
</span>
|
| 247 |
+
<span className="block text-[8.5px] text-[#8e9bb5] uppercase tracking-widest font-semibold">Microsoft Research Ecosystem</span>
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
|
| 251 |
+
{/* Desktop Menu links */}
|
| 252 |
+
<nav className="hidden md:flex items-center gap-8 text-xs font-semibold text-[#8e9bb5]">
|
| 253 |
+
<button onClick={() => scrollToSection('about')} className="hover:text-white hover:text-shadow transition duration-200 cursor-pointer">About</button>
|
| 254 |
+
<button onClick={() => scrollToSection('features')} className="hover:text-white hover:text-shadow transition duration-200 cursor-pointer">Features</button>
|
| 255 |
+
<button onClick={() => scrollToSection('capabilities')} className="hover:text-white hover:text-shadow transition duration-200 cursor-pointer">Capabilities</button>
|
| 256 |
+
<button onClick={() => scrollToSection('demo')} className="hover:text-white hover:text-shadow transition duration-200 cursor-pointer">Interactive Demo</button>
|
| 257 |
+
<button onClick={() => scrollToSection('open-source')} className="hover:text-white hover:text-shadow transition duration-200 cursor-pointer">Open Source</button>
|
| 258 |
+
<button onClick={() => scrollToSection('faq')} className="hover:text-white hover:text-shadow transition duration-200 cursor-pointer">FAQs</button>
|
| 259 |
+
</nav>
|
| 260 |
+
|
| 261 |
+
<div className="flex items-center gap-3">
|
| 262 |
+
<a
|
| 263 |
+
href="https://github.com"
|
| 264 |
+
target="_blank"
|
| 265 |
+
rel="noopener noreferrer"
|
| 266 |
+
className="p-2 bg-white/4 border border-white/5 hover:border-[#00f2fe]/40 rounded-xl hover:bg-white/8 transition duration-200 cursor-pointer"
|
| 267 |
+
>
|
| 268 |
+
<Github className="w-4 h-4 text-white" />
|
| 269 |
+
</a>
|
| 270 |
+
<button
|
| 271 |
+
onClick={onTryDemo}
|
| 272 |
+
className="relative group overflow-hidden px-4.5 py-2 rounded-xl bg-gradient-to-r from-[#00f2fe] to-[#a259ff] text-black font-bold text-xs transition duration-200 cursor-pointer active:scale-95 shadow-[0_4px_15px_rgba(0,242,254,0.3)] hover:shadow-[0_4px_25px_rgba(0,242,254,0.5)]"
|
| 273 |
+
>
|
| 274 |
+
Launch Studio Workspace
|
| 275 |
+
</button>
|
| 276 |
+
</div>
|
| 277 |
+
</div>
|
| 278 |
+
</header>
|
| 279 |
+
|
| 280 |
+
{/* 1. HERO SECTION */}
|
| 281 |
+
<section className="relative z-10 pt-16 md:pt-24 pb-20 max-w-[1300px] mx-auto px-6 grid grid-cols-1 lg:grid-cols-[1.1fr_0.9fr] gap-12 items-center">
|
| 282 |
+
<motion.div
|
| 283 |
+
initial={{ opacity: 0, x: -50 }}
|
| 284 |
+
animate={{ opacity: 1, x: 0 }}
|
| 285 |
+
transition={{ duration: 0.8, ease: "easeOut" }}
|
| 286 |
+
className="flex flex-col gap-6 text-left"
|
| 287 |
+
>
|
| 288 |
+
{/* Neon Ribbon Badge */}
|
| 289 |
+
<div className="inline-flex items-center gap-2 self-start bg-[#00f2fe]/10 border border-[#00f2fe]/30 rounded-full px-4 py-1.5 text-xs text-neon-cyan select-none">
|
| 290 |
+
<Radio className="w-3.5 h-3.5 animate-pulse text-neon-cyan" />
|
| 291 |
+
<span className="font-bold uppercase tracking-wider text-[10px]">Active Open-Source Model: VibeVoice v1.5</span>
|
| 292 |
+
</div>
|
| 293 |
+
|
| 294 |
+
<h1 className="text-5xl md:text-7xl font-extrabold tracking-tight leading-[1.08] text-white">
|
| 295 |
+
VibeVoice <span className="bg-gradient-to-r from-[#00f2fe] via-[#a259ff] to-[#ff5c8d] bg-clip-text text-transparent drop-shadow-[0_0_20px_rgba(0,242,254,0.15)]">AI</span>
|
| 296 |
+
</h1>
|
| 297 |
+
|
| 298 |
+
<p className="text-lg md:text-xl font-medium text-[#8e9bb5] max-w-[550px] leading-relaxed">
|
| 299 |
+
Human-like conversational speech generation powered by AI. Experience the future of multi-speaker dialogue and expressive zero-shot voice synthesis.
|
| 300 |
+
</p>
|
| 301 |
+
|
| 302 |
+
<div className="flex flex-col sm:flex-row gap-4 pt-3">
|
| 303 |
+
<button
|
| 304 |
+
onClick={onTryDemo}
|
| 305 |
+
className="flex items-center justify-center gap-2.5 px-7 py-4.5 rounded-xl bg-gradient-to-r from-[#00f2fe] to-[#a259ff] text-black font-extrabold text-sm transition duration-200 cursor-pointer hover:shadow-[0_0_25px_rgba(0,242,254,0.45)] active:scale-95 group"
|
| 306 |
+
>
|
| 307 |
+
Try Studio Playground
|
| 308 |
+
<ArrowRight className="w-4 h-4 group-hover:translate-x-1.5 transition-transform duration-200" />
|
| 309 |
+
</button>
|
| 310 |
+
<a
|
| 311 |
+
href="https://github.com"
|
| 312 |
+
target="_blank"
|
| 313 |
+
rel="noopener noreferrer"
|
| 314 |
+
className="flex items-center justify-center gap-2.5 px-7 py-4.5 rounded-xl bg-white/4 border border-white/8 hover:bg-white/8 hover:border-white/15 text-white font-bold text-sm transition duration-200 cursor-pointer active:scale-95"
|
| 315 |
+
>
|
| 316 |
+
<Github className="w-5 h-5" />
|
| 317 |
+
View on GitHub
|
| 318 |
+
</a>
|
| 319 |
+
</div>
|
| 320 |
+
|
| 321 |
+
{/* Feature highlights inside Hero */}
|
| 322 |
+
<div className="grid grid-cols-3 gap-6 pt-10 border-t border-white/5 max-w-[520px]">
|
| 323 |
+
<div>
|
| 324 |
+
<h4 className="text-2xl font-extrabold text-white">24kHz</h4>
|
| 325 |
+
<p className="text-[10px] text-[#8e9bb5] uppercase tracking-wide">High-Fidelity Audio</p>
|
| 326 |
+
</div>
|
| 327 |
+
<div>
|
| 328 |
+
<h4 className="text-2xl font-extrabold text-white">< 250ms</h4>
|
| 329 |
+
<p className="text-[10px] text-[#8e9bb5] uppercase tracking-wide">Dynamic Latency</p>
|
| 330 |
+
</div>
|
| 331 |
+
<div>
|
| 332 |
+
<h4 className="text-2xl font-extrabold text-white">1.5B</h4>
|
| 333 |
+
<p className="text-[10px] text-[#8e9bb5] uppercase tracking-wide">Quantized weights</p>
|
| 334 |
+
</div>
|
| 335 |
+
</div>
|
| 336 |
+
</motion.div>
|
| 337 |
+
|
| 338 |
+
{/* Hero Visualizer Graphic */}
|
| 339 |
+
<motion.div
|
| 340 |
+
initial={{ opacity: 0, scale: 0.9, y: 30 }}
|
| 341 |
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
| 342 |
+
transition={{ duration: 1, ease: "easeOut" }}
|
| 343 |
+
className="relative flex justify-center items-center"
|
| 344 |
+
>
|
| 345 |
+
{/* Ambient Background Circles */}
|
| 346 |
+
<div className="absolute w-[350px] h-[350px] rounded-full border border-dashed border-[#00f2fe]/20 animate-[orbSpin_40s_infinite_linear]"></div>
|
| 347 |
+
<div className="absolute w-[450px] h-[450px] rounded-full border border-dashed border-[#a259ff]/10 animate-[orbSpin_60s_infinite_linear_reverse]"></div>
|
| 348 |
+
|
| 349 |
+
{/* Central AI Orb Graphic */}
|
| 350 |
+
<div className="relative w-[280px] h-[280px] rounded-full bg-glass flex items-center justify-center border border-white/10 shadow-3xl">
|
| 351 |
+
{/* Spinning Holographic Waves */}
|
| 352 |
+
<div className="absolute inset-4 rounded-full bg-gradient-to-tr from-[#00f2fe]/20 to-[#a259ff]/20 animate-[orbSpin_12s_infinite_linear]"></div>
|
| 353 |
+
<div className="absolute inset-8 rounded-full bg-gradient-to-br from-[#ff5c8d]/15 to-[#a259ff]/10 animate-[orbSpin_8s_infinite_linear_reverse]"></div>
|
| 354 |
+
|
| 355 |
+
{/* Pulsating core */}
|
| 356 |
+
<div className="w-[120px] h-[120px] rounded-full bg-gradient-to-tr from-[#00f2fe] via-[#a259ff] to-[#ff5c8d] shadow-[0_0_40px_rgba(0,242,254,0.45)] animate-pulse flex items-center justify-center relative">
|
| 357 |
+
<Headphones className="w-12 h-12 text-black" />
|
| 358 |
+
</div>
|
| 359 |
+
|
| 360 |
+
{/* Glowing satellites */}
|
| 361 |
+
<div className="absolute top-[10%] left-[10%] w-6 h-6 rounded-full bg-[#00f2fe]/80 blur-[4px] animate-pulse"></div>
|
| 362 |
+
<div className="absolute bottom-[15%] right-[12%] w-8 h-8 rounded-full bg-[#ff5c8d]/60 blur-[6px] animate-pulse"></div>
|
| 363 |
+
</div>
|
| 364 |
+
|
| 365 |
+
{/* Floating UI Audio Indicators */}
|
| 366 |
+
<div className="absolute top-[15%] right-[5%] bg-glass p-3.5 rounded-2xl border border-white/10 flex items-center gap-2.5 shadow-2xl animate-[floatGlow_20s_infinite_alternate_ease-in-out]">
|
| 367 |
+
<Mic className="w-4 h-4 text-[#00f2fe]" />
|
| 368 |
+
<div className="text-left">
|
| 369 |
+
<span className="block text-[9px] text-[#8e9bb5] uppercase font-bold tracking-wide">Cloned Vocals</span>
|
| 370 |
+
<span className="text-[11px] font-bold text-white">David_Signature.wav</span>
|
| 371 |
+
</div>
|
| 372 |
+
</div>
|
| 373 |
+
|
| 374 |
+
<div className="absolute bottom-[20%] left-[0%] bg-glass p-3.5 rounded-2xl border border-white/10 flex items-center gap-2.5 shadow-2xl animate-[floatGlow_22s_infinite_alternate-reverse_ease-in-out]">
|
| 375 |
+
<Radio className="w-4 h-4 text-[#a259ff] animate-pulse" />
|
| 376 |
+
<div className="text-left">
|
| 377 |
+
<span className="block text-[9px] text-[#8e9bb5] uppercase font-bold tracking-wide">Stream Broadcast</span>
|
| 378 |
+
<span className="text-[11px] font-bold text-[#00e673]">Active turn-taking</span>
|
| 379 |
+
</div>
|
| 380 |
+
</div>
|
| 381 |
+
</motion.div>
|
| 382 |
+
</section>
|
| 383 |
+
|
| 384 |
+
{/* 2. ABOUT SECTION */}
|
| 385 |
+
<section id="about" className="relative z-10 py-24 border-t border-white/5 max-w-[1300px] mx-auto px-6">
|
| 386 |
+
<motion.div
|
| 387 |
+
initial="hidden"
|
| 388 |
+
whileInView="visible"
|
| 389 |
+
viewport={{ once: true, margin: "-100px" }}
|
| 390 |
+
variants={containerVariants}
|
| 391 |
+
className="grid grid-cols-1 lg:grid-cols-[0.8fr_1.2fr] gap-12 items-center"
|
| 392 |
+
>
|
| 393 |
+
<motion.div variants={itemVariants} className="flex flex-col gap-5 text-left">
|
| 394 |
+
<span className="text-xs font-bold uppercase tracking-widest text-neon-cyan">The Research Foundation</span>
|
| 395 |
+
<h2 className="text-3xl md:text-4xl font-extrabold text-white leading-tight">
|
| 396 |
+
A Leap Forward in Expressive Speech Synthesis
|
| 397 |
+
</h2>
|
| 398 |
+
<p className="text-sm text-[#8e9bb5] leading-relaxed">
|
| 399 |
+
VibeVoice is an advanced open-source AI voice model developed to bridge the gap between flat, robotic speech-to-text outputs and natural, multi-speaker conversational dialogue.
|
| 400 |
+
</p>
|
| 401 |
+
<div className="flex gap-4 pt-2">
|
| 402 |
+
<a
|
| 403 |
+
href="https://huggingface.co"
|
| 404 |
+
target="_blank"
|
| 405 |
+
rel="noopener noreferrer"
|
| 406 |
+
className="flex items-center gap-2 text-xs font-bold text-white hover:text-neon-cyan transition duration-200"
|
| 407 |
+
>
|
| 408 |
+
Hugging Face Portal <ExternalLink className="w-3.5 h-3.5" />
|
| 409 |
+
</a>
|
| 410 |
+
<a
|
| 411 |
+
href="https://github.com"
|
| 412 |
+
target="_blank"
|
| 413 |
+
rel="noopener noreferrer"
|
| 414 |
+
className="flex items-center gap-2 text-xs font-bold text-white hover:text-neon-purple transition duration-200"
|
| 415 |
+
>
|
| 416 |
+
Developer Docs <BookOpen className="w-3.5 h-3.5" />
|
| 417 |
+
</a>
|
| 418 |
+
</div>
|
| 419 |
+
</motion.div>
|
| 420 |
+
|
| 421 |
+
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-6 text-left">
|
| 422 |
+
{/* About capability cards */}
|
| 423 |
+
<div className="bg-glass p-6 rounded-2xl border border-white/5 hover:border-white/10 transition duration-300">
|
| 424 |
+
<div className="w-10 h-10 rounded-xl bg-[#00f2fe]/10 flex items-center justify-center text-neon-cyan mb-4">
|
| 425 |
+
<Layers className="w-5 h-5" />
|
| 426 |
+
</div>
|
| 427 |
+
<h3 className="text-md font-semibold text-white mb-2">Zero-Shot Cloning</h3>
|
| 428 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed">
|
| 429 |
+
By processing a brief, 10-second reference voice WAV, the network maps core vocal footprints to synthesize entirely new dialogue styles instantly.
|
| 430 |
+
</p>
|
| 431 |
+
</div>
|
| 432 |
+
|
| 433 |
+
<div className="bg-glass p-6 rounded-2xl border border-white/5 hover:border-white/10 transition duration-300">
|
| 434 |
+
<div className="w-10 h-10 rounded-xl bg-[#a259ff]/10 flex items-center justify-center text-neon-purple mb-4">
|
| 435 |
+
<MessageSquare className="w-5 h-5" />
|
| 436 |
+
</div>
|
| 437 |
+
<h3 className="text-md font-semibold text-white mb-2">Multi-Speaker Continuity</h3>
|
| 438 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed">
|
| 439 |
+
Models turn-taking triggers, pauses, and context queues so speakers flow smoothly between paragraphs, simulating authentic studio atmospheres.
|
| 440 |
+
</p>
|
| 441 |
+
</div>
|
| 442 |
+
|
| 443 |
+
<div className="bg-glass p-6 rounded-2xl border border-white/5 hover:border-white/10 transition duration-300">
|
| 444 |
+
<div className="w-10 h-10 rounded-xl bg-[#ff5c8d]/10 flex items-center justify-center text-[#ff5c8d] mb-4">
|
| 445 |
+
<Cpu className="w-5 h-5" />
|
| 446 |
+
</div>
|
| 447 |
+
<h3 className="text-md font-semibold text-white mb-2">Research-Grade Architecture</h3>
|
| 448 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed">
|
| 449 |
+
Decoupled neural acoustic tokenizers allow academic inspection and dynamic speed parameter tweaking for multi-agent experiments.
|
| 450 |
+
</p>
|
| 451 |
+
</div>
|
| 452 |
+
|
| 453 |
+
<div className="bg-glass p-6 rounded-2xl border border-white/5 hover:border-white/10 transition duration-300">
|
| 454 |
+
<div className="w-10 h-10 rounded-xl bg-[#00e673]/10 flex items-center justify-center text-[#00e673] mb-4">
|
| 455 |
+
<Globe className="w-5 h-5" />
|
| 456 |
+
</div>
|
| 457 |
+
<h3 className="text-md font-semibold text-white mb-2">Open Ecosystem</h3>
|
| 458 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed">
|
| 459 |
+
Seamless pipeline wrappers for Hugging Face datasets and local PyTorch containers to build production speech layers.
|
| 460 |
+
</p>
|
| 461 |
+
</div>
|
| 462 |
+
</motion.div>
|
| 463 |
+
</motion.div>
|
| 464 |
+
</section>
|
| 465 |
+
|
| 466 |
+
{/* 3. FEATURES SECTION */}
|
| 467 |
+
<section id="features" className="relative z-10 py-24 bg-black/30 border-t border-white/5 max-w-[1300px] mx-auto px-6">
|
| 468 |
+
<div className="text-center max-w-[700px] mx-auto mb-16 flex flex-col gap-3">
|
| 469 |
+
<span className="text-xs font-bold uppercase tracking-widest text-[#a259ff]">Enterprise SaaS Features</span>
|
| 470 |
+
<h2 className="text-3xl md:text-5xl font-extrabold text-white">Synthesize Without Limits</h2>
|
| 471 |
+
<p className="text-sm text-[#8e9bb5] leading-relaxed">
|
| 472 |
+
From co-hosted podcasts to responsive gaming characters, VibeVoice AI bundles powerful acoustic capabilities out-of-the-box.
|
| 473 |
+
</p>
|
| 474 |
+
</div>
|
| 475 |
+
|
| 476 |
+
<motion.div
|
| 477 |
+
initial="hidden"
|
| 478 |
+
whileInView="visible"
|
| 479 |
+
viewport={{ once: true, margin: "-100px" }}
|
| 480 |
+
variants={containerVariants}
|
| 481 |
+
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"
|
| 482 |
+
>
|
| 483 |
+
{/* Feature Card A */}
|
| 484 |
+
<motion.div variants={itemVariants} className="bg-glass p-6 rounded-2xl border border-white/5 hover:border-[#00f2fe]/40 hover:shadow-[0_0_20px_rgba(0,242,254,0.1)] transition duration-300 text-left group">
|
| 485 |
+
<div className="w-10 h-10 rounded-xl bg-[#00f2fe]/10 flex items-center justify-center text-neon-cyan mb-4 group-hover:scale-110 transition duration-300">
|
| 486 |
+
<Mic className="w-5 h-5" />
|
| 487 |
+
</div>
|
| 488 |
+
<h3 className="text-md font-bold text-white mb-2">Multi-Speaker Dialogue</h3>
|
| 489 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed">
|
| 490 |
+
Generate natural conversations between multiple AI speakers. Handles realistic speaker transitions and context-aware continuity automatically.
|
| 491 |
+
</p>
|
| 492 |
+
</motion.div>
|
| 493 |
+
|
| 494 |
+
{/* Feature Card B */}
|
| 495 |
+
<motion.div variants={itemVariants} className="bg-glass p-6 rounded-2xl border border-white/5 hover:border-[#a259ff]/40 hover:shadow-[0_0_20px_rgba(162,89,255,0.1)] transition duration-300 text-left group">
|
| 496 |
+
<div className="w-10 h-10 rounded-xl bg-[#a259ff]/10 flex items-center justify-center text-neon-purple mb-4 group-hover:scale-110 transition duration-300">
|
| 497 |
+
<Headphones className="w-5 h-5" />
|
| 498 |
+
</div>
|
| 499 |
+
<h3 className="text-md font-bold text-white mb-2">AI Podcast Generation</h3>
|
| 500 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed">
|
| 501 |
+
Automatically create complete podcast-style discussions with human-like speaking flow, dynamic pacing, and interactive turn exchanges.
|
| 502 |
+
</p>
|
| 503 |
+
</motion.div>
|
| 504 |
+
|
| 505 |
+
{/* Feature Card C */}
|
| 506 |
+
<motion.div variants={itemVariants} className="bg-glass p-6 rounded-2xl border border-white/5 hover:border-[#ff5c8d]/40 hover:shadow-[0_0_20px_rgba(255,92,141,0.1)] transition duration-300 text-left group">
|
| 507 |
+
<div className="w-10 h-10 rounded-xl bg-[#ff5c8d]/10 flex items-center justify-center text-[#ff5c8d] mb-4 group-hover:scale-110 transition duration-300">
|
| 508 |
+
<Volume2 className="w-5 h-5" />
|
| 509 |
+
</div>
|
| 510 |
+
<h3 className="text-md font-bold text-white mb-2">Expressive Text-to-Speech</h3>
|
| 511 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed">
|
| 512 |
+
Emotion-aware speech synthesis capturing raw tone, breathing rhythm, natural pauses, and precise vocal emphasis.
|
| 513 |
+
</p>
|
| 514 |
+
</motion.div>
|
| 515 |
+
|
| 516 |
+
{/* Feature Card D */}
|
| 517 |
+
<motion.div variants={itemVariants} className="bg-glass p-6 rounded-2xl border border-white/5 hover:border-[#00e673]/40 hover:shadow-[0_0_20px_rgba(0,230,115,0.1)] transition duration-300 text-left group">
|
| 518 |
+
<div className="w-10 h-10 rounded-xl bg-[#00e673]/10 flex items-center justify-center text-[#00e673] mb-4 group-hover:scale-110 transition duration-300">
|
| 519 |
+
<Disc className="w-5 h-5" />
|
| 520 |
+
</div>
|
| 521 |
+
<h3 className="text-md font-bold text-white mb-2">Long-Form Audio</h3>
|
| 522 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed">
|
| 523 |
+
Stable long-duration speech generation that maintains voice quality consistency and active conversation memory across chapters.
|
| 524 |
+
</p>
|
| 525 |
+
</motion.div>
|
| 526 |
+
|
| 527 |
+
{/* Feature Card E */}
|
| 528 |
+
<motion.div variants={itemVariants} className="bg-glass p-6 rounded-2xl border border-white/5 hover:border-[#ffb703]/40 hover:shadow-[0_0_20px_rgba(255,183,3,0.1)] transition duration-300 text-left group">
|
| 529 |
+
<div className="w-10 h-10 rounded-xl bg-[#ffb703]/10 flex items-center justify-center text-[#ffb703] mb-4 group-hover:scale-110 transition duration-300">
|
| 530 |
+
<Settings className="w-5 h-5" />
|
| 531 |
+
</div>
|
| 532 |
+
<h3 className="text-md font-bold text-white mb-2">Voice Style Transfer</h3>
|
| 533 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed">
|
| 534 |
+
Vocal adaptation matching target speaker style, footprint imitation, and fast personalized AI voice mapping.
|
| 535 |
+
</p>
|
| 536 |
+
</motion.div>
|
| 537 |
+
|
| 538 |
+
{/* Feature Card F */}
|
| 539 |
+
<motion.div variants={itemVariants} className="bg-glass p-6 rounded-2xl border border-white/5 hover:border-[#00f2fe]/40 hover:shadow-[0_0_20px_rgba(0,242,254,0.1)] transition duration-300 text-left group">
|
| 540 |
+
<div className="w-10 h-10 rounded-xl bg-[#00f2fe]/10 flex items-center justify-center text-neon-cyan mb-4 group-hover:scale-110 transition duration-300">
|
| 541 |
+
<Radio className="w-5 h-5" />
|
| 542 |
+
</div>
|
| 543 |
+
<h3 className="text-md font-bold text-white mb-2">Conversational AI Speech</h3>
|
| 544 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed">
|
| 545 |
+
Human-like interaction flow, clean turn-taking logic, and advanced prosody modeling for responsive AI web support.
|
| 546 |
+
</p>
|
| 547 |
+
</motion.div>
|
| 548 |
+
|
| 549 |
+
{/* Feature Card G */}
|
| 550 |
+
<motion.div variants={itemVariants} className="bg-glass p-6 rounded-2xl border border-white/5 hover:border-[#a259ff]/40 hover:shadow-[0_0_20px_rgba(162,89,255,0.1)] transition duration-300 text-left group">
|
| 551 |
+
<div className="w-10 h-10 rounded-xl bg-[#a259ff]/10 flex items-center justify-center text-neon-purple mb-4 group-hover:scale-110 transition duration-300">
|
| 552 |
+
<Github className="w-5 h-5" />
|
| 553 |
+
</div>
|
| 554 |
+
<h3 className="text-md font-bold text-white mb-2">Open Source Ecosystem</h3>
|
| 555 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed">
|
| 556 |
+
First-class Hugging Face model hub integration, comprehensive GitHub community repositories, and developer API templates.
|
| 557 |
+
</p>
|
| 558 |
+
</motion.div>
|
| 559 |
+
|
| 560 |
+
{/* Feature Card H */}
|
| 561 |
+
<motion.div variants={itemVariants} className="bg-glass p-6 rounded-2xl border border-white/5 hover:border-[#ff5c8d]/40 hover:shadow-[0_0_20px_rgba(255,92,141,0.1)] transition duration-300 text-left group">
|
| 562 |
+
<div className="w-10 h-10 rounded-xl bg-[#ff5c8d]/10 flex items-center justify-center text-[#ff5c8d] mb-4 group-hover:scale-110 transition duration-300">
|
| 563 |
+
<Cpu className="w-5 h-5" />
|
| 564 |
+
</div>
|
| 565 |
+
<h3 className="text-md font-bold text-white mb-2">Research-Grade Tech</h3>
|
| 566 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed">
|
| 567 |
+
Designed from first principles for conversational research, scalable local inference workloads, and custom quantization setups.
|
| 568 |
+
</p>
|
| 569 |
+
</motion.div>
|
| 570 |
+
</motion.div>
|
| 571 |
+
</section>
|
| 572 |
+
|
| 573 |
+
{/* 4. REAL WORLD USE CASES SECTION */}
|
| 574 |
+
<section id="cases" className="relative z-10 py-24 border-t border-white/5 max-w-[1300px] mx-auto px-6">
|
| 575 |
+
<div className="text-center max-w-[700px] mx-auto mb-16 flex flex-col gap-3">
|
| 576 |
+
<span className="text-xs font-bold uppercase tracking-widest text-[#00e673]">Infinite Possibilities</span>
|
| 577 |
+
<h2 className="text-3xl md:text-5xl font-extrabold text-white">Built for Creators & Engineers</h2>
|
| 578 |
+
<p className="text-sm text-[#8e9bb5] leading-relaxed">
|
| 579 |
+
Discover how product squads, creative developers, and researchers deploy VibeVoice AI in the wild.
|
| 580 |
+
</p>
|
| 581 |
+
</div>
|
| 582 |
+
|
| 583 |
+
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
| 584 |
+
{[
|
| 585 |
+
{ title: "AI Podcast Automation", icon: Headphones, color: "cyan" },
|
| 586 |
+
{ title: "Audiobook Narration", icon: BookOpen, color: "purple" },
|
| 587 |
+
{ title: "Virtual AI Hosts", icon: Sparkles, color: "emerald" },
|
| 588 |
+
{ title: "Game Character Voices", icon: Flame, color: "rose" },
|
| 589 |
+
{ title: "AI YouTube Narration", icon: PlayCircle, color: "amber" },
|
| 590 |
+
{ title: "Conversational Agents", icon: MessageSquare, color: "blue" },
|
| 591 |
+
{ title: "Educational Voice", icon: User, color: "cyan" },
|
| 592 |
+
{ title: "Synthetic Media", icon: Radio, color: "purple" },
|
| 593 |
+
{ title: "Voice Cloning Research", icon: Mic, color: "rose" },
|
| 594 |
+
{ title: "Multilingual Dialogue", icon: Globe, color: "emerald" }
|
| 595 |
+
].map((item, idx) => {
|
| 596 |
+
const IconComponent = item.icon;
|
| 597 |
+
const borderColors = item.color === 'cyan' ? 'hover:border-[#00f2fe]/40'
|
| 598 |
+
: item.color === 'purple' ? 'hover:border-[#a259ff]/40'
|
| 599 |
+
: item.color === 'emerald' ? 'hover:border-[#00e673]/40'
|
| 600 |
+
: item.color === 'rose' ? 'hover:border-[#ff5c8d]/40'
|
| 601 |
+
: 'hover:border-white/20';
|
| 602 |
+
|
| 603 |
+
return (
|
| 604 |
+
<motion.div
|
| 605 |
+
key={idx}
|
| 606 |
+
whileHover={{ scale: 1.04, y: -4 }}
|
| 607 |
+
transition={{ type: "spring", stiffness: 200, damping: 10 }}
|
| 608 |
+
className={`bg-glass p-5 rounded-2xl border border-white/5 cursor-pointer flex flex-col items-center justify-center text-center gap-3 ${borderColors}`}
|
| 609 |
+
>
|
| 610 |
+
<div className={`w-9 h-9 rounded-xl bg-white/4 flex items-center justify-center text-white`}>
|
| 611 |
+
<IconComponent className="w-4.5 h-4.5" />
|
| 612 |
+
</div>
|
| 613 |
+
<span className="text-xs font-semibold text-white tracking-tight">{item.title}</span>
|
| 614 |
+
</motion.div>
|
| 615 |
+
);
|
| 616 |
+
})}
|
| 617 |
+
</div>
|
| 618 |
+
</section>
|
| 619 |
+
|
| 620 |
+
{/* 5. HOW IT WORKS SECTION */}
|
| 621 |
+
<section className="relative z-10 py-24 bg-black/40 border-t border-white/5 max-w-[1300px] mx-auto px-6">
|
| 622 |
+
<div className="text-center max-w-[700px] mx-auto mb-16 flex flex-col gap-3">
|
| 623 |
+
<span className="text-xs font-bold uppercase tracking-widest text-[#ff5c8d]">Simplifying Complexity</span>
|
| 624 |
+
<h2 className="text-3xl md:text-5xl font-extrabold text-white">How It Works</h2>
|
| 625 |
+
<p className="text-sm text-[#8e9bb5] leading-relaxed">
|
| 626 |
+
Generate customized, context-aware speech dialogue files locally in three simple milestones.
|
| 627 |
+
</p>
|
| 628 |
+
</div>
|
| 629 |
+
|
| 630 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 relative">
|
| 631 |
+
|
| 632 |
+
{/* Animated linking lines inside background */}
|
| 633 |
+
<div className="hidden lg:block absolute top-[30%] left-[25%] right-[25%] h-0.5 bg-gradient-to-r from-[#00f2fe]/35 via-[#a259ff]/35 to-[#ff5c8d]/35 z-0"></div>
|
| 634 |
+
|
| 635 |
+
{/* Step 1 */}
|
| 636 |
+
<motion.div
|
| 637 |
+
initial={{ opacity: 0, y: 30 }}
|
| 638 |
+
whileInView={{ opacity: 1, y: 0 }}
|
| 639 |
+
viewport={{ once: true }}
|
| 640 |
+
transition={{ duration: 0.6 }}
|
| 641 |
+
className="bg-glass p-8 rounded-3xl border border-white/5 text-left relative z-10 flex flex-col gap-4"
|
| 642 |
+
>
|
| 643 |
+
<div className="absolute top-4 right-6 text-5xl font-black text-white/5">01</div>
|
| 644 |
+
<div className="w-12 h-12 rounded-2xl bg-[#00f2fe]/10 flex items-center justify-center text-neon-cyan font-bold">
|
| 645 |
+
<FileText className="w-6 h-6" />
|
| 646 |
+
</div>
|
| 647 |
+
<h3 className="text-lg font-bold text-white">Input Text & Script</h3>
|
| 648 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed">
|
| 649 |
+
Format your text using simple speaker tags (e.g., `Speaker 0`, `Speaker 1`). Paste dialogue script snippets directly into the studio box.
|
| 650 |
+
</p>
|
| 651 |
+
</motion.div>
|
| 652 |
+
|
| 653 |
+
{/* Step 2 */}
|
| 654 |
+
<motion.div
|
| 655 |
+
initial={{ opacity: 0, y: 30 }}
|
| 656 |
+
whileInView={{ opacity: 1, y: 0 }}
|
| 657 |
+
viewport={{ once: true }}
|
| 658 |
+
transition={{ duration: 0.6, delay: 0.2 }}
|
| 659 |
+
className="bg-glass p-8 rounded-3xl border border-white/5 text-left relative z-10 flex flex-col gap-4"
|
| 660 |
+
>
|
| 661 |
+
<div className="absolute top-4 right-6 text-5xl font-black text-white/5">02</div>
|
| 662 |
+
<div className="w-12 h-12 rounded-2xl bg-[#a259ff]/10 flex items-center justify-center text-neon-purple font-bold">
|
| 663 |
+
<Cpu className="w-6 h-6" />
|
| 664 |
+
</div>
|
| 665 |
+
<h3 className="text-lg font-bold text-white">Acoustic Processing</h3>
|
| 666 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed">
|
| 667 |
+
The model maps dialogue turn-taking patterns, computes real-time prosody adjustments, and blends speakers based on custom voice signatures.
|
| 668 |
+
</p>
|
| 669 |
+
</motion.div>
|
| 670 |
+
|
| 671 |
+
{/* Step 3 */}
|
| 672 |
+
<motion.div
|
| 673 |
+
initial={{ opacity: 0, y: 30 }}
|
| 674 |
+
whileInView={{ opacity: 1, y: 0 }}
|
| 675 |
+
viewport={{ once: true }}
|
| 676 |
+
transition={{ duration: 0.6, delay: 0.4 }}
|
| 677 |
+
className="bg-glass p-8 rounded-3xl border border-white/5 text-left relative z-10 flex flex-col gap-4"
|
| 678 |
+
>
|
| 679 |
+
<div className="absolute top-4 right-6 text-5xl font-black text-white/5">03</div>
|
| 680 |
+
<div className="w-12 h-12 rounded-2xl bg-[#00e673]/10 flex items-center justify-center text-[#00e673] font-bold">
|
| 681 |
+
<Volume2 className="w-6 h-6" />
|
| 682 |
+
</div>
|
| 683 |
+
<h3 className="text-lg font-bold text-white">Seamless Broadcast Output</h3>
|
| 684 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed">
|
| 685 |
+
Stream binary PCM audio chunks back to the client browser in under 250ms, then run the client packager to download the mastered WAV file.
|
| 686 |
+
</p>
|
| 687 |
+
</motion.div>
|
| 688 |
+
</div>
|
| 689 |
+
</section>
|
| 690 |
+
|
| 691 |
+
{/* 6. TECHNICAL CAPABILITIES SECTION */}
|
| 692 |
+
<section id="capabilities" className="relative z-10 py-24 border-t border-white/5 max-w-[1300px] mx-auto px-6 grid grid-cols-1 lg:grid-cols-[0.9fr_1.1fr] gap-12 items-center">
|
| 693 |
+
<div className="flex flex-col gap-6 text-left">
|
| 694 |
+
<span className="text-xs font-bold uppercase tracking-widest text-neon-cyan">Deep Tech Specifications</span>
|
| 695 |
+
<h2 className="text-3xl md:text-5xl font-extrabold text-white leading-tight">Quantized for Low-Latency Performance</h2>
|
| 696 |
+
<p className="text-sm text-[#8e9bb5] leading-relaxed">
|
| 697 |
+
By shifting away from multi-gigabyte models that require expensive dedicated GPUs, VibeVoice provides extreme research scalability directly on home laptops.
|
| 698 |
+
</p>
|
| 699 |
+
<button onClick={onTryDemo} className="self-start flex items-center gap-2 text-xs font-bold text-[#00f2fe] hover:text-[#00f2fe]/80 group transition duration-200">
|
| 700 |
+
Open the Studio Workspace now <ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform" />
|
| 701 |
+
</button>
|
| 702 |
+
</div>
|
| 703 |
+
|
| 704 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-left">
|
| 705 |
+
{[
|
| 706 |
+
{ label: "Long-Context Synthesis", desc: "Stable generation beyond 30-minute scripts without memory bottlenecks." },
|
| 707 |
+
{ label: "Multi-Turn Dialogue", desc: "Simulates interruptions, dynamic transitions, and collaborative dialogue." },
|
| 708 |
+
{ label: "Context Retention", desc: "Tracks prosody style consistency across speaker exchanges." },
|
| 709 |
+
{ label: "Emotional Speech Modeling", desc: "Adapts speaking pace and emphasis dynamically depending on context." },
|
| 710 |
+
{ label: "Realistic Timing & Pauses", desc: "Injects breathing frames, silence, and natural hesitation timings." },
|
| 711 |
+
{ label: "High-Quality Neural TTS", desc: "Synthesizes standard 24,000Hz speech directly client-side." },
|
| 712 |
+
{ label: "Scalable Deployment", desc: "Supports Docker, Kubernetes deployment, and ONNX Runtime scaling." },
|
| 713 |
+
{ label: "GPU-Optimized Inference", desc: "Compiles cleanly to TensorRT for enterprise SaaS frameworks." }
|
| 714 |
+
].map((cap, index) => (
|
| 715 |
+
<div key={index} className="bg-glass-card p-4 rounded-xl border border-white/4 flex flex-col gap-1.5 hover:border-[#00f2fe]/30 transition duration-300">
|
| 716 |
+
<div className="flex items-center gap-2 text-white font-bold text-xs select-none">
|
| 717 |
+
<Check className="w-4 h-4 text-[#00e673]" />
|
| 718 |
+
<span>{cap.label}</span>
|
| 719 |
+
</div>
|
| 720 |
+
<p className="text-[11px] text-[#8e9bb5] leading-relaxed pl-6">{cap.desc}</p>
|
| 721 |
+
</div>
|
| 722 |
+
))}
|
| 723 |
+
</div>
|
| 724 |
+
</section>
|
| 725 |
+
|
| 726 |
+
{/* 7. DEMO SECTION */}
|
| 727 |
+
<section id="demo" className="relative z-10 py-24 bg-black/40 border-t border-white/5 max-w-[1300px] mx-auto px-6">
|
| 728 |
+
<div className="text-center max-w-[700px] mx-auto mb-16 flex flex-col gap-3">
|
| 729 |
+
<span className="text-xs font-bold uppercase tracking-widest text-[#a259ff]">Live Visual Playground</span>
|
| 730 |
+
<h2 className="text-3xl md:text-5xl font-extrabold text-white">Experience the VibeVoice flow</h2>
|
| 731 |
+
<p className="text-sm text-[#8e9bb5] leading-relaxed">
|
| 732 |
+
Click play below to explore interactive speaker switching, custom scripts, and visual voice-wave modulations.
|
| 733 |
+
</p>
|
| 734 |
+
</div>
|
| 735 |
+
|
| 736 |
+
{/* Custom Mock Interactive Demo Component */}
|
| 737 |
+
<div className="max-w-[850px] mx-auto bg-glass rounded-3xl p-6 md:p-8 border border-white/8 backdrop-blur-2xl shadow-3xl flex flex-col gap-6">
|
| 738 |
+
<div className="flex flex-col md:flex-row justify-between items-center gap-4 border-b border-white/5 pb-5">
|
| 739 |
+
<div className="text-left flex flex-col gap-1">
|
| 740 |
+
<span className="text-[9px] text-[#8e9bb5] uppercase font-bold tracking-wider">Demo Playback Studio</span>
|
| 741 |
+
<h3 className="text-md font-bold text-white flex items-center gap-2">
|
| 742 |
+
<Headphones className="w-4.5 h-4.5 text-[#00f2fe] animate-pulse" />
|
| 743 |
+
{voicePresets[selectedVoicePreset].title}
|
| 744 |
+
</h3>
|
| 745 |
+
</div>
|
| 746 |
+
|
| 747 |
+
{/* Select Presets bar */}
|
| 748 |
+
<div className="flex items-center gap-2 bg-white/4 p-1 rounded-xl border border-white/5">
|
| 749 |
+
{Object.keys(voicePresets).map(presetKey => (
|
| 750 |
+
<button
|
| 751 |
+
key={presetKey}
|
| 752 |
+
onClick={() => setSelectedVoicePreset(presetKey)}
|
| 753 |
+
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition cursor-pointer select-none ${
|
| 754 |
+
selectedVoicePreset === presetKey
|
| 755 |
+
? 'bg-gradient-to-r from-[#00f2fe] to-[#a259ff] text-black shadow-md'
|
| 756 |
+
: 'text-[#8e9bb5] hover:text-white hover:bg-white/2'
|
| 757 |
+
}`}
|
| 758 |
+
>
|
| 759 |
+
{presetKey}
|
| 760 |
+
</button>
|
| 761 |
+
))}
|
| 762 |
+
</div>
|
| 763 |
+
</div>
|
| 764 |
+
|
| 765 |
+
<div className="grid grid-cols-1 md:grid-cols-[1fr_240px] gap-6 items-start">
|
| 766 |
+
|
| 767 |
+
{/* Visualizer Area */}
|
| 768 |
+
<div className="flex flex-col gap-4 w-full">
|
| 769 |
+
<div className="bg-black/40 border border-white/4 rounded-2xl p-6 min-h-[160px] flex flex-col justify-center items-center relative overflow-hidden">
|
| 770 |
+
<canvas ref={canvasRef} width="500" height="100" className="w-full h-[100px]"></canvas>
|
| 771 |
+
|
| 772 |
+
{/* Animated Orb matching active speaker */}
|
| 773 |
+
{mockIsPlaying && (
|
| 774 |
+
<div className="absolute top-4 right-4 flex items-center gap-2">
|
| 775 |
+
<span className="w-2 h-2 rounded-full bg-[#ff5c8d] animate-ping"></span>
|
| 776 |
+
<span className="text-[9px] text-[#ff5c8d] font-bold uppercase tracking-wider">
|
| 777 |
+
Active: {voicePresets[selectedVoicePreset].speakers[mockActiveSpeaker]?.name || "Speaker"}
|
| 778 |
+
</span>
|
| 779 |
+
</div>
|
| 780 |
+
)}
|
| 781 |
+
</div>
|
| 782 |
+
|
| 783 |
+
{/* Action play controls */}
|
| 784 |
+
<div className="flex items-center gap-4 bg-white/2 p-3 rounded-2xl border border-white/4">
|
| 785 |
+
<button
|
| 786 |
+
onClick={() => setMockIsPlaying(!mockIsPlaying)}
|
| 787 |
+
className="w-10 h-10 rounded-full bg-gradient-to-r from-[#00f2fe] to-[#a259ff] hover:shadow-[0_0_15px_rgba(0,242,254,0.35)] flex items-center justify-center text-black hover:scale-105 active:scale-95 transition cursor-pointer"
|
| 788 |
+
>
|
| 789 |
+
{mockIsPlaying ? (
|
| 790 |
+
<Square className="w-4 h-4 fill-current" />
|
| 791 |
+
) : (
|
| 792 |
+
<Play className="w-4 h-4 fill-current ml-0.5" />
|
| 793 |
+
)}
|
| 794 |
+
</button>
|
| 795 |
+
|
| 796 |
+
<div className="flex-1 text-left">
|
| 797 |
+
<div className="flex justify-between text-[10px] text-[#8e9bb5] mb-1">
|
| 798 |
+
<span>{mockIsPlaying ? "Playing Dynamic Dialogue Chunks..." : "Ready to playback preview"}</span>
|
| 799 |
+
<span>{Math.floor(mockTimeline)}s / {voicePresets[selectedVoicePreset].duration}s</span>
|
| 800 |
+
</div>
|
| 801 |
+
<div className="h-1.5 w-full bg-white/6 rounded-full overflow-hidden relative">
|
| 802 |
+
<div
|
| 803 |
+
className="absolute left-0 top-0 bottom-0 bg-gradient-to-r from-[#00f2fe] to-[#a259ff] transition-all duration-100 rounded-full"
|
| 804 |
+
style={{ width: `${(mockTimeline / voicePresets[selectedVoicePreset].duration) * 100}%` }}
|
| 805 |
+
></div>
|
| 806 |
+
</div>
|
| 807 |
+
</div>
|
| 808 |
+
</div>
|
| 809 |
+
</div>
|
| 810 |
+
|
| 811 |
+
{/* Script Viewer */}
|
| 812 |
+
<div className="flex flex-col gap-3 text-left w-full">
|
| 813 |
+
<span className="text-[10px] text-[#8e9bb5] uppercase font-bold tracking-wider">Turn-by-Turn Transcript</span>
|
| 814 |
+
<div className="bg-black/25 rounded-2xl p-4 border border-white/4 flex flex-col gap-3 max-h-[220px] overflow-y-auto">
|
| 815 |
+
{voicePresets[selectedVoicePreset].script.map((line, idx) => {
|
| 816 |
+
const speakerDetails = voicePresets[selectedVoicePreset].speakers[line.spk] || { name: "Guest", color: "cyan" };
|
| 817 |
+
const colorBadge = speakerDetails.color === 'cyan' ? 'text-[#00f2fe]'
|
| 818 |
+
: speakerDetails.color === 'purple' ? 'text-[#a259ff]'
|
| 819 |
+
: speakerDetails.color === 'amber' ? 'text-[#ffb703]'
|
| 820 |
+
: 'text-white';
|
| 821 |
+
|
| 822 |
+
const isActiveLine = mockIsPlaying && mockTimeline >= line.time &&
|
| 823 |
+
(idx === voicePresets[selectedVoicePreset].script.length - 1 || mockTimeline < voicePresets[selectedVoicePreset].script[idx + 1].time);
|
| 824 |
+
|
| 825 |
+
return (
|
| 826 |
+
<div
|
| 827 |
+
key={idx}
|
| 828 |
+
className={`p-2.5 rounded-xl border transition-all duration-300 ${
|
| 829 |
+
isActiveLine
|
| 830 |
+
? 'bg-[#00f2fe]/6 border-[#00f2fe]/20 shadow-[0_0_8px_rgba(0,242,254,0.05)]'
|
| 831 |
+
: 'bg-transparent border-transparent opacity-50'
|
| 832 |
+
}`}
|
| 833 |
+
>
|
| 834 |
+
<span className={`block text-[9px] font-bold uppercase tracking-wider mb-0.5 ${colorBadge}`}>
|
| 835 |
+
{speakerDetails.name}
|
| 836 |
+
</span>
|
| 837 |
+
<p className="text-[11px] text-[#f0f3fa] font-mono leading-relaxed">{line.text}</p>
|
| 838 |
+
</div>
|
| 839 |
+
);
|
| 840 |
+
})}
|
| 841 |
+
</div>
|
| 842 |
+
</div>
|
| 843 |
+
|
| 844 |
+
</div>
|
| 845 |
+
|
| 846 |
+
<div className="bg-[#00f2fe]/4 border border-[#00f2fe]/10 p-4 rounded-2xl flex flex-col sm:flex-row justify-between items-center gap-4 text-left">
|
| 847 |
+
<div className="flex items-center gap-3">
|
| 848 |
+
<Flame className="w-5 h-5 text-neon-cyan animate-bounce" />
|
| 849 |
+
<div>
|
| 850 |
+
<h4 className="text-xs font-bold text-white">Unlock full control over model layers</h4>
|
| 851 |
+
<p className="text-[10px] text-[#8e9bb5]">Upload voice profiles, customize speeds, and download master tracks in the playground.</p>
|
| 852 |
+
</div>
|
| 853 |
+
</div>
|
| 854 |
+
<button
|
| 855 |
+
onClick={onTryDemo}
|
| 856 |
+
className="w-full sm:w-auto flex items-center justify-center gap-1.5 px-4 py-2 bg-gradient-to-r from-[#00f2fe] to-[#a259ff] text-black font-bold text-xs rounded-xl transition cursor-pointer hover:shadow-lg active:scale-95 whitespace-nowrap"
|
| 857 |
+
>
|
| 858 |
+
Open Studio Workspace
|
| 859 |
+
</button>
|
| 860 |
+
</div>
|
| 861 |
+
|
| 862 |
+
</div>
|
| 863 |
+
</section>
|
| 864 |
+
|
| 865 |
+
{/* 8. OPEN SOURCE SECTION */}
|
| 866 |
+
<section id="open-source" className="relative z-10 py-24 border-t border-white/5 max-w-[1300px] mx-auto px-6">
|
| 867 |
+
<motion.div
|
| 868 |
+
initial="hidden"
|
| 869 |
+
whileInView="visible"
|
| 870 |
+
viewport={{ once: true, margin: "-100px" }}
|
| 871 |
+
variants={containerVariants}
|
| 872 |
+
className="bg-glass rounded-3xl p-8 md:p-12 border border-white/8 backdrop-blur-2xl shadow-3xl text-left grid grid-cols-1 lg:grid-cols-[1fr_0.8fr] gap-12 items-center"
|
| 873 |
+
>
|
| 874 |
+
<motion.div variants={itemVariants} className="flex flex-col gap-5">
|
| 875 |
+
<span className="text-xs font-bold uppercase tracking-widest text-[#00e673]">100% Free & Open Source</span>
|
| 876 |
+
<h2 className="text-3xl md:text-5xl font-extrabold text-white">Join the VibeVoice developer ecosystem</h2>
|
| 877 |
+
<p className="text-sm text-[#8e9bb5] leading-relaxed">
|
| 878 |
+
We believe conversational voice models should be accessible to all developers. Get instant access to pre-quantized model base checkpoints, training layers, and custom fine-tuning scripts.
|
| 879 |
+
</p>
|
| 880 |
+
|
| 881 |
+
<div className="flex flex-wrap gap-4 pt-2">
|
| 882 |
+
<a
|
| 883 |
+
href="https://github.com"
|
| 884 |
+
target="_blank"
|
| 885 |
+
rel="noopener noreferrer"
|
| 886 |
+
className="flex items-center gap-2.5 px-5 py-3 rounded-xl bg-white/4 border border-white/8 hover:bg-white/8 hover:border-white/15 text-white font-bold text-xs transition duration-200 cursor-pointer active:scale-95"
|
| 887 |
+
>
|
| 888 |
+
<Github className="w-4 h-4" />
|
| 889 |
+
GitHub Repository
|
| 890 |
+
</a>
|
| 891 |
+
<a
|
| 892 |
+
href="https://huggingface.co"
|
| 893 |
+
target="_blank"
|
| 894 |
+
rel="noopener noreferrer"
|
| 895 |
+
className="flex items-center gap-2.5 px-5 py-3 rounded-xl bg-white/4 border border-white/8 hover:bg-white/8 hover:border-white/15 text-white font-bold text-xs transition duration-200 cursor-pointer active:scale-95"
|
| 896 |
+
>
|
| 897 |
+
<Layers className="w-4 h-4 text-neon-cyan" />
|
| 898 |
+
Hugging Face Base Models
|
| 899 |
+
</a>
|
| 900 |
+
</div>
|
| 901 |
+
</motion.div>
|
| 902 |
+
|
| 903 |
+
<motion.div variants={itemVariants} className="bg-black/35 rounded-2xl border border-white/4 p-6 flex flex-col gap-5">
|
| 904 |
+
<h3 className="text-sm font-bold text-white uppercase tracking-wider border-b border-white/5 pb-2 flex items-center gap-2">
|
| 905 |
+
<Cpu className="w-4 h-4 text-[#a259ff]" /> Model Checklist
|
| 906 |
+
</h3>
|
| 907 |
+
|
| 908 |
+
<div className="flex flex-col gap-3">
|
| 909 |
+
<div className="flex items-center gap-3">
|
| 910 |
+
<span className="w-2.5 h-2.5 rounded-full bg-[#00e673] shadow-[0_0_6px_#00e673]"></span>
|
| 911 |
+
<span className="text-xs font-semibold text-white">Quantized 1.5B ONNX Checkpoint</span>
|
| 912 |
+
</div>
|
| 913 |
+
<p className="text-[10.5px] text-[#8e9bb5] leading-relaxed pl-5.5">Ideal for deployment on edge systems and low-resource CPU servers.</p>
|
| 914 |
+
|
| 915 |
+
<div className="flex items-center gap-3 border-t border-dashed border-white/5 pt-3">
|
| 916 |
+
<span className="w-2.5 h-2.5 rounded-full bg-[#00e673] shadow-[0_0_6px_#00e673]"></span>
|
| 917 |
+
<span className="text-xs font-semibold text-white">Full PyTorch Training Recipes</span>
|
| 918 |
+
</div>
|
| 919 |
+
<p className="text-[10.5px] text-[#8e9bb5] leading-relaxed pl-5.5">Prepackaged datasets, gradient-accumulation code, and training layers.</p>
|
| 920 |
+
|
| 921 |
+
<div className="flex items-center gap-3 border-t border-dashed border-white/5 pt-3">
|
| 922 |
+
<span className="w-2.5 h-2.5 rounded-full bg-[#00e673] shadow-[0_0_6px_#00e673]"></span>
|
| 923 |
+
<span className="text-xs font-semibold text-white">Zero-Shot Cloning Checkpoints</span>
|
| 924 |
+
</div>
|
| 925 |
+
<p className="text-[10.5px] text-[#8e9bb5] leading-relaxed pl-5.5">Allows robust speaker feature mapping with zero gradient training runs.</p>
|
| 926 |
+
</div>
|
| 927 |
+
</motion.div>
|
| 928 |
+
</motion.div>
|
| 929 |
+
</section>
|
| 930 |
+
|
| 931 |
+
{/* 9. TESTIMONIALS SECTION */}
|
| 932 |
+
<section className="relative z-10 py-24 bg-black/30 border-t border-white/5 max-w-[1300px] mx-auto px-6">
|
| 933 |
+
<div className="text-center max-w-[700px] mx-auto mb-16 flex flex-col gap-3">
|
| 934 |
+
<span className="text-xs font-bold uppercase tracking-widest text-[#00f2fe]">User Reviews</span>
|
| 935 |
+
<h2 className="text-3xl md:text-5xl font-extrabold text-white">Loved by Researchers & Creators</h2>
|
| 936 |
+
<p className="text-sm text-[#8e9bb5] leading-relaxed">
|
| 937 |
+
Read how developers are applying VibeVoice AI to rewrite the vocal product stack.
|
| 938 |
+
</p>
|
| 939 |
+
</div>
|
| 940 |
+
|
| 941 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 text-left">
|
| 942 |
+
|
| 943 |
+
{/* Review 1 */}
|
| 944 |
+
<div className="bg-glass p-6 rounded-2xl border border-white/5 flex flex-col gap-4 justify-between">
|
| 945 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed italic">
|
| 946 |
+
"We migrated all virtual co-hosts for our audiobooks to VibeVoice. The zero-shot reference cloning accuracy is astonishing; it matches emotional cadence with negligible distortion."
|
| 947 |
+
</p>
|
| 948 |
+
<div className="flex items-center gap-3 border-t border-white/5 pt-4">
|
| 949 |
+
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-[#00f2fe] to-[#a259ff] flex items-center justify-center text-[10px] font-bold text-black font-mono select-none">
|
| 950 |
+
AR
|
| 951 |
+
</div>
|
| 952 |
+
<div>
|
| 953 |
+
<h4 className="text-xs font-bold text-white">Dr. Aris Vance</h4>
|
| 954 |
+
<p className="text-[9px] text-[#8e9bb5] uppercase">AI Audio Researcher</p>
|
| 955 |
+
</div>
|
| 956 |
+
</div>
|
| 957 |
+
</div>
|
| 958 |
+
|
| 959 |
+
{/* Review 2 */}
|
| 960 |
+
<div className="bg-glass p-6 rounded-2xl border border-white/5 flex flex-col gap-4 justify-between">
|
| 961 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed italic">
|
| 962 |
+
"Building responsive conversational pipelines is exceptionally straightforward. Standard WebSockets streaming audio chunks under 250ms is exactly what SaaS developers need."
|
| 963 |
+
</p>
|
| 964 |
+
<div className="flex items-center gap-3 border-t border-white/5 pt-4">
|
| 965 |
+
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-[#a259ff] to-[#ff5c8d] flex items-center justify-center text-[10px] font-bold text-black font-mono select-none">
|
| 966 |
+
KL
|
| 967 |
+
</div>
|
| 968 |
+
<div>
|
| 969 |
+
<h4 className="text-xs font-bold text-white">Karen Lim</h4>
|
| 970 |
+
<p className="text-[9px] text-[#8e9bb5] uppercase">Vocal Startup Founder</p>
|
| 971 |
+
</div>
|
| 972 |
+
</div>
|
| 973 |
+
</div>
|
| 974 |
+
|
| 975 |
+
{/* Review 3 */}
|
| 976 |
+
<div className="bg-glass p-6 rounded-2xl border border-white/5 flex flex-col gap-4 justify-between">
|
| 977 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed italic">
|
| 978 |
+
"The local CPU inference speeds are a massive paradigm shift. Generating multi-speaker podcasts without scaling complex Kubernetes GPU clusters completely changed our billing models."
|
| 979 |
+
</p>
|
| 980 |
+
<div className="flex items-center gap-3 border-t border-white/5 pt-4">
|
| 981 |
+
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-[#ff5c8d] to-[#ffb703] flex items-center justify-center text-[10px] font-bold text-black font-mono select-none">
|
| 982 |
+
MB
|
| 983 |
+
</div>
|
| 984 |
+
<div>
|
| 985 |
+
<h4 className="text-xs font-bold text-white">Marcus Brody</h4>
|
| 986 |
+
<p className="text-[9px] text-[#8e9bb5] uppercase">Podcast Platform Creator</p>
|
| 987 |
+
</div>
|
| 988 |
+
</div>
|
| 989 |
+
</div>
|
| 990 |
+
|
| 991 |
+
{/* Review 4 */}
|
| 992 |
+
<div className="bg-glass p-6 rounded-2xl border border-white/5 flex flex-col gap-4 justify-between">
|
| 993 |
+
<p className="text-xs text-[#8e9bb5] leading-relaxed italic">
|
| 994 |
+
"Being able to review training scripts and inspect decoupled prosody token architectures is brilliant. This model is exceptionally clean and well-structured for university research projects."
|
| 995 |
+
</p>
|
| 996 |
+
<div className="flex items-center gap-3 border-t border-white/5 pt-4">
|
| 997 |
+
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-[#00f2fe] to-[#00e673] flex items-center justify-center text-[10px] font-bold text-black font-mono select-none">
|
| 998 |
+
SC
|
| 999 |
+
</div>
|
| 1000 |
+
<div>
|
| 1001 |
+
<h4 className="text-xs font-bold text-white">Sonia Chen</h4>
|
| 1002 |
+
<p className="text-[9px] text-[#8e9bb5] uppercase">ML PhD Scholar</p>
|
| 1003 |
+
</div>
|
| 1004 |
+
</div>
|
| 1005 |
+
</div>
|
| 1006 |
+
|
| 1007 |
+
</div>
|
| 1008 |
+
</section>
|
| 1009 |
+
|
| 1010 |
+
{/* 10. FAQ SECTION */}
|
| 1011 |
+
<section id="faq" className="relative z-10 py-24 border-t border-white/5 max-w-[850px] mx-auto px-6 text-left">
|
| 1012 |
+
<div className="text-center max-w-[700px] mx-auto mb-16 flex flex-col gap-3">
|
| 1013 |
+
<span className="text-xs font-bold uppercase tracking-widest text-[#a259ff] flex items-center justify-center gap-1.5">
|
| 1014 |
+
<HelpCircle className="w-3.5 h-3.5 text-[#a259ff]" /> Common Questions
|
| 1015 |
+
</span>
|
| 1016 |
+
<h2 className="text-3xl md:text-5xl font-extrabold text-white">Frequently Asked Questions</h2>
|
| 1017 |
+
<p className="text-sm text-[#8e9bb5]">Everything you need to know about setting up and running VibeVoice AI.</p>
|
| 1018 |
+
</div>
|
| 1019 |
+
|
| 1020 |
+
<div className="flex flex-col gap-4">
|
| 1021 |
+
{faqs.map((faq, idx) => (
|
| 1022 |
+
<div
|
| 1023 |
+
key={idx}
|
| 1024 |
+
className="bg-glass rounded-2xl border border-white/5 hover:border-white/10 overflow-hidden transition-colors duration-200"
|
| 1025 |
+
>
|
| 1026 |
+
<button
|
| 1027 |
+
onClick={() => setActiveFaq(activeFaq === idx ? null : idx)}
|
| 1028 |
+
className="w-full flex justify-between items-center p-6 text-left font-bold text-white text-sm md:text-base cursor-pointer focus:outline-none"
|
| 1029 |
+
>
|
| 1030 |
+
<span>{faq.q}</span>
|
| 1031 |
+
<ChevronDown className={`w-5 h-5 text-[#8e9bb5] transition-transform duration-300 ${activeFaq === idx ? 'transform rotate-180 text-neon-cyan' : ''}`} />
|
| 1032 |
+
</button>
|
| 1033 |
+
|
| 1034 |
+
<AnimatePresence initial={false}>
|
| 1035 |
+
{activeFaq === idx && (
|
| 1036 |
+
<motion.div
|
| 1037 |
+
initial={{ height: 0, opacity: 0 }}
|
| 1038 |
+
animate={{ height: "auto", opacity: 1 }}
|
| 1039 |
+
exit={{ height: 0, opacity: 0 }}
|
| 1040 |
+
transition={{ duration: 0.25, ease: "easeInOut" }}
|
| 1041 |
+
>
|
| 1042 |
+
<div className="p-6 pt-0 border-t border-white/4 text-xs md:text-sm text-[#8e9bb5] leading-relaxed">
|
| 1043 |
+
{faq.a}
|
| 1044 |
+
</div>
|
| 1045 |
+
</motion.div>
|
| 1046 |
+
)}
|
| 1047 |
+
</AnimatePresence>
|
| 1048 |
+
</div>
|
| 1049 |
+
))}
|
| 1050 |
+
</div>
|
| 1051 |
+
</section>
|
| 1052 |
+
|
| 1053 |
+
{/* 11. FINAL CTA SECTION */}
|
| 1054 |
+
<section className="relative z-10 py-28 border-t border-white/5 max-w-[1300px] mx-auto px-6 text-center">
|
| 1055 |
+
<div className="bg-glass rounded-3xl p-12 md:p-20 border border-white/8 relative overflow-hidden bg-gradient-to-b from-[#101830]/40 to-transparent flex flex-col items-center gap-6 shadow-3xl">
|
| 1056 |
+
|
| 1057 |
+
<div className="absolute inset-0 pointer-events-none z-0">
|
| 1058 |
+
<div className="absolute w-[450px] h-[450px] rounded-full top-[-20%] left-[-10%] bg-gradient-to-br from-[#00f2fe]/10 to-transparent blur-[120px] animate-pulse"></div>
|
| 1059 |
+
<div className="absolute w-[450px] h-[450px] rounded-full bottom-[-20%] right-[-10%] bg-gradient-to-br from-[#a259ff]/10 to-transparent blur-[120px] animate-pulse"></div>
|
| 1060 |
+
</div>
|
| 1061 |
+
|
| 1062 |
+
<span className="relative z-10 text-xs font-bold uppercase tracking-widest text-[#00f2fe]">Experience It Live</span>
|
| 1063 |
+
<h2 className="relative z-10 text-4xl md:text-6xl font-extrabold text-white tracking-tight max-w-[700px] leading-tight">
|
| 1064 |
+
Build the Future of Conversational Voice AI
|
| 1065 |
+
</h2>
|
| 1066 |
+
<p className="relative z-10 text-sm md:text-base text-[#8e9bb5] max-w-[520px] leading-relaxed">
|
| 1067 |
+
Instantly record dynamic speakers, compile realistic podcast dialogues, and master local WAV tracks completely in your browser.
|
| 1068 |
+
</p>
|
| 1069 |
+
|
| 1070 |
+
<div className="relative z-10 flex flex-col sm:flex-row gap-4 pt-4 w-full justify-center max-w-[450px]">
|
| 1071 |
+
<button
|
| 1072 |
+
onClick={onTryDemo}
|
| 1073 |
+
className="flex-1 flex items-center justify-center gap-2.5 px-6 py-4 rounded-xl bg-gradient-to-r from-[#00f2fe] to-[#a259ff] text-black font-extrabold text-sm transition duration-200 cursor-pointer hover:shadow-lg active:scale-95 shadow-[0_4px_15px_rgba(0,242,254,0.25)]"
|
| 1074 |
+
>
|
| 1075 |
+
Get Started
|
| 1076 |
+
</button>
|
| 1077 |
+
<a
|
| 1078 |
+
href="https://github.com"
|
| 1079 |
+
target="_blank"
|
| 1080 |
+
rel="noopener noreferrer"
|
| 1081 |
+
className="flex-1 flex items-center justify-center gap-2.5 px-6 py-4 rounded-xl bg-white/4 border border-white/8 hover:bg-white/8 text-white font-bold text-sm transition duration-200 cursor-pointer active:scale-95"
|
| 1082 |
+
>
|
| 1083 |
+
Explore GitHub
|
| 1084 |
+
</a>
|
| 1085 |
+
</div>
|
| 1086 |
+
</div>
|
| 1087 |
+
</section>
|
| 1088 |
+
|
| 1089 |
+
{/* 12. FOOTER */}
|
| 1090 |
+
<footer className="relative z-10 border-t border-white/5 py-12 max-w-[1300px] mx-auto px-6 text-left">
|
| 1091 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 mb-12">
|
| 1092 |
+
|
| 1093 |
+
<div className="flex flex-col gap-4">
|
| 1094 |
+
<div className="flex items-center gap-3">
|
| 1095 |
+
<Sparkles className="w-6 h-6 text-neon-cyan" />
|
| 1096 |
+
<span className="font-bold text-white text-base">VibeVoice AI</span>
|
| 1097 |
+
</div>
|
| 1098 |
+
<p className="text-[11px] text-[#8e9bb5] leading-relaxed max-w-[240px]">
|
| 1099 |
+
Open-source neural acoustics model for hyper-realistic conversational turn-taking, dialogue flows, and zero-shot voice cloning.
|
| 1100 |
+
</p>
|
| 1101 |
+
</div>
|
| 1102 |
+
|
| 1103 |
+
<div className="flex flex-col gap-3">
|
| 1104 |
+
<h4 className="text-xs font-bold text-white uppercase tracking-wider">Models & Weights</h4>
|
| 1105 |
+
<div className="flex flex-col gap-2 text-xs text-[#8e9bb5]">
|
| 1106 |
+
<a href="https://huggingface.co" target="_blank" rel="noopener noreferrer" className="hover:text-neon-cyan transition">VibeVoice-0.5B Checkpoint</a>
|
| 1107 |
+
<a href="https://huggingface.co" target="_blank" rel="noopener noreferrer" className="hover:text-neon-cyan transition">Acoustic Tokenizer 24kHz</a>
|
| 1108 |
+
<a href="https://huggingface.co" target="_blank" rel="noopener noreferrer" className="hover:text-neon-cyan transition">Prosody Footprint Encoder</a>
|
| 1109 |
+
</div>
|
| 1110 |
+
</div>
|
| 1111 |
+
|
| 1112 |
+
<div className="flex flex-col gap-3">
|
| 1113 |
+
<h4 className="text-xs font-bold text-white uppercase tracking-wider">Resources</h4>
|
| 1114 |
+
<div className="flex flex-col gap-2 text-xs text-[#8e9bb5]">
|
| 1115 |
+
<a href="https://github.com" target="_blank" rel="noopener noreferrer" className="hover:text-neon-purple transition">Research Publications</a>
|
| 1116 |
+
<a href="https://github.com" target="_blank" rel="noopener noreferrer" className="hover:text-neon-purple transition">Inference CLI Recipes</a>
|
| 1117 |
+
<a href="https://github.com" target="_blank" rel="noopener noreferrer" className="hover:text-neon-purple transition">Fine-Tuning Documentation</a>
|
| 1118 |
+
</div>
|
| 1119 |
+
</div>
|
| 1120 |
+
|
| 1121 |
+
<div className="flex flex-col gap-3">
|
| 1122 |
+
<h4 className="text-xs font-bold text-white uppercase tracking-wider">Developer Community</h4>
|
| 1123 |
+
<div className="flex flex-col gap-2 text-xs text-[#8e9bb5]">
|
| 1124 |
+
<a href="https://github.com" target="_blank" rel="noopener noreferrer" className="hover:text-[#ff5c8d] transition">GitHub Ecosystem</a>
|
| 1125 |
+
<a href="https://huggingface.co" target="_blank" rel="noopener noreferrer" className="hover:text-[#ff5c8d] transition">Hugging Face Discussions</a>
|
| 1126 |
+
<a href="https://github.com" target="_blank" rel="noopener noreferrer" className="hover:text-[#ff5c8d] transition">Contribute Code</a>
|
| 1127 |
+
</div>
|
| 1128 |
+
</div>
|
| 1129 |
+
|
| 1130 |
+
</div>
|
| 1131 |
+
|
| 1132 |
+
<div className="border-t border-white/5 pt-8 flex flex-col md:flex-row justify-between items-center gap-4 text-xs text-[#8e9bb5]">
|
| 1133 |
+
<span>© {new Date().getFullYear()} Microsoft VibeVoice AI. Research-grade open-source software license.</span>
|
| 1134 |
+
<div className="flex items-center gap-4">
|
| 1135 |
+
<a href="https://github.com" target="_blank" rel="noopener noreferrer" className="hover:text-white transition flex items-center gap-1">
|
| 1136 |
+
<Github className="w-3.5 h-3.5" /> GitHub
|
| 1137 |
+
</a>
|
| 1138 |
+
<span>•</span>
|
| 1139 |
+
<span className="flex items-center gap-1 text-[#ff5c8d]">
|
| 1140 |
+
Made for AI Researchers <Heart className="w-3.5 h-3.5 fill-current text-[#ff5c8d]" />
|
| 1141 |
+
</span>
|
| 1142 |
+
</div>
|
| 1143 |
+
</div>
|
| 1144 |
+
</footer>
|
| 1145 |
+
|
| 1146 |
+
</div>
|
| 1147 |
+
);
|
| 1148 |
+
}
|
frontend/src/assets/hero.png
ADDED
|
frontend/src/assets/react.svg
ADDED
|
|
frontend/src/assets/vite.svg
ADDED
|
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
/* ==========================================================================
|
| 4 |
+
VibeVoice React Studio CSS Styling
|
| 5 |
+
Combines Tailwind v4 utility engine with custom high-end visuals and glows
|
| 6 |
+
========================================================================== */
|
| 7 |
+
|
| 8 |
+
@layer utilities {
|
| 9 |
+
.text-neon-cyan {
|
| 10 |
+
color: hsl(185, 95%, 48%);
|
| 11 |
+
text-shadow: 0 0 10px hsla(185, 95%, 48%, 0.4);
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
.text-neon-purple {
|
| 15 |
+
color: hsl(265, 88%, 64%);
|
| 16 |
+
text-shadow: 0 0 10px hsla(265, 88%, 64%, 0.4);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.text-neon-pink {
|
| 20 |
+
color: hsl(330, 92%, 60%);
|
| 21 |
+
text-shadow: 0 0 10px hsla(330, 92%, 60%, 0.4);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.bg-glass {
|
| 25 |
+
background: rgba(13, 17, 28, 0.45);
|
| 26 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 27 |
+
backdrop-filter: blur(24px);
|
| 28 |
+
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.bg-glass-card {
|
| 32 |
+
background: rgba(255, 255, 255, 0.02);
|
| 33 |
+
border: 1px solid rgba(255, 255, 255, 0.04);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.bg-glass-card:hover {
|
| 37 |
+
background: rgba(255, 255, 255, 0.03);
|
| 38 |
+
border-color: rgba(255, 255, 255, 0.08);
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/* Animations */
|
| 43 |
+
@keyframes orbPulse {
|
| 44 |
+
0% { transform: scale(1); }
|
| 45 |
+
50% { transform: scale(1.08); }
|
| 46 |
+
100% { transform: scale(1); }
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
@keyframes orbSpin {
|
| 50 |
+
0% { transform: rotate(0deg); }
|
| 51 |
+
100% { transform: rotate(360deg); }
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
@keyframes orbGlowScale {
|
| 55 |
+
0% { transform: scale(0.95); opacity: 0.4; }
|
| 56 |
+
100% { transform: scale(1.2); opacity: 0.75; }
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
@keyframes waveExpand {
|
| 60 |
+
0% { transform: scale(0.9); opacity: 0.9; }
|
| 61 |
+
100% { transform: scale(1.8); opacity: 0; }
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
@keyframes floatGlow {
|
| 65 |
+
0% { transform: translate(0, 0) scale(1); }
|
| 66 |
+
50% { transform: translate(100px, 80px) scale(1.15); }
|
| 67 |
+
100% { transform: translate(-50px, 150px) scale(0.9); }
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/* Classes for visual states */
|
| 71 |
+
.animate-float-glow-1 {
|
| 72 |
+
animation: floatGlow 25s infinite alternate ease-in-out;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.animate-float-glow-2 {
|
| 76 |
+
animation: floatGlow 30s infinite alternate-reverse ease-in-out;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/* Custom Orb styling states */
|
| 80 |
+
.orb-state-idle {
|
| 81 |
+
background: radial-gradient(circle, #00f2fe 0%, rgba(0, 150, 255, 0.3) 100%);
|
| 82 |
+
box-shadow: 0 0 20px rgba(0, 242, 254, 0.35);
|
| 83 |
+
animation: orbPulse 4s infinite ease-in-out;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.orb-state-thinking {
|
| 87 |
+
background: radial-gradient(circle, #fff 0%, #a259ff 70%, #00f2fe 100%);
|
| 88 |
+
box-shadow: 0 0 35px rgba(162, 89, 255, 0.35);
|
| 89 |
+
animation: orbSpin 2s infinite linear, orbPulse 1s infinite ease-in-out;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.orb-state-speaking {
|
| 93 |
+
background: radial-gradient(circle, #fff 0%, #ff5c8d 60%, #a259ff 100%);
|
| 94 |
+
box-shadow: 0 0 30px rgba(255, 92, 141, 0.35);
|
| 95 |
+
animation: orbPulse 0.4s infinite ease-in-out;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.orb-state-listening {
|
| 99 |
+
background: radial-gradient(circle, #fff 0%, #00e673 70%);
|
| 100 |
+
box-shadow: 0 0 25px rgba(0, 230, 115, 0.4);
|
| 101 |
+
animation: orbPulse 1.2s infinite ease-in-out;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
/* Scrollbar customizations */
|
| 105 |
+
::-webkit-scrollbar {
|
| 106 |
+
width: 5px;
|
| 107 |
+
height: 5px;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
::-webkit-scrollbar-track {
|
| 111 |
+
background: transparent;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
::-webkit-scrollbar-thumb {
|
| 115 |
+
background: rgba(255, 255, 255, 0.08);
|
| 116 |
+
border-radius: 10px;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
::-webkit-scrollbar-thumb:hover {
|
| 120 |
+
background: rgba(255, 255, 255, 0.15);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/* Custom Modal animations */
|
| 124 |
+
@keyframes fadeIn {
|
| 125 |
+
from { opacity: 0; }
|
| 126 |
+
to { opacity: 1; }
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
@keyframes scaleUp {
|
| 130 |
+
from { transform: scale(0.95); opacity: 0; }
|
| 131 |
+
to { transform: scale(1); opacity: 1; }
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.animate-fade-in {
|
| 135 |
+
animation: fadeIn 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.animate-scale-up {
|
| 139 |
+
animation: scaleUp 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
| 140 |
+
}
|
frontend/src/main.jsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from 'react'
|
| 2 |
+
import { createRoot } from 'react-dom/client'
|
| 3 |
+
import './index.css'
|
| 4 |
+
import App from './App.jsx'
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
)
|
frontend/src/supabaseClient.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createClient } from '@supabase/supabase-js'
|
| 2 |
+
|
| 3 |
+
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
| 4 |
+
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
|
| 5 |
+
|
| 6 |
+
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
frontend/vite.config.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite'
|
| 2 |
+
import react from '@vitejs/plugin-react'
|
| 3 |
+
import tailwindcss from '@tailwindcss/vite'
|
| 4 |
+
|
| 5 |
+
// https://vite.dev/config/
|
| 6 |
+
export default defineConfig({
|
| 7 |
+
plugins: [
|
| 8 |
+
react(),
|
| 9 |
+
tailwindcss(),
|
| 10 |
+
],
|
| 11 |
+
server: {
|
| 12 |
+
proxy: {
|
| 13 |
+
'/api': {
|
| 14 |
+
target: 'http://127.0.0.1:8000',
|
| 15 |
+
changeOrigin: true,
|
| 16 |
+
ws: true,
|
| 17 |
+
},
|
| 18 |
+
'/static': {
|
| 19 |
+
target: 'http://127.0.0.1:8000',
|
| 20 |
+
changeOrigin: true,
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
},
|
| 24 |
+
})
|
| 25 |
+
|
| 26 |
+
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.100.0
|
| 2 |
+
uvicorn>=0.20.0
|
| 3 |
+
python-multipart>=0.0.6
|
| 4 |
+
soundfile>=0.12.1
|
| 5 |
+
torch>=2.0.0
|
| 6 |
+
transformers>=4.36.0
|
| 7 |
+
numpy>=1.20.0
|
| 8 |
+
websockets>=12.0
|
| 9 |
+
pyttsx3>=2.90
|
| 10 |
+
comtypes>=1.4.0
|
run.bat
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
title VibeVoice Studio Fullstack Launcher
|
| 3 |
+
echo ===================================================
|
| 4 |
+
echo VibeVoice React + FastAPI Studio Launcher
|
| 5 |
+
echo ===================================================
|
| 6 |
+
echo.
|
| 7 |
+
echo [1/3] Starting VibeVoice FastAPI Backend...
|
| 8 |
+
start "VibeVoice API Server" cmd /k "python app.py"
|
| 9 |
+
|
| 10 |
+
echo.
|
| 11 |
+
echo [2/3] Starting Vite React + Tailwind Dev Server...
|
| 12 |
+
start "VibeVoice React Frontend" cmd /k "cd frontend && npm run dev"
|
| 13 |
+
|
| 14 |
+
echo.
|
| 15 |
+
echo ===================================================
|
| 16 |
+
echo FastAPI API server running on: http://127.0.0.1:8000
|
| 17 |
+
echo React Dev frontend running on: http://localhost:5173
|
| 18 |
+
echo ===================================================
|
| 19 |
+
echo.
|
| 20 |
+
echo This launcher window can now be safely closed.
|
| 21 |
+
pause
|
static/app.js
ADDED
|
@@ -0,0 +1,729 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* VibeVoice Studio - Front-End Audio Engine & UI Controller
|
| 3 |
+
* Handles WebSocket PCM streaming, gapless scheduling, Web Audio API,
|
| 4 |
+
* microphone recording, canvas visualization, and dashboard state management.
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
// Global App State
|
| 8 |
+
let audioCtx = null;
|
| 9 |
+
let analyserNode = null;
|
| 10 |
+
let gainNode = null;
|
| 11 |
+
let ws = null;
|
| 12 |
+
let currentAudioSource = null;
|
| 13 |
+
|
| 14 |
+
// Streaming Audio Queue Variables (Gapless Playback)
|
| 15 |
+
let playbackQueue = [];
|
| 16 |
+
let nextPlayTime = 0;
|
| 17 |
+
let isStreamingActive = false;
|
| 18 |
+
let scheduledBufferSources = [];
|
| 19 |
+
const SAMPLING_RATE = 24000; // VibeVoice standard samplerate
|
| 20 |
+
|
| 21 |
+
// Voice Recording State (Voice Cloning)
|
| 22 |
+
let mediaRecorders = [null, null];
|
| 23 |
+
let audioChunks = [[], []];
|
| 24 |
+
let voicePaths = [null, null];
|
| 25 |
+
|
| 26 |
+
// HTML Elements Selection
|
| 27 |
+
const modelStatusVal = document.getElementById("modelStatusVal");
|
| 28 |
+
const modelDot = document.getElementById("modelDot");
|
| 29 |
+
const btnToggleModel = document.getElementById("btnToggleModel");
|
| 30 |
+
const connBadge = document.getElementById("connBadge");
|
| 31 |
+
const connIcon = document.getElementById("connIcon");
|
| 32 |
+
const connText = document.getElementById("connText");
|
| 33 |
+
|
| 34 |
+
const txtScript = document.getElementById("txtScript");
|
| 35 |
+
const chkStreamMode = document.getElementById("chkStreamMode");
|
| 36 |
+
const btnSynthesize = document.getElementById("btnSynthesize");
|
| 37 |
+
const btnStop = document.getElementById("btnStop");
|
| 38 |
+
|
| 39 |
+
const agentOrb = document.getElementById("agentOrb");
|
| 40 |
+
const agentStateText = document.getElementById("agentStateText");
|
| 41 |
+
const agentStateSub = document.getElementById("agentStateSub");
|
| 42 |
+
|
| 43 |
+
const canvasWaveform = document.getElementById("canvasWaveform");
|
| 44 |
+
const canvasCtx = canvasWaveform.getContext("2d");
|
| 45 |
+
|
| 46 |
+
// Playback Toolbar Elements
|
| 47 |
+
const audioToolbar = document.getElementById("audioToolbar");
|
| 48 |
+
const btnToolbarPlay = document.getElementById("btnToolbarPlay");
|
| 49 |
+
const sliderTimeline = document.getElementById("sliderTimeline");
|
| 50 |
+
const progressTimelineFill = document.getElementById("progressTimelineFill");
|
| 51 |
+
const lblTimeStart = document.getElementById("lblTimeStart");
|
| 52 |
+
const lblTimeEnd = document.getElementById("lblTimeEnd");
|
| 53 |
+
const sliderVolume = document.getElementById("sliderVolume");
|
| 54 |
+
const iconVolume = document.getElementById("iconVolume");
|
| 55 |
+
const selectSpeed = document.getElementById("selectSpeed");
|
| 56 |
+
|
| 57 |
+
// Voice Cloning Slots Elements
|
| 58 |
+
const spkName0 = document.getElementById("spkName0");
|
| 59 |
+
const btnRecord0 = document.getElementById("btnRecord0");
|
| 60 |
+
const fileVoice0 = document.getElementById("fileVoice0");
|
| 61 |
+
const clonedStatus0 = document.getElementById("clonedStatus0");
|
| 62 |
+
const audioPreview0 = document.getElementById("audioPreview0");
|
| 63 |
+
|
| 64 |
+
const spkName1 = document.getElementById("spkName1");
|
| 65 |
+
const btnRecord1 = document.getElementById("btnRecord1");
|
| 66 |
+
const fileVoice1 = document.getElementById("fileVoice1");
|
| 67 |
+
const clonedStatus1 = document.getElementById("clonedStatus1");
|
| 68 |
+
const audioPreview1 = document.getElementById("audioPreview1");
|
| 69 |
+
|
| 70 |
+
const consoleBody = document.getElementById("consoleBody");
|
| 71 |
+
const btnClearLogs = document.getElementById("btnClearLogs");
|
| 72 |
+
|
| 73 |
+
// Presets Elements
|
| 74 |
+
const presetPodcast = document.getElementById("presetPodcast");
|
| 75 |
+
const presetAssistant = document.getElementById("presetAssistant");
|
| 76 |
+
const presetNews = document.getElementById("presetNews");
|
| 77 |
+
|
| 78 |
+
// ==========================================================================
|
| 79 |
+
// Initialization & Server Sync
|
| 80 |
+
// ==========================================================================
|
| 81 |
+
|
| 82 |
+
window.addEventListener("DOMContentLoaded", () => {
|
| 83 |
+
log("system", "Initializing Audio Engines...");
|
| 84 |
+
checkServerStatus();
|
| 85 |
+
setupCanvasVisualizer();
|
| 86 |
+
bindEvents();
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
// Logs messages into the built-in Console Log card on UI
|
| 90 |
+
function log(type, message) {
|
| 91 |
+
const timeStr = new Date().toLocaleTimeString();
|
| 92 |
+
const line = document.createElement("div");
|
| 93 |
+
line.className = `log-line ${type}`;
|
| 94 |
+
line.innerHTML = `[${timeStr}] ${message}`;
|
| 95 |
+
consoleBody.appendChild(line);
|
| 96 |
+
consoleBody.scrollTop = consoleBody.scrollHeight;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
let statusInterval = null;
|
| 100 |
+
|
| 101 |
+
// Queries server backend to find if model weights are loaded
|
| 102 |
+
async function checkServerStatus() {
|
| 103 |
+
try {
|
| 104 |
+
const res = await fetch("/api/status");
|
| 105 |
+
const data = await res.json();
|
| 106 |
+
|
| 107 |
+
updateModelUI(data.use_real_model, data.loaded, data.loading, data.error);
|
| 108 |
+
|
| 109 |
+
// Auto poll if loading
|
| 110 |
+
if (data.loading) {
|
| 111 |
+
if (!statusInterval) {
|
| 112 |
+
statusInterval = setInterval(checkServerStatus, 3000);
|
| 113 |
+
}
|
| 114 |
+
} else {
|
| 115 |
+
if (statusInterval) {
|
| 116 |
+
clearInterval(statusInterval);
|
| 117 |
+
statusInterval = null;
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// Try opening websocket streaming channel if not already connected/connecting
|
| 122 |
+
if (!ws || ws.readyState === WebSocket.CLOSED) {
|
| 123 |
+
initWebSocket();
|
| 124 |
+
}
|
| 125 |
+
} catch (e) {
|
| 126 |
+
log("error", "Failed to connect to VibeVoice backend API server.");
|
| 127 |
+
updateModelUI(false, false, false, "Offline");
|
| 128 |
+
if (statusInterval) {
|
| 129 |
+
clearInterval(statusInterval);
|
| 130 |
+
statusInterval = null;
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
function updateModelUI(useReal, loaded, loading, error) {
|
| 136 |
+
if (useReal) {
|
| 137 |
+
if (loading) {
|
| 138 |
+
modelStatusVal.innerText = "Downloading AI weights (1.2GB)...";
|
| 139 |
+
modelDot.className = "status-dot yellow animate-pulse";
|
| 140 |
+
btnToggleModel.classList.add("active");
|
| 141 |
+
log("info", "VibeVoice Model weights downloading from Hugging Face. Please stand by...");
|
| 142 |
+
} else if (loaded) {
|
| 143 |
+
modelStatusVal.innerText = "VibeVoice-0.5B AI";
|
| 144 |
+
modelDot.className = "status-dot green";
|
| 145 |
+
btnToggleModel.classList.add("active");
|
| 146 |
+
log("success", "VibeVoice Model weights loaded successfully. Zero-shot cloning activated!");
|
| 147 |
+
} else if (error) {
|
| 148 |
+
modelStatusVal.innerText = "Error Loading AI";
|
| 149 |
+
modelDot.className = "status-dot red";
|
| 150 |
+
btnToggleModel.classList.remove("active");
|
| 151 |
+
log("error", `Model Load Error: ${error}`);
|
| 152 |
+
}
|
| 153 |
+
} else {
|
| 154 |
+
modelStatusVal.innerText = "Demo Synthesizer";
|
| 155 |
+
modelDot.className = "status-dot green";
|
| 156 |
+
btnToggleModel.classList.remove("active");
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// Handles switching model modes on clicking the Toggle button
|
| 161 |
+
btnToggleModel.addEventListener("click", async () => {
|
| 162 |
+
const isActive = btnToggleModel.classList.contains("active");
|
| 163 |
+
const enable = !isActive;
|
| 164 |
+
log("info", `Requesting server mode toggle: ${enable ? "Enable AI Model" : "Enable Demo Synthesizer"}`);
|
| 165 |
+
|
| 166 |
+
try {
|
| 167 |
+
btnToggleModel.disabled = true;
|
| 168 |
+
const res = await fetch(`/api/toggle-model?enable=${enable}`, { method: "POST" });
|
| 169 |
+
const data = await res.json();
|
| 170 |
+
|
| 171 |
+
btnToggleModel.disabled = false;
|
| 172 |
+
checkServerStatus();
|
| 173 |
+
} catch (e) {
|
| 174 |
+
btnToggleModel.disabled = false;
|
| 175 |
+
log("error", "Error toggling AI Model mode.");
|
| 176 |
+
}
|
| 177 |
+
});
|
| 178 |
+
|
| 179 |
+
// Initialize real-time WebSockets
|
| 180 |
+
function initWebSocket() {
|
| 181 |
+
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
| 182 |
+
const host = window.location.host;
|
| 183 |
+
const wsUrl = `${protocol}//${host}/api/stream`;
|
| 184 |
+
|
| 185 |
+
log("system", `Opening WebSocket stream canal: ${wsUrl}`);
|
| 186 |
+
ws = new WebSocket(wsUrl);
|
| 187 |
+
|
| 188 |
+
ws.onopen = () => {
|
| 189 |
+
connBadge.className = "connection-badge online";
|
| 190 |
+
connIcon.className = "fa-solid fa-link";
|
| 191 |
+
connText.innerText = "Connected";
|
| 192 |
+
log("success", "Real-time streaming audio WebSocket channel ESTABLISHED.");
|
| 193 |
+
};
|
| 194 |
+
|
| 195 |
+
ws.onclose = () => {
|
| 196 |
+
connBadge.className = "connection-badge";
|
| 197 |
+
connIcon.className = "fa-solid fa-link-slash";
|
| 198 |
+
connText.innerText = "Offline";
|
| 199 |
+
log("error", "Real-time streaming WebSocket channel disconnected.");
|
| 200 |
+
// Try reconnecting in 5 seconds
|
| 201 |
+
setTimeout(initWebSocket, 5000);
|
| 202 |
+
};
|
| 203 |
+
|
| 204 |
+
ws.onerror = (e) => {
|
| 205 |
+
log("error", "WebSocket channel encountered error. Resetting...");
|
| 206 |
+
};
|
| 207 |
+
|
| 208 |
+
ws.onmessage = async (event) => {
|
| 209 |
+
// Handle incoming WebSocket messages (could be text status or binary audio)
|
| 210 |
+
if (typeof event.data === "string") {
|
| 211 |
+
const data = JSON.parse(event.data);
|
| 212 |
+
if (data.type === "word") {
|
| 213 |
+
log("word", `Agent spoke word: <strong style="font-size:12px;">${data.word}</strong>`);
|
| 214 |
+
} else if (data.type === "done") {
|
| 215 |
+
log("success", "WebSocket audio stream finished downloading.");
|
| 216 |
+
} else if (data.type === "error") {
|
| 217 |
+
log("error", `Server Stream Error: ${data.message}`);
|
| 218 |
+
stopPlayback();
|
| 219 |
+
}
|
| 220 |
+
} else {
|
| 221 |
+
// Audio chunk arrived as binary raw data
|
| 222 |
+
const arrayBuffer = await event.data.arrayBuffer();
|
| 223 |
+
// Convert 16-bit signed PCM buffer to Float32 array
|
| 224 |
+
const float32Array = convertPCM16ToFloat32(new Int16Array(arrayBuffer));
|
| 225 |
+
|
| 226 |
+
if (isStreamingActive) {
|
| 227 |
+
playbackQueue.push(float32Array);
|
| 228 |
+
scheduleNextChunk();
|
| 229 |
+
}
|
| 230 |
+
}
|
| 231 |
+
};
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
// Helper to convert 16-bit integer bytes to floating values between -1.0 and 1.0
|
| 235 |
+
function convertPCM16ToFloat32(int16Array) {
|
| 236 |
+
const float32 = new Float32Array(int16Array.length);
|
| 237 |
+
for (let i = 0; i < int16Array.length; i++) {
|
| 238 |
+
// Divide by max positive value of 16-bit integer (32767)
|
| 239 |
+
float32[i] = int16Array[i] / 32767.0;
|
| 240 |
+
}
|
| 241 |
+
return float32;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
// ==========================================================================
|
| 245 |
+
// Web Audio Context & Playback Engine (Pillar 2 & 3)
|
| 246 |
+
// ==========================================================================
|
| 247 |
+
|
| 248 |
+
function initAudioContext() {
|
| 249 |
+
if (audioCtx) return;
|
| 250 |
+
|
| 251 |
+
// Create modern AudioContext inside browser
|
| 252 |
+
audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLING_RATE });
|
| 253 |
+
|
| 254 |
+
// Create Analyser Node for the Oscilloscope Waveform Canvas
|
| 255 |
+
analyserNode = audioCtx.createAnalyser();
|
| 256 |
+
analyserNode.fftSize = 256;
|
| 257 |
+
|
| 258 |
+
// Create Gain Node for volume adjustments
|
| 259 |
+
gainNode = audioCtx.createGain();
|
| 260 |
+
setVolume(sliderVolume.value / 100);
|
| 261 |
+
|
| 262 |
+
// Connect nodes together: Source -> Gain -> Analyser -> Output Speakers
|
| 263 |
+
gainNode.connect(analyserNode);
|
| 264 |
+
analyserNode.connect(audioCtx.destination);
|
| 265 |
+
|
| 266 |
+
log("system", `Browser AudioContext initiated at ${SAMPLING_RATE}Hz.`);
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
function setVolume(val) {
|
| 270 |
+
if (gainNode && audioCtx) {
|
| 271 |
+
gainNode.gain.setValueAtTime(val, audioCtx.currentTime);
|
| 272 |
+
// Toggle volume icon based on level
|
| 273 |
+
if (val === 0) iconVolume.className = "fa-solid fa-volume-mute";
|
| 274 |
+
else if (val < 0.5) iconVolume.className = "fa-solid fa-volume-low";
|
| 275 |
+
else iconVolume.className = "fa-solid fa-volume-high";
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
// Streaming Scheduling: Gapless stitched playing of successive floats
|
| 280 |
+
function startStreamingPlayback() {
|
| 281 |
+
initAudioContext();
|
| 282 |
+
if (audioCtx.state === "suspended") {
|
| 283 |
+
audioCtx.resume();
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
playbackQueue = [];
|
| 287 |
+
scheduledBufferSources = [];
|
| 288 |
+
nextPlayTime = audioCtx.currentTime + 0.15; // Brief offset padding buffer to start
|
| 289 |
+
isStreamingActive = true;
|
| 290 |
+
|
| 291 |
+
btnSynthesize.disabled = true;
|
| 292 |
+
btnStop.disabled = false;
|
| 293 |
+
|
| 294 |
+
setAgentOrbState("speaking", "STREAMING VOICE", "Synthesizing and playing real-time speech...");
|
| 295 |
+
log("info", "Playback stream started. Waiting for speech chunks...");
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
function scheduleNextChunk() {
|
| 299 |
+
if (!isStreamingActive || playbackQueue.length === 0) return;
|
| 300 |
+
|
| 301 |
+
const chunkFloats = playbackQueue.shift();
|
| 302 |
+
if (chunkFloats.length === 0) return;
|
| 303 |
+
|
| 304 |
+
// Create custom Audio Buffer for the float data
|
| 305 |
+
const buffer = audioCtx.createBuffer(1, chunkFloats.length, SAMPLING_RATE);
|
| 306 |
+
buffer.copyToChannel(chunkFloats, 0);
|
| 307 |
+
|
| 308 |
+
// Create Source Node
|
| 309 |
+
const sourceNode = audioCtx.createBufferSource();
|
| 310 |
+
sourceNode.buffer = buffer;
|
| 311 |
+
|
| 312 |
+
// Apply playback speed modifier selected by user
|
| 313 |
+
sourceNode.playbackRate.value = parseFloat(selectSpeed.value);
|
| 314 |
+
|
| 315 |
+
// Stitch chunk directly onto Gain node
|
| 316 |
+
sourceNode.connect(gainNode);
|
| 317 |
+
|
| 318 |
+
// Precise scheduling time calculation
|
| 319 |
+
const startTime = Math.max(audioCtx.currentTime, nextPlayTime);
|
| 320 |
+
sourceNode.start(startTime);
|
| 321 |
+
|
| 322 |
+
// Keep track of active playing nodes so we can cut off audio if requested
|
| 323 |
+
scheduledBufferSources.push(sourceNode);
|
| 324 |
+
|
| 325 |
+
// Update scheduler deadline
|
| 326 |
+
const chunkDuration = buffer.duration / sourceNode.playbackRate.value;
|
| 327 |
+
nextPlayTime = startTime + chunkDuration;
|
| 328 |
+
|
| 329 |
+
// Release reference on completion
|
| 330 |
+
sourceNode.onended = () => {
|
| 331 |
+
const idx = scheduledBufferSources.indexOf(sourceNode);
|
| 332 |
+
if (idx > -1) scheduledBufferSources.splice(idx, 1);
|
| 333 |
+
|
| 334 |
+
// If queue is exhausted and no active audio is left, reset agent to idle
|
| 335 |
+
if (scheduledBufferSources.length === 0 && playbackQueue.length === 0 && isStreamingActive) {
|
| 336 |
+
stopPlayback();
|
| 337 |
+
log("info", "Agent finished speaking.");
|
| 338 |
+
}
|
| 339 |
+
};
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
// Stops all active sounds and resets the dashboard execution state
|
| 343 |
+
function stopPlayback() {
|
| 344 |
+
isStreamingActive = false;
|
| 345 |
+
playbackQueue = [];
|
| 346 |
+
|
| 347 |
+
// Halt all audio buffers immediately
|
| 348 |
+
scheduledBufferSources.forEach(source => {
|
| 349 |
+
try { source.stop(); } catch(e) {}
|
| 350 |
+
});
|
| 351 |
+
scheduledBufferSources = [];
|
| 352 |
+
|
| 353 |
+
// Stop standard player if active
|
| 354 |
+
if (currentAudioSource) {
|
| 355 |
+
try { currentAudioSource.stop(); } catch(e) {}
|
| 356 |
+
currentAudioSource = null;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
btnSynthesize.disabled = false;
|
| 360 |
+
btnStop.disabled = true;
|
| 361 |
+
setAgentOrbState("idle", "AGENT READY", "Configure scripts and trigger generation below");
|
| 362 |
+
audioToolbar.classList.add("hidden");
|
| 363 |
+
|
| 364 |
+
log("system", "Agent audio playback halted.");
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
btnStop.addEventListener("click", stopPlayback);
|
| 368 |
+
|
| 369 |
+
// Sets the visual status and glowing styles of the central agent orb
|
| 370 |
+
function setAgentOrbState(stateClass, title, subtitle) {
|
| 371 |
+
agentOrb.className = `agent-orb ${stateClass}`;
|
| 372 |
+
agentStateText.innerText = title;
|
| 373 |
+
agentStateSub.innerText = subtitle;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
// ==========================================================================
|
| 377 |
+
// Canvas Oscilloscope Visualizer Rendering (Pillar 3)
|
| 378 |
+
// ==========================================================================
|
| 379 |
+
|
| 380 |
+
function setupCanvasVisualizer() {
|
| 381 |
+
const width = canvasWaveform.width;
|
| 382 |
+
const height = canvasWaveform.height;
|
| 383 |
+
|
| 384 |
+
function draw() {
|
| 385 |
+
requestAnimationFrame(draw);
|
| 386 |
+
|
| 387 |
+
canvasCtx.fillStyle = "rgba(7, 9, 14, 0.25)"; // Semitransparent sweep back
|
| 388 |
+
canvasCtx.fillRect(0, 0, width, height);
|
| 389 |
+
|
| 390 |
+
let dataArray = null;
|
| 391 |
+
let bufferLength = 0;
|
| 392 |
+
|
| 393 |
+
if (analyserNode && isAudioPlaying()) {
|
| 394 |
+
bufferLength = analyserNode.frequencyBinCount;
|
| 395 |
+
dataArray = new Uint8Array(bufferLength);
|
| 396 |
+
analyserNode.getByteTimeDomainData(dataArray);
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
canvasCtx.lineWidth = 2.5;
|
| 400 |
+
// Apply beautiful purple-to-cyan gradient stroke to waveform curves
|
| 401 |
+
const gradient = canvasCtx.createLinearGradient(0, 0, width, 0);
|
| 402 |
+
gradient.addColorStop(0, "hsl(265, 88%, 64%)"); // Purple
|
| 403 |
+
gradient.addColorStop(0.5, "hsl(185, 95%, 48%)"); // Cyan
|
| 404 |
+
gradient.addColorStop(1, "hsl(330, 92%, 60%)"); // Pink
|
| 405 |
+
canvasCtx.strokeStyle = gradient;
|
| 406 |
+
|
| 407 |
+
canvasCtx.shadowBlur = isAudioPlaying() ? 8 : 0;
|
| 408 |
+
canvasCtx.shadowColor = "rgba(0, 242, 254, 0.4)";
|
| 409 |
+
|
| 410 |
+
canvasCtx.beginPath();
|
| 411 |
+
|
| 412 |
+
if (dataArray) {
|
| 413 |
+
// Draw actual audio ripples
|
| 414 |
+
const sliceWidth = width / bufferLength;
|
| 415 |
+
let x = 0;
|
| 416 |
+
|
| 417 |
+
for (let i = 0; i < bufferLength; i++) {
|
| 418 |
+
const v = dataArray[i] / 128.0;
|
| 419 |
+
const y = (v * height) / 2;
|
| 420 |
+
|
| 421 |
+
if (i === 0) {
|
| 422 |
+
canvasCtx.moveTo(x, y);
|
| 423 |
+
} else {
|
| 424 |
+
canvasCtx.lineTo(x, y);
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
x += sliceWidth;
|
| 428 |
+
}
|
| 429 |
+
} else {
|
| 430 |
+
// Draw a calm breathing idle baseline
|
| 431 |
+
const points = 40;
|
| 432 |
+
const sliceWidth = width / points;
|
| 433 |
+
let x = 0;
|
| 434 |
+
const time = Date.now() * 0.003;
|
| 435 |
+
|
| 436 |
+
for (let i = 0; i < points; i++) {
|
| 437 |
+
// Subtle sine rhythm for visual engagement when silent
|
| 438 |
+
const breathingFactor = Math.sin(time) * 0.2 + 0.8;
|
| 439 |
+
const y = height / 2 + Math.sin(i * 0.15 + time * 2) * (2.5 * breathingFactor);
|
| 440 |
+
|
| 441 |
+
if (i === 0) {
|
| 442 |
+
canvasCtx.moveTo(x, y);
|
| 443 |
+
} else {
|
| 444 |
+
canvasCtx.lineTo(x, y);
|
| 445 |
+
}
|
| 446 |
+
x += sliceWidth;
|
| 447 |
+
}
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
canvasCtx.lineTo(width, height / 2);
|
| 451 |
+
canvasCtx.stroke();
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
draw();
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
function isAudioPlaying() {
|
| 458 |
+
return isStreamingActive || currentAudioSource !== null;
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
// ==========================================================================
|
| 462 |
+
// Microphone Recorder & Speaker Voice Cloning (Pillar 4)
|
| 463 |
+
// ==========================================================================
|
| 464 |
+
|
| 465 |
+
async function setupRecording(speakerId) {
|
| 466 |
+
const btnRecord = speakerId === 0 ? btnRecord0 : btnRecord1;
|
| 467 |
+
const isRecording = btnRecord.classList.contains("recording");
|
| 468 |
+
|
| 469 |
+
if (isRecording) {
|
| 470 |
+
// Stop active recording
|
| 471 |
+
if (mediaRecorders[speakerId]) {
|
| 472 |
+
mediaRecorders[speakerId].stop();
|
| 473 |
+
btnRecord.classList.remove("recording");
|
| 474 |
+
btnRecord.innerHTML = `<i class="fa-solid fa-microphone"></i> Record Mic`;
|
| 475 |
+
setAgentOrbState("idle", "AGENT READY", "Voice profile recorded. Transmitting to backend...");
|
| 476 |
+
}
|
| 477 |
+
} else {
|
| 478 |
+
// Start recording
|
| 479 |
+
try {
|
| 480 |
+
log("info", `Requesting microphone access for Speaker ${speakerId}...`);
|
| 481 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 482 |
+
|
| 483 |
+
initAudioContext();
|
| 484 |
+
setAgentOrbState("listening", "LISTENING...", `Say a short 10-15s dialogue sentence to clone voice profile.`);
|
| 485 |
+
|
| 486 |
+
audioChunks[speakerId] = [];
|
| 487 |
+
const mediaRecorder = new MediaRecorder(stream);
|
| 488 |
+
mediaRecorders[speakerId] = mediaRecorder;
|
| 489 |
+
|
| 490 |
+
mediaRecorder.ondataavailable = (event) => {
|
| 491 |
+
audioChunks[speakerId].push(event.data);
|
| 492 |
+
};
|
| 493 |
+
|
| 494 |
+
mediaRecorder.onstop = async () => {
|
| 495 |
+
log("info", "Processing audio chunks...");
|
| 496 |
+
const audioBlob = new Blob(audioChunks[speakerId], { type: "audio/wav" });
|
| 497 |
+
|
| 498 |
+
// Release microphore stream tracks
|
| 499 |
+
stream.getTracks().forEach(track => track.stop());
|
| 500 |
+
|
| 501 |
+
// Upload recorded WAV profile to server
|
| 502 |
+
const spkName = speakerId === 0 ? spkName0.value : spkName1.value;
|
| 503 |
+
await uploadVoiceProfile(audioBlob, speakerId, spkName);
|
| 504 |
+
};
|
| 505 |
+
|
| 506 |
+
mediaRecorder.start();
|
| 507 |
+
btnRecord.classList.add("recording");
|
| 508 |
+
btnRecord.innerHTML = `<i class="fa-solid fa-square"></i> Stop Mic`;
|
| 509 |
+
log("success", "Microphone recording started. Speak clearly now!");
|
| 510 |
+
} catch (e) {
|
| 511 |
+
log("error", `Could not record voice: ${e.message}`);
|
| 512 |
+
setAgentOrbState("idle", "AGENT READY", "Microphone permission denied or source invalid.");
|
| 513 |
+
}
|
| 514 |
+
}
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
// Transmit Voice profile file to FastAPI backend
|
| 518 |
+
async function uploadVoiceProfile(blob, speakerId, name) {
|
| 519 |
+
const formData = new FormData();
|
| 520 |
+
formData.append("file", blob, `mic_speaker_${speakerId}.wav`);
|
| 521 |
+
formData.append("speaker_name", name);
|
| 522 |
+
|
| 523 |
+
try {
|
| 524 |
+
log("info", "Uploading voice clone prompt to server database...");
|
| 525 |
+
const res = await fetch("/api/upload-voice", {
|
| 526 |
+
method: "POST",
|
| 527 |
+
body: formData
|
| 528 |
+
});
|
| 529 |
+
const data = await res.json();
|
| 530 |
+
|
| 531 |
+
if (data.status === "success") {
|
| 532 |
+
voicePaths[speakerId] = data.voice_path;
|
| 533 |
+
|
| 534 |
+
// Show preview player
|
| 535 |
+
const previewPlayer = speakerId === 0 ? audioPreview0 : audioPreview1;
|
| 536 |
+
const statusBox = speakerId === 0 ? clonedStatus0 : clonedStatus1;
|
| 537 |
+
|
| 538 |
+
previewPlayer.src = `/static/cloned_voices/${data.filename}`;
|
| 539 |
+
statusBox.classList.remove("hidden");
|
| 540 |
+
|
| 541 |
+
log("success", `Voice cloned successfully under speaker code "${name}"!`);
|
| 542 |
+
}
|
| 543 |
+
} catch(e) {
|
| 544 |
+
log("error", "Error uploading voice cloning profile.");
|
| 545 |
+
}
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
// Wire File Selector uploads for pre-existing WAV voice samples
|
| 549 |
+
async function handleFileSelect(e, speakerId) {
|
| 550 |
+
const file = e.target.files[0];
|
| 551 |
+
if (!file) return;
|
| 552 |
+
|
| 553 |
+
log("info", `Uploading selected file: ${file.name}`);
|
| 554 |
+
const name = speakerId === 0 ? spkName0.value : spkName1.value;
|
| 555 |
+
await uploadVoiceProfile(file, speakerId, name);
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
// ==========================================================================
|
| 559 |
+
// Speech Synthesis Triggers (REST vs WebSocket Stream)
|
| 560 |
+
// ==========================================================================
|
| 561 |
+
|
| 562 |
+
async function triggerSynthesis() {
|
| 563 |
+
const text = txtScript.value.trim();
|
| 564 |
+
if (!text) {
|
| 565 |
+
log("error", "Cannot synthesize speech. Script text field is empty!");
|
| 566 |
+
return;
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
initAudioContext();
|
| 570 |
+
stopPlayback(); // Clean up existing audio cycles
|
| 571 |
+
|
| 572 |
+
const isStream = chkStreamMode.checked;
|
| 573 |
+
|
| 574 |
+
const speaker_voices = {};
|
| 575 |
+
if (voicePaths[0]) speaker_voices["0"] = voicePaths[0];
|
| 576 |
+
if (voicePaths[1]) speaker_voices["1"] = voicePaths[1];
|
| 577 |
+
|
| 578 |
+
if (isStream) {
|
| 579 |
+
// Mode A: Real-Time WebSockets Streaming
|
| 580 |
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
| 581 |
+
log("error", "WebSocket channel is not active. Streaming offline.");
|
| 582 |
+
return;
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
startStreamingPlayback();
|
| 586 |
+
|
| 587 |
+
// Push script parameters to WebSocket
|
| 588 |
+
ws.send(JSON.stringify({
|
| 589 |
+
text: text,
|
| 590 |
+
voice_sample_path: voicePaths[0], // primary voice clone prompt path
|
| 591 |
+
speaker_id: 0,
|
| 592 |
+
speaker_voices: speaker_voices
|
| 593 |
+
}));
|
| 594 |
+
|
| 595 |
+
} else {
|
| 596 |
+
// Mode B: Standard Full WAV synthesis
|
| 597 |
+
try {
|
| 598 |
+
log("info", "Initiating complete script compilation... Please wait...");
|
| 599 |
+
setAgentOrbState("thinking", "COMPILING SCRIPT", "Executing neural modeling and saving audio...");
|
| 600 |
+
btnSynthesize.disabled = true;
|
| 601 |
+
|
| 602 |
+
const res = await fetch("/api/generate", {
|
| 603 |
+
method: "POST",
|
| 604 |
+
headers: { "Content-Type": "application/json" },
|
| 605 |
+
body: JSON.stringify({
|
| 606 |
+
text: text,
|
| 607 |
+
voice_sample_path: voicePaths[0],
|
| 608 |
+
speaker_id: 0,
|
| 609 |
+
speaker_voices: speaker_voices
|
| 610 |
+
})
|
| 611 |
+
});
|
| 612 |
+
const data = await res.json();
|
| 613 |
+
|
| 614 |
+
btnSynthesize.disabled = false;
|
| 615 |
+
|
| 616 |
+
if (data.status === "success") {
|
| 617 |
+
log("success", `Script generated successfully using engine mode: [${data.mode}]`);
|
| 618 |
+
loadTraditionalPlayer(data.audio_url);
|
| 619 |
+
} else {
|
| 620 |
+
throw new Error("API return code was not successful.");
|
| 621 |
+
}
|
| 622 |
+
} catch (e) {
|
| 623 |
+
btnSynthesize.disabled = false;
|
| 624 |
+
setAgentOrbState("idle", "AGENT READY", "Dialogue generation failed.");
|
| 625 |
+
log("error", `Synthesis generation failed: ${e.message}`);
|
| 626 |
+
}
|
| 627 |
+
}
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
// Controls standard player execution for downloaded WAV files
|
| 631 |
+
async function loadTraditionalPlayer(audioUrl) {
|
| 632 |
+
try {
|
| 633 |
+
log("info", "Loading generated voice track into audio player...");
|
| 634 |
+
const res = await fetch(audioUrl);
|
| 635 |
+
const arrayBuffer = await res.arrayBuffer();
|
| 636 |
+
|
| 637 |
+
audioCtx.decodeAudioData(arrayBuffer, (decodedBuffer) => {
|
| 638 |
+
currentAudioSource = audioCtx.createBufferSource();
|
| 639 |
+
currentAudioSource.buffer = decodedBuffer;
|
| 640 |
+
|
| 641 |
+
currentAudioSource.connect(gainNode);
|
| 642 |
+
currentAudioSource.start(0);
|
| 643 |
+
|
| 644 |
+
setAgentOrbState("speaking", "PLAYING AUDIO", "Playing compiled high-fidelity voice track...");
|
| 645 |
+
audioToolbar.classList.remove("hidden");
|
| 646 |
+
btnStop.disabled = false;
|
| 647 |
+
btnSynthesize.disabled = true;
|
| 648 |
+
|
| 649 |
+
// Set up player timeline meters
|
| 650 |
+
lblTimeStart.innerText = "00:00";
|
| 651 |
+
lblTimeEnd.innerText = formatTime(decodedBuffer.duration);
|
| 652 |
+
sliderTimeline.max = Math.floor(decodedBuffer.duration);
|
| 653 |
+
sliderTimeline.value = 0;
|
| 654 |
+
|
| 655 |
+
const startTime = audioCtx.currentTime;
|
| 656 |
+
const updateTimeline = () => {
|
| 657 |
+
if (currentAudioSource) {
|
| 658 |
+
const elapsed = audioCtx.currentTime - startTime;
|
| 659 |
+
sliderTimeline.value = Math.floor(elapsed);
|
| 660 |
+
progressTimelineFill.style.width = `${(elapsed / decodedBuffer.duration) * 100}%`;
|
| 661 |
+
lblTimeStart.innerText = formatTime(elapsed);
|
| 662 |
+
|
| 663 |
+
if (elapsed < decodedBuffer.duration) {
|
| 664 |
+
requestAnimationFrame(updateTimeline);
|
| 665 |
+
}
|
| 666 |
+
}
|
| 667 |
+
};
|
| 668 |
+
|
| 669 |
+
updateTimeline();
|
| 670 |
+
|
| 671 |
+
currentAudioSource.onended = () => {
|
| 672 |
+
stopPlayback();
|
| 673 |
+
};
|
| 674 |
+
}, (err) => {
|
| 675 |
+
log("error", "Error decoding audio buffer track.");
|
| 676 |
+
});
|
| 677 |
+
} catch(e) {
|
| 678 |
+
log("error", "Failed to compile compiled WAV player.");
|
| 679 |
+
}
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
function formatTime(seconds) {
|
| 683 |
+
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
| 684 |
+
const s = Math.floor(seconds % 60).toString().padStart(2, '0');
|
| 685 |
+
return `${m}:${s}`;
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
// ==========================================================================
|
| 689 |
+
// Event Binding & Presets Loading (Dashboard HUD controls)
|
| 690 |
+
// ==========================================================================
|
| 691 |
+
|
| 692 |
+
function bindEvents() {
|
| 693 |
+
// Primary execution actions
|
| 694 |
+
btnSynthesize.addEventListener("click", triggerSynthesis);
|
| 695 |
+
|
| 696 |
+
// Voice profiling recording hooks
|
| 697 |
+
btnRecord0.addEventListener("click", () => setupRecording(0));
|
| 698 |
+
btnRecord1.addEventListener("click", () => setupRecording(1));
|
| 699 |
+
|
| 700 |
+
fileVoice0.addEventListener("change", (e) => handleFileSelect(e, 0));
|
| 701 |
+
fileVoice1.addEventListener("change", (e) => handleFileSelect(e, 1));
|
| 702 |
+
|
| 703 |
+
// Standard audio adjustment nodes
|
| 704 |
+
sliderVolume.addEventListener("input", (e) => {
|
| 705 |
+
setVolume(e.target.value / 100);
|
| 706 |
+
});
|
| 707 |
+
|
| 708 |
+
btnClearLogs.addEventListener("click", () => {
|
| 709 |
+
consoleBody.innerHTML = `<div class="log-line system">[System] Console buffer cleared.</div>`;
|
| 710 |
+
});
|
| 711 |
+
|
| 712 |
+
// Presets loaders
|
| 713 |
+
presetPodcast.addEventListener("click", () => {
|
| 714 |
+
txtScript.value = `Speaker 0: Welcome back to the Vibe Voice podcast segment. Today we are cloning human expressions.
|
| 715 |
+
Speaker 1: That is right! The 0.5B model manages this dialogue transition on a local CPU extremely fast.
|
| 716 |
+
Speaker 0: Incredible! It sounds like two real people sitting in this glassmorphic studio together.`;
|
| 717 |
+
log("info", "Loaded preset script: [Podcast Dialogue]");
|
| 718 |
+
});
|
| 719 |
+
|
| 720 |
+
presetAssistant.addEventListener("click", () => {
|
| 721 |
+
txtScript.value = `Speaker 0: Hello, I am your personalized voice assistant. How can I help you customize your Dine Direct orders today?`;
|
| 722 |
+
log("info", "Loaded preset script: [AI Voice Assistant]");
|
| 723 |
+
});
|
| 724 |
+
|
| 725 |
+
presetNews.addEventListener("click", () => {
|
| 726 |
+
txtScript.value = `Speaker 1: Breaking news from Microsoft Research. The Vibe Voice speech synthesis models are now live on local devices.`;
|
| 727 |
+
log("info", "Loaded preset script: [News Broadcast]");
|
| 728 |
+
});
|
| 729 |
+
}
|
static/index.css
ADDED
|
@@ -0,0 +1,1067 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ==========================================================================
|
| 2 |
+
VibeVoice Studio CSS Stylesheet
|
| 3 |
+
Design Paradigm: Deep Space Dark Mode Glassmorphism with Neon Accents
|
| 4 |
+
========================================================================== */
|
| 5 |
+
|
| 6 |
+
:root {
|
| 7 |
+
--bg-dark: #07090e;
|
| 8 |
+
--card-bg: rgba(13, 17, 28, 0.45);
|
| 9 |
+
--card-border: rgba(255, 255, 255, 0.08);
|
| 10 |
+
--text-primary: #f0f3fa;
|
| 11 |
+
--text-secondary: #8e9bb5;
|
| 12 |
+
|
| 13 |
+
/* Neon Accents HSL */
|
| 14 |
+
--accent-cyan: hsl(185, 95%, 48%);
|
| 15 |
+
--accent-cyan-glow: hsla(185, 95%, 48%, 0.35);
|
| 16 |
+
--accent-purple: hsl(265, 88%, 64%);
|
| 17 |
+
--accent-purple-glow: hsla(265, 88%, 64%, 0.35);
|
| 18 |
+
--accent-pink: hsl(330, 92%, 60%);
|
| 19 |
+
--accent-pink-glow: hsla(330, 92%, 60%, 0.35);
|
| 20 |
+
--accent-green: hsl(145, 85%, 50%);
|
| 21 |
+
|
| 22 |
+
/* Transition Timings */
|
| 23 |
+
--ease-smooth: cubic-bezier(0.25, 0.8, 0.25, 1);
|
| 24 |
+
--transition-quick: 0.2s var(--ease-smooth);
|
| 25 |
+
--transition-mid: 0.4s var(--ease-smooth);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/* Reset & Core Styling */
|
| 29 |
+
* {
|
| 30 |
+
box-sizing: border-box;
|
| 31 |
+
margin: 0;
|
| 32 |
+
padding: 0;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
body {
|
| 36 |
+
background-color: var(--bg-dark);
|
| 37 |
+
color: var(--text-primary);
|
| 38 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 39 |
+
min-height: 100vh;
|
| 40 |
+
overflow-x: hidden;
|
| 41 |
+
position: relative;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/* Stunning Floating Background Gradients */
|
| 45 |
+
.glow-bg {
|
| 46 |
+
position: fixed;
|
| 47 |
+
top: 0;
|
| 48 |
+
left: 0;
|
| 49 |
+
right: 0;
|
| 50 |
+
bottom: 0;
|
| 51 |
+
z-index: -1;
|
| 52 |
+
overflow: hidden;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.glow-bg::before, .glow-bg::after {
|
| 56 |
+
content: '';
|
| 57 |
+
position: absolute;
|
| 58 |
+
border-radius: 50%;
|
| 59 |
+
filter: blur(140px);
|
| 60 |
+
opacity: 0.15;
|
| 61 |
+
pointer-events: none;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.glow-bg::before {
|
| 65 |
+
background: radial-gradient(circle, var(--accent-cyan) 0%, transparent 70%);
|
| 66 |
+
width: 600px;
|
| 67 |
+
height: 600px;
|
| 68 |
+
top: -200px;
|
| 69 |
+
left: -150px;
|
| 70 |
+
animation: floatGlow 25s infinite alternate ease-in-out;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.glow-bg::after {
|
| 74 |
+
background: radial-gradient(circle, var(--accent-purple) 0%, transparent 70%);
|
| 75 |
+
width: 700px;
|
| 76 |
+
height: 700px;
|
| 77 |
+
bottom: -250px;
|
| 78 |
+
right: -150px;
|
| 79 |
+
animation: floatGlow 30s infinite alternate-reverse ease-in-out;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
@keyframes floatGlow {
|
| 83 |
+
0% { transform: translate(0, 0) scale(1); }
|
| 84 |
+
50% { transform: translate(100px, 80px) scale(1.15); }
|
| 85 |
+
100% { transform: translate(-50px, 150px) scale(0.9); }
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/* Layout Containers */
|
| 89 |
+
.container {
|
| 90 |
+
width: 100%;
|
| 91 |
+
max-width: 1300px;
|
| 92 |
+
margin: 0 auto;
|
| 93 |
+
padding: 24px 16px;
|
| 94 |
+
display: flex;
|
| 95 |
+
flex-direction: column;
|
| 96 |
+
min-height: 100vh;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/* ==========================================================================
|
| 100 |
+
Header Styling
|
| 101 |
+
========================================================================== */
|
| 102 |
+
.app-header {
|
| 103 |
+
display: flex;
|
| 104 |
+
justify-content: space-between;
|
| 105 |
+
align-items: center;
|
| 106 |
+
padding: 16px 24px;
|
| 107 |
+
background: rgba(10, 14, 26, 0.4);
|
| 108 |
+
border-radius: 16px;
|
| 109 |
+
border: 1px solid var(--card-border);
|
| 110 |
+
backdrop-filter: blur(16px);
|
| 111 |
+
margin-bottom: 24px;
|
| 112 |
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.logo-area {
|
| 116 |
+
display: flex;
|
| 117 |
+
align-items: center;
|
| 118 |
+
gap: 14px;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.logo-icon {
|
| 122 |
+
font-size: 28px;
|
| 123 |
+
background: linear-gradient(135deg, var(--accent-cyan) 0%, var(--accent-purple) 100%);
|
| 124 |
+
-webkit-background-clip: text;
|
| 125 |
+
-webkit-text-fill-color: transparent;
|
| 126 |
+
filter: drop-shadow(0 0 10px var(--accent-cyan-glow));
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.logo-text h1 {
|
| 130 |
+
font-size: 22px;
|
| 131 |
+
font-weight: 700;
|
| 132 |
+
letter-spacing: -0.5px;
|
| 133 |
+
color: var(--text-primary);
|
| 134 |
+
line-height: 1.1;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.logo-text h1 span {
|
| 138 |
+
background: linear-gradient(135deg, var(--accent-cyan) 0%, var(--accent-purple) 100%);
|
| 139 |
+
-webkit-background-clip: text;
|
| 140 |
+
-webkit-text-fill-color: transparent;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.logo-text p {
|
| 144 |
+
font-size: 11px;
|
| 145 |
+
color: var(--text-secondary);
|
| 146 |
+
text-transform: uppercase;
|
| 147 |
+
letter-spacing: 1px;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.status-controls {
|
| 151 |
+
display: flex;
|
| 152 |
+
align-items: center;
|
| 153 |
+
gap: 16px;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
/* Model Loading Card widget */
|
| 157 |
+
.model-status-card {
|
| 158 |
+
display: flex;
|
| 159 |
+
align-items: center;
|
| 160 |
+
gap: 12px;
|
| 161 |
+
background: rgba(255, 255, 255, 0.04);
|
| 162 |
+
padding: 8px 14px;
|
| 163 |
+
border-radius: 12px;
|
| 164 |
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.status-dot {
|
| 168 |
+
width: 8px;
|
| 169 |
+
height: 8px;
|
| 170 |
+
border-radius: 50%;
|
| 171 |
+
display: inline-block;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.status-dot.green {
|
| 175 |
+
background-color: var(--accent-green);
|
| 176 |
+
box-shadow: 0 0 8px var(--accent-green);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.status-dot.yellow {
|
| 180 |
+
background-color: #ffb703;
|
| 181 |
+
box-shadow: 0 0 8px #ffb703;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.status-dot.red {
|
| 185 |
+
background-color: var(--accent-pink);
|
| 186 |
+
box-shadow: 0 0 8px var(--accent-pink);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.status-lbl {
|
| 190 |
+
font-size: 12px;
|
| 191 |
+
color: var(--text-secondary);
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.status-lbl strong {
|
| 195 |
+
color: var(--text-primary);
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
/* Online/Offline connection badge */
|
| 199 |
+
.connection-badge {
|
| 200 |
+
display: flex;
|
| 201 |
+
align-items: center;
|
| 202 |
+
gap: 8px;
|
| 203 |
+
font-size: 12px;
|
| 204 |
+
font-weight: 500;
|
| 205 |
+
padding: 8px 14px;
|
| 206 |
+
border-radius: 12px;
|
| 207 |
+
background: rgba(255, 255, 255, 0.04);
|
| 208 |
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
| 209 |
+
color: var(--text-secondary);
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
.connection-badge.online {
|
| 213 |
+
color: var(--accent-cyan);
|
| 214 |
+
background: rgba(0, 242, 254, 0.06);
|
| 215 |
+
border-color: rgba(0, 242, 254, 0.2);
|
| 216 |
+
filter: drop-shadow(0 0 4px rgba(0, 242, 254, 0.1));
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
/* ==========================================================================
|
| 220 |
+
Dashboard Grid Layout
|
| 221 |
+
========================================================================== */
|
| 222 |
+
.dashboard-grid {
|
| 223 |
+
display: grid;
|
| 224 |
+
grid-template-columns: 360px 1fr;
|
| 225 |
+
gap: 24px;
|
| 226 |
+
flex: 1;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
/* Premium Glassmorphic Cards */
|
| 230 |
+
.glass-card {
|
| 231 |
+
background: var(--card-bg);
|
| 232 |
+
border: 1px solid var(--card-border);
|
| 233 |
+
border-radius: 20px;
|
| 234 |
+
padding: 24px;
|
| 235 |
+
backdrop-filter: blur(24px);
|
| 236 |
+
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
|
| 237 |
+
position: relative;
|
| 238 |
+
overflow: hidden;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.panel-header {
|
| 242 |
+
display: flex;
|
| 243 |
+
align-items: center;
|
| 244 |
+
gap: 12px;
|
| 245 |
+
margin-bottom: 8px;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
.panel-header i {
|
| 249 |
+
font-size: 20px;
|
| 250 |
+
background: linear-gradient(135deg, var(--accent-cyan) 0%, var(--accent-purple) 100%);
|
| 251 |
+
-webkit-background-clip: text;
|
| 252 |
+
-webkit-text-fill-color: transparent;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.panel-header h2 {
|
| 256 |
+
font-size: 18px;
|
| 257 |
+
font-weight: 600;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
.panel-desc {
|
| 261 |
+
font-size: 13px;
|
| 262 |
+
color: var(--text-secondary);
|
| 263 |
+
line-height: 1.5;
|
| 264 |
+
margin-bottom: 20px;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
/* ==========================================================================
|
| 268 |
+
Speaker Profiles / Voice Cloning Center
|
| 269 |
+
========================================================================== */
|
| 270 |
+
.speaker-profile-box {
|
| 271 |
+
background: rgba(255, 255, 255, 0.02);
|
| 272 |
+
border: 1px solid rgba(255, 255, 255, 0.04);
|
| 273 |
+
border-radius: 16px;
|
| 274 |
+
padding: 16px;
|
| 275 |
+
margin-bottom: 16px;
|
| 276 |
+
transition: var(--transition-mid);
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.speaker-profile-box:hover {
|
| 280 |
+
border-color: rgba(255, 255, 255, 0.08);
|
| 281 |
+
background: rgba(255, 255, 255, 0.03);
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
.speaker-badge-header {
|
| 285 |
+
display: flex;
|
| 286 |
+
justify-content: space-between;
|
| 287 |
+
align-items: center;
|
| 288 |
+
margin-bottom: 14px;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
.speaker-num {
|
| 292 |
+
font-size: 13px;
|
| 293 |
+
font-weight: 700;
|
| 294 |
+
color: var(--text-primary);
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.badge {
|
| 298 |
+
font-size: 10px;
|
| 299 |
+
font-weight: 600;
|
| 300 |
+
padding: 3px 8px;
|
| 301 |
+
border-radius: 20px;
|
| 302 |
+
text-transform: uppercase;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.cyan-glow {
|
| 306 |
+
background: rgba(0, 242, 254, 0.08);
|
| 307 |
+
border: 1px solid rgba(0, 242, 254, 0.2);
|
| 308 |
+
color: var(--accent-cyan);
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.purple-glow {
|
| 312 |
+
background: rgba(162, 89, 255, 0.08);
|
| 313 |
+
border: 1px solid rgba(162, 89, 255, 0.2);
|
| 314 |
+
color: var(--accent-purple);
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
.speaker-info {
|
| 318 |
+
display: flex;
|
| 319 |
+
flex-direction: column;
|
| 320 |
+
gap: 6px;
|
| 321 |
+
margin-bottom: 14px;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.speaker-info label {
|
| 325 |
+
font-size: 11px;
|
| 326 |
+
color: var(--text-secondary);
|
| 327 |
+
text-transform: uppercase;
|
| 328 |
+
letter-spacing: 0.5px;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
.form-input {
|
| 332 |
+
background: rgba(0, 0, 0, 0.2);
|
| 333 |
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
| 334 |
+
border-radius: 8px;
|
| 335 |
+
padding: 8px 12px;
|
| 336 |
+
font-size: 13px;
|
| 337 |
+
color: var(--text-primary);
|
| 338 |
+
transition: var(--transition-quick);
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
.form-input:focus {
|
| 342 |
+
border-color: var(--accent-cyan);
|
| 343 |
+
box-shadow: 0 0 8px var(--accent-cyan-glow);
|
| 344 |
+
outline: none;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
.cloning-options {
|
| 348 |
+
display: grid;
|
| 349 |
+
grid-template-columns: 1fr 1fr;
|
| 350 |
+
gap: 8px;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
.cloned-status {
|
| 354 |
+
display: flex;
|
| 355 |
+
flex-direction: column;
|
| 356 |
+
gap: 8px;
|
| 357 |
+
margin-top: 12px;
|
| 358 |
+
padding-top: 12px;
|
| 359 |
+
border-top: 1px dashed rgba(255, 255, 255, 0.06);
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
.lbl-status {
|
| 363 |
+
font-size: 11px;
|
| 364 |
+
color: var(--accent-green);
|
| 365 |
+
font-weight: 500;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
.mini-player {
|
| 369 |
+
width: 100%;
|
| 370 |
+
height: 28px;
|
| 371 |
+
border-radius: 4px;
|
| 372 |
+
outline: none;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
/* Preset Buttons */
|
| 376 |
+
.preset-box {
|
| 377 |
+
margin-top: 24px;
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
.preset-box h3 {
|
| 381 |
+
font-size: 13px;
|
| 382 |
+
font-weight: 600;
|
| 383 |
+
color: var(--text-secondary);
|
| 384 |
+
margin-bottom: 10px;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
.preset-buttons {
|
| 388 |
+
display: flex;
|
| 389 |
+
flex-direction: column;
|
| 390 |
+
gap: 8px;
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
/* Buttons System */
|
| 394 |
+
.btn {
|
| 395 |
+
display: inline-flex;
|
| 396 |
+
align-items: center;
|
| 397 |
+
justify-content: center;
|
| 398 |
+
gap: 8px;
|
| 399 |
+
border-radius: 12px;
|
| 400 |
+
font-size: 14px;
|
| 401 |
+
font-weight: 500;
|
| 402 |
+
cursor: pointer;
|
| 403 |
+
transition: var(--transition-quick);
|
| 404 |
+
border: 1px solid transparent;
|
| 405 |
+
user-select: none;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.btn-primary {
|
| 409 |
+
background: linear-gradient(135deg, var(--accent-cyan) 0%, var(--accent-purple) 100%);
|
| 410 |
+
color: #000;
|
| 411 |
+
font-weight: 700;
|
| 412 |
+
box-shadow: 0 4px 15px rgba(0, 242, 254, 0.25);
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
.btn-primary:hover:not(:disabled) {
|
| 416 |
+
transform: translateY(-2px);
|
| 417 |
+
box-shadow: 0 6px 20px rgba(0, 242, 254, 0.4);
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
.btn-primary:active:not(:disabled) {
|
| 421 |
+
transform: translateY(0);
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
.btn-secondary {
|
| 425 |
+
background: rgba(255, 255, 255, 0.05);
|
| 426 |
+
color: var(--text-primary);
|
| 427 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
.btn-secondary:hover:not(:disabled) {
|
| 431 |
+
background: rgba(255, 255, 255, 0.1);
|
| 432 |
+
border-color: rgba(255, 255, 255, 0.15);
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
.btn-outline {
|
| 436 |
+
background: transparent;
|
| 437 |
+
color: var(--text-primary);
|
| 438 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
.btn-outline:hover:not(:disabled) {
|
| 442 |
+
border-color: var(--accent-cyan);
|
| 443 |
+
color: var(--accent-cyan);
|
| 444 |
+
background: rgba(0, 242, 254, 0.02);
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
.btn-toggle {
|
| 448 |
+
background: linear-gradient(135deg, rgba(162, 89, 255, 0.1) 0%, rgba(255, 92, 141, 0.1) 100%);
|
| 449 |
+
border: 1px solid rgba(162, 89, 255, 0.25);
|
| 450 |
+
color: var(--text-primary);
|
| 451 |
+
padding: 6px 12px;
|
| 452 |
+
font-size: 11px;
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
.btn-toggle:hover {
|
| 456 |
+
border-color: var(--accent-pink);
|
| 457 |
+
box-shadow: 0 0 8px var(--accent-pink-glow);
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
.btn-toggle.active {
|
| 461 |
+
background: linear-gradient(135deg, var(--accent-purple) 0%, var(--accent-pink) 100%);
|
| 462 |
+
color: #fff;
|
| 463 |
+
border-color: transparent;
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
.btn-preset {
|
| 467 |
+
width: 100%;
|
| 468 |
+
padding: 10px;
|
| 469 |
+
background: rgba(255, 255, 255, 0.02);
|
| 470 |
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
| 471 |
+
justify-content: flex-start;
|
| 472 |
+
font-size: 12px;
|
| 473 |
+
color: var(--text-secondary);
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
.btn-preset:hover {
|
| 477 |
+
background: rgba(255, 255, 255, 0.05);
|
| 478 |
+
border-color: rgba(255, 255, 255, 0.1);
|
| 479 |
+
color: var(--text-primary);
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
.btn-sm {
|
| 483 |
+
padding: 8px 12px;
|
| 484 |
+
font-size: 12px;
|
| 485 |
+
border-radius: 8px;
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
.btn-xs {
|
| 489 |
+
padding: 6px 10px;
|
| 490 |
+
font-size: 11px;
|
| 491 |
+
border-radius: 6px;
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
.btn-lg {
|
| 495 |
+
padding: 14px 28px;
|
| 496 |
+
font-size: 15px;
|
| 497 |
+
border-radius: 14px;
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
.btn:disabled {
|
| 501 |
+
opacity: 0.4;
|
| 502 |
+
cursor: not-allowed;
|
| 503 |
+
box-shadow: none !important;
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
.btn-file {
|
| 507 |
+
position: relative;
|
| 508 |
+
overflow: hidden;
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
/* ==========================================================================
|
| 512 |
+
Execution Panel Right Column
|
| 513 |
+
========================================================================== */
|
| 514 |
+
.execution-panel {
|
| 515 |
+
display: flex;
|
| 516 |
+
flex-direction: column;
|
| 517 |
+
gap: 24px;
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
/* ==========================================================================
|
| 521 |
+
Visualizer Core & Holographic Orb
|
| 522 |
+
========================================================================== */
|
| 523 |
+
.visualizer-container {
|
| 524 |
+
display: flex;
|
| 525 |
+
flex-direction: column;
|
| 526 |
+
align-items: center;
|
| 527 |
+
justify-content: center;
|
| 528 |
+
padding: 30px;
|
| 529 |
+
min-height: 250px;
|
| 530 |
+
background: radial-gradient(circle at center, rgba(16, 24, 48, 0.3) 0%, var(--card-bg) 100%);
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
.agent-orb-wrapper {
|
| 534 |
+
display: flex;
|
| 535 |
+
align-items: center;
|
| 536 |
+
gap: 36px;
|
| 537 |
+
margin-bottom: 20px;
|
| 538 |
+
width: 100%;
|
| 539 |
+
max-width: 500px;
|
| 540 |
+
justify-content: center;
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
.agent-details {
|
| 544 |
+
display: flex;
|
| 545 |
+
flex-direction: column;
|
| 546 |
+
gap: 4px;
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
.agent-state-text {
|
| 550 |
+
font-family: 'Orbitron', monospace;
|
| 551 |
+
font-size: 18px;
|
| 552 |
+
font-weight: 700;
|
| 553 |
+
letter-spacing: 1px;
|
| 554 |
+
color: var(--text-primary);
|
| 555 |
+
text-shadow: 0 0 10px rgba(255, 255, 255, 0.1);
|
| 556 |
+
transition: var(--transition-mid);
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
.agent-state-sub {
|
| 560 |
+
font-size: 12px;
|
| 561 |
+
color: var(--text-secondary);
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
/* The Animated Holographic Orb */
|
| 565 |
+
.agent-orb {
|
| 566 |
+
width: 90px;
|
| 567 |
+
height: 90px;
|
| 568 |
+
border-radius: 50%;
|
| 569 |
+
position: relative;
|
| 570 |
+
display: flex;
|
| 571 |
+
align-items: center;
|
| 572 |
+
justify-content: center;
|
| 573 |
+
transition: var(--transition-mid);
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
.orb-inner {
|
| 577 |
+
width: 60px;
|
| 578 |
+
height: 60px;
|
| 579 |
+
border-radius: 50%;
|
| 580 |
+
position: relative;
|
| 581 |
+
z-index: 5;
|
| 582 |
+
transition: var(--transition-mid);
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
.orb-wave {
|
| 586 |
+
position: absolute;
|
| 587 |
+
top: 0;
|
| 588 |
+
left: 0;
|
| 589 |
+
right: 0;
|
| 590 |
+
bottom: 0;
|
| 591 |
+
border-radius: 50%;
|
| 592 |
+
opacity: 0;
|
| 593 |
+
pointer-events: none;
|
| 594 |
+
z-index: 2;
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
.orb-glow {
|
| 598 |
+
position: absolute;
|
| 599 |
+
top: -15px;
|
| 600 |
+
left: -15px;
|
| 601 |
+
right: -15px;
|
| 602 |
+
bottom: -15px;
|
| 603 |
+
border-radius: 50%;
|
| 604 |
+
filter: blur(25px);
|
| 605 |
+
z-index: 1;
|
| 606 |
+
opacity: 0.5;
|
| 607 |
+
transition: var(--transition-mid);
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
/* ORB STATE: Idle (Slow Breathing) */
|
| 611 |
+
.agent-orb.idle .orb-inner {
|
| 612 |
+
background: radial-gradient(circle, var(--accent-cyan) 0%, rgba(0, 150, 255, 0.3) 100%);
|
| 613 |
+
box-shadow: 0 0 20px var(--accent-cyan-glow);
|
| 614 |
+
animation: orbPulse 4s infinite ease-in-out;
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
.agent-orb.idle .orb-glow {
|
| 618 |
+
background: var(--accent-cyan);
|
| 619 |
+
opacity: 0.3;
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
/* ORB STATE: Thinking (Vibrant Spinning Glow) */
|
| 623 |
+
.agent-orb.thinking .orb-inner {
|
| 624 |
+
background: radial-gradient(circle, #fff 0%, var(--accent-purple) 70%, var(--accent-cyan) 100%);
|
| 625 |
+
box-shadow: 0 0 35px var(--accent-purple-glow);
|
| 626 |
+
animation: orbSpin 2s infinite linear, orbPulse 1s infinite ease-in-out;
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
.agent-orb.thinking .orb-glow {
|
| 630 |
+
background: var(--accent-purple);
|
| 631 |
+
opacity: 0.6;
|
| 632 |
+
animation: orbGlowScale 1.5s infinite alternate ease-in-out;
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
/* ORB STATE: Speaking (Dynamic Ring Waves) */
|
| 636 |
+
.agent-orb.speaking .orb-inner {
|
| 637 |
+
background: radial-gradient(circle, #fff 0%, var(--accent-pink) 60%, var(--accent-purple) 100%);
|
| 638 |
+
box-shadow: 0 0 30px var(--accent-pink-glow);
|
| 639 |
+
animation: orbPulse 0.4s infinite ease-in-out;
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
.agent-orb.speaking .orb-glow {
|
| 643 |
+
background: var(--accent-pink);
|
| 644 |
+
opacity: 0.7;
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
.agent-orb.speaking .orb-wave {
|
| 648 |
+
border: 2.5px solid var(--accent-pink);
|
| 649 |
+
box-shadow: 0 0 10px var(--accent-pink-glow);
|
| 650 |
+
animation: waveExpand 1.2s infinite cubic-bezier(0.1, 0.8, 0.3, 1);
|
| 651 |
+
opacity: 1;
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
.agent-orb.speaking .orb-wave.wave-2 {
|
| 655 |
+
animation-delay: 0.6s;
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
/* ORB STATE: Listening (Green organic pulses) */
|
| 659 |
+
.agent-orb.listening .orb-inner {
|
| 660 |
+
background: radial-gradient(circle, #fff 0%, var(--accent-green) 70%);
|
| 661 |
+
box-shadow: 0 0 25px rgba(0, 230, 115, 0.4);
|
| 662 |
+
animation: orbPulse 1.2s infinite ease-in-out;
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
.agent-orb.listening .orb-glow {
|
| 666 |
+
background: var(--accent-green);
|
| 667 |
+
opacity: 0.4;
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
/* Keyframes animations */
|
| 671 |
+
@keyframes orbPulse {
|
| 672 |
+
0% { transform: scale(1); }
|
| 673 |
+
50% { transform: scale(1.08); }
|
| 674 |
+
100% { transform: scale(1); }
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
@keyframes orbSpin {
|
| 678 |
+
0% { transform: rotate(0deg); }
|
| 679 |
+
100% { transform: rotate(360deg); }
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
@keyframes orbGlowScale {
|
| 683 |
+
0% { transform: scale(0.95); opacity: 0.4; }
|
| 684 |
+
100% { transform: scale(1.2); opacity: 0.75; }
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
@keyframes waveExpand {
|
| 688 |
+
0% { transform: scale(0.9); opacity: 0.9; }
|
| 689 |
+
100% { transform: scale(1.8); opacity: 0; }
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
/* Real-Time Waveform visualizer container */
|
| 693 |
+
.visualizer-canvas-box {
|
| 694 |
+
width: 100%;
|
| 695 |
+
display: flex;
|
| 696 |
+
justify-content: center;
|
| 697 |
+
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
| 698 |
+
padding-top: 16px;
|
| 699 |
+
margin-top: 10px;
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
#canvasWaveform {
|
| 703 |
+
width: 100%;
|
| 704 |
+
max-width: 600px;
|
| 705 |
+
height: 75px;
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
/* ==========================================================================
|
| 709 |
+
Script Editor Studio
|
| 710 |
+
========================================================================== */
|
| 711 |
+
.script-editor-card {
|
| 712 |
+
display: flex;
|
| 713 |
+
flex-direction: column;
|
| 714 |
+
gap: 16px;
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
.editor-header {
|
| 718 |
+
display: flex;
|
| 719 |
+
justify-content: space-between;
|
| 720 |
+
align-items: center;
|
| 721 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
| 722 |
+
padding-bottom: 14px;
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
.title-with-desc {
|
| 726 |
+
display: flex;
|
| 727 |
+
align-items: center;
|
| 728 |
+
gap: 10px;
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
.title-with-desc i {
|
| 732 |
+
font-size: 18px;
|
| 733 |
+
color: var(--accent-purple);
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
.title-with-desc h2 {
|
| 737 |
+
font-size: 16px;
|
| 738 |
+
font-weight: 600;
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
/* Real-time Streaming Switch */
|
| 742 |
+
.toggle-mode-container {
|
| 743 |
+
display: flex;
|
| 744 |
+
align-items: center;
|
| 745 |
+
gap: 10px;
|
| 746 |
+
cursor: pointer;
|
| 747 |
+
user-select: none;
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
+
.toggle-mode-container input {
|
| 751 |
+
display: none;
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
.toggle-slider {
|
| 755 |
+
width: 42px;
|
| 756 |
+
height: 22px;
|
| 757 |
+
background-color: rgba(255, 255, 255, 0.08);
|
| 758 |
+
border-radius: 20px;
|
| 759 |
+
position: relative;
|
| 760 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 761 |
+
transition: var(--transition-quick);
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
.toggle-slider::before {
|
| 765 |
+
content: '';
|
| 766 |
+
position: absolute;
|
| 767 |
+
width: 16px;
|
| 768 |
+
height: 16px;
|
| 769 |
+
background-color: var(--text-primary);
|
| 770 |
+
border-radius: 50%;
|
| 771 |
+
top: 2px;
|
| 772 |
+
left: 2px;
|
| 773 |
+
transition: var(--transition-quick);
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
.toggle-mode-container input:checked + .toggle-slider {
|
| 777 |
+
background-color: var(--accent-cyan-glow);
|
| 778 |
+
border-color: var(--accent-cyan);
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
.toggle-mode-container input:checked + .toggle-slider::before {
|
| 782 |
+
transform: translateX(20px);
|
| 783 |
+
background-color: var(--accent-cyan);
|
| 784 |
+
box-shadow: 0 0 6px var(--accent-cyan);
|
| 785 |
+
}
|
| 786 |
+
|
| 787 |
+
.toggle-label {
|
| 788 |
+
font-size: 12px;
|
| 789 |
+
font-weight: 500;
|
| 790 |
+
color: var(--text-secondary);
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
.toggle-mode-container input:checked ~ .toggle-label {
|
| 794 |
+
color: var(--accent-cyan);
|
| 795 |
+
}
|
| 796 |
+
|
| 797 |
+
/* Script Box Elements */
|
| 798 |
+
.script-area-wrapper {
|
| 799 |
+
background: rgba(0, 0, 0, 0.25);
|
| 800 |
+
border: 1px solid var(--card-border);
|
| 801 |
+
border-radius: 12px;
|
| 802 |
+
overflow: hidden;
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
+
.script-textarea {
|
| 806 |
+
width: 100%;
|
| 807 |
+
height: 180px;
|
| 808 |
+
background: transparent;
|
| 809 |
+
border: none;
|
| 810 |
+
resize: none;
|
| 811 |
+
padding: 16px;
|
| 812 |
+
color: var(--text-primary);
|
| 813 |
+
font-family: 'Inter', monospace;
|
| 814 |
+
font-size: 14px;
|
| 815 |
+
line-height: 1.6;
|
| 816 |
+
outline: none;
|
| 817 |
+
}
|
| 818 |
+
|
| 819 |
+
.script-textarea::placeholder {
|
| 820 |
+
color: rgba(255, 255, 255, 0.2);
|
| 821 |
+
}
|
| 822 |
+
|
| 823 |
+
.execution-actions {
|
| 824 |
+
display: flex;
|
| 825 |
+
gap: 16px;
|
| 826 |
+
}
|
| 827 |
+
|
| 828 |
+
.execution-actions .btn {
|
| 829 |
+
flex: 1;
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
/* ==========================================================================
|
| 833 |
+
Premium Audio Timeline Player Toolbar
|
| 834 |
+
========================================================================== */
|
| 835 |
+
.audio-control-bar {
|
| 836 |
+
padding: 16px 24px;
|
| 837 |
+
background: rgba(13, 17, 28, 0.7);
|
| 838 |
+
border-color: rgba(0, 242, 254, 0.15);
|
| 839 |
+
box-shadow: 0 8px 32px rgba(0, 242, 254, 0.05);
|
| 840 |
+
}
|
| 841 |
+
|
| 842 |
+
.playback-controls {
|
| 843 |
+
display: flex;
|
| 844 |
+
align-items: center;
|
| 845 |
+
gap: 20px;
|
| 846 |
+
width: 100%;
|
| 847 |
+
}
|
| 848 |
+
|
| 849 |
+
.playback-btn {
|
| 850 |
+
width: 36px;
|
| 851 |
+
height: 36px;
|
| 852 |
+
border-radius: 50%;
|
| 853 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 854 |
+
background: rgba(255, 255, 255, 0.05);
|
| 855 |
+
color: var(--text-primary);
|
| 856 |
+
cursor: pointer;
|
| 857 |
+
display: flex;
|
| 858 |
+
align-items: center;
|
| 859 |
+
justify-content: center;
|
| 860 |
+
transition: var(--transition-quick);
|
| 861 |
+
}
|
| 862 |
+
|
| 863 |
+
.playback-btn:hover {
|
| 864 |
+
background: var(--accent-cyan);
|
| 865 |
+
border-color: transparent;
|
| 866 |
+
color: #000;
|
| 867 |
+
box-shadow: 0 0 10px var(--accent-cyan-glow);
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
+
.timeline-container {
|
| 871 |
+
display: flex;
|
| 872 |
+
align-items: center;
|
| 873 |
+
gap: 12px;
|
| 874 |
+
flex: 1;
|
| 875 |
+
}
|
| 876 |
+
|
| 877 |
+
.time-stamp {
|
| 878 |
+
font-family: 'Orbitron', monospace;
|
| 879 |
+
font-size: 11px;
|
| 880 |
+
color: var(--text-secondary);
|
| 881 |
+
min-width: 40px;
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
.progress-bar-wrapper {
|
| 885 |
+
position: relative;
|
| 886 |
+
flex: 1;
|
| 887 |
+
height: 6px;
|
| 888 |
+
display: flex;
|
| 889 |
+
align-items: center;
|
| 890 |
+
}
|
| 891 |
+
|
| 892 |
+
/* Sliders Styling (Cross-Browser) */
|
| 893 |
+
.slider-theme {
|
| 894 |
+
-webkit-appearance: none;
|
| 895 |
+
width: 100%;
|
| 896 |
+
height: 4px;
|
| 897 |
+
background: rgba(255, 255, 255, 0.08);
|
| 898 |
+
border-radius: 10px;
|
| 899 |
+
outline: none;
|
| 900 |
+
cursor: pointer;
|
| 901 |
+
position: relative;
|
| 902 |
+
z-index: 3;
|
| 903 |
+
}
|
| 904 |
+
|
| 905 |
+
.slider-theme::-webkit-slider-thumb {
|
| 906 |
+
-webkit-appearance: none;
|
| 907 |
+
width: 12px;
|
| 908 |
+
height: 12px;
|
| 909 |
+
border-radius: 50%;
|
| 910 |
+
background-color: var(--accent-cyan);
|
| 911 |
+
box-shadow: 0 0 6px var(--accent-cyan);
|
| 912 |
+
transition: 0.15s;
|
| 913 |
+
}
|
| 914 |
+
|
| 915 |
+
.slider-theme::-webkit-slider-thumb:hover {
|
| 916 |
+
transform: scale(1.3);
|
| 917 |
+
}
|
| 918 |
+
|
| 919 |
+
.progress-fill {
|
| 920 |
+
position: absolute;
|
| 921 |
+
left: 0;
|
| 922 |
+
top: 50%;
|
| 923 |
+
transform: translateY(-50%);
|
| 924 |
+
height: 4px;
|
| 925 |
+
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-purple));
|
| 926 |
+
border-radius: 10px;
|
| 927 |
+
pointer-events: none;
|
| 928 |
+
z-index: 1;
|
| 929 |
+
}
|
| 930 |
+
|
| 931 |
+
.volume-container {
|
| 932 |
+
display: flex;
|
| 933 |
+
align-items: center;
|
| 934 |
+
gap: 10px;
|
| 935 |
+
width: 130px;
|
| 936 |
+
}
|
| 937 |
+
|
| 938 |
+
.volume-container i {
|
| 939 |
+
color: var(--text-secondary);
|
| 940 |
+
font-size: 13px;
|
| 941 |
+
width: 16px;
|
| 942 |
+
}
|
| 943 |
+
|
| 944 |
+
.slider-volume {
|
| 945 |
+
width: 90px;
|
| 946 |
+
}
|
| 947 |
+
|
| 948 |
+
.speed-selector {
|
| 949 |
+
background: rgba(255, 255, 255, 0.05);
|
| 950 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 951 |
+
border-radius: 8px;
|
| 952 |
+
padding: 6px 10px;
|
| 953 |
+
font-size: 12px;
|
| 954 |
+
color: var(--text-primary);
|
| 955 |
+
outline: none;
|
| 956 |
+
cursor: pointer;
|
| 957 |
+
}
|
| 958 |
+
|
| 959 |
+
.speed-selector option {
|
| 960 |
+
background-color: var(--bg-dark);
|
| 961 |
+
}
|
| 962 |
+
|
| 963 |
+
.hidden {
|
| 964 |
+
display: none !important;
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
+
/* ==========================================================================
|
| 968 |
+
Console Log Viewer
|
| 969 |
+
========================================================================== */
|
| 970 |
+
.console-card {
|
| 971 |
+
background: rgba(4, 5, 8, 0.7);
|
| 972 |
+
border: 1px solid rgba(255, 255, 255, 0.04);
|
| 973 |
+
}
|
| 974 |
+
|
| 975 |
+
.console-header {
|
| 976 |
+
display: flex;
|
| 977 |
+
justify-content: space-between;
|
| 978 |
+
align-items: center;
|
| 979 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
| 980 |
+
padding-bottom: 8px;
|
| 981 |
+
margin-bottom: 10px;
|
| 982 |
+
font-size: 12px;
|
| 983 |
+
font-weight: 600;
|
| 984 |
+
color: var(--text-secondary);
|
| 985 |
+
}
|
| 986 |
+
|
| 987 |
+
.console-header i {
|
| 988 |
+
color: var(--accent-cyan);
|
| 989 |
+
margin-right: 6px;
|
| 990 |
+
}
|
| 991 |
+
|
| 992 |
+
.console-body {
|
| 993 |
+
height: 110px;
|
| 994 |
+
overflow-y: auto;
|
| 995 |
+
font-family: 'Orbitron', monospace;
|
| 996 |
+
font-size: 11px;
|
| 997 |
+
line-height: 1.8;
|
| 998 |
+
display: flex;
|
| 999 |
+
flex-direction: column;
|
| 1000 |
+
gap: 4px;
|
| 1001 |
+
padding-right: 6px;
|
| 1002 |
+
}
|
| 1003 |
+
|
| 1004 |
+
/* Custom Scrollbar for console and script text areas */
|
| 1005 |
+
.console-body::-webkit-scrollbar, .script-textarea::-webkit-scrollbar {
|
| 1006 |
+
width: 5px;
|
| 1007 |
+
}
|
| 1008 |
+
|
| 1009 |
+
.console-body::-webkit-scrollbar-track, .script-textarea::-webkit-scrollbar-track {
|
| 1010 |
+
background: transparent;
|
| 1011 |
+
}
|
| 1012 |
+
|
| 1013 |
+
.console-body::-webkit-scrollbar-thumb, .script-textarea::-webkit-scrollbar-thumb {
|
| 1014 |
+
background: rgba(255, 255, 255, 0.08);
|
| 1015 |
+
border-radius: 10px;
|
| 1016 |
+
}
|
| 1017 |
+
|
| 1018 |
+
.log-line {
|
| 1019 |
+
padding: 2px 6px;
|
| 1020 |
+
border-radius: 4px;
|
| 1021 |
+
word-break: break-all;
|
| 1022 |
+
}
|
| 1023 |
+
|
| 1024 |
+
.log-line.system {
|
| 1025 |
+
color: var(--text-secondary);
|
| 1026 |
+
background: rgba(255, 255, 255, 0.02);
|
| 1027 |
+
}
|
| 1028 |
+
|
| 1029 |
+
.log-line.info {
|
| 1030 |
+
color: var(--accent-cyan);
|
| 1031 |
+
background: rgba(0, 242, 254, 0.02);
|
| 1032 |
+
}
|
| 1033 |
+
|
| 1034 |
+
.log-line.success {
|
| 1035 |
+
color: var(--accent-green);
|
| 1036 |
+
background: rgba(0, 230, 115, 0.02);
|
| 1037 |
+
}
|
| 1038 |
+
|
| 1039 |
+
.log-line.error {
|
| 1040 |
+
color: var(--accent-pink);
|
| 1041 |
+
background: rgba(255, 92, 141, 0.02);
|
| 1042 |
+
}
|
| 1043 |
+
|
| 1044 |
+
.log-line.word {
|
| 1045 |
+
color: #fff;
|
| 1046 |
+
font-weight: 500;
|
| 1047 |
+
text-shadow: 0 0 6px rgba(255, 255, 255, 0.2);
|
| 1048 |
+
}
|
| 1049 |
+
|
| 1050 |
+
/* ==========================================================================
|
| 1051 |
+
Footer
|
| 1052 |
+
========================================================================== */
|
| 1053 |
+
.app-footer {
|
| 1054 |
+
text-align: center;
|
| 1055 |
+
margin-top: 40px;
|
| 1056 |
+
padding-top: 20px;
|
| 1057 |
+
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
| 1058 |
+
font-size: 12px;
|
| 1059 |
+
color: var(--text-secondary);
|
| 1060 |
+
}
|
| 1061 |
+
|
| 1062 |
+
/* Responsive Scaling */
|
| 1063 |
+
@media (max-width: 1024px) {
|
| 1064 |
+
.dashboard-grid {
|
| 1065 |
+
grid-template-columns: 1fr;
|
| 1066 |
+
}
|
| 1067 |
+
}
|
static/index.html
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>VibeVoice Agent - Microsoft Research Voice Synthesis Studio</title>
|
| 7 |
+
<meta name="description" content="A state-of-the-art voice agent cloning and synthesis platform powered by Microsoft VibeVoice models with real-time streaming and custom speaker cloning.">
|
| 8 |
+
<!-- Google Fonts -->
|
| 9 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 10 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 11 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Orbitron:wght@500;700;900&display=swap" rel="stylesheet">
|
| 12 |
+
<!-- FontAwesome Icons -->
|
| 13 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
| 14 |
+
<!-- Main Style Sheet -->
|
| 15 |
+
<link rel="stylesheet" href="/index.css">
|
| 16 |
+
</head>
|
| 17 |
+
<body>
|
| 18 |
+
<div class="glow-bg"></div>
|
| 19 |
+
<div class="container">
|
| 20 |
+
|
| 21 |
+
<!-- Header Section -->
|
| 22 |
+
<header class="app-header">
|
| 23 |
+
<div class="logo-area">
|
| 24 |
+
<i class="fa-solid fa-wand-magic-sparkles logo-icon"></i>
|
| 25 |
+
<div class="logo-text">
|
| 26 |
+
<h1>VibeVoice<span>Studio</span></h1>
|
| 27 |
+
<p>Microsoft Frontier Speech Synthesis</p>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<div class="status-controls">
|
| 32 |
+
<!-- AI Model Loader Status -->
|
| 33 |
+
<div class="model-status-card" id="modelStatusCard">
|
| 34 |
+
<span class="status-dot green animate-pulse" id="modelDot"></span>
|
| 35 |
+
<span class="status-lbl">Model: <strong id="modelStatusVal">Demo Synthesizer</strong></span>
|
| 36 |
+
<button class="btn btn-toggle" id="btnToggleModel" title="Switch between fast local synthesizer & full VibeVoice weights">
|
| 37 |
+
<i class="fa-solid fa-power-off"></i> Toggle AI Model
|
| 38 |
+
</button>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<!-- Websocket Connection Status -->
|
| 42 |
+
<div class="connection-badge" id="connBadge">
|
| 43 |
+
<i class="fa-solid fa-link-slash icon" id="connIcon"></i>
|
| 44 |
+
<span id="connText">Offline</span>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
</header>
|
| 48 |
+
|
| 49 |
+
<!-- Main Dashboard Layout -->
|
| 50 |
+
<main class="dashboard-grid">
|
| 51 |
+
|
| 52 |
+
<!-- Left Column: Voice Profiles & Configurations -->
|
| 53 |
+
<section class="control-panel glass-card">
|
| 54 |
+
<div class="panel-header">
|
| 55 |
+
<i class="fa-solid fa-user-gear"></i>
|
| 56 |
+
<h2>Voice Cloning Center</h2>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<p class="panel-desc">Clone any voice in seconds. Record or upload a 10-30s audio sample to create a speaker profile prompt.</p>
|
| 60 |
+
|
| 61 |
+
<!-- Speaker Settings Slot 1 -->
|
| 62 |
+
<div class="speaker-profile-box" id="speakerSlot0">
|
| 63 |
+
<div class="speaker-badge-header">
|
| 64 |
+
<span class="speaker-num">Speaker 0</span>
|
| 65 |
+
<div class="badge cyan-glow">Primary Speaker</div>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
<div class="speaker-info">
|
| 69 |
+
<label for="spkName0">Speaker Name</label>
|
| 70 |
+
<input type="text" id="spkName0" value="Alex" class="form-input">
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<div class="cloning-options">
|
| 74 |
+
<button class="btn btn-sm btn-outline" id="btnRecord0">
|
| 75 |
+
<i class="fa-solid fa-microphone"></i> Record Mic
|
| 76 |
+
</button>
|
| 77 |
+
<label class="btn btn-sm btn-outline btn-file">
|
| 78 |
+
<i class="fa-solid fa-upload"></i> Upload WAV
|
| 79 |
+
<input type="file" id="fileVoice0" accept="audio/wav,audio/mp3" style="display: none;">
|
| 80 |
+
</label>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
<div class="cloned-status hidden" id="clonedStatus0">
|
| 84 |
+
<span class="lbl-status"><i class="fa-solid fa-check-double"></i> Voice Cloned:</span>
|
| 85 |
+
<audio id="audioPreview0" controls class="mini-player"></audio>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
<!-- Speaker Settings Slot 2 -->
|
| 90 |
+
<div class="speaker-profile-box" id="speakerSlot1">
|
| 91 |
+
<div class="speaker-badge-header">
|
| 92 |
+
<span class="speaker-num">Speaker 1</span>
|
| 93 |
+
<div class="badge purple-glow">Dialogue Partner</div>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<div class="speaker-info">
|
| 97 |
+
<label for="spkName1">Speaker Name</label>
|
| 98 |
+
<input type="text" id="spkName1" value="Sarah" class="form-input">
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<div class="cloning-options">
|
| 102 |
+
<button class="btn btn-sm btn-outline" id="btnRecord1">
|
| 103 |
+
<i class="fa-solid fa-microphone"></i> Record Mic
|
| 104 |
+
</button>
|
| 105 |
+
<label class="btn btn-sm btn-outline btn-file">
|
| 106 |
+
<i class="fa-solid fa-upload"></i> Upload WAV
|
| 107 |
+
<input type="file" id="fileVoice1" accept="audio/wav,audio/mp3" style="display: none;">
|
| 108 |
+
</label>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
<div class="cloned-status hidden" id="clonedStatus1">
|
| 112 |
+
<span class="lbl-status"><i class="fa-solid fa-check-double"></i> Voice Cloned:</span>
|
| 113 |
+
<audio id="audioPreview1" controls class="mini-player"></audio>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
<!-- Presets & Sample Scripts -->
|
| 118 |
+
<div class="preset-box">
|
| 119 |
+
<h3>Dialogue Presets</h3>
|
| 120 |
+
<div class="preset-buttons">
|
| 121 |
+
<button class="btn btn-xs btn-preset" id="presetPodcast">
|
| 122 |
+
<i class="fa-solid fa-podcast"></i> Podcast Host
|
| 123 |
+
</button>
|
| 124 |
+
<button class="btn btn-xs btn-preset" id="presetAssistant">
|
| 125 |
+
<i class="fa-solid fa-headset"></i> AI Voice Agent
|
| 126 |
+
</button>
|
| 127 |
+
<button class="btn btn-xs btn-preset" id="presetNews">
|
| 128 |
+
<i class="fa-solid fa-newspaper"></i> News Broadcast
|
| 129 |
+
</button>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
</section>
|
| 133 |
+
|
| 134 |
+
<!-- Right Column: Interactive Script Synthesizer & Visualizer -->
|
| 135 |
+
<section class="execution-panel">
|
| 136 |
+
|
| 137 |
+
<!-- Main Visualizer Core Box -->
|
| 138 |
+
<div class="visualizer-container glass-card">
|
| 139 |
+
<!-- Holographic Pulsing Agent Orb -->
|
| 140 |
+
<div class="agent-orb-wrapper">
|
| 141 |
+
<div class="agent-orb idle" id="agentOrb">
|
| 142 |
+
<div class="orb-inner"></div>
|
| 143 |
+
<div class="orb-wave"></div>
|
| 144 |
+
<div class="orb-wave wave-2"></div>
|
| 145 |
+
<div class="orb-glow"></div>
|
| 146 |
+
</div>
|
| 147 |
+
<div class="agent-details">
|
| 148 |
+
<span class="agent-state-text" id="agentStateText">AGENT READY</span>
|
| 149 |
+
<p class="agent-state-sub" id="agentStateSub">Configure scripts and trigger generation below</p>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
|
| 153 |
+
<!-- Canvas Audio Visualizer -->
|
| 154 |
+
<div class="visualizer-canvas-box">
|
| 155 |
+
<canvas id="canvasWaveform" width="600" height="90"></canvas>
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
<!-- Script Editor Studio -->
|
| 160 |
+
<div class="script-editor-card glass-card">
|
| 161 |
+
<div class="editor-header">
|
| 162 |
+
<div class="title-with-desc">
|
| 163 |
+
<i class="fa-solid fa-pen-nib"></i>
|
| 164 |
+
<h2>Podcast Script Studio</h2>
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
+
<!-- Synthesis Type Controls -->
|
| 168 |
+
<div class="synthesis-mode-selector">
|
| 169 |
+
<label class="toggle-mode-container" title="Streams audio in real-time as words are spoken">
|
| 170 |
+
<input type="checkbox" id="chkStreamMode" checked>
|
| 171 |
+
<span class="toggle-slider"></span>
|
| 172 |
+
<span class="toggle-label"><i class="fa-solid fa-bolt"></i> Real-time Streaming</span>
|
| 173 |
+
</label>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
<div class="script-area-wrapper">
|
| 178 |
+
<textarea id="txtScript" placeholder="Write speaker turn-taking script...
|
| 179 |
+
Format: Speaker ID: Text content
|
| 180 |
+
Example:
|
| 181 |
+
Speaker 0: Hello, how are you?
|
| 182 |
+
Speaker 1: I am doing fantastic! Let's clone some voices." class="script-textarea">Speaker 0: Welcome to Vibe Voice Studio. I am cloned from your custom voice sample.
|
| 183 |
+
Speaker 1: That is incredible! The transition between speakers sounds so smooth and natural.
|
| 184 |
+
Speaker 0: Yes, Microsoft's continuous acoustic tokenizers make multi-speaker podcasts sound beautiful.</textarea>
|
| 185 |
+
</div>
|
| 186 |
+
|
| 187 |
+
<!-- Execution Buttons -->
|
| 188 |
+
<div class="execution-actions">
|
| 189 |
+
<button class="btn btn-primary btn-lg" id="btnSynthesize">
|
| 190 |
+
<i class="fa-solid fa-circle-play icon-play"></i> Synthesize Speech
|
| 191 |
+
</button>
|
| 192 |
+
<button class="btn btn-secondary btn-lg" id="btnStop" disabled>
|
| 193 |
+
<i class="fa-solid fa-circle-stop"></i> Stop Agent
|
| 194 |
+
</button>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
<!-- Custom Audio Control Toolbar -->
|
| 199 |
+
<div class="audio-control-bar glass-card hidden" id="audioToolbar">
|
| 200 |
+
<div class="playback-controls">
|
| 201 |
+
<button class="playback-btn" id="btnToolbarPlay" title="Play / Pause"><i class="fa-solid fa-play"></i></button>
|
| 202 |
+
|
| 203 |
+
<div class="timeline-container">
|
| 204 |
+
<span class="time-stamp" id="lblTimeStart">00:00</span>
|
| 205 |
+
<div class="progress-bar-wrapper">
|
| 206 |
+
<input type="range" id="sliderTimeline" min="0" max="100" value="0" class="slider-theme">
|
| 207 |
+
<div class="progress-fill" id="progressTimelineFill"></div>
|
| 208 |
+
</div>
|
| 209 |
+
<span class="time-stamp" id="lblTimeEnd">00:00</span>
|
| 210 |
+
</div>
|
| 211 |
+
|
| 212 |
+
<!-- Volume Controls -->
|
| 213 |
+
<div class="volume-container">
|
| 214 |
+
<i class="fa-solid fa-volume-high" id="iconVolume"></i>
|
| 215 |
+
<input type="range" id="sliderVolume" min="0" max="100" value="80" class="slider-theme slider-volume">
|
| 216 |
+
</div>
|
| 217 |
+
|
| 218 |
+
<!-- Speed Controls -->
|
| 219 |
+
<select id="selectSpeed" class="speed-selector">
|
| 220 |
+
<option value="0.75">0.75x</option>
|
| 221 |
+
<option value="1.0" selected>1.0x</option>
|
| 222 |
+
<option value="1.25">1.25x</option>
|
| 223 |
+
<option value="1.5">1.5x</option>
|
| 224 |
+
</select>
|
| 225 |
+
</div>
|
| 226 |
+
</div>
|
| 227 |
+
|
| 228 |
+
<!-- Debug Console Log Viewer -->
|
| 229 |
+
<div class="console-card glass-card">
|
| 230 |
+
<div class="console-header">
|
| 231 |
+
<span><i class="fa-solid fa-terminal"></i> Agent Console Logs</span>
|
| 232 |
+
<button class="btn btn-xs btn-outline" id="btnClearLogs">Clear</button>
|
| 233 |
+
</div>
|
| 234 |
+
<div class="console-body" id="consoleBody">
|
| 235 |
+
<div class="log-line system">[System] Welcome to VibeVoice Agent Console. System ready.</div>
|
| 236 |
+
<div class="log-line info">[Info] Default mode: Simulated Synthesizer (runs instantly on CPU). Click 'Toggle AI Model' to load the full 0.5B neural net!</div>
|
| 237 |
+
</div>
|
| 238 |
+
</div>
|
| 239 |
+
|
| 240 |
+
</section>
|
| 241 |
+
</main>
|
| 242 |
+
|
| 243 |
+
<!-- Footer -->
|
| 244 |
+
<footer class="app-footer">
|
| 245 |
+
<p>VibeVoice Voice Synthesis Platform © 2026. Built with Microsoft Research VibeVoice Models & FastAPI.</p>
|
| 246 |
+
</footer>
|
| 247 |
+
</div>
|
| 248 |
+
|
| 249 |
+
<!-- Script assets -->
|
| 250 |
+
<script src="/app.js"></script>
|
| 251 |
+
</body>
|
| 252 |
+
</html>
|