Spaces:
Runtime error
Runtime error
Upload 20 files
Browse files- Dockerfile +34 -36
- __pycache__/i18n.cpython-313.pyc +0 -0
- __pycache__/websocket_handler.cpython-313.pyc +0 -0
- app.py +988 -0
- i18n.py +238 -0
- requirements.txt +17 -1
- run.py +15 -0
- static/css/style.css +1399 -0
- static/js/i18n.js +221 -0
- static/js/playground-enhanced-fixed.js +712 -0
- static/js/playground.js +861 -0
- static/js/websocket-tts.js +366 -0
- templates/base.html +363 -0
- templates/docs.html +734 -0
- templates/index.html +156 -0
- templates/playground.html +317 -0
- templates/websocket_demo.html +390 -0
- translations/en.json +224 -0
- translations/zh.json +224 -0
- websocket_handler.py +231 -0
Dockerfile
CHANGED
|
@@ -1,36 +1,34 @@
|
|
| 1 |
-
FROM python:3.
|
| 2 |
-
|
| 3 |
-
WORKDIR /app
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
# Use run.py for proper eventlet initialization
|
| 36 |
-
CMD ["python", "app.py"]
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install system dependencies
|
| 6 |
+
RUN apt-get update && apt-get install -y \
|
| 7 |
+
gcc \
|
| 8 |
+
g++ \
|
| 9 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
# Copy requirements and install Python dependencies
|
| 12 |
+
COPY requirements.txt .
|
| 13 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 14 |
+
|
| 15 |
+
# Create necessary directories
|
| 16 |
+
RUN mkdir -p static data
|
| 17 |
+
|
| 18 |
+
# Copy application files
|
| 19 |
+
COPY app.py .
|
| 20 |
+
COPY translations/ translations/
|
| 21 |
+
COPY i18n.py .
|
| 22 |
+
COPY websocket_handler.py .
|
| 23 |
+
COPY static/ static/
|
| 24 |
+
COPY data/ data/
|
| 25 |
+
|
| 26 |
+
# Create a non-root user
|
| 27 |
+
RUN useradd -m -u 1000 user && chown -R user:user /app
|
| 28 |
+
USER user
|
| 29 |
+
|
| 30 |
+
# Expose port for Hugging Face Spaces
|
| 31 |
+
EXPOSE 7860
|
| 32 |
+
|
| 33 |
+
# Run the application
|
| 34 |
+
CMD ["python", "app.py"]
|
|
|
|
|
|
__pycache__/i18n.cpython-313.pyc
ADDED
|
Binary file (9.46 kB). View file
|
|
|
__pycache__/websocket_handler.cpython-313.pyc
ADDED
|
Binary file (10.3 kB). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,988 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
TTSFM Web Application
|
| 3 |
+
|
| 4 |
+
A Flask web application that provides a user-friendly interface
|
| 5 |
+
for the TTSFM text-to-speech package.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
import tempfile
|
| 12 |
+
import io
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Dict, Any, Optional, List
|
| 16 |
+
from functools import wraps
|
| 17 |
+
from urllib.parse import urlparse, urljoin
|
| 18 |
+
|
| 19 |
+
from flask import Flask, request, jsonify, send_file, Response, render_template, redirect, url_for
|
| 20 |
+
from flask_cors import CORS
|
| 21 |
+
from flask_socketio import SocketIO
|
| 22 |
+
from dotenv import load_dotenv
|
| 23 |
+
|
| 24 |
+
# Import i18n support
|
| 25 |
+
from i18n import init_i18n, get_locale, set_locale, _
|
| 26 |
+
|
| 27 |
+
# Import the TTSFM package
|
| 28 |
+
try:
|
| 29 |
+
from ttsfm import TTSClient, Voice, AudioFormat, TTSException
|
| 30 |
+
from ttsfm.exceptions import APIException, NetworkException, ValidationException
|
| 31 |
+
from ttsfm.utils import validate_text_length, split_text_by_length
|
| 32 |
+
except ImportError:
|
| 33 |
+
# Fallback for development when package is not installed
|
| 34 |
+
import sys
|
| 35 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
| 36 |
+
from ttsfm import TTSClient, Voice, AudioFormat, TTSException
|
| 37 |
+
from ttsfm.exceptions import APIException, NetworkException, ValidationException
|
| 38 |
+
from ttsfm.utils import validate_text_length, split_text_by_length
|
| 39 |
+
|
| 40 |
+
# Load environment variables
|
| 41 |
+
load_dotenv()
|
| 42 |
+
|
| 43 |
+
# Configure logging
|
| 44 |
+
logging.basicConfig(
|
| 45 |
+
level=logging.INFO,
|
| 46 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 47 |
+
)
|
| 48 |
+
logger = logging.getLogger(__name__)
|
| 49 |
+
|
| 50 |
+
# Create Flask app
|
| 51 |
+
app = Flask(__name__, static_folder='static', static_url_path='/static')
|
| 52 |
+
app.secret_key = os.getenv("SECRET_KEY", "ttsfm-secret-key-change-in-production")
|
| 53 |
+
CORS(app)
|
| 54 |
+
|
| 55 |
+
# Configuration (moved up for socketio initialization)
|
| 56 |
+
HOST = os.getenv("HOST", "localhost")
|
| 57 |
+
PORT = int(os.getenv("PORT", "7860"))
|
| 58 |
+
DEBUG = os.getenv("DEBUG", "true").lower() == "true"
|
| 59 |
+
|
| 60 |
+
# Initialize SocketIO with proper async mode
|
| 61 |
+
# Using eventlet for production, threading for development
|
| 62 |
+
async_mode = 'eventlet' if not DEBUG else 'threading'
|
| 63 |
+
socketio = SocketIO(app, cors_allowed_origins="*", async_mode=async_mode)
|
| 64 |
+
|
| 65 |
+
# Initialize i18n support
|
| 66 |
+
init_i18n(app)
|
| 67 |
+
|
| 68 |
+
# API Key configuration
|
| 69 |
+
API_KEY = os.getenv("TTSFM_API_KEY") # Set this environment variable for API protection
|
| 70 |
+
REQUIRE_API_KEY = os.getenv("REQUIRE_API_KEY", "false").lower() == "true"
|
| 71 |
+
|
| 72 |
+
# Create TTS client - now uses openai.fm directly, no configuration needed
|
| 73 |
+
tts_client = TTSClient()
|
| 74 |
+
|
| 75 |
+
# Initialize WebSocket handler
|
| 76 |
+
from websocket_handler import WebSocketTTSHandler
|
| 77 |
+
websocket_handler = WebSocketTTSHandler(socketio, tts_client)
|
| 78 |
+
|
| 79 |
+
logger.info("Initialized web app with TTSFM using openai.fm free service")
|
| 80 |
+
logger.info(f"WebSocket support enabled with {async_mode} async mode")
|
| 81 |
+
|
| 82 |
+
# API Key validation decorator
|
| 83 |
+
def require_api_key(f):
|
| 84 |
+
"""Decorator to require API key for protected endpoints."""
|
| 85 |
+
@wraps(f)
|
| 86 |
+
def decorated_function(*args, **kwargs):
|
| 87 |
+
# Skip API key check if not required
|
| 88 |
+
if not REQUIRE_API_KEY:
|
| 89 |
+
return f(*args, **kwargs)
|
| 90 |
+
|
| 91 |
+
# Check if API key is configured
|
| 92 |
+
if not API_KEY:
|
| 93 |
+
logger.warning("API key protection is enabled but TTSFM_API_KEY is not set")
|
| 94 |
+
return jsonify({
|
| 95 |
+
"error": "API key protection is enabled but not configured properly"
|
| 96 |
+
}), 500
|
| 97 |
+
|
| 98 |
+
# Get API key from request headers - prioritize Authorization header (OpenAI compatible)
|
| 99 |
+
provided_key = None
|
| 100 |
+
|
| 101 |
+
# 1. Check Authorization header first (OpenAI standard)
|
| 102 |
+
auth_header = request.headers.get('Authorization')
|
| 103 |
+
if auth_header and auth_header.startswith('Bearer '):
|
| 104 |
+
provided_key = auth_header[7:] # Remove 'Bearer ' prefix
|
| 105 |
+
|
| 106 |
+
# 2. Check X-API-Key header as fallback
|
| 107 |
+
if not provided_key:
|
| 108 |
+
provided_key = request.headers.get('X-API-Key')
|
| 109 |
+
|
| 110 |
+
# 3. Check API key from query parameters as fallback
|
| 111 |
+
if not provided_key:
|
| 112 |
+
provided_key = request.args.get('api_key')
|
| 113 |
+
|
| 114 |
+
# 4. Check API key from JSON body as fallback
|
| 115 |
+
if not provided_key and request.is_json:
|
| 116 |
+
data = request.get_json(silent=True)
|
| 117 |
+
if data:
|
| 118 |
+
provided_key = data.get('api_key')
|
| 119 |
+
|
| 120 |
+
# Validate API key
|
| 121 |
+
if not provided_key or provided_key != API_KEY:
|
| 122 |
+
logger.warning(f"Invalid API key attempt from {request.remote_addr}")
|
| 123 |
+
return jsonify({
|
| 124 |
+
"error": {
|
| 125 |
+
"message": "Invalid API key provided",
|
| 126 |
+
"type": "invalid_request_error",
|
| 127 |
+
"code": "invalid_api_key"
|
| 128 |
+
}
|
| 129 |
+
}), 401
|
| 130 |
+
|
| 131 |
+
return f(*args, **kwargs)
|
| 132 |
+
return decorated_function
|
| 133 |
+
|
| 134 |
+
def combine_audio_chunks(audio_chunks: List[bytes], format_type: str = "mp3") -> bytes:
|
| 135 |
+
"""
|
| 136 |
+
Combine multiple audio chunks into a single audio file.
|
| 137 |
+
|
| 138 |
+
Args:
|
| 139 |
+
audio_chunks: List of audio data as bytes
|
| 140 |
+
format_type: Audio format (mp3, wav, etc.)
|
| 141 |
+
|
| 142 |
+
Returns:
|
| 143 |
+
bytes: Combined audio data
|
| 144 |
+
"""
|
| 145 |
+
try:
|
| 146 |
+
# Try to use pydub for audio processing if available
|
| 147 |
+
try:
|
| 148 |
+
from pydub import AudioSegment
|
| 149 |
+
|
| 150 |
+
# Convert each chunk to AudioSegment
|
| 151 |
+
audio_segments = []
|
| 152 |
+
for chunk in audio_chunks:
|
| 153 |
+
if format_type.lower() == "mp3":
|
| 154 |
+
segment = AudioSegment.from_mp3(io.BytesIO(chunk))
|
| 155 |
+
elif format_type.lower() == "wav":
|
| 156 |
+
segment = AudioSegment.from_wav(io.BytesIO(chunk))
|
| 157 |
+
elif format_type.lower() == "opus":
|
| 158 |
+
# For OPUS, we'll treat it as WAV since openai.fm returns WAV for OPUS requests
|
| 159 |
+
segment = AudioSegment.from_wav(io.BytesIO(chunk))
|
| 160 |
+
else:
|
| 161 |
+
# For other formats, try to auto-detect or default to WAV
|
| 162 |
+
try:
|
| 163 |
+
segment = AudioSegment.from_file(io.BytesIO(chunk))
|
| 164 |
+
except:
|
| 165 |
+
segment = AudioSegment.from_wav(io.BytesIO(chunk))
|
| 166 |
+
|
| 167 |
+
audio_segments.append(segment)
|
| 168 |
+
|
| 169 |
+
# Combine all segments
|
| 170 |
+
combined = audio_segments[0]
|
| 171 |
+
for segment in audio_segments[1:]:
|
| 172 |
+
combined += segment
|
| 173 |
+
|
| 174 |
+
# Export to bytes
|
| 175 |
+
output_buffer = io.BytesIO()
|
| 176 |
+
if format_type.lower() == "mp3":
|
| 177 |
+
combined.export(output_buffer, format="mp3")
|
| 178 |
+
elif format_type.lower() == "wav":
|
| 179 |
+
combined.export(output_buffer, format="wav")
|
| 180 |
+
else:
|
| 181 |
+
# Default to the original format or WAV
|
| 182 |
+
try:
|
| 183 |
+
combined.export(output_buffer, format=format_type.lower())
|
| 184 |
+
except:
|
| 185 |
+
combined.export(output_buffer, format="wav")
|
| 186 |
+
|
| 187 |
+
return output_buffer.getvalue()
|
| 188 |
+
|
| 189 |
+
except ImportError:
|
| 190 |
+
# Fallback: Simple concatenation for WAV files
|
| 191 |
+
logger.warning("pydub not available, using simple concatenation for WAV files")
|
| 192 |
+
|
| 193 |
+
if format_type.lower() == "wav":
|
| 194 |
+
return _simple_wav_concatenation(audio_chunks)
|
| 195 |
+
else:
|
| 196 |
+
# For non-WAV formats without pydub, just concatenate raw bytes
|
| 197 |
+
# This won't produce valid audio but is better than failing
|
| 198 |
+
logger.warning(f"Cannot properly combine {format_type} files without pydub, using raw concatenation")
|
| 199 |
+
return b''.join(audio_chunks)
|
| 200 |
+
|
| 201 |
+
except Exception as e:
|
| 202 |
+
logger.error(f"Error combining audio chunks: {e}")
|
| 203 |
+
# Fallback to simple concatenation
|
| 204 |
+
return b''.join(audio_chunks)
|
| 205 |
+
|
| 206 |
+
def _simple_wav_concatenation(wav_chunks: List[bytes]) -> bytes:
|
| 207 |
+
"""
|
| 208 |
+
Simple WAV file concatenation without external dependencies.
|
| 209 |
+
This is a basic implementation that works for simple WAV files.
|
| 210 |
+
"""
|
| 211 |
+
if not wav_chunks:
|
| 212 |
+
return b''
|
| 213 |
+
|
| 214 |
+
if len(wav_chunks) == 1:
|
| 215 |
+
return wav_chunks[0]
|
| 216 |
+
|
| 217 |
+
try:
|
| 218 |
+
# For WAV files, we can do a simple concatenation by:
|
| 219 |
+
# 1. Taking the header from the first file
|
| 220 |
+
# 2. Concatenating all the audio data
|
| 221 |
+
# 3. Updating the file size in the header
|
| 222 |
+
|
| 223 |
+
first_wav = wav_chunks[0]
|
| 224 |
+
if len(first_wav) < 44: # WAV header is at least 44 bytes
|
| 225 |
+
return b''.join(wav_chunks)
|
| 226 |
+
|
| 227 |
+
# Extract header from first file (first 44 bytes)
|
| 228 |
+
header = bytearray(first_wav[:44])
|
| 229 |
+
|
| 230 |
+
# Collect all audio data (skip headers for subsequent files)
|
| 231 |
+
audio_data = first_wav[44:] # Audio data from first file
|
| 232 |
+
|
| 233 |
+
for wav_chunk in wav_chunks[1:]:
|
| 234 |
+
if len(wav_chunk) > 44:
|
| 235 |
+
audio_data += wav_chunk[44:] # Skip header, append audio data
|
| 236 |
+
|
| 237 |
+
# Update file size in header (bytes 4-7)
|
| 238 |
+
total_size = len(header) + len(audio_data) - 8
|
| 239 |
+
header[4:8] = total_size.to_bytes(4, byteorder='little')
|
| 240 |
+
|
| 241 |
+
# Update data chunk size in header (bytes 40-43)
|
| 242 |
+
data_size = len(audio_data)
|
| 243 |
+
header[40:44] = data_size.to_bytes(4, byteorder='little')
|
| 244 |
+
|
| 245 |
+
return bytes(header) + audio_data
|
| 246 |
+
|
| 247 |
+
except Exception as e:
|
| 248 |
+
logger.error(f"Error in simple WAV concatenation: {e}")
|
| 249 |
+
# Ultimate fallback
|
| 250 |
+
return b''.join(wav_chunks)
|
| 251 |
+
|
| 252 |
+
def _is_safe_url(target: Optional[str]) -> bool:
|
| 253 |
+
"""Validate that a target URL is safe for redirection.
|
| 254 |
+
|
| 255 |
+
Allows only relative URLs or absolute URLs that match this server's host
|
| 256 |
+
and http/https schemes. Prevents open redirects to external domains.
|
| 257 |
+
"""
|
| 258 |
+
if not target:
|
| 259 |
+
return False
|
| 260 |
+
|
| 261 |
+
parsed = urlparse(target)
|
| 262 |
+
if parsed.scheme or parsed.netloc or target.startswith('//'):
|
| 263 |
+
return False
|
| 264 |
+
if not parsed.path.startswith('/'):
|
| 265 |
+
return False
|
| 266 |
+
joined = urljoin(request.host_url, target)
|
| 267 |
+
host = urlparse(request.host_url)
|
| 268 |
+
j = urlparse(joined)
|
| 269 |
+
return j.scheme in ("http", "https") and j.netloc == host.netloc
|
| 270 |
+
|
| 271 |
+
@app.route('/set-language/<lang_code>')
|
| 272 |
+
def set_language(lang_code):
|
| 273 |
+
"""Set the user's language preference."""
|
| 274 |
+
if set_locale(lang_code):
|
| 275 |
+
# Redirect back only if the referrer is safe; otherwise go home
|
| 276 |
+
target = request.referrer
|
| 277 |
+
if _is_safe_url(target):
|
| 278 |
+
return redirect(target)
|
| 279 |
+
return redirect(url_for('index'))
|
| 280 |
+
else:
|
| 281 |
+
# Invalid language code, redirect to home
|
| 282 |
+
return redirect(url_for('index'))
|
| 283 |
+
|
| 284 |
+
@app.route('/')
|
| 285 |
+
def index():
|
| 286 |
+
"""Serve the main web interface."""
|
| 287 |
+
return render_template('index.html')
|
| 288 |
+
|
| 289 |
+
@app.route('/playground')
|
| 290 |
+
def playground():
|
| 291 |
+
"""Serve the interactive playground."""
|
| 292 |
+
return render_template('playground.html')
|
| 293 |
+
|
| 294 |
+
@app.route('/docs')
|
| 295 |
+
def docs():
|
| 296 |
+
"""Serve the API documentation."""
|
| 297 |
+
return render_template('docs.html')
|
| 298 |
+
|
| 299 |
+
@app.route('/websocket-demo')
|
| 300 |
+
def websocket_demo():
|
| 301 |
+
"""Serve the WebSocket streaming demo page."""
|
| 302 |
+
return render_template('websocket_demo.html')
|
| 303 |
+
|
| 304 |
+
@app.route('/api/voices', methods=['GET'])
|
| 305 |
+
def get_voices():
|
| 306 |
+
"""Get list of available voices."""
|
| 307 |
+
try:
|
| 308 |
+
voices = [
|
| 309 |
+
{
|
| 310 |
+
"id": voice.value,
|
| 311 |
+
"name": voice.value.title(),
|
| 312 |
+
"description": f"{voice.value.title()} voice"
|
| 313 |
+
}
|
| 314 |
+
for voice in Voice
|
| 315 |
+
]
|
| 316 |
+
|
| 317 |
+
return jsonify({
|
| 318 |
+
"voices": voices,
|
| 319 |
+
"count": len(voices)
|
| 320 |
+
})
|
| 321 |
+
|
| 322 |
+
except Exception as e:
|
| 323 |
+
logger.error(f"Error getting voices: {e}")
|
| 324 |
+
return jsonify({"error": "Failed to get voices"}), 500
|
| 325 |
+
|
| 326 |
+
@app.route('/api/formats', methods=['GET'])
|
| 327 |
+
def get_formats():
|
| 328 |
+
"""Get list of supported audio formats."""
|
| 329 |
+
try:
|
| 330 |
+
formats = [
|
| 331 |
+
{
|
| 332 |
+
"id": "mp3",
|
| 333 |
+
"name": "MP3",
|
| 334 |
+
"mime_type": "audio/mpeg",
|
| 335 |
+
"description": "MP3 audio format - good quality, small file size",
|
| 336 |
+
"quality": "Good",
|
| 337 |
+
"file_size": "Small",
|
| 338 |
+
"use_case": "Web, mobile apps, general use"
|
| 339 |
+
},
|
| 340 |
+
{
|
| 341 |
+
"id": "opus",
|
| 342 |
+
"name": "OPUS",
|
| 343 |
+
"mime_type": "audio/opus",
|
| 344 |
+
"description": "OPUS audio format - excellent quality, small file size",
|
| 345 |
+
"quality": "Excellent",
|
| 346 |
+
"file_size": "Small",
|
| 347 |
+
"use_case": "Web streaming, VoIP"
|
| 348 |
+
},
|
| 349 |
+
{
|
| 350 |
+
"id": "aac",
|
| 351 |
+
"name": "AAC",
|
| 352 |
+
"mime_type": "audio/aac",
|
| 353 |
+
"description": "AAC audio format - good quality, medium file size",
|
| 354 |
+
"quality": "Good",
|
| 355 |
+
"file_size": "Medium",
|
| 356 |
+
"use_case": "Apple devices, streaming"
|
| 357 |
+
},
|
| 358 |
+
{
|
| 359 |
+
"id": "flac",
|
| 360 |
+
"name": "FLAC",
|
| 361 |
+
"mime_type": "audio/flac",
|
| 362 |
+
"description": "FLAC audio format - lossless quality, large file size",
|
| 363 |
+
"quality": "Lossless",
|
| 364 |
+
"file_size": "Large",
|
| 365 |
+
"use_case": "High-quality archival"
|
| 366 |
+
},
|
| 367 |
+
{
|
| 368 |
+
"id": "wav",
|
| 369 |
+
"name": "WAV",
|
| 370 |
+
"mime_type": "audio/wav",
|
| 371 |
+
"description": "WAV audio format - lossless quality, large file size",
|
| 372 |
+
"quality": "Lossless",
|
| 373 |
+
"file_size": "Large",
|
| 374 |
+
"use_case": "Professional audio"
|
| 375 |
+
},
|
| 376 |
+
{
|
| 377 |
+
"id": "pcm",
|
| 378 |
+
"name": "PCM",
|
| 379 |
+
"mime_type": "audio/pcm",
|
| 380 |
+
"description": "PCM audio format - raw audio data, large file size",
|
| 381 |
+
"quality": "Raw",
|
| 382 |
+
"file_size": "Large",
|
| 383 |
+
"use_case": "Audio processing"
|
| 384 |
+
}
|
| 385 |
+
]
|
| 386 |
+
|
| 387 |
+
return jsonify({
|
| 388 |
+
"formats": formats,
|
| 389 |
+
"count": len(formats)
|
| 390 |
+
})
|
| 391 |
+
|
| 392 |
+
except Exception as e:
|
| 393 |
+
logger.error(f"Error getting formats: {e}")
|
| 394 |
+
return jsonify({"error": "Failed to get formats"}), 500
|
| 395 |
+
|
| 396 |
+
@app.route('/api/validate-text', methods=['POST'])
|
| 397 |
+
def validate_text():
|
| 398 |
+
"""Validate text length and provide splitting suggestions."""
|
| 399 |
+
try:
|
| 400 |
+
data = request.get_json()
|
| 401 |
+
if not data:
|
| 402 |
+
return jsonify({"error": "No JSON data provided"}), 400
|
| 403 |
+
|
| 404 |
+
text = data.get('text', '').strip()
|
| 405 |
+
max_length = data.get('max_length', 4096)
|
| 406 |
+
|
| 407 |
+
if not text:
|
| 408 |
+
return jsonify({"error": "Text is required"}), 400
|
| 409 |
+
|
| 410 |
+
text_length = len(text)
|
| 411 |
+
is_valid = text_length <= max_length
|
| 412 |
+
|
| 413 |
+
result = {
|
| 414 |
+
"text_length": text_length,
|
| 415 |
+
"max_length": max_length,
|
| 416 |
+
"is_valid": is_valid,
|
| 417 |
+
"needs_splitting": not is_valid
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
if not is_valid:
|
| 421 |
+
# Provide splitting suggestions
|
| 422 |
+
chunks = split_text_by_length(text, max_length, preserve_words=True)
|
| 423 |
+
result.update({
|
| 424 |
+
"suggested_chunks": len(chunks),
|
| 425 |
+
"chunk_preview": [chunk[:100] + "..." if len(chunk) > 100 else chunk for chunk in chunks[:3]]
|
| 426 |
+
})
|
| 427 |
+
|
| 428 |
+
return jsonify(result)
|
| 429 |
+
|
| 430 |
+
except Exception as e:
|
| 431 |
+
logger.error(f"Text validation error: {e}")
|
| 432 |
+
return jsonify({"error": "Text validation failed"}), 500
|
| 433 |
+
|
| 434 |
+
@app.route('/api/generate', methods=['POST'])
|
| 435 |
+
@require_api_key
|
| 436 |
+
def generate_speech():
|
| 437 |
+
"""Generate speech from text using the TTSFM package."""
|
| 438 |
+
try:
|
| 439 |
+
# Parse request data
|
| 440 |
+
data = request.get_json()
|
| 441 |
+
if not data:
|
| 442 |
+
return jsonify({"error": "No JSON data provided"}), 400
|
| 443 |
+
|
| 444 |
+
# Extract parameters
|
| 445 |
+
text = data.get('text', '').strip()
|
| 446 |
+
voice = data.get('voice', Voice.ALLOY.value)
|
| 447 |
+
response_format = data.get('format', AudioFormat.MP3.value)
|
| 448 |
+
instructions = data.get('instructions', '').strip() or None
|
| 449 |
+
max_length = data.get('max_length', 4096)
|
| 450 |
+
validate_length = data.get('validate_length', True)
|
| 451 |
+
|
| 452 |
+
# Validate required fields
|
| 453 |
+
if not text:
|
| 454 |
+
return jsonify({"error": "Text is required"}), 400
|
| 455 |
+
|
| 456 |
+
# Validate voice
|
| 457 |
+
try:
|
| 458 |
+
voice_enum = Voice(voice.lower())
|
| 459 |
+
except ValueError:
|
| 460 |
+
return jsonify({
|
| 461 |
+
"error": f"Invalid voice: {voice}. Must be one of: {[v.value for v in Voice]}"
|
| 462 |
+
}), 400
|
| 463 |
+
|
| 464 |
+
# Validate format
|
| 465 |
+
try:
|
| 466 |
+
format_enum = AudioFormat(response_format.lower())
|
| 467 |
+
except ValueError:
|
| 468 |
+
return jsonify({
|
| 469 |
+
"error": f"Invalid format: {response_format}. Must be one of: {[f.value for f in AudioFormat]}"
|
| 470 |
+
}), 400
|
| 471 |
+
|
| 472 |
+
logger.info(f"Generating speech: text='{text[:50]}...', voice={voice}, format={response_format}")
|
| 473 |
+
|
| 474 |
+
# Generate speech using the TTSFM package with validation
|
| 475 |
+
response = tts_client.generate_speech(
|
| 476 |
+
text=text,
|
| 477 |
+
voice=voice_enum,
|
| 478 |
+
response_format=format_enum,
|
| 479 |
+
instructions=instructions,
|
| 480 |
+
max_length=max_length,
|
| 481 |
+
validate_length=validate_length
|
| 482 |
+
)
|
| 483 |
+
|
| 484 |
+
# Return audio data
|
| 485 |
+
return Response(
|
| 486 |
+
response.audio_data,
|
| 487 |
+
mimetype=response.content_type,
|
| 488 |
+
headers={
|
| 489 |
+
'Content-Disposition': f'attachment; filename="speech.{response.format.value}"',
|
| 490 |
+
'Content-Length': str(response.size),
|
| 491 |
+
'X-Audio-Format': response.format.value,
|
| 492 |
+
'X-Audio-Size': str(response.size)
|
| 493 |
+
}
|
| 494 |
+
)
|
| 495 |
+
|
| 496 |
+
except ValidationException as e:
|
| 497 |
+
logger.warning(f"Validation error: {e}")
|
| 498 |
+
return jsonify({"error": "Invalid input parameters"}), 400
|
| 499 |
+
|
| 500 |
+
except APIException as e:
|
| 501 |
+
logger.error(f"API error: {e}")
|
| 502 |
+
return jsonify({
|
| 503 |
+
"error": "TTS service error",
|
| 504 |
+
"status_code": getattr(e, 'status_code', 500)
|
| 505 |
+
}), getattr(e, 'status_code', 500)
|
| 506 |
+
|
| 507 |
+
except NetworkException as e:
|
| 508 |
+
logger.error(f"Network error: {e}")
|
| 509 |
+
return jsonify({
|
| 510 |
+
"error": "TTS service is currently unavailable"
|
| 511 |
+
}), 503
|
| 512 |
+
|
| 513 |
+
except TTSException as e:
|
| 514 |
+
logger.error(f"TTS error: {e}")
|
| 515 |
+
return jsonify({"error": "Text-to-speech generation failed"}), 500
|
| 516 |
+
|
| 517 |
+
except Exception as e:
|
| 518 |
+
logger.error(f"Unexpected error: {e}")
|
| 519 |
+
return jsonify({"error": "Internal server error"}), 500
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
|
| 523 |
+
@app.route('/api/generate-combined', methods=['POST'])
|
| 524 |
+
@require_api_key
|
| 525 |
+
def generate_speech_combined():
|
| 526 |
+
"""Generate speech from long text and return a single combined audio file."""
|
| 527 |
+
try:
|
| 528 |
+
data = request.get_json()
|
| 529 |
+
if not data:
|
| 530 |
+
return jsonify({"error": "No JSON data provided"}), 400
|
| 531 |
+
|
| 532 |
+
text = data.get('text', '').strip()
|
| 533 |
+
voice = data.get('voice', Voice.ALLOY.value)
|
| 534 |
+
response_format = data.get('format', AudioFormat.MP3.value)
|
| 535 |
+
instructions = data.get('instructions', '').strip() or None
|
| 536 |
+
max_length = data.get('max_length', 4096)
|
| 537 |
+
preserve_words = data.get('preserve_words', True)
|
| 538 |
+
|
| 539 |
+
if not text:
|
| 540 |
+
return jsonify({"error": "Text is required"}), 400
|
| 541 |
+
|
| 542 |
+
# Check if text needs splitting
|
| 543 |
+
if len(text) <= max_length:
|
| 544 |
+
# Text is short enough, use regular generation
|
| 545 |
+
try:
|
| 546 |
+
voice_enum = Voice(voice.lower())
|
| 547 |
+
format_enum = AudioFormat(response_format.lower())
|
| 548 |
+
except ValueError as e:
|
| 549 |
+
logger.warning(f"Invalid voice or format: {e}")
|
| 550 |
+
return jsonify({"error": "Invalid voice or format specified"}), 400
|
| 551 |
+
|
| 552 |
+
response = tts_client.generate_speech(
|
| 553 |
+
text=text,
|
| 554 |
+
voice=voice_enum,
|
| 555 |
+
response_format=format_enum,
|
| 556 |
+
instructions=instructions,
|
| 557 |
+
max_length=max_length,
|
| 558 |
+
validate_length=True
|
| 559 |
+
)
|
| 560 |
+
|
| 561 |
+
return Response(
|
| 562 |
+
response.audio_data,
|
| 563 |
+
mimetype=response.content_type,
|
| 564 |
+
headers={
|
| 565 |
+
'Content-Disposition': f'attachment; filename="combined_speech.{response.format.value}"',
|
| 566 |
+
'Content-Length': str(response.size),
|
| 567 |
+
'X-Audio-Format': response.format.value,
|
| 568 |
+
'X-Audio-Size': str(response.size),
|
| 569 |
+
'X-Chunks-Combined': '1'
|
| 570 |
+
}
|
| 571 |
+
)
|
| 572 |
+
|
| 573 |
+
# Text is long, split and combine
|
| 574 |
+
try:
|
| 575 |
+
voice_enum = Voice(voice.lower())
|
| 576 |
+
format_enum = AudioFormat(response_format.lower())
|
| 577 |
+
except ValueError as e:
|
| 578 |
+
logger.warning(f"Invalid voice or format: {e}")
|
| 579 |
+
return jsonify({"error": "Invalid voice or format specified"}), 400
|
| 580 |
+
|
| 581 |
+
logger.info(f"Generating combined speech for long text: {len(text)} characters, splitting into chunks")
|
| 582 |
+
|
| 583 |
+
# Generate speech chunks
|
| 584 |
+
try:
|
| 585 |
+
responses = tts_client.generate_speech_long_text(
|
| 586 |
+
text=text,
|
| 587 |
+
voice=voice_enum,
|
| 588 |
+
response_format=format_enum,
|
| 589 |
+
instructions=instructions,
|
| 590 |
+
max_length=max_length,
|
| 591 |
+
preserve_words=preserve_words
|
| 592 |
+
)
|
| 593 |
+
except Exception as e:
|
| 594 |
+
logger.error(f"Long text generation failed: {e}")
|
| 595 |
+
return jsonify({"error": "Long text generation failed"}), 500
|
| 596 |
+
|
| 597 |
+
if not responses:
|
| 598 |
+
return jsonify({"error": "No valid text chunks found"}), 400
|
| 599 |
+
|
| 600 |
+
logger.info(f"Generated {len(responses)} chunks, combining into single audio file")
|
| 601 |
+
|
| 602 |
+
# Extract audio data from responses
|
| 603 |
+
audio_chunks = [response.audio_data for response in responses]
|
| 604 |
+
|
| 605 |
+
# Combine audio chunks
|
| 606 |
+
try:
|
| 607 |
+
combined_audio = combine_audio_chunks(audio_chunks, format_enum.value)
|
| 608 |
+
except Exception as e:
|
| 609 |
+
logger.error(f"Failed to combine audio chunks: {e}")
|
| 610 |
+
return jsonify({"error": "Failed to combine audio chunks"}), 500
|
| 611 |
+
|
| 612 |
+
if not combined_audio:
|
| 613 |
+
return jsonify({"error": "Failed to generate combined audio"}), 500
|
| 614 |
+
|
| 615 |
+
# Determine content type
|
| 616 |
+
content_type = responses[0].content_type # Use content type from first chunk
|
| 617 |
+
|
| 618 |
+
logger.info(f"Successfully combined {len(responses)} chunks into single audio file ({len(combined_audio)} bytes)")
|
| 619 |
+
|
| 620 |
+
return Response(
|
| 621 |
+
combined_audio,
|
| 622 |
+
mimetype=content_type,
|
| 623 |
+
headers={
|
| 624 |
+
'Content-Disposition': f'attachment; filename="combined_speech.{format_enum.value}"',
|
| 625 |
+
'Content-Length': str(len(combined_audio)),
|
| 626 |
+
'X-Audio-Format': format_enum.value,
|
| 627 |
+
'X-Audio-Size': str(len(combined_audio)),
|
| 628 |
+
'X-Chunks-Combined': str(len(responses)),
|
| 629 |
+
'X-Original-Text-Length': str(len(text))
|
| 630 |
+
}
|
| 631 |
+
)
|
| 632 |
+
|
| 633 |
+
except ValidationException as e:
|
| 634 |
+
logger.warning(f"Validation error: {e}")
|
| 635 |
+
return jsonify({"error": "Invalid input parameters"}), 400
|
| 636 |
+
|
| 637 |
+
except APIException as e:
|
| 638 |
+
logger.error(f"API error: {e}")
|
| 639 |
+
return jsonify({
|
| 640 |
+
"error": "TTS service error",
|
| 641 |
+
"status_code": getattr(e, 'status_code', 500)
|
| 642 |
+
}), getattr(e, 'status_code', 500)
|
| 643 |
+
|
| 644 |
+
except NetworkException as e:
|
| 645 |
+
logger.error(f"Network error: {e}")
|
| 646 |
+
return jsonify({
|
| 647 |
+
"error": "TTS service is currently unavailable"
|
| 648 |
+
}), 503
|
| 649 |
+
|
| 650 |
+
except TTSException as e:
|
| 651 |
+
logger.error(f"TTS error: {e}")
|
| 652 |
+
return jsonify({"error": "Text-to-speech generation failed"}), 500
|
| 653 |
+
|
| 654 |
+
except Exception as e:
|
| 655 |
+
logger.error(f"Combined generation error: {e}")
|
| 656 |
+
return jsonify({"error": "Combined audio generation failed"}), 500
|
| 657 |
+
|
| 658 |
+
@app.route('/api/status', methods=['GET'])
|
| 659 |
+
def get_status():
|
| 660 |
+
"""Get service status."""
|
| 661 |
+
try:
|
| 662 |
+
# Try to make a simple request to check if the TTS service is available
|
| 663 |
+
test_response = tts_client.generate_speech(
|
| 664 |
+
text="test",
|
| 665 |
+
voice=Voice.ALLOY,
|
| 666 |
+
response_format=AudioFormat.MP3
|
| 667 |
+
)
|
| 668 |
+
|
| 669 |
+
return jsonify({
|
| 670 |
+
"status": "online",
|
| 671 |
+
"tts_service": "openai.fm (free)",
|
| 672 |
+
"package_version": "3.2.3",
|
| 673 |
+
"timestamp": datetime.now().isoformat()
|
| 674 |
+
})
|
| 675 |
+
|
| 676 |
+
except Exception as e:
|
| 677 |
+
logger.error(f"Status check failed: {e}")
|
| 678 |
+
return jsonify({
|
| 679 |
+
"status": "error",
|
| 680 |
+
"tts_service": "openai.fm (free)",
|
| 681 |
+
"error": "Service status check failed",
|
| 682 |
+
"timestamp": datetime.now().isoformat()
|
| 683 |
+
}), 503
|
| 684 |
+
|
| 685 |
+
@app.route('/api/health', methods=['GET'])
|
| 686 |
+
def health_check():
|
| 687 |
+
"""Simple health check endpoint."""
|
| 688 |
+
return jsonify({
|
| 689 |
+
"status": "healthy",
|
| 690 |
+
"package_version": "3.2.3",
|
| 691 |
+
"timestamp": datetime.now().isoformat()
|
| 692 |
+
})
|
| 693 |
+
|
| 694 |
+
@app.route('/api/websocket/status', methods=['GET'])
|
| 695 |
+
def websocket_status():
|
| 696 |
+
"""Get WebSocket server status and active connections."""
|
| 697 |
+
return jsonify({
|
| 698 |
+
"websocket_enabled": True,
|
| 699 |
+
"async_mode": async_mode,
|
| 700 |
+
"active_sessions": websocket_handler.get_active_sessions_count(),
|
| 701 |
+
"transport_options": ["websocket", "polling"],
|
| 702 |
+
"endpoint": f"ws{'s' if request.is_secure else ''}://{request.host}/socket.io/",
|
| 703 |
+
"timestamp": datetime.now().isoformat()
|
| 704 |
+
})
|
| 705 |
+
|
| 706 |
+
@app.route('/api/auth-status', methods=['GET'])
|
| 707 |
+
def auth_status():
|
| 708 |
+
"""Get authentication status and requirements."""
|
| 709 |
+
return jsonify({
|
| 710 |
+
"api_key_required": REQUIRE_API_KEY,
|
| 711 |
+
"api_key_configured": bool(API_KEY) if REQUIRE_API_KEY else None,
|
| 712 |
+
"timestamp": datetime.now().isoformat()
|
| 713 |
+
})
|
| 714 |
+
|
| 715 |
+
@app.route('/api/translations/<lang_code>', methods=['GET'])
|
| 716 |
+
def get_translations(lang_code):
|
| 717 |
+
"""Get translations for a specific language."""
|
| 718 |
+
try:
|
| 719 |
+
if hasattr(app, 'language_manager'):
|
| 720 |
+
translations = app.language_manager.translations.get(lang_code, {})
|
| 721 |
+
return jsonify(translations)
|
| 722 |
+
else:
|
| 723 |
+
return jsonify({}), 404
|
| 724 |
+
except Exception as e:
|
| 725 |
+
logger.error(f"Error getting translations for {lang_code}: {e}")
|
| 726 |
+
return jsonify({"error": "Failed to get translations"}), 500
|
| 727 |
+
|
| 728 |
+
# OpenAI-compatible API endpoints
|
| 729 |
+
@app.route('/v1/audio/speech', methods=['POST'])
|
| 730 |
+
@require_api_key
|
| 731 |
+
def openai_speech():
|
| 732 |
+
"""OpenAI-compatible speech generation endpoint with auto-combine feature."""
|
| 733 |
+
try:
|
| 734 |
+
# Parse request data
|
| 735 |
+
data = request.get_json()
|
| 736 |
+
if not data:
|
| 737 |
+
return jsonify({
|
| 738 |
+
"error": {
|
| 739 |
+
"message": "No JSON data provided",
|
| 740 |
+
"type": "invalid_request_error",
|
| 741 |
+
"code": "missing_data"
|
| 742 |
+
}
|
| 743 |
+
}), 400
|
| 744 |
+
|
| 745 |
+
# Extract OpenAI-compatible parameters
|
| 746 |
+
model = data.get('model', 'gpt-4o-mini-tts') # Accept but ignore model
|
| 747 |
+
input_text = data.get('input', '').strip()
|
| 748 |
+
voice = data.get('voice', 'alloy')
|
| 749 |
+
response_format = data.get('response_format', 'mp3')
|
| 750 |
+
instructions = data.get('instructions', '').strip() or None
|
| 751 |
+
speed = data.get('speed', 1.0) # Accept but ignore speed
|
| 752 |
+
|
| 753 |
+
# TTSFM-specific parameters
|
| 754 |
+
auto_combine = data.get('auto_combine', True) # New parameter: auto-combine long text (default: True)
|
| 755 |
+
max_length = data.get('max_length', 4096) # Custom parameter for chunk size
|
| 756 |
+
|
| 757 |
+
# Validate required fields
|
| 758 |
+
if not input_text:
|
| 759 |
+
return jsonify({
|
| 760 |
+
"error": {
|
| 761 |
+
"message": "Input text is required",
|
| 762 |
+
"type": "invalid_request_error",
|
| 763 |
+
"code": "missing_input"
|
| 764 |
+
}
|
| 765 |
+
}), 400
|
| 766 |
+
|
| 767 |
+
# Validate voice
|
| 768 |
+
try:
|
| 769 |
+
voice_enum = Voice(voice.lower())
|
| 770 |
+
except ValueError:
|
| 771 |
+
return jsonify({
|
| 772 |
+
"error": {
|
| 773 |
+
"message": f"Invalid voice: {voice}. Must be one of: {[v.value for v in Voice]}",
|
| 774 |
+
"type": "invalid_request_error",
|
| 775 |
+
"code": "invalid_voice"
|
| 776 |
+
}
|
| 777 |
+
}), 400
|
| 778 |
+
|
| 779 |
+
# Validate format
|
| 780 |
+
try:
|
| 781 |
+
format_enum = AudioFormat(response_format.lower())
|
| 782 |
+
except ValueError:
|
| 783 |
+
return jsonify({
|
| 784 |
+
"error": {
|
| 785 |
+
"message": f"Invalid response_format: {response_format}. Must be one of: {[f.value for f in AudioFormat]}",
|
| 786 |
+
"type": "invalid_request_error",
|
| 787 |
+
"code": "invalid_format"
|
| 788 |
+
}
|
| 789 |
+
}), 400
|
| 790 |
+
|
| 791 |
+
logger.info(f"OpenAI API: Generating speech: text='{input_text[:50]}...', voice={voice}, format={response_format}, auto_combine={auto_combine}")
|
| 792 |
+
|
| 793 |
+
# Check if text exceeds limit and auto_combine is enabled
|
| 794 |
+
if len(input_text) > max_length and auto_combine:
|
| 795 |
+
# Long text with auto-combine enabled: split and combine
|
| 796 |
+
logger.info(f"Long text detected ({len(input_text)} chars), auto-combining enabled")
|
| 797 |
+
|
| 798 |
+
# Generate speech chunks
|
| 799 |
+
responses = tts_client.generate_speech_long_text(
|
| 800 |
+
text=input_text,
|
| 801 |
+
voice=voice_enum,
|
| 802 |
+
response_format=format_enum,
|
| 803 |
+
instructions=instructions,
|
| 804 |
+
max_length=max_length,
|
| 805 |
+
preserve_words=True
|
| 806 |
+
)
|
| 807 |
+
|
| 808 |
+
if not responses:
|
| 809 |
+
return jsonify({
|
| 810 |
+
"error": {
|
| 811 |
+
"message": "No valid text chunks found",
|
| 812 |
+
"type": "processing_error",
|
| 813 |
+
"code": "no_chunks"
|
| 814 |
+
}
|
| 815 |
+
}), 400
|
| 816 |
+
|
| 817 |
+
# Extract audio data and combine
|
| 818 |
+
audio_chunks = [response.audio_data for response in responses]
|
| 819 |
+
combined_audio = combine_audio_chunks(audio_chunks, format_enum.value)
|
| 820 |
+
|
| 821 |
+
if not combined_audio:
|
| 822 |
+
return jsonify({
|
| 823 |
+
"error": {
|
| 824 |
+
"message": "Failed to combine audio chunks",
|
| 825 |
+
"type": "processing_error",
|
| 826 |
+
"code": "combine_failed"
|
| 827 |
+
}
|
| 828 |
+
}), 500
|
| 829 |
+
|
| 830 |
+
content_type = responses[0].content_type
|
| 831 |
+
|
| 832 |
+
logger.info(f"Successfully combined {len(responses)} chunks into single audio file")
|
| 833 |
+
|
| 834 |
+
return Response(
|
| 835 |
+
combined_audio,
|
| 836 |
+
mimetype=content_type,
|
| 837 |
+
headers={
|
| 838 |
+
'Content-Type': content_type,
|
| 839 |
+
'Content-Length': str(len(combined_audio)),
|
| 840 |
+
'X-Audio-Format': format_enum.value,
|
| 841 |
+
'X-Audio-Size': str(len(combined_audio)),
|
| 842 |
+
'X-Chunks-Combined': str(len(responses)),
|
| 843 |
+
'X-Original-Text-Length': str(len(input_text)),
|
| 844 |
+
'X-Auto-Combine': 'true',
|
| 845 |
+
'X-Powered-By': 'TTSFM-OpenAI-Compatible'
|
| 846 |
+
}
|
| 847 |
+
)
|
| 848 |
+
|
| 849 |
+
else:
|
| 850 |
+
# Short text or auto_combine disabled: use regular generation
|
| 851 |
+
if len(input_text) > max_length and not auto_combine:
|
| 852 |
+
# Text is too long but auto_combine is disabled - return error
|
| 853 |
+
return jsonify({
|
| 854 |
+
"error": {
|
| 855 |
+
"message": f"Input text is too long ({len(input_text)} characters). Maximum allowed length is {max_length} characters. Enable auto_combine parameter to automatically split and combine long text.",
|
| 856 |
+
"type": "invalid_request_error",
|
| 857 |
+
"code": "text_too_long"
|
| 858 |
+
}
|
| 859 |
+
}), 400
|
| 860 |
+
|
| 861 |
+
# Generate speech using the TTSFM package
|
| 862 |
+
response = tts_client.generate_speech(
|
| 863 |
+
text=input_text,
|
| 864 |
+
voice=voice_enum,
|
| 865 |
+
response_format=format_enum,
|
| 866 |
+
instructions=instructions,
|
| 867 |
+
max_length=max_length,
|
| 868 |
+
validate_length=True
|
| 869 |
+
)
|
| 870 |
+
|
| 871 |
+
# Return audio data in OpenAI format
|
| 872 |
+
return Response(
|
| 873 |
+
response.audio_data,
|
| 874 |
+
mimetype=response.content_type,
|
| 875 |
+
headers={
|
| 876 |
+
'Content-Type': response.content_type,
|
| 877 |
+
'Content-Length': str(response.size),
|
| 878 |
+
'X-Audio-Format': response.format.value,
|
| 879 |
+
'X-Audio-Size': str(response.size),
|
| 880 |
+
'X-Chunks-Combined': '1',
|
| 881 |
+
'X-Auto-Combine': str(auto_combine).lower(),
|
| 882 |
+
'X-Powered-By': 'TTSFM-OpenAI-Compatible'
|
| 883 |
+
}
|
| 884 |
+
)
|
| 885 |
+
|
| 886 |
+
except ValidationException as e:
|
| 887 |
+
logger.warning(f"OpenAI API validation error: {e}")
|
| 888 |
+
return jsonify({
|
| 889 |
+
"error": {
|
| 890 |
+
"message": "Invalid request parameters",
|
| 891 |
+
"type": "invalid_request_error",
|
| 892 |
+
"code": "validation_error"
|
| 893 |
+
}
|
| 894 |
+
}), 400
|
| 895 |
+
|
| 896 |
+
except APIException as e:
|
| 897 |
+
logger.error(f"OpenAI API error: {e}")
|
| 898 |
+
return jsonify({
|
| 899 |
+
"error": {
|
| 900 |
+
"message": "Text-to-speech generation failed",
|
| 901 |
+
"type": "api_error",
|
| 902 |
+
"code": "tts_error"
|
| 903 |
+
}
|
| 904 |
+
}), getattr(e, 'status_code', 500)
|
| 905 |
+
|
| 906 |
+
except NetworkException as e:
|
| 907 |
+
logger.error(f"OpenAI API network error: {e}")
|
| 908 |
+
return jsonify({
|
| 909 |
+
"error": {
|
| 910 |
+
"message": "TTS service is currently unavailable",
|
| 911 |
+
"type": "service_unavailable_error",
|
| 912 |
+
"code": "service_unavailable"
|
| 913 |
+
}
|
| 914 |
+
}), 503
|
| 915 |
+
|
| 916 |
+
except Exception as e:
|
| 917 |
+
logger.error(f"OpenAI API unexpected error: {e}")
|
| 918 |
+
return jsonify({
|
| 919 |
+
"error": {
|
| 920 |
+
"message": "An unexpected error occurred",
|
| 921 |
+
"type": "internal_error",
|
| 922 |
+
"code": "internal_error"
|
| 923 |
+
}
|
| 924 |
+
}), 500
|
| 925 |
+
|
| 926 |
+
|
| 927 |
+
|
| 928 |
+
@app.route('/v1/models', methods=['GET'])
|
| 929 |
+
def openai_models():
|
| 930 |
+
"""OpenAI-compatible models endpoint."""
|
| 931 |
+
return jsonify({
|
| 932 |
+
"object": "list",
|
| 933 |
+
"data": [
|
| 934 |
+
{
|
| 935 |
+
"id": "gpt-4o-mini-tts",
|
| 936 |
+
"object": "model",
|
| 937 |
+
"created": 1699564800,
|
| 938 |
+
"owned_by": "ttsfm",
|
| 939 |
+
"permission": [],
|
| 940 |
+
"root": "gpt-4o-mini-tts",
|
| 941 |
+
"parent": None
|
| 942 |
+
}
|
| 943 |
+
]
|
| 944 |
+
})
|
| 945 |
+
|
| 946 |
+
@app.errorhandler(404)
|
| 947 |
+
def not_found(error):
|
| 948 |
+
"""Handle 404 errors."""
|
| 949 |
+
return jsonify({"error": "Endpoint not found"}), 404
|
| 950 |
+
|
| 951 |
+
@app.errorhandler(405)
|
| 952 |
+
def method_not_allowed(error):
|
| 953 |
+
"""Handle 405 errors."""
|
| 954 |
+
return jsonify({"error": "Method not allowed"}), 405
|
| 955 |
+
|
| 956 |
+
@app.errorhandler(500)
|
| 957 |
+
def internal_error(error):
|
| 958 |
+
"""Handle 500 errors."""
|
| 959 |
+
logger.error(f"Internal server error: {error}")
|
| 960 |
+
return jsonify({"error": "Internal server error"}), 500
|
| 961 |
+
|
| 962 |
+
if __name__ == '__main__':
|
| 963 |
+
logger.info(f"Starting TTSFM web application on {HOST}:{PORT}")
|
| 964 |
+
logger.info("Using openai.fm free TTS service")
|
| 965 |
+
logger.info(f"Debug mode: {DEBUG}")
|
| 966 |
+
|
| 967 |
+
# Log API key protection status
|
| 968 |
+
if REQUIRE_API_KEY:
|
| 969 |
+
if API_KEY:
|
| 970 |
+
logger.info("🔒 API key protection is ENABLED")
|
| 971 |
+
logger.info("All TTS generation requests require a valid API key")
|
| 972 |
+
else:
|
| 973 |
+
logger.warning("⚠️ API key protection is enabled but TTSFM_API_KEY is not set!")
|
| 974 |
+
logger.warning("Please set the TTSFM_API_KEY environment variable")
|
| 975 |
+
else:
|
| 976 |
+
logger.info("🔓 API key protection is DISABLED - all requests are allowed")
|
| 977 |
+
logger.info("Set REQUIRE_API_KEY=true to enable API key protection")
|
| 978 |
+
|
| 979 |
+
try:
|
| 980 |
+
logger.info(f"Starting with {async_mode} async mode")
|
| 981 |
+
socketio.run(app, host=HOST, port=PORT, debug=DEBUG)
|
| 982 |
+
except KeyboardInterrupt:
|
| 983 |
+
logger.info("Application stopped by user")
|
| 984 |
+
except Exception as e:
|
| 985 |
+
logger.error(f"Failed to start application: {e}")
|
| 986 |
+
finally:
|
| 987 |
+
# Clean up TTS client
|
| 988 |
+
tts_client.close()
|
i18n.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Internationalization (i18n) support for TTSFM Web Application
|
| 3 |
+
|
| 4 |
+
This module provides multi-language support for the Flask web application,
|
| 5 |
+
including language detection, translation management, and template functions.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
from typing import Dict, Any, Optional
|
| 11 |
+
from flask import request, session, current_app
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class LanguageManager:
|
| 15 |
+
"""Manages language detection, translation loading, and text translation."""
|
| 16 |
+
|
| 17 |
+
def __init__(self, app=None, translations_dir: str = "translations"):
|
| 18 |
+
"""
|
| 19 |
+
Initialize the LanguageManager.
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
app: Flask application instance
|
| 23 |
+
translations_dir: Directory containing translation files
|
| 24 |
+
"""
|
| 25 |
+
self.translations_dir = translations_dir
|
| 26 |
+
self.translations: Dict[str, Dict[str, Any]] = {}
|
| 27 |
+
self.supported_languages = ['en', 'zh']
|
| 28 |
+
self.default_language = 'en'
|
| 29 |
+
|
| 30 |
+
if app is not None:
|
| 31 |
+
self.init_app(app)
|
| 32 |
+
|
| 33 |
+
def init_app(self, app):
|
| 34 |
+
"""Initialize the Flask application with i18n support."""
|
| 35 |
+
app.config.setdefault('LANGUAGES', self.supported_languages)
|
| 36 |
+
app.config.setdefault('DEFAULT_LANGUAGE', self.default_language)
|
| 37 |
+
|
| 38 |
+
# Load translations
|
| 39 |
+
self.load_translations()
|
| 40 |
+
|
| 41 |
+
# Register template functions
|
| 42 |
+
app.jinja_env.globals['_'] = self.translate
|
| 43 |
+
app.jinja_env.globals['get_locale'] = self.get_locale
|
| 44 |
+
app.jinja_env.globals['get_supported_languages'] = self.get_supported_languages
|
| 45 |
+
|
| 46 |
+
# Store reference to this instance
|
| 47 |
+
app.language_manager = self
|
| 48 |
+
|
| 49 |
+
def load_translations(self):
|
| 50 |
+
"""Load all translation files from the translations directory."""
|
| 51 |
+
translations_path = os.path.join(
|
| 52 |
+
os.path.dirname(__file__),
|
| 53 |
+
self.translations_dir
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
if not os.path.exists(translations_path):
|
| 57 |
+
print(f"Warning: Translations directory not found: {translations_path}")
|
| 58 |
+
return
|
| 59 |
+
|
| 60 |
+
for lang_code in self.supported_languages:
|
| 61 |
+
file_path = os.path.join(translations_path, f"{lang_code}.json")
|
| 62 |
+
|
| 63 |
+
if os.path.exists(file_path):
|
| 64 |
+
try:
|
| 65 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 66 |
+
self.translations[lang_code] = json.load(f)
|
| 67 |
+
print(f"Info: Loaded translations for language: {lang_code}")
|
| 68 |
+
except Exception as e:
|
| 69 |
+
print(f"Error: Failed to load translations for {lang_code}: {e}")
|
| 70 |
+
else:
|
| 71 |
+
print(f"Warning: Translation file not found: {file_path}")
|
| 72 |
+
|
| 73 |
+
def get_locale(self) -> str:
|
| 74 |
+
"""
|
| 75 |
+
Get the current locale based on user preference, session, or browser settings.
|
| 76 |
+
|
| 77 |
+
Returns:
|
| 78 |
+
Language code (e.g., 'en', 'zh')
|
| 79 |
+
"""
|
| 80 |
+
# 1. Check URL parameter (for language switching)
|
| 81 |
+
if 'lang' in request.args:
|
| 82 |
+
lang = request.args.get('lang')
|
| 83 |
+
if lang in self.supported_languages:
|
| 84 |
+
session['language'] = lang
|
| 85 |
+
return lang
|
| 86 |
+
|
| 87 |
+
# 2. Check session (user's previous choice)
|
| 88 |
+
if 'language' in session:
|
| 89 |
+
lang = session['language']
|
| 90 |
+
if lang in self.supported_languages:
|
| 91 |
+
return lang
|
| 92 |
+
|
| 93 |
+
# 3. Check browser's Accept-Language header
|
| 94 |
+
if request.headers.get('Accept-Language'):
|
| 95 |
+
browser_langs = request.headers.get('Accept-Language').split(',')
|
| 96 |
+
for browser_lang in browser_langs:
|
| 97 |
+
# Extract language code (e.g., 'zh-CN' -> 'zh')
|
| 98 |
+
lang_code = browser_lang.split(';')[0].split('-')[0].strip().lower()
|
| 99 |
+
if lang_code in self.supported_languages:
|
| 100 |
+
session['language'] = lang_code
|
| 101 |
+
return lang_code
|
| 102 |
+
|
| 103 |
+
# 4. Fall back to default language
|
| 104 |
+
return self.default_language
|
| 105 |
+
|
| 106 |
+
def set_locale(self, lang_code: str) -> bool:
|
| 107 |
+
"""
|
| 108 |
+
Set the current locale.
|
| 109 |
+
|
| 110 |
+
Args:
|
| 111 |
+
lang_code: Language code to set
|
| 112 |
+
|
| 113 |
+
Returns:
|
| 114 |
+
True if successful, False if language not supported
|
| 115 |
+
"""
|
| 116 |
+
if lang_code in self.supported_languages:
|
| 117 |
+
session['language'] = lang_code
|
| 118 |
+
return True
|
| 119 |
+
return False
|
| 120 |
+
|
| 121 |
+
def translate(self, key: str, **kwargs) -> str:
|
| 122 |
+
"""
|
| 123 |
+
Translate a text key to the current locale.
|
| 124 |
+
|
| 125 |
+
Args:
|
| 126 |
+
key: Translation key in dot notation (e.g., 'nav.home')
|
| 127 |
+
**kwargs: Variables for string formatting
|
| 128 |
+
|
| 129 |
+
Returns:
|
| 130 |
+
Translated text or the key if translation not found
|
| 131 |
+
"""
|
| 132 |
+
locale = self.get_locale()
|
| 133 |
+
|
| 134 |
+
# Get translation for current locale
|
| 135 |
+
translation = self._get_nested_value(
|
| 136 |
+
self.translations.get(locale, {}),
|
| 137 |
+
key
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
# Fall back to default language if not found
|
| 141 |
+
if translation is None and locale != self.default_language:
|
| 142 |
+
translation = self._get_nested_value(
|
| 143 |
+
self.translations.get(self.default_language, {}),
|
| 144 |
+
key
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
# Fall back to key if still not found
|
| 148 |
+
if translation is None:
|
| 149 |
+
translation = key
|
| 150 |
+
|
| 151 |
+
# Format with variables if provided
|
| 152 |
+
if kwargs and isinstance(translation, str):
|
| 153 |
+
try:
|
| 154 |
+
translation = translation.format(**kwargs)
|
| 155 |
+
except (KeyError, ValueError):
|
| 156 |
+
pass # Ignore formatting errors
|
| 157 |
+
|
| 158 |
+
return translation
|
| 159 |
+
|
| 160 |
+
def _get_nested_value(self, data: Dict[str, Any], key: str) -> Optional[str]:
|
| 161 |
+
"""
|
| 162 |
+
Get a nested value from a dictionary using dot notation.
|
| 163 |
+
|
| 164 |
+
Args:
|
| 165 |
+
data: Dictionary to search in
|
| 166 |
+
key: Dot-separated key (e.g., 'nav.home')
|
| 167 |
+
|
| 168 |
+
Returns:
|
| 169 |
+
Value if found, None otherwise
|
| 170 |
+
"""
|
| 171 |
+
keys = key.split('.')
|
| 172 |
+
current = data
|
| 173 |
+
|
| 174 |
+
for k in keys:
|
| 175 |
+
if isinstance(current, dict) and k in current:
|
| 176 |
+
current = current[k]
|
| 177 |
+
else:
|
| 178 |
+
return None
|
| 179 |
+
|
| 180 |
+
return current if isinstance(current, str) else None
|
| 181 |
+
|
| 182 |
+
def get_supported_languages(self) -> Dict[str, str]:
|
| 183 |
+
"""
|
| 184 |
+
Get a dictionary of supported languages with their display names.
|
| 185 |
+
|
| 186 |
+
Returns:
|
| 187 |
+
Dictionary mapping language codes to display names
|
| 188 |
+
"""
|
| 189 |
+
return {
|
| 190 |
+
'en': 'English',
|
| 191 |
+
'zh': '中文'
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
def get_language_info(self, lang_code: str) -> Dict[str, str]:
|
| 195 |
+
"""
|
| 196 |
+
Get information about a specific language.
|
| 197 |
+
|
| 198 |
+
Args:
|
| 199 |
+
lang_code: Language code
|
| 200 |
+
|
| 201 |
+
Returns:
|
| 202 |
+
Dictionary with language information
|
| 203 |
+
"""
|
| 204 |
+
language_names = {
|
| 205 |
+
'en': {'name': 'English', 'native': 'English'},
|
| 206 |
+
'zh': {'name': 'Chinese', 'native': '中文'}
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
return language_names.get(lang_code, {
|
| 210 |
+
'name': lang_code.upper(),
|
| 211 |
+
'native': lang_code.upper()
|
| 212 |
+
})
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# Global instance
|
| 216 |
+
language_manager = LanguageManager()
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def init_i18n(app):
|
| 220 |
+
"""Initialize i18n support for the Flask application."""
|
| 221 |
+
language_manager.init_app(app)
|
| 222 |
+
return language_manager
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# Template helper functions
|
| 226 |
+
def _(key: str, **kwargs) -> str:
|
| 227 |
+
"""Shorthand translation function for use in templates and code."""
|
| 228 |
+
return language_manager.translate(key, **kwargs)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def get_locale() -> str:
|
| 232 |
+
"""Get the current locale."""
|
| 233 |
+
return language_manager.get_locale()
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def set_locale(lang_code: str) -> bool:
|
| 237 |
+
"""Set the current locale."""
|
| 238 |
+
return language_manager.set_locale(lang_code)
|
requirements.txt
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# Core dependencies for the TTSFM package
|
| 2 |
requests>=2.25.0
|
| 3 |
aiohttp>=3.8.0
|
| 4 |
-
fake-useragent>=1.4.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Web application dependencies
|
| 2 |
+
flask>=2.0.0
|
| 3 |
+
flask-cors>=3.0.10
|
| 4 |
+
flask-socketio>=5.3.0
|
| 5 |
+
python-socketio>=5.10.0
|
| 6 |
+
eventlet>=0.33.3
|
| 7 |
# Core dependencies for the TTSFM package
|
| 8 |
requests>=2.25.0
|
| 9 |
aiohttp>=3.8.0
|
| 10 |
+
fake-useragent>=1.4.0
|
| 11 |
+
waitress>=3.0.0
|
| 12 |
+
python-dotenv>=1.0.0
|
| 13 |
+
|
| 14 |
+
# Audio processing (optional, for combining audio files)
|
| 15 |
+
# If not installed, will fall back to simple concatenation for WAV files
|
| 16 |
+
pydub>=0.25.0
|
| 17 |
+
|
| 18 |
+
# TTSFM package (install from local directory or PyPI)
|
| 19 |
+
# For local development: pip install -e ../
|
| 20 |
+
# For Docker/production: installed via pyproject.toml[web] dependencies
|
run.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""
|
| 3 |
+
Run script for TTSFM web application with proper eventlet initialization
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
# MUST be the first imports for eventlet to work properly
|
| 7 |
+
import eventlet
|
| 8 |
+
eventlet.monkey_patch()
|
| 9 |
+
|
| 10 |
+
# Now import the app
|
| 11 |
+
from app import app, socketio, HOST, PORT, DEBUG
|
| 12 |
+
|
| 13 |
+
if __name__ == '__main__':
|
| 14 |
+
print(f"Starting TTSFM with WebSocket support on {HOST}:{PORT}")
|
| 15 |
+
socketio.run(app, host=HOST, port=PORT, debug=DEBUG, allow_unsafe_werkzeug=True)
|
static/css/style.css
ADDED
|
@@ -0,0 +1,1399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* TTSFM Web Application Custom Styles */
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
/* Clean Color Palette */
|
| 5 |
+
--primary-color: #4f46e5;
|
| 6 |
+
--primary-dark: #3730a3;
|
| 7 |
+
--primary-light: #6366f1;
|
| 8 |
+
--secondary-color: #6b7280;
|
| 9 |
+
--secondary-dark: #4b5563;
|
| 10 |
+
--accent-color: #059669;
|
| 11 |
+
--accent-dark: #047857;
|
| 12 |
+
|
| 13 |
+
/* Status Colors */
|
| 14 |
+
--success-color: #059669;
|
| 15 |
+
--warning-color: #d97706;
|
| 16 |
+
--danger-color: #dc2626;
|
| 17 |
+
--info-color: #2563eb;
|
| 18 |
+
|
| 19 |
+
/* Clean Neutral Colors */
|
| 20 |
+
--light-color: #ffffff;
|
| 21 |
+
--light-gray: #f9fafb;
|
| 22 |
+
--medium-gray: #6b7280;
|
| 23 |
+
--dark-color: #111827;
|
| 24 |
+
--text-color: #374151;
|
| 25 |
+
--text-muted: #6b7280;
|
| 26 |
+
|
| 27 |
+
/* Design System */
|
| 28 |
+
--border-radius: 0.75rem;
|
| 29 |
+
--border-radius-sm: 0.5rem;
|
| 30 |
+
--border-radius-lg: 1rem;
|
| 31 |
+
--box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
| 32 |
+
--box-shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
| 33 |
+
--box-shadow-xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
| 34 |
+
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 35 |
+
--transition-fast: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
| 36 |
+
|
| 37 |
+
/* Gradients */
|
| 38 |
+
--gradient-primary: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%);
|
| 39 |
+
--gradient-secondary: linear-gradient(135deg, var(--secondary-color) 0%, var(--secondary-dark) 100%);
|
| 40 |
+
--gradient-accent: linear-gradient(135deg, var(--accent-color) 0%, var(--accent-dark) 100%);
|
| 41 |
+
--gradient-hero: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 50%, var(--accent-color) 100%);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/* Global Styles */
|
| 45 |
+
body {
|
| 46 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 47 |
+
line-height: 1.6;
|
| 48 |
+
color: var(--text-color);
|
| 49 |
+
background-color: #ffffff;
|
| 50 |
+
font-weight: 400;
|
| 51 |
+
-webkit-font-smoothing: antialiased;
|
| 52 |
+
-moz-osx-font-smoothing: grayscale;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/* Enhanced Typography */
|
| 56 |
+
h1, h2, h3, h4, h5, h6 {
|
| 57 |
+
font-weight: 700;
|
| 58 |
+
line-height: 1.3;
|
| 59 |
+
color: var(--dark-color);
|
| 60 |
+
letter-spacing: -0.025em;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.display-1, .display-2, .display-3, .display-4 {
|
| 64 |
+
font-weight: 800;
|
| 65 |
+
letter-spacing: -0.05em;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.lead {
|
| 69 |
+
font-size: 1.125rem;
|
| 70 |
+
font-weight: 400;
|
| 71 |
+
color: var(--text-muted);
|
| 72 |
+
line-height: 1.8;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/* Simplified Button Styles */
|
| 76 |
+
.btn {
|
| 77 |
+
font-weight: 600;
|
| 78 |
+
border-radius: 12px;
|
| 79 |
+
transition: all 0.3s ease;
|
| 80 |
+
letter-spacing: 0.025em;
|
| 81 |
+
border: none;
|
| 82 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.btn-primary {
|
| 86 |
+
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%);
|
| 87 |
+
color: white;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.btn-primary:hover {
|
| 91 |
+
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary-color) 100%);
|
| 92 |
+
color: white;
|
| 93 |
+
transform: translateY(-1px);
|
| 94 |
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.btn-outline-primary {
|
| 98 |
+
border: 2px solid var(--primary-color);
|
| 99 |
+
color: var(--primary-color);
|
| 100 |
+
background: transparent;
|
| 101 |
+
box-shadow: none;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.btn-outline-primary:hover {
|
| 105 |
+
background: var(--primary-color);
|
| 106 |
+
border-color: var(--primary-color);
|
| 107 |
+
color: white;
|
| 108 |
+
transform: translateY(-1px);
|
| 109 |
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.btn-lg {
|
| 113 |
+
padding: 0.875rem 2rem;
|
| 114 |
+
font-size: 1.125rem;
|
| 115 |
+
border-radius: var(--border-radius);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.btn-sm {
|
| 119 |
+
padding: 0.5rem 1rem;
|
| 120 |
+
font-size: 0.875rem;
|
| 121 |
+
border-radius: var(--border-radius-sm);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
/* Clean Card Styles */
|
| 125 |
+
.card {
|
| 126 |
+
border: 1px solid #e5e7eb;
|
| 127 |
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
| 128 |
+
transition: all 0.3s ease;
|
| 129 |
+
border-radius: 16px;
|
| 130 |
+
background: white;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.card:hover {
|
| 134 |
+
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
| 135 |
+
border-color: var(--primary-light);
|
| 136 |
+
transform: translateY(-2px);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.card-body {
|
| 140 |
+
padding: 2rem;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/* Clean Hero Section */
|
| 144 |
+
.hero-section {
|
| 145 |
+
background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%);
|
| 146 |
+
color: var(--text-color);
|
| 147 |
+
padding: 5rem 0;
|
| 148 |
+
min-height: 75vh;
|
| 149 |
+
display: flex;
|
| 150 |
+
align-items: center;
|
| 151 |
+
border-bottom: 1px solid #e5e7eb;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.min-vh-75 {
|
| 155 |
+
min-height: 75vh;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/* Status Indicators */
|
| 159 |
+
.status-indicator {
|
| 160 |
+
display: inline-block;
|
| 161 |
+
width: 8px;
|
| 162 |
+
height: 8px;
|
| 163 |
+
border-radius: 50%;
|
| 164 |
+
background-color: #6c757d;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.status-online {
|
| 168 |
+
background-color: #28a745;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.status-offline {
|
| 172 |
+
background-color: #dc3545;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/* Footer */
|
| 176 |
+
.footer {
|
| 177 |
+
margin-top: auto;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
/* Clean Code Blocks */
|
| 181 |
+
pre {
|
| 182 |
+
background-color: #f8fafc !important;
|
| 183 |
+
border: 1px solid #e5e7eb;
|
| 184 |
+
border-radius: 8px;
|
| 185 |
+
font-size: 0.875rem;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
code {
|
| 189 |
+
color: #374151;
|
| 190 |
+
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
/* Enhanced Form Styles */
|
| 194 |
+
.form-control, .form-select {
|
| 195 |
+
border-radius: 12px;
|
| 196 |
+
border: 2px solid #e5e7eb;
|
| 197 |
+
transition: var(--transition);
|
| 198 |
+
padding: 1rem 1.25rem;
|
| 199 |
+
font-size: 1rem;
|
| 200 |
+
background-color: #ffffff;
|
| 201 |
+
color: var(--text-color);
|
| 202 |
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.form-control:focus, .form-select:focus {
|
| 206 |
+
border-color: var(--primary-color);
|
| 207 |
+
box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.1);
|
| 208 |
+
outline: none;
|
| 209 |
+
background-color: #ffffff;
|
| 210 |
+
transform: translateY(-1px);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
.form-control:hover, .form-select:hover {
|
| 214 |
+
border-color: var(--primary-light);
|
| 215 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.form-label {
|
| 219 |
+
font-weight: 600;
|
| 220 |
+
color: var(--dark-color);
|
| 221 |
+
margin-bottom: 0.75rem;
|
| 222 |
+
font-size: 0.95rem;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.form-text {
|
| 226 |
+
color: var(--text-muted);
|
| 227 |
+
font-size: 0.875rem;
|
| 228 |
+
margin-top: 0.5rem;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.form-check-input {
|
| 232 |
+
border-radius: var(--border-radius-sm);
|
| 233 |
+
border: 2px solid #e2e8f0;
|
| 234 |
+
width: 1.25rem;
|
| 235 |
+
height: 1.25rem;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.form-check-input:checked {
|
| 239 |
+
background-color: var(--primary-color);
|
| 240 |
+
border-color: var(--primary-color);
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
.form-check-input:focus {
|
| 244 |
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
.form-check-label {
|
| 248 |
+
color: var(--text-color);
|
| 249 |
+
font-weight: 500;
|
| 250 |
+
margin-left: 0.5rem;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
/* Enhanced Status Indicators */
|
| 254 |
+
.status-indicator {
|
| 255 |
+
display: inline-block;
|
| 256 |
+
width: 12px;
|
| 257 |
+
height: 12px;
|
| 258 |
+
border-radius: 50%;
|
| 259 |
+
margin-right: 8px;
|
| 260 |
+
position: relative;
|
| 261 |
+
animation: statusPulse 2s infinite;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
.status-indicator::before {
|
| 265 |
+
content: '';
|
| 266 |
+
position: absolute;
|
| 267 |
+
top: -2px;
|
| 268 |
+
left: -2px;
|
| 269 |
+
right: -2px;
|
| 270 |
+
bottom: -2px;
|
| 271 |
+
border-radius: 50%;
|
| 272 |
+
opacity: 0.3;
|
| 273 |
+
animation: statusRing 2s infinite;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.status-online {
|
| 277 |
+
background-color: var(--success-color);
|
| 278 |
+
box-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
.status-online::before {
|
| 282 |
+
background-color: var(--success-color);
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
.status-offline {
|
| 286 |
+
background-color: var(--danger-color);
|
| 287 |
+
box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.status-offline::before {
|
| 291 |
+
background-color: var(--danger-color);
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
@keyframes statusPulse {
|
| 295 |
+
0%, 100% { opacity: 1; }
|
| 296 |
+
50% { opacity: 0.7; }
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
@keyframes statusRing {
|
| 300 |
+
0% { transform: scale(0.8); opacity: 0.8; }
|
| 301 |
+
100% { transform: scale(1.4); opacity: 0; }
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
/* Enhanced Audio Player */
|
| 305 |
+
.audio-player {
|
| 306 |
+
width: 100%;
|
| 307 |
+
margin-top: 1rem;
|
| 308 |
+
border-radius: var(--border-radius);
|
| 309 |
+
box-shadow: var(--box-shadow);
|
| 310 |
+
background: var(--light-color);
|
| 311 |
+
padding: 0.5rem;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
.audio-player::-webkit-media-controls-panel {
|
| 315 |
+
background-color: var(--light-color);
|
| 316 |
+
border-radius: var(--border-radius-sm);
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
/* Enhanced Sections */
|
| 320 |
+
.features-section {
|
| 321 |
+
padding: 6rem 0;
|
| 322 |
+
background: linear-gradient(180deg, #ffffff 0%, var(--light-color) 100%);
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
.stats-section {
|
| 326 |
+
padding: 4rem 0;
|
| 327 |
+
background: var(--gradient-primary);
|
| 328 |
+
color: white;
|
| 329 |
+
position: relative;
|
| 330 |
+
overflow: hidden;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
.stats-section::before {
|
| 334 |
+
content: '';
|
| 335 |
+
position: absolute;
|
| 336 |
+
top: 0;
|
| 337 |
+
left: 0;
|
| 338 |
+
right: 0;
|
| 339 |
+
bottom: 0;
|
| 340 |
+
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="stats-pattern" width="40" height="40" patternUnits="userSpaceOnUse"><circle cx="20" cy="20" r="1" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23stats-pattern)"/></svg>');
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.stat-card {
|
| 344 |
+
text-align: center;
|
| 345 |
+
padding: 2rem 1rem;
|
| 346 |
+
background: rgba(255, 255, 255, 0.1);
|
| 347 |
+
border-radius: var(--border-radius);
|
| 348 |
+
backdrop-filter: blur(10px);
|
| 349 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 350 |
+
transition: var(--transition);
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
.stat-card:hover {
|
| 354 |
+
transform: translateY(-5px);
|
| 355 |
+
background: rgba(255, 255, 255, 0.15);
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.stat-icon {
|
| 359 |
+
font-size: 2.5rem;
|
| 360 |
+
margin-bottom: 1rem;
|
| 361 |
+
color: rgba(255, 255, 255, 0.9);
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
.stat-number {
|
| 365 |
+
font-size: 3rem;
|
| 366 |
+
font-weight: 800;
|
| 367 |
+
color: white;
|
| 368 |
+
margin-bottom: 0.5rem;
|
| 369 |
+
display: block;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
.stat-label {
|
| 373 |
+
color: rgba(255, 255, 255, 0.9);
|
| 374 |
+
font-weight: 500;
|
| 375 |
+
font-size: 0.95rem;
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
.quick-start-section {
|
| 379 |
+
padding: 6rem 0;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
.use-cases-section {
|
| 383 |
+
padding: 6rem 0;
|
| 384 |
+
background: var(--light-color);
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
.tech-specs-section {
|
| 388 |
+
padding: 6rem 0;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
.faq-section {
|
| 392 |
+
padding: 6rem 0;
|
| 393 |
+
background: var(--light-color);
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
.final-cta-section {
|
| 397 |
+
padding: 6rem 0;
|
| 398 |
+
background: var(--gradient-hero);
|
| 399 |
+
color: white;
|
| 400 |
+
position: relative;
|
| 401 |
+
overflow: hidden;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.cta-background-animation {
|
| 405 |
+
position: absolute;
|
| 406 |
+
top: 0;
|
| 407 |
+
left: 0;
|
| 408 |
+
right: 0;
|
| 409 |
+
bottom: 0;
|
| 410 |
+
background: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.05) 50%, transparent 70%);
|
| 411 |
+
animation: shimmer 4s ease-in-out infinite;
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
.section-badge {
|
| 415 |
+
display: inline-block;
|
| 416 |
+
background: var(--gradient-primary);
|
| 417 |
+
color: white;
|
| 418 |
+
padding: 0.5rem 1.5rem;
|
| 419 |
+
border-radius: 2rem;
|
| 420 |
+
font-size: 0.875rem;
|
| 421 |
+
font-weight: 600;
|
| 422 |
+
margin-bottom: 1.5rem;
|
| 423 |
+
box-shadow: 0 4px 14px 0 rgba(99, 102, 241, 0.3);
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
/* Enhanced Loading States */
|
| 427 |
+
.loading-spinner {
|
| 428 |
+
display: none;
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
.loading .loading-spinner {
|
| 432 |
+
display: inline-block;
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
.loading .btn-text {
|
| 436 |
+
display: none;
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
.loading {
|
| 440 |
+
position: relative;
|
| 441 |
+
overflow: hidden;
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
.loading::after {
|
| 445 |
+
content: '';
|
| 446 |
+
position: absolute;
|
| 447 |
+
top: 0;
|
| 448 |
+
left: -100%;
|
| 449 |
+
width: 100%;
|
| 450 |
+
height: 100%;
|
| 451 |
+
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
|
| 452 |
+
animation: loading-shimmer 1.5s infinite;
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
@keyframes loading-shimmer {
|
| 456 |
+
0% { left: -100%; }
|
| 457 |
+
100% { left: 100%; }
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
/* Enhanced Code Blocks */
|
| 461 |
+
.code-card {
|
| 462 |
+
background: white;
|
| 463 |
+
border-radius: var(--border-radius);
|
| 464 |
+
box-shadow: var(--box-shadow);
|
| 465 |
+
overflow: hidden;
|
| 466 |
+
border: 1px solid #e2e8f0;
|
| 467 |
+
transition: var(--transition);
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
.code-card:hover {
|
| 471 |
+
transform: translateY(-2px);
|
| 472 |
+
box-shadow: var(--box-shadow-lg);
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
.code-header {
|
| 476 |
+
background: var(--light-gray);
|
| 477 |
+
padding: 1rem 1.5rem;
|
| 478 |
+
border-bottom: 1px solid #e2e8f0;
|
| 479 |
+
display: flex;
|
| 480 |
+
justify-content: between;
|
| 481 |
+
align-items: center;
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
.code-header h4 {
|
| 485 |
+
margin: 0;
|
| 486 |
+
font-size: 1.1rem;
|
| 487 |
+
color: var(--dark-color);
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
.code-content {
|
| 491 |
+
padding: 1.5rem;
|
| 492 |
+
background: #f8fafc;
|
| 493 |
+
margin: 0;
|
| 494 |
+
overflow-x: auto;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
.code-content code {
|
| 498 |
+
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
| 499 |
+
font-size: 0.9rem;
|
| 500 |
+
line-height: 1.6;
|
| 501 |
+
color: var(--text-color);
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
.code-footer {
|
| 505 |
+
padding: 1rem 1.5rem;
|
| 506 |
+
background: white;
|
| 507 |
+
border-top: 1px solid #e2e8f0;
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
.copy-btn {
|
| 511 |
+
font-size: 0.8rem;
|
| 512 |
+
padding: 0.25rem 0.75rem;
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
/* Enhanced Use Case Cards */
|
| 516 |
+
.use-case-card {
|
| 517 |
+
background: white;
|
| 518 |
+
border-radius: var(--border-radius);
|
| 519 |
+
padding: 2rem;
|
| 520 |
+
box-shadow: var(--box-shadow);
|
| 521 |
+
transition: var(--transition);
|
| 522 |
+
border: 1px solid #e2e8f0;
|
| 523 |
+
height: 100%;
|
| 524 |
+
text-align: center;
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
.use-case-card:hover {
|
| 528 |
+
transform: translateY(-4px);
|
| 529 |
+
box-shadow: var(--box-shadow-lg);
|
| 530 |
+
border-color: rgba(99, 102, 241, 0.2);
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
.use-case-icon {
|
| 534 |
+
width: 4rem;
|
| 535 |
+
height: 4rem;
|
| 536 |
+
background: var(--gradient-primary);
|
| 537 |
+
border-radius: 50%;
|
| 538 |
+
display: flex;
|
| 539 |
+
align-items: center;
|
| 540 |
+
justify-content: center;
|
| 541 |
+
font-size: 1.5rem;
|
| 542 |
+
color: white;
|
| 543 |
+
margin: 0 auto 1.5rem;
|
| 544 |
+
box-shadow: 0 4px 14px 0 rgba(99, 102, 241, 0.3);
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
.use-case-title {
|
| 548 |
+
font-size: 1.25rem;
|
| 549 |
+
font-weight: 700;
|
| 550 |
+
color: var(--dark-color);
|
| 551 |
+
margin-bottom: 1rem;
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
.use-case-description {
|
| 555 |
+
color: var(--text-muted);
|
| 556 |
+
margin-bottom: 1.5rem;
|
| 557 |
+
line-height: 1.7;
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
.use-case-examples {
|
| 561 |
+
display: flex;
|
| 562 |
+
flex-wrap: wrap;
|
| 563 |
+
gap: 0.5rem;
|
| 564 |
+
justify-content: center;
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
.use-case-examples .badge {
|
| 568 |
+
font-size: 0.75rem;
|
| 569 |
+
padding: 0.4rem 0.8rem;
|
| 570 |
+
border-radius: 1rem;
|
| 571 |
+
background: var(--light-gray);
|
| 572 |
+
color: var(--text-color);
|
| 573 |
+
border: 1px solid #e2e8f0;
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
/* Enhanced Tech Spec Cards */
|
| 577 |
+
.tech-spec-card {
|
| 578 |
+
background: white;
|
| 579 |
+
border-radius: var(--border-radius);
|
| 580 |
+
padding: 2rem;
|
| 581 |
+
box-shadow: var(--box-shadow);
|
| 582 |
+
transition: var(--transition);
|
| 583 |
+
border: 1px solid #e2e8f0;
|
| 584 |
+
height: 100%;
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
.tech-spec-card:hover {
|
| 588 |
+
transform: translateY(-2px);
|
| 589 |
+
box-shadow: var(--box-shadow-lg);
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
.tech-spec-icon {
|
| 593 |
+
width: 3rem;
|
| 594 |
+
height: 3rem;
|
| 595 |
+
background: var(--gradient-accent);
|
| 596 |
+
border-radius: var(--border-radius-sm);
|
| 597 |
+
display: flex;
|
| 598 |
+
align-items: center;
|
| 599 |
+
justify-content: center;
|
| 600 |
+
font-size: 1.25rem;
|
| 601 |
+
color: white;
|
| 602 |
+
margin: 0 auto 1rem;
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
.tech-spec-card h4, .tech-spec-card h5 {
|
| 606 |
+
color: var(--dark-color);
|
| 607 |
+
margin-bottom: 1.5rem;
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
.tech-spec-card ul {
|
| 611 |
+
list-style: none;
|
| 612 |
+
padding: 0;
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
.tech-spec-card li {
|
| 616 |
+
padding: 0.5rem 0;
|
| 617 |
+
color: var(--text-color);
|
| 618 |
+
border-bottom: 1px solid #f1f5f9;
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
.tech-spec-card li:last-child {
|
| 622 |
+
border-bottom: none;
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
/* Enhanced Validation Styles */
|
| 626 |
+
.badge {
|
| 627 |
+
font-size: 0.75em;
|
| 628 |
+
padding: 0.4em 0.8em;
|
| 629 |
+
border-radius: 1rem;
|
| 630 |
+
font-weight: 600;
|
| 631 |
+
letter-spacing: 0.025em;
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
.validation-result {
|
| 635 |
+
animation: slideDown 0.3s ease;
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
@keyframes slideDown {
|
| 639 |
+
from {
|
| 640 |
+
opacity: 0;
|
| 641 |
+
transform: translateY(-10px);
|
| 642 |
+
}
|
| 643 |
+
to {
|
| 644 |
+
opacity: 1;
|
| 645 |
+
transform: translateY(0);
|
| 646 |
+
}
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
/* Enhanced Alert Styles */
|
| 650 |
+
.alert {
|
| 651 |
+
border-radius: var(--border-radius);
|
| 652 |
+
border: none;
|
| 653 |
+
box-shadow: var(--box-shadow);
|
| 654 |
+
padding: 1rem 1.5rem;
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
.alert-success {
|
| 658 |
+
background: linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(16, 185, 129, 0.05) 100%);
|
| 659 |
+
color: #065f46;
|
| 660 |
+
border-left: 4px solid var(--success-color);
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
.alert-warning {
|
| 664 |
+
background: linear-gradient(135deg, rgba(245, 158, 11, 0.1) 0%, rgba(245, 158, 11, 0.05) 100%);
|
| 665 |
+
color: #92400e;
|
| 666 |
+
border-left: 4px solid var(--warning-color);
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
.alert-danger {
|
| 670 |
+
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(239, 68, 68, 0.05) 100%);
|
| 671 |
+
color: #991b1b;
|
| 672 |
+
border-left: 4px solid var(--danger-color);
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
.alert-info {
|
| 676 |
+
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(59, 130, 246, 0.05) 100%);
|
| 677 |
+
color: #1e40af;
|
| 678 |
+
border-left: 4px solid var(--info-color);
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
/* Enhanced Accordion */
|
| 682 |
+
.accordion-item {
|
| 683 |
+
border: none;
|
| 684 |
+
margin-bottom: 1rem;
|
| 685 |
+
border-radius: var(--border-radius) !important;
|
| 686 |
+
box-shadow: var(--box-shadow);
|
| 687 |
+
overflow: hidden;
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
.accordion-button {
|
| 691 |
+
background: white;
|
| 692 |
+
border: none;
|
| 693 |
+
padding: 1.5rem;
|
| 694 |
+
font-weight: 600;
|
| 695 |
+
color: var(--dark-color);
|
| 696 |
+
border-radius: var(--border-radius) !important;
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
+
.accordion-button:not(.collapsed) {
|
| 700 |
+
background: var(--light-gray);
|
| 701 |
+
color: var(--primary-color);
|
| 702 |
+
box-shadow: none;
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
.accordion-button:focus {
|
| 706 |
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
| 707 |
+
border-color: transparent;
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
.accordion-body {
|
| 711 |
+
padding: 1.5rem;
|
| 712 |
+
background: white;
|
| 713 |
+
color: var(--text-color);
|
| 714 |
+
line-height: 1.7;
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
/* Enhanced CTA Buttons */
|
| 718 |
+
.cta-btn-primary, .cta-btn-secondary {
|
| 719 |
+
position: relative;
|
| 720 |
+
overflow: hidden;
|
| 721 |
+
backdrop-filter: blur(10px);
|
| 722 |
+
border-radius: var(--border-radius);
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
.cta-btn-primary small, .cta-btn-secondary small {
|
| 726 |
+
font-size: 0.75rem;
|
| 727 |
+
opacity: 0.9;
|
| 728 |
+
font-weight: 400;
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
.cta-content {
|
| 732 |
+
position: relative;
|
| 733 |
+
z-index: 2;
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
.cta-buttons {
|
| 737 |
+
margin: 2rem 0;
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
.cta-stats {
|
| 741 |
+
margin-top: 3rem;
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
.cta-stat h4 {
|
| 745 |
+
font-size: 2rem;
|
| 746 |
+
font-weight: 800;
|
| 747 |
+
margin-bottom: 0.25rem;
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
+
.cta-stat small {
|
| 751 |
+
font-size: 0.9rem;
|
| 752 |
+
opacity: 0.9;
|
| 753 |
+
}
|
| 754 |
+
|
| 755 |
+
/* Enhanced Quick Start */
|
| 756 |
+
.quick-start-cta {
|
| 757 |
+
background: white;
|
| 758 |
+
border-radius: var(--border-radius-lg);
|
| 759 |
+
padding: 3rem;
|
| 760 |
+
box-shadow: var(--box-shadow-lg);
|
| 761 |
+
text-align: center;
|
| 762 |
+
border: 1px solid #e2e8f0;
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
.quick-start-cta h4 {
|
| 766 |
+
color: var(--dark-color);
|
| 767 |
+
margin-bottom: 1.5rem;
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
+
/* Enhanced Batch Processing */
|
| 771 |
+
.batch-chunk-card {
|
| 772 |
+
transition: var(--transition);
|
| 773 |
+
border: 1px solid #e2e8f0;
|
| 774 |
+
border-radius: var(--border-radius);
|
| 775 |
+
overflow: hidden;
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
.batch-chunk-card:hover {
|
| 779 |
+
transform: translateY(-2px);
|
| 780 |
+
box-shadow: var(--box-shadow-lg);
|
| 781 |
+
border-color: rgba(99, 102, 241, 0.2);
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
.batch-chunk-card .card-body {
|
| 785 |
+
padding: 1.5rem;
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
.batch-chunk-card .card-title {
|
| 789 |
+
font-size: 1rem;
|
| 790 |
+
font-weight: 600;
|
| 791 |
+
color: var(--dark-color);
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
.batch-chunk-card .card-text {
|
| 795 |
+
color: var(--text-muted);
|
| 796 |
+
line-height: 1.6;
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
.download-chunk {
|
| 800 |
+
transition: var(--transition-fast);
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
.download-chunk:hover {
|
| 804 |
+
transform: scale(1.1);
|
| 805 |
+
}
|
| 806 |
+
|
| 807 |
+
/* Enhanced Navigation */
|
| 808 |
+
.navbar {
|
| 809 |
+
backdrop-filter: blur(10px);
|
| 810 |
+
background: rgba(255, 255, 255, 0.95) !important;
|
| 811 |
+
border-bottom: 1px solid rgba(226, 232, 240, 0.8);
|
| 812 |
+
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
.navbar-brand {
|
| 816 |
+
font-weight: 800;
|
| 817 |
+
font-size: 1.5rem;
|
| 818 |
+
color: var(--primary-color) !important;
|
| 819 |
+
transition: var(--transition);
|
| 820 |
+
}
|
| 821 |
+
|
| 822 |
+
.navbar-brand:hover {
|
| 823 |
+
transform: scale(1.05);
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
.navbar-nav .nav-link {
|
| 827 |
+
font-weight: 500;
|
| 828 |
+
transition: var(--transition);
|
| 829 |
+
color: var(--text-color) !important;
|
| 830 |
+
position: relative;
|
| 831 |
+
padding: 0.75rem 1rem !important;
|
| 832 |
+
}
|
| 833 |
+
|
| 834 |
+
.navbar-nav .nav-link::after {
|
| 835 |
+
content: '';
|
| 836 |
+
position: absolute;
|
| 837 |
+
bottom: 0;
|
| 838 |
+
left: 50%;
|
| 839 |
+
width: 0;
|
| 840 |
+
height: 2px;
|
| 841 |
+
background: var(--gradient-primary);
|
| 842 |
+
transition: var(--transition);
|
| 843 |
+
transform: translateX(-50%);
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
.navbar-nav .nav-link:hover::after {
|
| 847 |
+
width: 80%;
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
+
.navbar-nav .nav-link:hover {
|
| 851 |
+
color: var(--primary-color) !important;
|
| 852 |
+
}
|
| 853 |
+
|
| 854 |
+
.navbar-text {
|
| 855 |
+
color: var(--text-muted) !important;
|
| 856 |
+
font-weight: 500;
|
| 857 |
+
}
|
| 858 |
+
|
| 859 |
+
/* Enhanced Footer */
|
| 860 |
+
.footer {
|
| 861 |
+
background: linear-gradient(135deg, var(--dark-color) 0%, #2d3748 100%);
|
| 862 |
+
color: white;
|
| 863 |
+
padding: 3rem 0 2rem;
|
| 864 |
+
margin-top: 6rem;
|
| 865 |
+
position: relative;
|
| 866 |
+
overflow: hidden;
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
.footer::before {
|
| 870 |
+
content: '';
|
| 871 |
+
position: absolute;
|
| 872 |
+
top: 0;
|
| 873 |
+
left: 0;
|
| 874 |
+
right: 0;
|
| 875 |
+
bottom: 0;
|
| 876 |
+
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="footer-pattern" width="20" height="20" patternUnits="userSpaceOnUse"><circle cx="10" cy="10" r="0.5" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23footer-pattern)"/></svg>');
|
| 877 |
+
}
|
| 878 |
+
|
| 879 |
+
.footer h5 {
|
| 880 |
+
color: white;
|
| 881 |
+
font-weight: 700;
|
| 882 |
+
margin-bottom: 1rem;
|
| 883 |
+
}
|
| 884 |
+
|
| 885 |
+
.footer p, .footer a {
|
| 886 |
+
color: rgba(255, 255, 255, 0.8);
|
| 887 |
+
transition: var(--transition);
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
.footer a:hover {
|
| 891 |
+
color: white;
|
| 892 |
+
text-decoration: none;
|
| 893 |
+
}
|
| 894 |
+
|
| 895 |
+
/* Enhanced Responsive Design */
|
| 896 |
+
@media (max-width: 1200px) {
|
| 897 |
+
.hero-section {
|
| 898 |
+
padding: 4rem 0;
|
| 899 |
+
}
|
| 900 |
+
|
| 901 |
+
.floating-icon-container {
|
| 902 |
+
width: 250px;
|
| 903 |
+
height: 250px;
|
| 904 |
+
}
|
| 905 |
+
|
| 906 |
+
.floating-icon {
|
| 907 |
+
width: 50px;
|
| 908 |
+
height: 50px;
|
| 909 |
+
font-size: 1.25rem;
|
| 910 |
+
}
|
| 911 |
+
|
| 912 |
+
.hero-main-icon {
|
| 913 |
+
width: 100px;
|
| 914 |
+
height: 100px;
|
| 915 |
+
font-size: 2.5rem;
|
| 916 |
+
}
|
| 917 |
+
}
|
| 918 |
+
|
| 919 |
+
@media (max-width: 992px) {
|
| 920 |
+
.hero-section {
|
| 921 |
+
padding: 3rem 0;
|
| 922 |
+
min-height: auto;
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
+
.display-3 {
|
| 926 |
+
font-size: 2.5rem;
|
| 927 |
+
}
|
| 928 |
+
|
| 929 |
+
.features-section, .stats-section, .quick-start-section,
|
| 930 |
+
.use-cases-section, .tech-specs-section, .faq-section,
|
| 931 |
+
.final-cta-section {
|
| 932 |
+
padding: 4rem 0;
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
.floating-icon-container {
|
| 936 |
+
display: none;
|
| 937 |
+
}
|
| 938 |
+
|
| 939 |
+
.hero-visual {
|
| 940 |
+
margin-top: 2rem;
|
| 941 |
+
}
|
| 942 |
+
}
|
| 943 |
+
|
| 944 |
+
@media (max-width: 768px) {
|
| 945 |
+
.hero-section {
|
| 946 |
+
padding: 2rem 0;
|
| 947 |
+
text-align: center;
|
| 948 |
+
}
|
| 949 |
+
|
| 950 |
+
.display-3 {
|
| 951 |
+
font-size: 2rem;
|
| 952 |
+
}
|
| 953 |
+
|
| 954 |
+
.lead {
|
| 955 |
+
font-size: 1rem;
|
| 956 |
+
}
|
| 957 |
+
|
| 958 |
+
.btn-lg {
|
| 959 |
+
padding: 0.75rem 1.5rem;
|
| 960 |
+
font-size: 1rem;
|
| 961 |
+
width: 100%;
|
| 962 |
+
margin-bottom: 1rem;
|
| 963 |
+
}
|
| 964 |
+
|
| 965 |
+
.hero-stats .col-4 {
|
| 966 |
+
margin-bottom: 1rem;
|
| 967 |
+
}
|
| 968 |
+
|
| 969 |
+
.stat-item h3 {
|
| 970 |
+
font-size: 2rem;
|
| 971 |
+
}
|
| 972 |
+
|
| 973 |
+
.features-section, .stats-section, .quick-start-section,
|
| 974 |
+
.use-cases-section, .tech-specs-section, .faq-section,
|
| 975 |
+
.final-cta-section {
|
| 976 |
+
padding: 3rem 0;
|
| 977 |
+
}
|
| 978 |
+
|
| 979 |
+
.feature-card-enhanced, .use-case-card, .tech-spec-card {
|
| 980 |
+
margin-bottom: 2rem;
|
| 981 |
+
}
|
| 982 |
+
|
| 983 |
+
.code-card {
|
| 984 |
+
margin-bottom: 1.5rem;
|
| 985 |
+
}
|
| 986 |
+
|
| 987 |
+
.code-header {
|
| 988 |
+
flex-direction: column;
|
| 989 |
+
gap: 1rem;
|
| 990 |
+
text-align: center;
|
| 991 |
+
}
|
| 992 |
+
|
| 993 |
+
.quick-start-cta {
|
| 994 |
+
padding: 2rem 1rem;
|
| 995 |
+
}
|
| 996 |
+
|
| 997 |
+
.cta-buttons .btn {
|
| 998 |
+
width: 100%;
|
| 999 |
+
margin-bottom: 1rem;
|
| 1000 |
+
}
|
| 1001 |
+
|
| 1002 |
+
.navbar-nav {
|
| 1003 |
+
text-align: center;
|
| 1004 |
+
padding: 1rem 0;
|
| 1005 |
+
}
|
| 1006 |
+
|
| 1007 |
+
.toc {
|
| 1008 |
+
position: static;
|
| 1009 |
+
margin-bottom: 2rem;
|
| 1010 |
+
max-height: none;
|
| 1011 |
+
}
|
| 1012 |
+
}
|
| 1013 |
+
|
| 1014 |
+
@media (max-width: 576px) {
|
| 1015 |
+
.container {
|
| 1016 |
+
padding-left: 1rem;
|
| 1017 |
+
padding-right: 1rem;
|
| 1018 |
+
}
|
| 1019 |
+
|
| 1020 |
+
.hero-section {
|
| 1021 |
+
padding: 1.5rem 0;
|
| 1022 |
+
}
|
| 1023 |
+
|
| 1024 |
+
.display-3 {
|
| 1025 |
+
font-size: 1.75rem;
|
| 1026 |
+
}
|
| 1027 |
+
|
| 1028 |
+
.card-body {
|
| 1029 |
+
padding: 1.5rem;
|
| 1030 |
+
}
|
| 1031 |
+
|
| 1032 |
+
.feature-card-enhanced, .use-case-card, .tech-spec-card {
|
| 1033 |
+
padding: 1.5rem;
|
| 1034 |
+
}
|
| 1035 |
+
|
| 1036 |
+
.stat-number {
|
| 1037 |
+
font-size: 2.5rem;
|
| 1038 |
+
}
|
| 1039 |
+
|
| 1040 |
+
.hero-main-icon {
|
| 1041 |
+
width: 80px;
|
| 1042 |
+
height: 80px;
|
| 1043 |
+
font-size: 2rem;
|
| 1044 |
+
}
|
| 1045 |
+
|
| 1046 |
+
.pulse-ring {
|
| 1047 |
+
width: 100px;
|
| 1048 |
+
height: 100px;
|
| 1049 |
+
}
|
| 1050 |
+
}
|
| 1051 |
+
|
| 1052 |
+
/* Enhanced Accessibility */
|
| 1053 |
+
.btn:focus,
|
| 1054 |
+
.form-control:focus,
|
| 1055 |
+
.form-select:focus,
|
| 1056 |
+
.form-check-input:focus {
|
| 1057 |
+
outline: 3px solid rgba(99, 102, 241, 0.3);
|
| 1058 |
+
outline-offset: 2px;
|
| 1059 |
+
}
|
| 1060 |
+
|
| 1061 |
+
.btn:focus-visible,
|
| 1062 |
+
.form-control:focus-visible,
|
| 1063 |
+
.form-select:focus-visible {
|
| 1064 |
+
outline: 3px solid var(--primary-color);
|
| 1065 |
+
outline-offset: 2px;
|
| 1066 |
+
}
|
| 1067 |
+
|
| 1068 |
+
/* Skip to content link for screen readers */
|
| 1069 |
+
.skip-link {
|
| 1070 |
+
position: absolute;
|
| 1071 |
+
top: -40px;
|
| 1072 |
+
left: 6px;
|
| 1073 |
+
background: var(--primary-color);
|
| 1074 |
+
color: white;
|
| 1075 |
+
padding: 8px;
|
| 1076 |
+
text-decoration: none;
|
| 1077 |
+
border-radius: 4px;
|
| 1078 |
+
z-index: 1000;
|
| 1079 |
+
}
|
| 1080 |
+
|
| 1081 |
+
.skip-link:focus {
|
| 1082 |
+
top: 6px;
|
| 1083 |
+
}
|
| 1084 |
+
|
| 1085 |
+
/* Enhanced Animation Classes */
|
| 1086 |
+
.fade-in {
|
| 1087 |
+
animation: fadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
| 1088 |
+
}
|
| 1089 |
+
|
| 1090 |
+
@keyframes fadeIn {
|
| 1091 |
+
from {
|
| 1092 |
+
opacity: 0;
|
| 1093 |
+
transform: translateY(10px);
|
| 1094 |
+
}
|
| 1095 |
+
to {
|
| 1096 |
+
opacity: 1;
|
| 1097 |
+
transform: translateY(0);
|
| 1098 |
+
}
|
| 1099 |
+
}
|
| 1100 |
+
|
| 1101 |
+
.slide-up {
|
| 1102 |
+
animation: slideUp 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
| 1103 |
+
}
|
| 1104 |
+
|
| 1105 |
+
@keyframes slideUp {
|
| 1106 |
+
from {
|
| 1107 |
+
opacity: 0;
|
| 1108 |
+
transform: translateY(30px);
|
| 1109 |
+
}
|
| 1110 |
+
to {
|
| 1111 |
+
opacity: 1;
|
| 1112 |
+
transform: translateY(0);
|
| 1113 |
+
}
|
| 1114 |
+
}
|
| 1115 |
+
|
| 1116 |
+
.scale-in {
|
| 1117 |
+
animation: scaleIn 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
| 1118 |
+
}
|
| 1119 |
+
|
| 1120 |
+
@keyframes scaleIn {
|
| 1121 |
+
from {
|
| 1122 |
+
opacity: 0;
|
| 1123 |
+
transform: scale(0.9);
|
| 1124 |
+
}
|
| 1125 |
+
to {
|
| 1126 |
+
opacity: 1;
|
| 1127 |
+
transform: scale(1);
|
| 1128 |
+
}
|
| 1129 |
+
}
|
| 1130 |
+
|
| 1131 |
+
/* Enhanced Utility Classes */
|
| 1132 |
+
.text-gradient {
|
| 1133 |
+
background: var(--gradient-primary);
|
| 1134 |
+
-webkit-background-clip: text;
|
| 1135 |
+
-webkit-text-fill-color: transparent;
|
| 1136 |
+
background-clip: text;
|
| 1137 |
+
}
|
| 1138 |
+
|
| 1139 |
+
.text-gradient-secondary {
|
| 1140 |
+
background: var(--gradient-secondary);
|
| 1141 |
+
-webkit-background-clip: text;
|
| 1142 |
+
-webkit-text-fill-color: transparent;
|
| 1143 |
+
background-clip: text;
|
| 1144 |
+
}
|
| 1145 |
+
|
| 1146 |
+
.shadow-custom {
|
| 1147 |
+
box-shadow: var(--box-shadow);
|
| 1148 |
+
}
|
| 1149 |
+
|
| 1150 |
+
.shadow-lg-custom {
|
| 1151 |
+
box-shadow: var(--box-shadow-lg);
|
| 1152 |
+
}
|
| 1153 |
+
|
| 1154 |
+
.shadow-xl-custom {
|
| 1155 |
+
box-shadow: var(--box-shadow-xl);
|
| 1156 |
+
}
|
| 1157 |
+
|
| 1158 |
+
.border-radius-custom {
|
| 1159 |
+
border-radius: var(--border-radius);
|
| 1160 |
+
}
|
| 1161 |
+
|
| 1162 |
+
.bg-gradient-primary {
|
| 1163 |
+
background: var(--gradient-primary);
|
| 1164 |
+
}
|
| 1165 |
+
|
| 1166 |
+
.bg-gradient-secondary {
|
| 1167 |
+
background: var(--gradient-secondary);
|
| 1168 |
+
}
|
| 1169 |
+
|
| 1170 |
+
.bg-gradient-accent {
|
| 1171 |
+
background: var(--gradient-accent);
|
| 1172 |
+
}
|
| 1173 |
+
|
| 1174 |
+
/* Enhanced Progress Indicators */
|
| 1175 |
+
.progress-custom {
|
| 1176 |
+
height: 10px;
|
| 1177 |
+
border-radius: var(--border-radius-sm);
|
| 1178 |
+
background-color: #e2e8f0;
|
| 1179 |
+
overflow: hidden;
|
| 1180 |
+
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
|
| 1181 |
+
}
|
| 1182 |
+
|
| 1183 |
+
.progress-bar-custom {
|
| 1184 |
+
height: 100%;
|
| 1185 |
+
background: var(--gradient-primary);
|
| 1186 |
+
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
| 1187 |
+
position: relative;
|
| 1188 |
+
overflow: hidden;
|
| 1189 |
+
}
|
| 1190 |
+
|
| 1191 |
+
.progress-bar-custom::after {
|
| 1192 |
+
content: '';
|
| 1193 |
+
position: absolute;
|
| 1194 |
+
top: 0;
|
| 1195 |
+
left: 0;
|
| 1196 |
+
right: 0;
|
| 1197 |
+
bottom: 0;
|
| 1198 |
+
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
|
| 1199 |
+
animation: progress-shimmer 2s infinite;
|
| 1200 |
+
}
|
| 1201 |
+
|
| 1202 |
+
@keyframes progress-shimmer {
|
| 1203 |
+
0% { transform: translateX(-100%); }
|
| 1204 |
+
100% { transform: translateX(100%); }
|
| 1205 |
+
}
|
| 1206 |
+
|
| 1207 |
+
/* Enhanced Tooltip */
|
| 1208 |
+
.tooltip-inner {
|
| 1209 |
+
background-color: var(--dark-color);
|
| 1210 |
+
border-radius: var(--border-radius-sm);
|
| 1211 |
+
font-size: 0.875rem;
|
| 1212 |
+
padding: 0.5rem 0.75rem;
|
| 1213 |
+
box-shadow: var(--box-shadow);
|
| 1214 |
+
}
|
| 1215 |
+
|
| 1216 |
+
/* Enhanced Custom Scrollbar */
|
| 1217 |
+
::-webkit-scrollbar {
|
| 1218 |
+
width: 10px;
|
| 1219 |
+
height: 10px;
|
| 1220 |
+
}
|
| 1221 |
+
|
| 1222 |
+
::-webkit-scrollbar-track {
|
| 1223 |
+
background: var(--light-gray);
|
| 1224 |
+
border-radius: var(--border-radius-sm);
|
| 1225 |
+
}
|
| 1226 |
+
|
| 1227 |
+
::-webkit-scrollbar-thumb {
|
| 1228 |
+
background: var(--gradient-primary);
|
| 1229 |
+
border-radius: var(--border-radius-sm);
|
| 1230 |
+
border: 2px solid var(--light-gray);
|
| 1231 |
+
}
|
| 1232 |
+
|
| 1233 |
+
::-webkit-scrollbar-thumb:hover {
|
| 1234 |
+
background: var(--gradient-secondary);
|
| 1235 |
+
}
|
| 1236 |
+
|
| 1237 |
+
::-webkit-scrollbar-corner {
|
| 1238 |
+
background: var(--light-gray);
|
| 1239 |
+
}
|
| 1240 |
+
|
| 1241 |
+
/* Print Styles */
|
| 1242 |
+
@media print {
|
| 1243 |
+
.navbar, .footer, .hero-scroll-indicator, .floating-icon-container {
|
| 1244 |
+
display: none !important;
|
| 1245 |
+
}
|
| 1246 |
+
|
| 1247 |
+
.hero-section {
|
| 1248 |
+
background: white !important;
|
| 1249 |
+
color: black !important;
|
| 1250 |
+
padding: 1rem 0 !important;
|
| 1251 |
+
}
|
| 1252 |
+
|
| 1253 |
+
.card {
|
| 1254 |
+
box-shadow: none !important;
|
| 1255 |
+
border: 1px solid #ddd !important;
|
| 1256 |
+
}
|
| 1257 |
+
|
| 1258 |
+
.btn {
|
| 1259 |
+
border: 1px solid #ddd !important;
|
| 1260 |
+
background: white !important;
|
| 1261 |
+
color: black !important;
|
| 1262 |
+
}
|
| 1263 |
+
}
|
| 1264 |
+
|
| 1265 |
+
/* Playground-Specific Styles */
|
| 1266 |
+
.playground-visual {
|
| 1267 |
+
position: relative;
|
| 1268 |
+
display: flex;
|
| 1269 |
+
justify-content: center;
|
| 1270 |
+
align-items: center;
|
| 1271 |
+
height: 200px;
|
| 1272 |
+
}
|
| 1273 |
+
|
| 1274 |
+
.playground-icon {
|
| 1275 |
+
width: 100px;
|
| 1276 |
+
height: 100px;
|
| 1277 |
+
background: rgba(255, 255, 255, 0.15);
|
| 1278 |
+
border-radius: 50%;
|
| 1279 |
+
display: flex;
|
| 1280 |
+
align-items: center;
|
| 1281 |
+
justify-content: center;
|
| 1282 |
+
font-size: 2.5rem;
|
| 1283 |
+
color: white;
|
| 1284 |
+
backdrop-filter: blur(20px);
|
| 1285 |
+
border: 2px solid rgba(255, 255, 255, 0.3);
|
| 1286 |
+
position: relative;
|
| 1287 |
+
}
|
| 1288 |
+
|
| 1289 |
+
.audio-player-container {
|
| 1290 |
+
border: 2px solid #e2e8f0;
|
| 1291 |
+
transition: var(--transition);
|
| 1292 |
+
}
|
| 1293 |
+
|
| 1294 |
+
.audio-player-container:hover {
|
| 1295 |
+
border-color: var(--primary-color);
|
| 1296 |
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
| 1297 |
+
}
|
| 1298 |
+
|
| 1299 |
+
.stat-item {
|
| 1300 |
+
padding: 1rem;
|
| 1301 |
+
text-align: center;
|
| 1302 |
+
}
|
| 1303 |
+
|
| 1304 |
+
.stat-item i {
|
| 1305 |
+
font-size: 1.5rem;
|
| 1306 |
+
margin-bottom: 0.5rem;
|
| 1307 |
+
display: block;
|
| 1308 |
+
}
|
| 1309 |
+
|
| 1310 |
+
.stat-value {
|
| 1311 |
+
font-size: 1.25rem;
|
| 1312 |
+
font-weight: 700;
|
| 1313 |
+
color: var(--dark-color);
|
| 1314 |
+
margin-bottom: 0.25rem;
|
| 1315 |
+
}
|
| 1316 |
+
|
| 1317 |
+
.stat-label {
|
| 1318 |
+
font-size: 0.875rem;
|
| 1319 |
+
color: var(--text-muted);
|
| 1320 |
+
font-weight: 500;
|
| 1321 |
+
}
|
| 1322 |
+
|
| 1323 |
+
.card-header {
|
| 1324 |
+
border-bottom: none;
|
| 1325 |
+
border-radius: var(--border-radius) var(--border-radius) 0 0 !important;
|
| 1326 |
+
}
|
| 1327 |
+
|
| 1328 |
+
/* Enhanced Form Controls for Playground */
|
| 1329 |
+
.playground .form-control,
|
| 1330 |
+
.playground .form-select {
|
| 1331 |
+
border: 2px solid #e2e8f0;
|
| 1332 |
+
border-radius: var(--border-radius-sm);
|
| 1333 |
+
padding: 1rem;
|
| 1334 |
+
font-size: 1rem;
|
| 1335 |
+
transition: var(--transition);
|
| 1336 |
+
}
|
| 1337 |
+
|
| 1338 |
+
.playground .form-control:focus,
|
| 1339 |
+
.playground .form-select:focus {
|
| 1340 |
+
border-color: var(--primary-color);
|
| 1341 |
+
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1);
|
| 1342 |
+
transform: translateY(-1px);
|
| 1343 |
+
}
|
| 1344 |
+
|
| 1345 |
+
.playground .btn-group .btn {
|
| 1346 |
+
border-radius: var(--border-radius-sm);
|
| 1347 |
+
}
|
| 1348 |
+
|
| 1349 |
+
.playground .btn-group .btn:first-child {
|
| 1350 |
+
border-top-right-radius: 0;
|
| 1351 |
+
border-bottom-right-radius: 0;
|
| 1352 |
+
}
|
| 1353 |
+
|
| 1354 |
+
.playground .btn-group .btn:last-child {
|
| 1355 |
+
border-top-left-radius: 0;
|
| 1356 |
+
border-bottom-left-radius: 0;
|
| 1357 |
+
}
|
| 1358 |
+
|
| 1359 |
+
/* Audio Player Enhancements */
|
| 1360 |
+
audio::-webkit-media-controls-panel {
|
| 1361 |
+
background-color: var(--light-gray);
|
| 1362 |
+
border-radius: var(--border-radius-sm);
|
| 1363 |
+
}
|
| 1364 |
+
|
| 1365 |
+
audio::-webkit-media-controls-play-button,
|
| 1366 |
+
audio::-webkit-media-controls-pause-button {
|
| 1367 |
+
background-color: var(--primary-color);
|
| 1368 |
+
border-radius: 50%;
|
| 1369 |
+
}
|
| 1370 |
+
|
| 1371 |
+
audio::-webkit-media-controls-timeline {
|
| 1372 |
+
background-color: var(--light-gray);
|
| 1373 |
+
border-radius: var(--border-radius-sm);
|
| 1374 |
+
}
|
| 1375 |
+
|
| 1376 |
+
audio::-webkit-media-controls-current-time-display,
|
| 1377 |
+
audio::-webkit-media-controls-time-remaining-display {
|
| 1378 |
+
color: var(--text-color);
|
| 1379 |
+
font-weight: 500;
|
| 1380 |
+
}
|
| 1381 |
+
|
| 1382 |
+
/* Reduced Motion Support */
|
| 1383 |
+
@media (prefers-reduced-motion: reduce) {
|
| 1384 |
+
*,
|
| 1385 |
+
*::before,
|
| 1386 |
+
*::after {
|
| 1387 |
+
animation-duration: 0.01ms !important;
|
| 1388 |
+
animation-iteration-count: 1 !important;
|
| 1389 |
+
transition-duration: 0.01ms !important;
|
| 1390 |
+
}
|
| 1391 |
+
|
| 1392 |
+
.hero-background-animation,
|
| 1393 |
+
.floating-icon,
|
| 1394 |
+
.pulse-ring,
|
| 1395 |
+
.hero-scroll-indicator,
|
| 1396 |
+
.playground-icon {
|
| 1397 |
+
animation: none !important;
|
| 1398 |
+
}
|
| 1399 |
+
}
|
static/js/i18n.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// JavaScript Internationalization Support for TTSFM
|
| 2 |
+
|
| 3 |
+
// Translation data - this will be populated by the server
|
| 4 |
+
window.i18nData = window.i18nData || {};
|
| 5 |
+
|
| 6 |
+
// Current locale
|
| 7 |
+
window.currentLocale = document.documentElement.lang || 'en';
|
| 8 |
+
|
| 9 |
+
// Translation function
|
| 10 |
+
function _(key, params = {}) {
|
| 11 |
+
const keys = key.split('.');
|
| 12 |
+
let value = window.i18nData;
|
| 13 |
+
|
| 14 |
+
// Navigate through the nested object
|
| 15 |
+
for (const k of keys) {
|
| 16 |
+
if (value && typeof value === 'object' && k in value) {
|
| 17 |
+
value = value[k];
|
| 18 |
+
} else {
|
| 19 |
+
// Fallback to key if translation not found
|
| 20 |
+
return key;
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// If we found a string, apply parameters
|
| 25 |
+
if (typeof value === 'string') {
|
| 26 |
+
return formatString(value, params);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
// Fallback to key
|
| 30 |
+
return key;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
// Format string with parameters
|
| 34 |
+
function formatString(str, params) {
|
| 35 |
+
return str.replace(/\{(\w+)\}/g, (match, key) => {
|
| 36 |
+
return params.hasOwnProperty(key) ? params[key] : match;
|
| 37 |
+
});
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// Load translations from server
|
| 41 |
+
async function loadTranslations() {
|
| 42 |
+
try {
|
| 43 |
+
const response = await fetch(`/api/translations/${window.currentLocale}`);
|
| 44 |
+
if (response.ok) {
|
| 45 |
+
window.i18nData = await response.json();
|
| 46 |
+
}
|
| 47 |
+
} catch (error) {
|
| 48 |
+
console.warn('Failed to load translations:', error);
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// Sample texts for different languages
|
| 53 |
+
const sampleTexts = {
|
| 54 |
+
en: {
|
| 55 |
+
welcome: "Welcome to TTSFM! This is a free text-to-speech service that converts your text into high-quality audio using advanced AI technology.",
|
| 56 |
+
story: "Once upon a time, in a digital world far away, there lived a small Python package that could transform any text into beautiful speech. This package was called TTSFM, and it brought joy to developers everywhere.",
|
| 57 |
+
technical: "TTSFM is a Python client for text-to-speech APIs that provides both synchronous and asynchronous interfaces. It supports multiple voices and audio formats, making it perfect for various applications.",
|
| 58 |
+
multilingual: "TTSFM supports multiple languages and voices, allowing you to create diverse audio content for global audiences. The service is completely free and requires no API keys.",
|
| 59 |
+
long: "This is a longer text sample designed to test the auto-combine feature of TTSFM. When text exceeds the maximum length limit, TTSFM automatically splits it into smaller chunks, generates audio for each chunk, and then seamlessly combines them into a single audio file. This process is completely transparent to the user and ensures that you can convert text of any length without worrying about technical limitations. The resulting audio maintains consistent quality and natural flow throughout the entire content."
|
| 60 |
+
},
|
| 61 |
+
zh: {
|
| 62 |
+
welcome: "欢迎使用TTSFM!这是一个免费的文本转语音服务,使用先进的AI技术将您的文本转换为高质量音频。",
|
| 63 |
+
story: "很久很久以前,在一个遥远的数字世界里,住着一个小小的Python包,它能够将任何文本转换成美妙的语音。这个包叫做TTSFM,它为世界各地的开发者带来了快乐。",
|
| 64 |
+
technical: "TTSFM是一个用于文本转语音API的Python客户端,提供同步和异步接口。它支持多种声音和音频格式,非常适合各种应用。",
|
| 65 |
+
multilingual: "TTSFM支持多种语言和声音,让您能够为全球受众创建多样化的音频内容。该服务完全免费,无需API密钥。",
|
| 66 |
+
long: "这是一个较长的文本示例,用于测试TTSFM的自动合并功能。当文本超过最大长度限制时,TTSFM会自动将其分割成较小的片段,为每个片段生成音频,然后无缝地将它们合并成一个音频文件。这个过程对用户完全透明,确保您可以转换任何长度的文本,而无需担心技术限制。生成的音频在整个内容中保持一致的质量和自然的流畅性。"
|
| 67 |
+
}
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
// Get sample text for current locale
|
| 71 |
+
function getSampleText(type) {
|
| 72 |
+
const locale = window.currentLocale;
|
| 73 |
+
const texts = sampleTexts[locale] || sampleTexts.en;
|
| 74 |
+
return texts[type] || texts.welcome;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// Error messages
|
| 78 |
+
const errorMessages = {
|
| 79 |
+
en: {
|
| 80 |
+
empty_text: "Please enter some text to convert.",
|
| 81 |
+
generation_failed: "Failed to generate speech. Please try again.",
|
| 82 |
+
network_error: "Network error. Please check your connection and try again.",
|
| 83 |
+
invalid_format: "Invalid audio format selected.",
|
| 84 |
+
invalid_voice: "Invalid voice selected.",
|
| 85 |
+
text_too_long: "Text is too long. Please reduce the length or enable auto-combine.",
|
| 86 |
+
server_error: "Server error. Please try again later."
|
| 87 |
+
},
|
| 88 |
+
zh: {
|
| 89 |
+
empty_text: "请输入要转换的文本。",
|
| 90 |
+
generation_failed: "语音生成失败。请重试。",
|
| 91 |
+
network_error: "网络错误。请检查您的连接并重��。",
|
| 92 |
+
invalid_format: "选择的音频格式无效。",
|
| 93 |
+
invalid_voice: "选择的声音无效。",
|
| 94 |
+
text_too_long: "文本太长。请减少长度或启用自动合并。",
|
| 95 |
+
server_error: "服务器错误。请稍后重试。"
|
| 96 |
+
}
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
// Success messages
|
| 100 |
+
const successMessages = {
|
| 101 |
+
en: {
|
| 102 |
+
generation_complete: "Speech generated successfully!",
|
| 103 |
+
text_copied: "Text copied to clipboard!",
|
| 104 |
+
download_started: "Download started!"
|
| 105 |
+
},
|
| 106 |
+
zh: {
|
| 107 |
+
generation_complete: "语音生成成功!",
|
| 108 |
+
text_copied: "文本已复制到剪贴板!",
|
| 109 |
+
download_started: "下载已开始!"
|
| 110 |
+
}
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
// Get error message
|
| 114 |
+
function getErrorMessage(key) {
|
| 115 |
+
const locale = window.currentLocale;
|
| 116 |
+
const messages = errorMessages[locale] || errorMessages.en;
|
| 117 |
+
return messages[key] || key;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
// Get success message
|
| 121 |
+
function getSuccessMessage(key) {
|
| 122 |
+
const locale = window.currentLocale;
|
| 123 |
+
const messages = successMessages[locale] || successMessages.en;
|
| 124 |
+
return messages[key] || key;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
// Format file size
|
| 128 |
+
function formatFileSize(bytes) {
|
| 129 |
+
if (bytes === 0) return '0 Bytes';
|
| 130 |
+
|
| 131 |
+
const k = 1024;
|
| 132 |
+
const sizes = window.currentLocale === 'zh'
|
| 133 |
+
? ['字节', 'KB', 'MB', 'GB']
|
| 134 |
+
: ['Bytes', 'KB', 'MB', 'GB'];
|
| 135 |
+
|
| 136 |
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
| 137 |
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// Format duration
|
| 141 |
+
function formatDuration(seconds) {
|
| 142 |
+
if (isNaN(seconds) || seconds < 0) {
|
| 143 |
+
return window.currentLocale === 'zh' ? '未知' : 'Unknown';
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
const minutes = Math.floor(seconds / 60);
|
| 147 |
+
const remainingSeconds = Math.floor(seconds % 60);
|
| 148 |
+
|
| 149 |
+
if (minutes > 0) {
|
| 150 |
+
return window.currentLocale === 'zh'
|
| 151 |
+
? `${minutes}分${remainingSeconds}秒`
|
| 152 |
+
: `${minutes}m ${remainingSeconds}s`;
|
| 153 |
+
} else {
|
| 154 |
+
return window.currentLocale === 'zh'
|
| 155 |
+
? `${remainingSeconds}秒`
|
| 156 |
+
: `${remainingSeconds}s`;
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// Update UI text based on current locale
|
| 161 |
+
function updateUIText() {
|
| 162 |
+
// Update button texts
|
| 163 |
+
const generateBtn = document.getElementById('generate-btn');
|
| 164 |
+
if (generateBtn && !generateBtn.disabled) {
|
| 165 |
+
generateBtn.innerHTML = window.currentLocale === 'zh'
|
| 166 |
+
? '<i class="fas fa-magic me-2"></i>生成语音'
|
| 167 |
+
: '<i class="fas fa-magic me-2"></i>Generate Speech';
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
// Update other dynamic text elements
|
| 171 |
+
const charCountElement = document.querySelector('#char-count');
|
| 172 |
+
if (charCountElement) {
|
| 173 |
+
const count = charCountElement.textContent;
|
| 174 |
+
const parent = charCountElement.parentElement;
|
| 175 |
+
if (parent) {
|
| 176 |
+
// Escape HTML characters to prevent XSS
|
| 177 |
+
const escapedCount = count.replace(/&/g, '&')
|
| 178 |
+
.replace(/</g, '<')
|
| 179 |
+
.replace(/>/g, '>')
|
| 180 |
+
.replace(/"/g, '"')
|
| 181 |
+
.replace(/'/g, ''');
|
| 182 |
+
|
| 183 |
+
parent.innerHTML = window.currentLocale === 'zh'
|
| 184 |
+
? `<i class="fas fa-keyboard me-1"></i><span id="char-count">${escapedCount}</span> 字符`
|
| 185 |
+
: `<i class="fas fa-keyboard me-1"></i><span id="char-count">${escapedCount}</span> characters`;
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
// Initialize i18n
|
| 191 |
+
function initI18n() {
|
| 192 |
+
// Load translations if needed
|
| 193 |
+
loadTranslations();
|
| 194 |
+
|
| 195 |
+
// Update UI text
|
| 196 |
+
updateUIText();
|
| 197 |
+
|
| 198 |
+
// Listen for language changes
|
| 199 |
+
document.addEventListener('languageChanged', function(event) {
|
| 200 |
+
window.currentLocale = event.detail.locale;
|
| 201 |
+
loadTranslations().then(() => {
|
| 202 |
+
updateUIText();
|
| 203 |
+
});
|
| 204 |
+
});
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
// Export functions for global use
|
| 208 |
+
window._ = _;
|
| 209 |
+
window.getSampleText = getSampleText;
|
| 210 |
+
window.getErrorMessage = getErrorMessage;
|
| 211 |
+
window.getSuccessMessage = getSuccessMessage;
|
| 212 |
+
window.formatFileSize = formatFileSize;
|
| 213 |
+
window.formatDuration = formatDuration;
|
| 214 |
+
window.initI18n = initI18n;
|
| 215 |
+
|
| 216 |
+
// Auto-initialize when DOM is ready
|
| 217 |
+
if (document.readyState === 'loading') {
|
| 218 |
+
document.addEventListener('DOMContentLoaded', initI18n);
|
| 219 |
+
} else {
|
| 220 |
+
initI18n();
|
| 221 |
+
}
|
static/js/playground-enhanced-fixed.js
ADDED
|
@@ -0,0 +1,712 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// TTSFM Enhanced Playground with WebSocket Streaming Support - Fixed Version
|
| 2 |
+
|
| 3 |
+
// Global variables
|
| 4 |
+
let currentAudioBlob = null;
|
| 5 |
+
let currentFormat = 'mp3';
|
| 6 |
+
let batchResults = [];
|
| 7 |
+
let wsClient = null;
|
| 8 |
+
let streamingMode = false;
|
| 9 |
+
let currentStreamRequest = null;
|
| 10 |
+
|
| 11 |
+
// Initialize playground
|
| 12 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 13 |
+
initializePlayground();
|
| 14 |
+
initializeWebSocket();
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
// Initialize WebSocket client
|
| 18 |
+
function initializeWebSocket() {
|
| 19 |
+
// Check if Socket.IO is available
|
| 20 |
+
if (typeof io === 'undefined') {
|
| 21 |
+
console.warn('Socket.IO not loaded. WebSocket streaming will be disabled.');
|
| 22 |
+
return;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
// Initialize WebSocket client
|
| 26 |
+
wsClient = new WebSocketTTSClient({
|
| 27 |
+
socketUrl: window.location.origin,
|
| 28 |
+
debug: true,
|
| 29 |
+
onConnect: () => {
|
| 30 |
+
console.log('WebSocket connected');
|
| 31 |
+
updateStreamingStatus('connected');
|
| 32 |
+
},
|
| 33 |
+
onDisconnect: () => {
|
| 34 |
+
console.log('WebSocket disconnected');
|
| 35 |
+
updateStreamingStatus('disconnected');
|
| 36 |
+
},
|
| 37 |
+
onError: (error) => {
|
| 38 |
+
console.error('WebSocket error:', error);
|
| 39 |
+
updateStreamingStatus('error');
|
| 40 |
+
}
|
| 41 |
+
});
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// Update streaming status indicator
|
| 45 |
+
function updateStreamingStatus(status) {
|
| 46 |
+
const indicator = document.getElementById('streaming-indicator');
|
| 47 |
+
if (!indicator) return;
|
| 48 |
+
|
| 49 |
+
indicator.className = 'streaming-status';
|
| 50 |
+
switch(status) {
|
| 51 |
+
case 'connected':
|
| 52 |
+
indicator.classList.add('connected');
|
| 53 |
+
indicator.innerHTML = '<i class="fas fa-bolt"></i> Streaming Ready';
|
| 54 |
+
enableStreamingMode(true);
|
| 55 |
+
break;
|
| 56 |
+
case 'disconnected':
|
| 57 |
+
indicator.classList.add('disconnected');
|
| 58 |
+
indicator.innerHTML = '<i class="fas fa-plug"></i> Streaming Offline';
|
| 59 |
+
enableStreamingMode(false);
|
| 60 |
+
break;
|
| 61 |
+
case 'error':
|
| 62 |
+
indicator.classList.add('error');
|
| 63 |
+
indicator.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Connection Error';
|
| 64 |
+
enableStreamingMode(false);
|
| 65 |
+
break;
|
| 66 |
+
case 'streaming':
|
| 67 |
+
indicator.classList.add('streaming');
|
| 68 |
+
indicator.innerHTML = '<i class="fas fa-stream"></i> Streaming...';
|
| 69 |
+
break;
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// Enable/disable streaming mode
|
| 74 |
+
function enableStreamingMode(enabled) {
|
| 75 |
+
const streamToggle = document.getElementById('stream-mode-toggle');
|
| 76 |
+
if (streamToggle) {
|
| 77 |
+
streamToggle.disabled = !enabled;
|
| 78 |
+
if (!enabled && streamingMode) {
|
| 79 |
+
streamingMode = false;
|
| 80 |
+
streamToggle.checked = false;
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// Check authentication status
|
| 86 |
+
async function checkAuthStatus() {
|
| 87 |
+
try {
|
| 88 |
+
const response = await fetch('/api/auth-status');
|
| 89 |
+
const data = await response.json();
|
| 90 |
+
|
| 91 |
+
const apiKeySection = document.getElementById('api-key-section');
|
| 92 |
+
if (apiKeySection) {
|
| 93 |
+
if (data.api_key_required) {
|
| 94 |
+
apiKeySection.style.display = 'block';
|
| 95 |
+
const apiKeyInput = document.getElementById('api-key-input');
|
| 96 |
+
if (apiKeyInput) {
|
| 97 |
+
apiKeyInput.required = true;
|
| 98 |
+
}
|
| 99 |
+
} else {
|
| 100 |
+
apiKeySection.style.display = 'none';
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
} catch (error) {
|
| 104 |
+
console.warn('Could not check auth status:', error);
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
function initializePlayground() {
|
| 109 |
+
console.log('Initializing enhanced playground...');
|
| 110 |
+
checkAuthStatus();
|
| 111 |
+
loadVoices();
|
| 112 |
+
loadFormats();
|
| 113 |
+
updateCharCount();
|
| 114 |
+
setupEventListeners();
|
| 115 |
+
setupStreamingControls();
|
| 116 |
+
console.log('Enhanced playground initialization complete');
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
function setupStreamingControls() {
|
| 120 |
+
// Add streaming mode toggle
|
| 121 |
+
const generateButton = document.getElementById('generate-btn');
|
| 122 |
+
if (generateButton && generateButton.parentElement) {
|
| 123 |
+
const streamingControls = document.createElement('div');
|
| 124 |
+
streamingControls.className = 'streaming-controls mt-3';
|
| 125 |
+
streamingControls.innerHTML = `
|
| 126 |
+
<div class="form-check form-switch">
|
| 127 |
+
<input class="form-check-input" type="checkbox" id="stream-mode-toggle" disabled>
|
| 128 |
+
<label class="form-check-label" for="stream-mode-toggle">
|
| 129 |
+
<i class="fas fa-bolt me-1"></i>
|
| 130 |
+
Enable WebSocket Streaming
|
| 131 |
+
<small class="text-muted">(Real-time audio chunks)</small>
|
| 132 |
+
</label>
|
| 133 |
+
</div>
|
| 134 |
+
<div id="streaming-indicator" class="streaming-status mt-2"></div>
|
| 135 |
+
`;
|
| 136 |
+
generateButton.parentElement.appendChild(streamingControls);
|
| 137 |
+
|
| 138 |
+
// Add toggle event listener
|
| 139 |
+
const toggle = document.getElementById('stream-mode-toggle');
|
| 140 |
+
if (toggle) {
|
| 141 |
+
toggle.addEventListener('change', (e) => {
|
| 142 |
+
streamingMode = e.target.checked;
|
| 143 |
+
console.log('Streaming mode:', streamingMode ? 'ON' : 'OFF');
|
| 144 |
+
|
| 145 |
+
// Update button text
|
| 146 |
+
const btnText = generateButton.querySelector('.btn-text');
|
| 147 |
+
if (btnText) {
|
| 148 |
+
if (streamingMode) {
|
| 149 |
+
btnText.innerHTML = '<i class="fas fa-bolt me-2"></i>Stream Speech';
|
| 150 |
+
} else {
|
| 151 |
+
btnText.innerHTML = '<i class="fas fa-magic me-2"></i>' +
|
| 152 |
+
(window.currentLocale === 'zh' ? '生成语音' : 'Generate Speech');
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
});
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
// Add streaming progress section and error message div
|
| 160 |
+
const audioResult = document.getElementById('audio-result');
|
| 161 |
+
if (audioResult && audioResult.parentElement) {
|
| 162 |
+
// Add error message div
|
| 163 |
+
const errorDiv = document.createElement('div');
|
| 164 |
+
errorDiv.id = 'error-message';
|
| 165 |
+
errorDiv.className = 'alert alert-danger';
|
| 166 |
+
errorDiv.style.display = 'none';
|
| 167 |
+
audioResult.parentElement.insertBefore(errorDiv, audioResult);
|
| 168 |
+
|
| 169 |
+
// Add loading section
|
| 170 |
+
const loadingDiv = document.createElement('div');
|
| 171 |
+
loadingDiv.id = 'loading-section';
|
| 172 |
+
loadingDiv.className = 'text-center';
|
| 173 |
+
loadingDiv.style.display = 'none';
|
| 174 |
+
loadingDiv.innerHTML = `
|
| 175 |
+
<div class="spinner-border text-primary" role="status">
|
| 176 |
+
<span class="visually-hidden">Loading...</span>
|
| 177 |
+
</div>
|
| 178 |
+
<p class="mt-2">Generating speech...</p>
|
| 179 |
+
`;
|
| 180 |
+
audioResult.parentElement.insertBefore(loadingDiv, audioResult);
|
| 181 |
+
|
| 182 |
+
// Add progress section
|
| 183 |
+
const progressSection = document.createElement('div');
|
| 184 |
+
progressSection.id = 'streaming-progress';
|
| 185 |
+
progressSection.className = 'streaming-progress-section';
|
| 186 |
+
progressSection.style.display = 'none';
|
| 187 |
+
progressSection.innerHTML = `
|
| 188 |
+
<div class="card border-primary">
|
| 189 |
+
<div class="card-body">
|
| 190 |
+
<h5 class="card-title">
|
| 191 |
+
<i class="fas fa-stream me-2"></i>Streaming Progress
|
| 192 |
+
</h5>
|
| 193 |
+
<div class="progress mb-3" style="height: 25px;">
|
| 194 |
+
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
| 195 |
+
id="stream-progress-bar"
|
| 196 |
+
role="progressbar"
|
| 197 |
+
style="width: 0%">
|
| 198 |
+
<span id="stream-progress-text">0%</span>
|
| 199 |
+
</div>
|
| 200 |
+
</div>
|
| 201 |
+
<div class="row text-center">
|
| 202 |
+
<div class="col-md-4">
|
| 203 |
+
<h6>Chunks</h6>
|
| 204 |
+
<p class="h5"><span id="chunks-count">0</span> / <span id="total-chunks">0</span></p>
|
| 205 |
+
</div>
|
| 206 |
+
<div class="col-md-4">
|
| 207 |
+
<h6>Data</h6>
|
| 208 |
+
<p class="h5" id="data-transferred">0 KB</p>
|
| 209 |
+
</div>
|
| 210 |
+
<div class="col-md-4">
|
| 211 |
+
<h6>Time</h6>
|
| 212 |
+
<p class="h5" id="stream-time">0.0s</p>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
<div id="chunks-visualization" class="chunks-visual mt-3"></div>
|
| 216 |
+
</div>
|
| 217 |
+
</div>
|
| 218 |
+
`;
|
| 219 |
+
audioResult.parentElement.insertBefore(progressSection, audioResult);
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
function setupEventListeners() {
|
| 224 |
+
console.log('Setting up event listeners...');
|
| 225 |
+
|
| 226 |
+
// Form and input events
|
| 227 |
+
const textInput = document.getElementById('text-input');
|
| 228 |
+
if (textInput) {
|
| 229 |
+
textInput.addEventListener('input', updateCharCount);
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
// Form submit
|
| 233 |
+
const form = document.getElementById('tts-form');
|
| 234 |
+
if (form) {
|
| 235 |
+
form.addEventListener('submit', function(event) {
|
| 236 |
+
event.preventDefault();
|
| 237 |
+
event.stopPropagation();
|
| 238 |
+
|
| 239 |
+
if (streamingMode && wsClient && wsClient.isConnected()) {
|
| 240 |
+
generateSpeechStreaming(event);
|
| 241 |
+
} else {
|
| 242 |
+
generateSpeech(event);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
return false;
|
| 246 |
+
});
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
// Download button
|
| 250 |
+
const downloadBtn = document.getElementById('download-btn');
|
| 251 |
+
if (downloadBtn) {
|
| 252 |
+
downloadBtn.addEventListener('click', downloadAudio);
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
// Generate speech using WebSocket streaming
|
| 257 |
+
async function generateSpeechStreaming(event) {
|
| 258 |
+
event.preventDefault();
|
| 259 |
+
|
| 260 |
+
const text = document.getElementById('text-input').value.trim();
|
| 261 |
+
const voice = document.getElementById('voice-select').value;
|
| 262 |
+
const format = document.getElementById('format-select').value;
|
| 263 |
+
|
| 264 |
+
if (!text) {
|
| 265 |
+
showError('Please enter some text to convert');
|
| 266 |
+
return;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
// Reset UI
|
| 270 |
+
hideError();
|
| 271 |
+
hideResults();
|
| 272 |
+
disableForm();
|
| 273 |
+
|
| 274 |
+
// Show streaming progress
|
| 275 |
+
const progressSection = document.getElementById('streaming-progress');
|
| 276 |
+
if (progressSection) progressSection.style.display = 'block';
|
| 277 |
+
|
| 278 |
+
// Reset progress
|
| 279 |
+
updateStreamingProgress(0, 0, 0);
|
| 280 |
+
const chunksViz = document.getElementById('chunks-visualization');
|
| 281 |
+
if (chunksViz) chunksViz.innerHTML = '';
|
| 282 |
+
|
| 283 |
+
// Update status
|
| 284 |
+
updateStreamingStatus('streaming');
|
| 285 |
+
|
| 286 |
+
const startTime = Date.now();
|
| 287 |
+
let audioChunks = [];
|
| 288 |
+
|
| 289 |
+
try {
|
| 290 |
+
const result = await wsClient.generateSpeech(text, {
|
| 291 |
+
voice: voice,
|
| 292 |
+
format: format,
|
| 293 |
+
chunkSize: 512,
|
| 294 |
+
onStart: (data) => {
|
| 295 |
+
currentStreamRequest = data.request_id;
|
| 296 |
+
console.log('Streaming started:', data);
|
| 297 |
+
},
|
| 298 |
+
onProgress: (progress) => {
|
| 299 |
+
updateStreamingProgress(
|
| 300 |
+
progress.progress,
|
| 301 |
+
progress.chunksCompleted,
|
| 302 |
+
progress.totalChunks
|
| 303 |
+
);
|
| 304 |
+
|
| 305 |
+
const elapsed = (Date.now() - startTime) / 1000;
|
| 306 |
+
const timeEl = document.getElementById('stream-time');
|
| 307 |
+
if (timeEl) timeEl.textContent = `${elapsed.toFixed(1)}s`;
|
| 308 |
+
},
|
| 309 |
+
onChunk: (chunk) => {
|
| 310 |
+
// Visualize chunk
|
| 311 |
+
const chunksViz = document.getElementById('chunks-visualization');
|
| 312 |
+
if (chunksViz) {
|
| 313 |
+
const chunkViz = document.createElement('div');
|
| 314 |
+
chunkViz.className = 'chunk-indicator';
|
| 315 |
+
chunkViz.title = `Chunk ${chunk.chunkIndex + 1} - ${(chunk.audioData.byteLength / 1024).toFixed(1)}KB`;
|
| 316 |
+
chunkViz.innerHTML = `<i class="fas fa-music"></i>`;
|
| 317 |
+
chunksViz.appendChild(chunkViz);
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
// Update data transferred
|
| 321 |
+
const dataEl = document.getElementById('data-transferred');
|
| 322 |
+
if (dataEl) {
|
| 323 |
+
const currentData = parseFloat(dataEl.textContent) || 0;
|
| 324 |
+
const newData = currentData + (chunk.audioData.byteLength / 1024);
|
| 325 |
+
dataEl.textContent = `${newData.toFixed(1)} KB`;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
audioChunks.push(chunk);
|
| 329 |
+
},
|
| 330 |
+
onComplete: (result) => {
|
| 331 |
+
console.log('Streaming complete:', result);
|
| 332 |
+
|
| 333 |
+
// Create blob from audio data
|
| 334 |
+
currentAudioBlob = new Blob([result.audioData], { type: `audio/${result.format}` });
|
| 335 |
+
currentFormat = result.format;
|
| 336 |
+
|
| 337 |
+
// Show results
|
| 338 |
+
showResults(currentAudioBlob, result.format);
|
| 339 |
+
|
| 340 |
+
// Update final stats
|
| 341 |
+
const totalTime = (Date.now() - startTime) / 1000;
|
| 342 |
+
showStreamingStats({
|
| 343 |
+
chunks: result.chunks.length,
|
| 344 |
+
totalSize: (result.audioData.byteLength / 1024).toFixed(1),
|
| 345 |
+
totalTime: totalTime.toFixed(2),
|
| 346 |
+
format: result.format
|
| 347 |
+
});
|
| 348 |
+
},
|
| 349 |
+
onError: (error) => {
|
| 350 |
+
showError(`Streaming error: ${error.message}`);
|
| 351 |
+
enableForm();
|
| 352 |
+
if (progressSection) progressSection.style.display = 'none';
|
| 353 |
+
}
|
| 354 |
+
});
|
| 355 |
+
|
| 356 |
+
} catch (error) {
|
| 357 |
+
showError(`Failed to stream speech: ${error.message}`);
|
| 358 |
+
enableForm();
|
| 359 |
+
if (progressSection) progressSection.style.display = 'none';
|
| 360 |
+
} finally {
|
| 361 |
+
updateStreamingStatus('connected');
|
| 362 |
+
currentStreamRequest = null;
|
| 363 |
+
}
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
function updateStreamingProgress(progress, chunks, totalChunks) {
|
| 367 |
+
const progressBar = document.getElementById('stream-progress-bar');
|
| 368 |
+
const progressText = document.getElementById('stream-progress-text');
|
| 369 |
+
const chunksCount = document.getElementById('chunks-count');
|
| 370 |
+
const totalChunksEl = document.getElementById('total-chunks');
|
| 371 |
+
|
| 372 |
+
if (progressBar) {
|
| 373 |
+
progressBar.style.width = `${progress}%`;
|
| 374 |
+
if (progressText) progressText.textContent = `${progress}%`;
|
| 375 |
+
}
|
| 376 |
+
if (chunksCount) chunksCount.textContent = chunks;
|
| 377 |
+
if (totalChunksEl) totalChunksEl.textContent = totalChunks;
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
function showStreamingStats(stats) {
|
| 381 |
+
const progressSection = document.getElementById('streaming-progress');
|
| 382 |
+
if (!progressSection) return;
|
| 383 |
+
|
| 384 |
+
const statsHtml = `
|
| 385 |
+
<div class="alert alert-success mt-3">
|
| 386 |
+
<h6><i class="fas fa-check-circle me-2"></i>Streaming Complete!</h6>
|
| 387 |
+
<div class="row mt-2">
|
| 388 |
+
<div class="col-md-3">
|
| 389 |
+
<strong>Chunks:</strong> ${stats.chunks}
|
| 390 |
+
</div>
|
| 391 |
+
<div class="col-md-3">
|
| 392 |
+
<strong>Total Size:</strong> ${stats.totalSize} KB
|
| 393 |
+
</div>
|
| 394 |
+
<div class="col-md-3">
|
| 395 |
+
<strong>Time:</strong> ${stats.totalTime}s
|
| 396 |
+
</div>
|
| 397 |
+
<div class="col-md-3">
|
| 398 |
+
<strong>Format:</strong> ${stats.format.toUpperCase()}
|
| 399 |
+
</div>
|
| 400 |
+
</div>
|
| 401 |
+
</div>
|
| 402 |
+
`;
|
| 403 |
+
|
| 404 |
+
const statsDiv = document.createElement('div');
|
| 405 |
+
statsDiv.innerHTML = statsHtml;
|
| 406 |
+
progressSection.appendChild(statsDiv);
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
// Load available voices
|
| 410 |
+
async function loadVoices() {
|
| 411 |
+
try {
|
| 412 |
+
const response = await fetch('/api/voices');
|
| 413 |
+
const data = await response.json();
|
| 414 |
+
|
| 415 |
+
const voiceSelect = document.getElementById('voice-select');
|
| 416 |
+
if (voiceSelect) {
|
| 417 |
+
voiceSelect.innerHTML = '';
|
| 418 |
+
|
| 419 |
+
data.voices.forEach(voice => {
|
| 420 |
+
const option = document.createElement('option');
|
| 421 |
+
option.value = voice.id;
|
| 422 |
+
option.textContent = voice.name;
|
| 423 |
+
if (voice.id === 'alloy') {
|
| 424 |
+
option.selected = true;
|
| 425 |
+
}
|
| 426 |
+
voiceSelect.appendChild(option);
|
| 427 |
+
});
|
| 428 |
+
}
|
| 429 |
+
} catch (error) {
|
| 430 |
+
console.error('Failed to load voices:', error);
|
| 431 |
+
}
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
// Load available formats
|
| 435 |
+
async function loadFormats() {
|
| 436 |
+
try {
|
| 437 |
+
const response = await fetch('/api/formats');
|
| 438 |
+
const data = await response.json();
|
| 439 |
+
|
| 440 |
+
const formatSelect = document.getElementById('format-select');
|
| 441 |
+
if (formatSelect) {
|
| 442 |
+
formatSelect.innerHTML = '';
|
| 443 |
+
|
| 444 |
+
data.formats.forEach(format => {
|
| 445 |
+
const option = document.createElement('option');
|
| 446 |
+
option.value = format.id;
|
| 447 |
+
option.textContent = `${format.name} - ${format.quality}`;
|
| 448 |
+
if (format.id === 'mp3') {
|
| 449 |
+
option.selected = true;
|
| 450 |
+
}
|
| 451 |
+
formatSelect.appendChild(option);
|
| 452 |
+
});
|
| 453 |
+
}
|
| 454 |
+
} catch (error) {
|
| 455 |
+
console.error('Failed to load formats:', error);
|
| 456 |
+
}
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
// Update character count
|
| 460 |
+
function updateCharCount() {
|
| 461 |
+
const textInput = document.getElementById('text-input');
|
| 462 |
+
const charCount = document.getElementById('char-count');
|
| 463 |
+
const maxLengthInput = document.getElementById('max-length-input');
|
| 464 |
+
|
| 465 |
+
if (textInput && charCount) {
|
| 466 |
+
const currentLength = textInput.value.length;
|
| 467 |
+
const maxLength = maxLengthInput ? parseInt(maxLengthInput.value) : 4096;
|
| 468 |
+
|
| 469 |
+
charCount.textContent = currentLength;
|
| 470 |
+
|
| 471 |
+
if (currentLength > maxLength) {
|
| 472 |
+
charCount.className = 'text-danger fw-bold';
|
| 473 |
+
} else if (currentLength > maxLength * 0.8) {
|
| 474 |
+
charCount.className = 'text-warning fw-bold';
|
| 475 |
+
} else {
|
| 476 |
+
charCount.className = '';
|
| 477 |
+
}
|
| 478 |
+
}
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
// Generate speech (original HTTP method)
|
| 482 |
+
async function generateSpeech(event) {
|
| 483 |
+
event.preventDefault();
|
| 484 |
+
|
| 485 |
+
const text = document.getElementById('text-input').value.trim();
|
| 486 |
+
const voice = document.getElementById('voice-select').value;
|
| 487 |
+
const format = document.getElementById('format-select').value;
|
| 488 |
+
const instructions = document.getElementById('instructions-input')?.value.trim() || '';
|
| 489 |
+
const apiKey = document.getElementById('api-key-input')?.value.trim() || '';
|
| 490 |
+
|
| 491 |
+
if (!text) {
|
| 492 |
+
showError('Please enter some text to convert');
|
| 493 |
+
return;
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
hideError();
|
| 497 |
+
hideResults();
|
| 498 |
+
showLoading();
|
| 499 |
+
disableForm();
|
| 500 |
+
|
| 501 |
+
try {
|
| 502 |
+
const headers = {
|
| 503 |
+
'Content-Type': 'application/json'
|
| 504 |
+
};
|
| 505 |
+
|
| 506 |
+
if (apiKey) {
|
| 507 |
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
const requestBody = {
|
| 511 |
+
text: text,
|
| 512 |
+
voice: voice,
|
| 513 |
+
format: format
|
| 514 |
+
};
|
| 515 |
+
|
| 516 |
+
if (instructions) {
|
| 517 |
+
requestBody.instructions = instructions;
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
const response = await fetch('/api/generate', {
|
| 521 |
+
method: 'POST',
|
| 522 |
+
headers: headers,
|
| 523 |
+
body: JSON.stringify(requestBody)
|
| 524 |
+
});
|
| 525 |
+
|
| 526 |
+
if (!response.ok) {
|
| 527 |
+
let errorMessage = `Error: ${response.status} ${response.statusText}`;
|
| 528 |
+
try {
|
| 529 |
+
const errorData = await response.json();
|
| 530 |
+
if (errorData.error?.message) {
|
| 531 |
+
errorMessage = errorData.error.message;
|
| 532 |
+
}
|
| 533 |
+
} catch (e) {
|
| 534 |
+
// Use default error message
|
| 535 |
+
}
|
| 536 |
+
throw new Error(errorMessage);
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
const blob = await response.blob();
|
| 540 |
+
currentAudioBlob = blob;
|
| 541 |
+
currentFormat = format;
|
| 542 |
+
|
| 543 |
+
showResults(blob, format);
|
| 544 |
+
|
| 545 |
+
} catch (error) {
|
| 546 |
+
showError(error.message);
|
| 547 |
+
} finally {
|
| 548 |
+
hideLoading();
|
| 549 |
+
enableForm();
|
| 550 |
+
}
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
// Show/hide functions
|
| 554 |
+
function showLoading() {
|
| 555 |
+
const loading = document.getElementById('loading-section');
|
| 556 |
+
if (loading) loading.style.display = 'block';
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
function hideLoading() {
|
| 560 |
+
const loading = document.getElementById('loading-section');
|
| 561 |
+
if (loading) loading.style.display = 'none';
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
function showResults(blob, format) {
|
| 565 |
+
const audioUrl = URL.createObjectURL(blob);
|
| 566 |
+
const audioPlayer = document.getElementById('audio-player');
|
| 567 |
+
if (audioPlayer) {
|
| 568 |
+
audioPlayer.src = audioUrl;
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
const audioResult = document.getElementById('audio-result');
|
| 572 |
+
if (audioResult) {
|
| 573 |
+
audioResult.classList.remove('d-none');
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
const downloadBtn = document.getElementById('download-btn');
|
| 577 |
+
if (downloadBtn) {
|
| 578 |
+
downloadBtn.disabled = false;
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
enableForm();
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
function hideResults() {
|
| 585 |
+
const audioResult = document.getElementById('audio-result');
|
| 586 |
+
if (audioResult) {
|
| 587 |
+
audioResult.classList.add('d-none');
|
| 588 |
+
}
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
function showError(message) {
|
| 592 |
+
const errorDiv = document.getElementById('error-message');
|
| 593 |
+
if (errorDiv) {
|
| 594 |
+
errorDiv.textContent = message;
|
| 595 |
+
errorDiv.style.display = 'block';
|
| 596 |
+
}
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
function hideError() {
|
| 600 |
+
const errorDiv = document.getElementById('error-message');
|
| 601 |
+
if (errorDiv) {
|
| 602 |
+
errorDiv.style.display = 'none';
|
| 603 |
+
}
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
function disableForm() {
|
| 607 |
+
const elements = ['generate-btn', 'text-input', 'voice-select', 'format-select'];
|
| 608 |
+
elements.forEach(id => {
|
| 609 |
+
const el = document.getElementById(id);
|
| 610 |
+
if (el) el.disabled = true;
|
| 611 |
+
});
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
function enableForm() {
|
| 615 |
+
const elements = ['generate-btn', 'text-input', 'voice-select', 'format-select'];
|
| 616 |
+
elements.forEach(id => {
|
| 617 |
+
const el = document.getElementById(id);
|
| 618 |
+
if (el) el.disabled = false;
|
| 619 |
+
});
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
// Download audio
|
| 623 |
+
function downloadAudio() {
|
| 624 |
+
if (!currentAudioBlob) return;
|
| 625 |
+
|
| 626 |
+
const url = URL.createObjectURL(currentAudioBlob);
|
| 627 |
+
const a = document.createElement('a');
|
| 628 |
+
a.href = url;
|
| 629 |
+
a.download = `tts_${Date.now()}.${currentFormat}`;
|
| 630 |
+
a.click();
|
| 631 |
+
URL.revokeObjectURL(url);
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
// Add CSS for streaming visualization
|
| 635 |
+
const style = document.createElement('style');
|
| 636 |
+
style.textContent = `
|
| 637 |
+
.streaming-controls {
|
| 638 |
+
padding: 15px;
|
| 639 |
+
background-color: #f8f9fa;
|
| 640 |
+
border-radius: 8px;
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
.streaming-status {
|
| 644 |
+
display: inline-block;
|
| 645 |
+
padding: 5px 10px;
|
| 646 |
+
border-radius: 20px;
|
| 647 |
+
font-size: 0.875rem;
|
| 648 |
+
font-weight: 500;
|
| 649 |
+
}
|
| 650 |
+
|
| 651 |
+
.streaming-status.connected {
|
| 652 |
+
background-color: #d4edda;
|
| 653 |
+
color: #155724;
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
.streaming-status.disconnected {
|
| 657 |
+
background-color: #f8d7da;
|
| 658 |
+
color: #721c24;
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
.streaming-status.error {
|
| 662 |
+
background-color: #fff3cd;
|
| 663 |
+
color: #856404;
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
.streaming-status.streaming {
|
| 667 |
+
background-color: #cce5ff;
|
| 668 |
+
color: #004085;
|
| 669 |
+
animation: pulse 1.5s infinite;
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
@keyframes pulse {
|
| 673 |
+
0% { opacity: 1; }
|
| 674 |
+
50% { opacity: 0.7; }
|
| 675 |
+
100% { opacity: 1; }
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
.streaming-progress-section {
|
| 679 |
+
margin-bottom: 20px;
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
.chunks-visual {
|
| 683 |
+
display: flex;
|
| 684 |
+
flex-wrap: wrap;
|
| 685 |
+
gap: 5px;
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
.chunk-indicator {
|
| 689 |
+
width: 30px;
|
| 690 |
+
height: 30px;
|
| 691 |
+
background-color: #007bff;
|
| 692 |
+
color: white;
|
| 693 |
+
border-radius: 4px;
|
| 694 |
+
display: flex;
|
| 695 |
+
align-items: center;
|
| 696 |
+
justify-content: center;
|
| 697 |
+
font-size: 0.75rem;
|
| 698 |
+
animation: chunkAppear 0.3s ease-out;
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
@keyframes chunkAppear {
|
| 702 |
+
from {
|
| 703 |
+
transform: scale(0);
|
| 704 |
+
opacity: 0;
|
| 705 |
+
}
|
| 706 |
+
to {
|
| 707 |
+
transform: scale(1);
|
| 708 |
+
opacity: 1;
|
| 709 |
+
}
|
| 710 |
+
}
|
| 711 |
+
`;
|
| 712 |
+
document.head.appendChild(style);
|
static/js/playground.js
ADDED
|
@@ -0,0 +1,861 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// TTSFM Playground JavaScript
|
| 2 |
+
|
| 3 |
+
// Global variables
|
| 4 |
+
let currentAudioBlob = null;
|
| 5 |
+
let currentFormat = 'mp3';
|
| 6 |
+
let batchResults = [];
|
| 7 |
+
|
| 8 |
+
// Initialize playground
|
| 9 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 10 |
+
initializePlayground();
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
// Check authentication status and show/hide API key field
|
| 14 |
+
async function checkAuthStatus() {
|
| 15 |
+
try {
|
| 16 |
+
const response = await fetch('/api/auth-status');
|
| 17 |
+
const data = await response.json();
|
| 18 |
+
|
| 19 |
+
const apiKeySection = document.getElementById('api-key-section');
|
| 20 |
+
if (apiKeySection) {
|
| 21 |
+
if (data.api_key_required) {
|
| 22 |
+
// Show API key field and mark as required
|
| 23 |
+
apiKeySection.style.display = 'block';
|
| 24 |
+
const apiKeyInput = document.getElementById('api-key-input');
|
| 25 |
+
const label = apiKeySection.querySelector('label');
|
| 26 |
+
|
| 27 |
+
if (apiKeyInput) {
|
| 28 |
+
apiKeyInput.required = true;
|
| 29 |
+
apiKeyInput.placeholder = 'Enter your API key (required)';
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
if (label) {
|
| 33 |
+
label.innerHTML = '<i class="fas fa-key me-2"></i>' + (window.currentLocale === 'zh' ? 'API密钥(必需)' : 'API Key (Required)');
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// Update form text
|
| 37 |
+
const formText = apiKeySection.querySelector('.form-text');
|
| 38 |
+
if (formText) {
|
| 39 |
+
formText.innerHTML = '<i class="fas fa-exclamation-triangle me-1 text-warning"></i>API key protection is enabled - this field is required';
|
| 40 |
+
}
|
| 41 |
+
} else {
|
| 42 |
+
// Hide API key field or mark as optional
|
| 43 |
+
apiKeySection.style.display = 'none';
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
} catch (error) {
|
| 47 |
+
console.warn('Could not check auth status:', error);
|
| 48 |
+
// If we can't check, assume API key might be required and show the field
|
| 49 |
+
const apiKeySection = document.getElementById('api-key-section');
|
| 50 |
+
if (apiKeySection) {
|
| 51 |
+
apiKeySection.style.display = 'block';
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function initializePlayground() {
|
| 57 |
+
console.log('Initializing playground...');
|
| 58 |
+
checkAuthStatus();
|
| 59 |
+
loadVoices();
|
| 60 |
+
loadFormats();
|
| 61 |
+
updateCharCount();
|
| 62 |
+
setupEventListeners();
|
| 63 |
+
console.log('Playground initialization complete');
|
| 64 |
+
|
| 65 |
+
// Initialize tooltips if Bootstrap is available
|
| 66 |
+
if (typeof bootstrap !== 'undefined') {
|
| 67 |
+
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
| 68 |
+
tooltipTriggerList.map(function (tooltipTriggerEl) {
|
| 69 |
+
return new bootstrap.Tooltip(tooltipTriggerEl);
|
| 70 |
+
});
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function setupEventListeners() {
|
| 75 |
+
console.log('Setting up event listeners...');
|
| 76 |
+
|
| 77 |
+
// Form and input events
|
| 78 |
+
const textInput = document.getElementById('text-input');
|
| 79 |
+
if (textInput) {
|
| 80 |
+
textInput.addEventListener('input', updateCharCount);
|
| 81 |
+
console.log('Text input event listener added');
|
| 82 |
+
} else {
|
| 83 |
+
console.error('Text input element not found!');
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// Add form submit event listener with better error handling
|
| 87 |
+
const form = document.getElementById('tts-form');
|
| 88 |
+
if (form) {
|
| 89 |
+
form.addEventListener('submit', function(event) {
|
| 90 |
+
console.log('Form submit event triggered');
|
| 91 |
+
event.preventDefault(); // Prevent default form submission
|
| 92 |
+
event.stopPropagation(); // Stop event bubbling
|
| 93 |
+
generateSpeech(event);
|
| 94 |
+
return false; // Additional prevention
|
| 95 |
+
});
|
| 96 |
+
} else {
|
| 97 |
+
console.error('TTS form not found!');
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
const maxLengthInput = document.getElementById('max-length-input');
|
| 101 |
+
if (maxLengthInput) {
|
| 102 |
+
maxLengthInput.addEventListener('input', updateCharCount);
|
| 103 |
+
console.log('Max length input event listener added');
|
| 104 |
+
} else {
|
| 105 |
+
console.error('Max length input element not found!');
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
const autoCombineCheck = document.getElementById('auto-combine-check');
|
| 109 |
+
if (autoCombineCheck) {
|
| 110 |
+
autoCombineCheck.addEventListener('change', updateAutoCombineStatus);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// Enhanced button events
|
| 114 |
+
const validateBtn = document.getElementById('validate-text-btn');
|
| 115 |
+
if (validateBtn) {
|
| 116 |
+
validateBtn.addEventListener('click', validateText);
|
| 117 |
+
console.log('Validate button event listener added');
|
| 118 |
+
} else {
|
| 119 |
+
console.error('Validate button not found!');
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
const randomBtn = document.getElementById('random-text-btn');
|
| 123 |
+
if (randomBtn) {
|
| 124 |
+
randomBtn.addEventListener('click', loadRandomText);
|
| 125 |
+
console.log('Random text button event listener added');
|
| 126 |
+
} else {
|
| 127 |
+
console.error('Random text button not found!');
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
const downloadBtn = document.getElementById('download-btn');
|
| 131 |
+
if (downloadBtn) {
|
| 132 |
+
downloadBtn.addEventListener('click', downloadAudio);
|
| 133 |
+
console.log('Download button event listener added');
|
| 134 |
+
} else {
|
| 135 |
+
console.error('Download button not found!');
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// Add direct click event listener for generate button as backup
|
| 139 |
+
const generateBtn = document.getElementById('generate-btn');
|
| 140 |
+
if (generateBtn) {
|
| 141 |
+
generateBtn.addEventListener('click', function(event) {
|
| 142 |
+
console.log('Generate button clicked directly');
|
| 143 |
+
event.preventDefault();
|
| 144 |
+
event.stopPropagation();
|
| 145 |
+
generateSpeech(event);
|
| 146 |
+
return false;
|
| 147 |
+
});
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
// New button events
|
| 151 |
+
const clearTextBtn = document.getElementById('clear-text-btn');
|
| 152 |
+
if (clearTextBtn) {
|
| 153 |
+
clearTextBtn.addEventListener('click', clearText);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
const resetFormBtn = document.getElementById('reset-form-btn');
|
| 159 |
+
if (resetFormBtn) {
|
| 160 |
+
resetFormBtn.addEventListener('click', resetForm);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
const replayBtn = document.getElementById('replay-btn');
|
| 164 |
+
if (replayBtn) {
|
| 165 |
+
replayBtn.addEventListener('click', replayAudio);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
const shareBtn = document.getElementById('share-btn');
|
| 169 |
+
if (shareBtn) {
|
| 170 |
+
shareBtn.addEventListener('click', shareAudio);
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
// API Key visibility toggle
|
| 174 |
+
const toggleApiKeyBtn = document.getElementById('toggle-api-key-visibility');
|
| 175 |
+
if (toggleApiKeyBtn) {
|
| 176 |
+
toggleApiKeyBtn.addEventListener('click', toggleApiKeyVisibility);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// Voice and format selection events
|
| 180 |
+
const voiceSelect = document.getElementById('voice-select');
|
| 181 |
+
if (voiceSelect) {
|
| 182 |
+
voiceSelect.addEventListener('change', updateVoiceInfo);
|
| 183 |
+
console.log('Voice select event listener added');
|
| 184 |
+
} else {
|
| 185 |
+
console.error('Voice select element not found!');
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
const formatSelect = document.getElementById('format-select');
|
| 189 |
+
if (formatSelect) {
|
| 190 |
+
formatSelect.addEventListener('change', updateFormatInfo);
|
| 191 |
+
console.log('Format select event listener added');
|
| 192 |
+
} else {
|
| 193 |
+
console.error('Format select element not found!');
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
// Example text buttons
|
| 197 |
+
document.querySelectorAll('.use-example').forEach(button => {
|
| 198 |
+
button.addEventListener('click', function() {
|
| 199 |
+
document.getElementById('text-input').value = this.dataset.text;
|
| 200 |
+
updateCharCount();
|
| 201 |
+
// Add visual feedback
|
| 202 |
+
this.classList.add('btn-success');
|
| 203 |
+
setTimeout(() => {
|
| 204 |
+
this.classList.remove('btn-success');
|
| 205 |
+
this.classList.add('btn-outline-primary');
|
| 206 |
+
}, 1000);
|
| 207 |
+
});
|
| 208 |
+
});
|
| 209 |
+
|
| 210 |
+
// Keyboard shortcuts
|
| 211 |
+
document.addEventListener('keydown', function(e) {
|
| 212 |
+
// Ctrl/Cmd + Enter to generate speech
|
| 213 |
+
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
| 214 |
+
e.preventDefault();
|
| 215 |
+
document.getElementById('generate-btn').click();
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
// Escape to clear results
|
| 219 |
+
if (e.key === 'Escape') {
|
| 220 |
+
clearResults();
|
| 221 |
+
}
|
| 222 |
+
});
|
| 223 |
+
|
| 224 |
+
// Initialize auto-combine status
|
| 225 |
+
updateAutoCombineStatus();
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
async function loadVoices() {
|
| 229 |
+
try {
|
| 230 |
+
// Prepare headers for API key if available (OpenAI compatible format)
|
| 231 |
+
const headers = {};
|
| 232 |
+
const apiKeyInput = document.getElementById('api-key-input');
|
| 233 |
+
if (apiKeyInput && apiKeyInput.value.trim()) {
|
| 234 |
+
headers['Authorization'] = `Bearer ${apiKeyInput.value.trim()}`;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
const response = await fetch('/api/voices', { headers });
|
| 238 |
+
const data = await response.json();
|
| 239 |
+
|
| 240 |
+
const select = document.getElementById('voice-select');
|
| 241 |
+
select.innerHTML = '';
|
| 242 |
+
|
| 243 |
+
data.voices.forEach(voice => {
|
| 244 |
+
const option = document.createElement('option');
|
| 245 |
+
option.value = voice.id;
|
| 246 |
+
option.textContent = `${voice.name} - ${voice.description}`;
|
| 247 |
+
select.appendChild(option);
|
| 248 |
+
});
|
| 249 |
+
|
| 250 |
+
// Select default voice
|
| 251 |
+
select.value = 'alloy';
|
| 252 |
+
|
| 253 |
+
} catch (error) {
|
| 254 |
+
console.error('Failed to load voices:', error);
|
| 255 |
+
console.log('Failed to load voices. Please refresh the page.');
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
async function loadFormats() {
|
| 260 |
+
try {
|
| 261 |
+
// Prepare headers for API key if available (OpenAI compatible format)
|
| 262 |
+
const headers = {};
|
| 263 |
+
const apiKeyInput = document.getElementById('api-key-input');
|
| 264 |
+
if (apiKeyInput && apiKeyInput.value.trim()) {
|
| 265 |
+
headers['Authorization'] = `Bearer ${apiKeyInput.value.trim()}`;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
const response = await fetch('/api/formats', { headers });
|
| 269 |
+
const data = await response.json();
|
| 270 |
+
|
| 271 |
+
const select = document.getElementById('format-select');
|
| 272 |
+
select.innerHTML = '';
|
| 273 |
+
|
| 274 |
+
data.formats.forEach(format => {
|
| 275 |
+
const option = document.createElement('option');
|
| 276 |
+
option.value = format.id;
|
| 277 |
+
option.textContent = `${format.name} - ${format.description}`;
|
| 278 |
+
select.appendChild(option);
|
| 279 |
+
});
|
| 280 |
+
|
| 281 |
+
// Select default format
|
| 282 |
+
select.value = 'mp3';
|
| 283 |
+
updateFormatInfo();
|
| 284 |
+
|
| 285 |
+
} catch (error) {
|
| 286 |
+
console.error('Failed to load formats:', error);
|
| 287 |
+
console.log('Failed to load formats. Please refresh the page.');
|
| 288 |
+
}
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
function updateCharCount() {
|
| 292 |
+
const textInput = document.getElementById('text-input');
|
| 293 |
+
const maxLengthInput = document.getElementById('max-length-input');
|
| 294 |
+
const charCountElement = document.getElementById('char-count');
|
| 295 |
+
|
| 296 |
+
if (!textInput || !maxLengthInput || !charCountElement) {
|
| 297 |
+
console.warn('Required elements not found for updateCharCount');
|
| 298 |
+
return;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
const text = textInput.value;
|
| 302 |
+
const maxLength = parseInt(maxLengthInput.value) || 4096;
|
| 303 |
+
const charCount = text.length;
|
| 304 |
+
|
| 305 |
+
charCountElement.textContent = charCount.toLocaleString();
|
| 306 |
+
|
| 307 |
+
// Update length status with better visual feedback
|
| 308 |
+
const statusElement = document.getElementById('length-status');
|
| 309 |
+
if (statusElement) {
|
| 310 |
+
const percentage = (charCount / maxLength) * 100;
|
| 311 |
+
|
| 312 |
+
if (charCount > maxLength) {
|
| 313 |
+
statusElement.innerHTML = '<span class="badge bg-danger"><i class="fas fa-exclamation-triangle me-1"></i>Exceeds limit</span>';
|
| 314 |
+
} else if (percentage > 80) {
|
| 315 |
+
statusElement.innerHTML = '<span class="badge bg-warning"><i class="fas fa-exclamation me-1"></i>Near limit</span>';
|
| 316 |
+
} else if (percentage > 50) {
|
| 317 |
+
statusElement.innerHTML = '<span class="badge bg-info"><i class="fas fa-info me-1"></i>Good</span>';
|
| 318 |
+
} else {
|
| 319 |
+
statusElement.innerHTML = '<span class="badge bg-success"><i class="fas fa-check me-1"></i>OK</span>';
|
| 320 |
+
}
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
updateGenerateButton();
|
| 324 |
+
updateAutoCombineStatus();
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
function updateGenerateButton() {
|
| 328 |
+
const text = document.getElementById('text-input').value;
|
| 329 |
+
const maxLength = parseInt(document.getElementById('max-length-input').value) || 4096;
|
| 330 |
+
const autoCombineCheck = document.getElementById('auto-combine-check');
|
| 331 |
+
const autoCombine = autoCombineCheck ? autoCombineCheck.checked : false;
|
| 332 |
+
const generateBtn = document.getElementById('generate-btn');
|
| 333 |
+
|
| 334 |
+
if (!generateBtn) {
|
| 335 |
+
console.warn('Generate button not found');
|
| 336 |
+
return;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
const btnText = generateBtn.querySelector('.btn-text');
|
| 340 |
+
|
| 341 |
+
if (!btnText) {
|
| 342 |
+
console.warn('Button text element not found');
|
| 343 |
+
return;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
if (text.length > maxLength && autoCombine) {
|
| 347 |
+
btnText.innerHTML = '<i class="fas fa-magic me-2"></i>Generate Speech (Auto-Combine)';
|
| 348 |
+
generateBtn.classList.add('btn-warning');
|
| 349 |
+
generateBtn.classList.remove('btn-primary');
|
| 350 |
+
} else {
|
| 351 |
+
btnText.innerHTML = '<i class="fas fa-magic me-2"></i>Generate Speech';
|
| 352 |
+
generateBtn.classList.add('btn-primary');
|
| 353 |
+
generateBtn.classList.remove('btn-warning');
|
| 354 |
+
}
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
async function validateText() {
|
| 358 |
+
const text = document.getElementById('text-input').value.trim();
|
| 359 |
+
const maxLength = parseInt(document.getElementById('max-length-input').value) || 4096;
|
| 360 |
+
|
| 361 |
+
if (!text) {
|
| 362 |
+
console.log('Please enter some text to validate');
|
| 363 |
+
return;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
const validateBtn = document.getElementById('validate-text-btn');
|
| 367 |
+
setLoading(validateBtn, true);
|
| 368 |
+
|
| 369 |
+
try {
|
| 370 |
+
const response = await fetch('/api/validate-text', {
|
| 371 |
+
method: 'POST',
|
| 372 |
+
headers: { 'Content-Type': 'application/json' },
|
| 373 |
+
body: JSON.stringify({ text, max_length: maxLength })
|
| 374 |
+
});
|
| 375 |
+
|
| 376 |
+
const data = await response.json();
|
| 377 |
+
const resultDiv = document.getElementById('validation-result');
|
| 378 |
+
|
| 379 |
+
if (data.is_valid) {
|
| 380 |
+
resultDiv.innerHTML = `
|
| 381 |
+
<div class="alert alert-success fade-in">
|
| 382 |
+
<i class="fas fa-check-circle me-2"></i>
|
| 383 |
+
<strong>Text is valid!</strong> (${data.text_length.toLocaleString()} characters)
|
| 384 |
+
<div class="progress progress-custom mt-2">
|
| 385 |
+
<div class="progress-bar-custom" style="width: ${(data.text_length / data.max_length) * 100}%"></div>
|
| 386 |
+
</div>
|
| 387 |
+
</div>
|
| 388 |
+
`;
|
| 389 |
+
} else {
|
| 390 |
+
resultDiv.innerHTML = `
|
| 391 |
+
<div class="alert alert-warning fade-in">
|
| 392 |
+
<i class="fas fa-exclamation-triangle me-2"></i>
|
| 393 |
+
<strong>Text exceeds limit!</strong> (${data.text_length.toLocaleString()}/${data.max_length.toLocaleString()} characters)
|
| 394 |
+
<br><small class="mt-2 d-block">Suggested chunks: ${data.suggested_chunks}</small>
|
| 395 |
+
<div class="mt-3">
|
| 396 |
+
<strong>Preview of chunks:</strong>
|
| 397 |
+
<div class="mt-2">
|
| 398 |
+
${data.chunk_preview.map((chunk, i) => `
|
| 399 |
+
<div class="border rounded p-2 mb-2 bg-light">
|
| 400 |
+
<small class="text-muted">Chunk ${i+1}:</small>
|
| 401 |
+
<div class="small">${chunk}</div>
|
| 402 |
+
</div>
|
| 403 |
+
`).join('')}
|
| 404 |
+
</div>
|
| 405 |
+
|
| 406 |
+
</div>
|
| 407 |
+
</div>
|
| 408 |
+
`;
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
resultDiv.classList.remove('d-none');
|
| 412 |
+
resultDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
| 413 |
+
|
| 414 |
+
} catch (error) {
|
| 415 |
+
console.error('Validation failed:', error);
|
| 416 |
+
console.log('Failed to validate text. Please try again.');
|
| 417 |
+
} finally {
|
| 418 |
+
setLoading(validateBtn, false);
|
| 419 |
+
}
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
function updateAutoCombineStatus() {
|
| 425 |
+
const autoCombineCheck = document.getElementById('auto-combine-check');
|
| 426 |
+
const statusBadge = document.getElementById('auto-combine-status');
|
| 427 |
+
const textInput = document.getElementById('text-input');
|
| 428 |
+
const maxLength = parseInt(document.getElementById('max-length-input').value) || 4096;
|
| 429 |
+
|
| 430 |
+
if (!autoCombineCheck || !statusBadge) return;
|
| 431 |
+
|
| 432 |
+
const isAutoCombineEnabled = autoCombineCheck.checked;
|
| 433 |
+
const textLength = textInput.value.length;
|
| 434 |
+
const isLongText = textLength > maxLength;
|
| 435 |
+
|
| 436 |
+
// Show/hide status badge
|
| 437 |
+
if (isAutoCombineEnabled && isLongText) {
|
| 438 |
+
statusBadge.classList.remove('d-none');
|
| 439 |
+
statusBadge.classList.add('bg-success');
|
| 440 |
+
statusBadge.classList.remove('bg-warning');
|
| 441 |
+
statusBadge.innerHTML = '<i class="fas fa-magic me-1"></i>Auto-combine enabled';
|
| 442 |
+
} else if (!isAutoCombineEnabled && isLongText) {
|
| 443 |
+
statusBadge.classList.remove('d-none');
|
| 444 |
+
statusBadge.classList.add('bg-warning');
|
| 445 |
+
statusBadge.classList.remove('bg-success');
|
| 446 |
+
statusBadge.innerHTML = '<i class="fas fa-exclamation-triangle me-1"></i>Long text detected';
|
| 447 |
+
} else {
|
| 448 |
+
statusBadge.classList.add('d-none');
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
// Remove the recursive call to updateCharCount() - this was causing infinite recursion
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
async function generateSpeech(event) {
|
| 455 |
+
console.log('generateSpeech function called');
|
| 456 |
+
|
| 457 |
+
// Prevent default form submission behavior
|
| 458 |
+
if (event) {
|
| 459 |
+
event.preventDefault();
|
| 460 |
+
event.stopPropagation();
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
const button = document.getElementById('generate-btn');
|
| 464 |
+
const audioResult = document.getElementById('audio-result');
|
| 465 |
+
|
| 466 |
+
// Get form data
|
| 467 |
+
const formData = getFormData();
|
| 468 |
+
|
| 469 |
+
if (!validateFormData(formData)) {
|
| 470 |
+
console.log('Form validation failed');
|
| 471 |
+
return false;
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
// Show loading state
|
| 475 |
+
setLoading(button, true);
|
| 476 |
+
clearResults();
|
| 477 |
+
|
| 478 |
+
try {
|
| 479 |
+
console.log('Starting speech generation...');
|
| 480 |
+
// Always use the unified endpoint with auto-combine
|
| 481 |
+
await generateUnifiedSpeech(formData);
|
| 482 |
+
console.log('Speech generation completed successfully');
|
| 483 |
+
} catch (error) {
|
| 484 |
+
console.error('Generation failed:', error);
|
| 485 |
+
console.log(`Failed to generate speech: ${error.message}`);
|
| 486 |
+
} finally {
|
| 487 |
+
setLoading(button, false);
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
return false; // Ensure form doesn't submit
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
function getFormData() {
|
| 494 |
+
return {
|
| 495 |
+
text: document.getElementById('text-input').value.trim(),
|
| 496 |
+
voice: document.getElementById('voice-select').value,
|
| 497 |
+
format: document.getElementById('format-select').value,
|
| 498 |
+
instructions: document.getElementById('instructions-input').value.trim(),
|
| 499 |
+
maxLength: parseInt(document.getElementById('max-length-input').value) || 4096,
|
| 500 |
+
validateLength: document.getElementById('validate-length-check').checked,
|
| 501 |
+
autoCombine: document.getElementById('auto-combine-check').checked,
|
| 502 |
+
apiKey: document.getElementById('api-key-input').value.trim()
|
| 503 |
+
};
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
function validateFormData(formData) {
|
| 507 |
+
if (!formData.text || !formData.voice || !formData.format) {
|
| 508 |
+
console.log('Please fill in all required fields');
|
| 509 |
+
return false;
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
if (formData.text.length > formData.maxLength && formData.validateLength && !formData.autoCombine) {
|
| 513 |
+
console.log(`Text is too long (${formData.text.length} characters). Enable auto-combine or reduce text length.`);
|
| 514 |
+
return false;
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
return true;
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
function clearResults() {
|
| 521 |
+
document.getElementById('audio-result').classList.add('d-none');
|
| 522 |
+
const batchResult = document.getElementById('batch-result');
|
| 523 |
+
if (batchResult) {
|
| 524 |
+
batchResult.classList.add('d-none');
|
| 525 |
+
}
|
| 526 |
+
document.getElementById('validation-result').classList.add('d-none');
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
// Utility functions
|
| 530 |
+
function setLoading(button, loading) {
|
| 531 |
+
if (loading) {
|
| 532 |
+
button.classList.add('loading');
|
| 533 |
+
button.disabled = true;
|
| 534 |
+
} else {
|
| 535 |
+
button.classList.remove('loading');
|
| 536 |
+
button.disabled = false;
|
| 537 |
+
}
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
// New unified function using OpenAI-compatible endpoint with auto-combine
|
| 543 |
+
async function generateUnifiedSpeech(formData) {
|
| 544 |
+
const audioResult = document.getElementById('audio-result');
|
| 545 |
+
|
| 546 |
+
// Prepare headers
|
| 547 |
+
const headers = { 'Content-Type': 'application/json' };
|
| 548 |
+
|
| 549 |
+
// Add API key if provided (OpenAI compatible format)
|
| 550 |
+
if (formData.apiKey) {
|
| 551 |
+
headers['Authorization'] = `Bearer ${formData.apiKey}`;
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
const response = await fetch('/v1/audio/speech', {
|
| 555 |
+
method: 'POST',
|
| 556 |
+
headers: headers,
|
| 557 |
+
body: JSON.stringify({
|
| 558 |
+
model: 'gpt-4o-mini-tts',
|
| 559 |
+
input: formData.text,
|
| 560 |
+
voice: formData.voice,
|
| 561 |
+
response_format: formData.format,
|
| 562 |
+
instructions: formData.instructions || undefined,
|
| 563 |
+
auto_combine: formData.autoCombine,
|
| 564 |
+
max_length: formData.maxLength
|
| 565 |
+
})
|
| 566 |
+
});
|
| 567 |
+
|
| 568 |
+
if (!response.ok) {
|
| 569 |
+
const errorData = await response.json();
|
| 570 |
+
const errorMessage = errorData.error?.message || errorData.error || `HTTP ${response.status}`;
|
| 571 |
+
throw new Error(errorMessage);
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
// Get audio data
|
| 575 |
+
const audioBlob = await response.blob();
|
| 576 |
+
currentAudioBlob = audioBlob;
|
| 577 |
+
currentFormat = formData.format;
|
| 578 |
+
|
| 579 |
+
// Create audio URL and setup player
|
| 580 |
+
const audioUrl = URL.createObjectURL(audioBlob);
|
| 581 |
+
const audioPlayer = document.getElementById('audio-player');
|
| 582 |
+
audioPlayer.src = audioUrl;
|
| 583 |
+
|
| 584 |
+
// Get response headers for enhanced display
|
| 585 |
+
const chunksCount = response.headers.get('X-Chunks-Combined') || '1';
|
| 586 |
+
const autoCombineUsed = response.headers.get('X-Auto-Combine') === 'true';
|
| 587 |
+
const originalLength = response.headers.get('X-Original-Text-Length');
|
| 588 |
+
|
| 589 |
+
// Use enhanced display function with new metadata
|
| 590 |
+
displayAudioResult(audioBlob, formData.format, formData.voice, formData.text, {
|
| 591 |
+
chunksCount,
|
| 592 |
+
autoCombineUsed,
|
| 593 |
+
originalLength
|
| 594 |
+
});
|
| 595 |
+
|
| 596 |
+
console.log('Speech generated successfully! Click play to listen.');
|
| 597 |
+
if (autoCombineUsed && chunksCount > 1) {
|
| 598 |
+
console.log(`Auto-combine feature combined ${chunksCount} chunks into a single audio file.`);
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
// Auto-play if user prefers
|
| 602 |
+
if (localStorage.getItem('autoPlay') === 'true') {
|
| 603 |
+
audioPlayer.play().catch(() => {
|
| 604 |
+
// Auto-play blocked, that's fine
|
| 605 |
+
});
|
| 606 |
+
}
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
// Legacy function for backward compatibility
|
| 610 |
+
async function generateSingleSpeech(formData) {
|
| 611 |
+
// Use the new unified function
|
| 612 |
+
await generateUnifiedSpeech(formData);
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
function downloadAudio() {
|
| 620 |
+
if (!currentAudioBlob) {
|
| 621 |
+
console.log('No audio to download');
|
| 622 |
+
return;
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
const url = URL.createObjectURL(currentAudioBlob);
|
| 626 |
+
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
| 627 |
+
downloadFromUrl(url, `ttsfm-speech-${timestamp}.${currentFormat}`);
|
| 628 |
+
URL.revokeObjectURL(url);
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
|
| 632 |
+
|
| 633 |
+
function downloadFromUrl(url, filename) {
|
| 634 |
+
const a = document.createElement('a');
|
| 635 |
+
a.href = url;
|
| 636 |
+
a.download = filename;
|
| 637 |
+
a.style.display = 'none';
|
| 638 |
+
document.body.appendChild(a);
|
| 639 |
+
a.click();
|
| 640 |
+
document.body.removeChild(a);
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
// New enhanced functions
|
| 644 |
+
function clearText() {
|
| 645 |
+
document.getElementById('text-input').value = '';
|
| 646 |
+
updateCharCount();
|
| 647 |
+
clearResults();
|
| 648 |
+
console.log('Text cleared successfully');
|
| 649 |
+
}
|
| 650 |
+
|
| 651 |
+
function loadRandomText() {
|
| 652 |
+
const randomTexts = [
|
| 653 |
+
// News & Information
|
| 654 |
+
"Breaking news: Scientists have discovered a revolutionary new method for generating incredibly natural synthetic speech using advanced neural networks and machine learning algorithms.",
|
| 655 |
+
"Weather update: Today will be partly cloudy with temperatures reaching 75 degrees Fahrenheit. Light winds from the southwest at 5 to 10 miles per hour.",
|
| 656 |
+
"Technology report: The latest advancements in artificial intelligence are revolutionizing how we interact with digital devices and services.",
|
| 657 |
+
|
| 658 |
+
// Educational & Informative
|
| 659 |
+
"The human brain contains approximately 86 billion neurons, each connected to thousands of others, creating a complex network that enables consciousness, memory, and thought.",
|
| 660 |
+
"Photosynthesis is the process by which plants convert sunlight, carbon dioxide, and water into glucose and oxygen, forming the foundation of most life on Earth.",
|
| 661 |
+
"The speed of light in a vacuum is exactly 299,792,458 meters per second, making it one of the fundamental constants of physics.",
|
| 662 |
+
|
| 663 |
+
// Creative & Storytelling
|
| 664 |
+
"Once upon a time, in a land far away, there lived a wise old wizard who could speak to the stars and understand their ancient secrets.",
|
| 665 |
+
"The mysterious lighthouse stood alone on the rocky cliff, its beacon cutting through the fog like a sword of light, guiding lost ships safely home.",
|
| 666 |
+
"In the depths of the enchanted forest, where sunbeams danced through emerald leaves, a young adventurer discovered a hidden path to destiny.",
|
| 667 |
+
|
| 668 |
+
// Business & Professional
|
| 669 |
+
"Our quarterly results demonstrate strong growth across all market segments, with revenue increasing by 23% compared to the same period last year.",
|
| 670 |
+
"The new product launch exceeded expectations, capturing 15% market share within the first six months and establishing our brand as an industry leader.",
|
| 671 |
+
"We are committed to sustainable business practices that benefit our customers, employees, and the environment for generations to come.",
|
| 672 |
+
|
| 673 |
+
// Technical & Programming
|
| 674 |
+
"The TTSFM package provides a comprehensive API for text-to-speech generation with support for multiple voices and audio formats.",
|
| 675 |
+
"Machine learning algorithms process vast amounts of data to identify patterns and make predictions with remarkable accuracy.",
|
| 676 |
+
"Cloud computing has transformed how businesses store, process, and access their data, enabling scalability and flexibility like never before.",
|
| 677 |
+
|
| 678 |
+
// Conversational & Casual
|
| 679 |
+
"Welcome to TTSFM! Experience the future of text-to-speech technology with our premium AI voices.",
|
| 680 |
+
"Good morning! Today is a beautiful day to learn something new and explore the possibilities of text-to-speech technology.",
|
| 681 |
+
"Have you ever wondered what it would be like if your computer could speak with perfect human-like intonation and emotion?"
|
| 682 |
+
];
|
| 683 |
+
|
| 684 |
+
const randomText = randomTexts[Math.floor(Math.random() * randomTexts.length)];
|
| 685 |
+
document.getElementById('text-input').value = randomText;
|
| 686 |
+
updateCharCount();
|
| 687 |
+
console.log('Random text loaded successfully');
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
|
| 691 |
+
|
| 692 |
+
function resetForm() {
|
| 693 |
+
// Reset form to default values
|
| 694 |
+
document.getElementById('text-input').value = 'Welcome to TTSFM! Experience the future of text-to-speech technology with our premium AI voices. Generate natural, expressive speech for any application.';
|
| 695 |
+
document.getElementById('voice-select').value = 'alloy';
|
| 696 |
+
document.getElementById('format-select').value = 'mp3';
|
| 697 |
+
document.getElementById('instructions-input').value = '';
|
| 698 |
+
document.getElementById('max-length-input').value = '4096';
|
| 699 |
+
document.getElementById('validate-length-check').checked = true;
|
| 700 |
+
const autoCombineCheck = document.getElementById('auto-combine-check');
|
| 701 |
+
if (autoCombineCheck) {
|
| 702 |
+
autoCombineCheck.checked = true;
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
updateCharCount();
|
| 706 |
+
updateGenerateButton();
|
| 707 |
+
clearResults();
|
| 708 |
+
console.log('Form reset to default values');
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
function replayAudio() {
|
| 712 |
+
const audioPlayer = document.getElementById('audio-player');
|
| 713 |
+
if (audioPlayer && audioPlayer.src) {
|
| 714 |
+
audioPlayer.currentTime = 0;
|
| 715 |
+
audioPlayer.play().catch(() => {
|
| 716 |
+
console.log('Unable to replay audio. Please check your browser settings.');
|
| 717 |
+
});
|
| 718 |
+
}
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
function shareAudio() {
|
| 722 |
+
if (navigator.share && currentAudioBlob) {
|
| 723 |
+
const file = new File([currentAudioBlob], `ttsfm-speech.${currentFormat}`, {
|
| 724 |
+
type: `audio/${currentFormat}`
|
| 725 |
+
});
|
| 726 |
+
|
| 727 |
+
navigator.share({
|
| 728 |
+
title: 'TTSFM Generated Speech',
|
| 729 |
+
text: 'Check out this speech generated with TTSFM!',
|
| 730 |
+
files: [file]
|
| 731 |
+
}).catch(() => {
|
| 732 |
+
// Fallback to copying link
|
| 733 |
+
copyAudioLink();
|
| 734 |
+
});
|
| 735 |
+
} else {
|
| 736 |
+
copyAudioLink();
|
| 737 |
+
}
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
function copyAudioLink() {
|
| 741 |
+
const audioPlayer = document.getElementById('audio-player');
|
| 742 |
+
if (audioPlayer && audioPlayer.src) {
|
| 743 |
+
navigator.clipboard.writeText(audioPlayer.src).then(() => {
|
| 744 |
+
console.log('Audio link copied to clipboard!');
|
| 745 |
+
}).catch(() => {
|
| 746 |
+
console.log('Unable to copy link. Please try downloading the audio instead.');
|
| 747 |
+
});
|
| 748 |
+
}
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
function updateVoiceInfo() {
|
| 752 |
+
const voiceSelect = document.getElementById('voice-select');
|
| 753 |
+
const previewBtn = document.getElementById('preview-voice-btn');
|
| 754 |
+
|
| 755 |
+
if (voiceSelect.value) {
|
| 756 |
+
previewBtn.disabled = false;
|
| 757 |
+
previewBtn.onclick = () => previewVoice(voiceSelect.value);
|
| 758 |
+
} else {
|
| 759 |
+
previewBtn.disabled = true;
|
| 760 |
+
}
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
function updateFormatInfo() {
|
| 764 |
+
const formatSelect = document.getElementById('format-select');
|
| 765 |
+
const formatInfo = document.getElementById('format-info');
|
| 766 |
+
|
| 767 |
+
const formatDescriptions = {
|
| 768 |
+
'mp3': '🎵 MP3 - Good quality, small file size. Best for web and general use.',
|
| 769 |
+
'opus': '📻 OPUS - Excellent quality, small file size. Best for streaming and VoIP.',
|
| 770 |
+
'aac': '📱 AAC - Good quality, medium file size. Best for Apple devices and streaming.',
|
| 771 |
+
'flac': '💿 FLAC - Lossless quality, large file size. Best for archival and high-quality audio.',
|
| 772 |
+
'wav': '🎧 WAV - Lossless quality, large file size. Best for professional audio production.',
|
| 773 |
+
'pcm': '🔊 PCM - Raw audio data, large file size. Best for audio processing.'
|
| 774 |
+
};
|
| 775 |
+
|
| 776 |
+
if (formatInfo && formatSelect.value) {
|
| 777 |
+
formatInfo.textContent = formatDescriptions[formatSelect.value] || 'High-quality audio format';
|
| 778 |
+
}
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
function previewVoice(voiceId) {
|
| 782 |
+
// This would typically play a short preview of the voice
|
| 783 |
+
console.log(`Voice preview for ${voiceId} - Feature coming soon!`);
|
| 784 |
+
}
|
| 785 |
+
|
| 786 |
+
// Enhanced audio result display with auto-combine metadata
|
| 787 |
+
function displayAudioResult(audioBlob, format, voice, text, metadata = {}) {
|
| 788 |
+
const audioResult = document.getElementById('audio-result');
|
| 789 |
+
const audioPlayer = document.getElementById('audio-player');
|
| 790 |
+
const audioInfo = document.getElementById('audio-info');
|
| 791 |
+
|
| 792 |
+
// Create audio URL and setup player
|
| 793 |
+
const audioUrl = URL.createObjectURL(audioBlob);
|
| 794 |
+
audioPlayer.src = audioUrl;
|
| 795 |
+
|
| 796 |
+
// Update audio stats
|
| 797 |
+
const sizeKB = (audioBlob.size / 1024).toFixed(1);
|
| 798 |
+
document.getElementById('audio-size').textContent = `${sizeKB} KB`;
|
| 799 |
+
document.getElementById('audio-format').textContent = format.toUpperCase();
|
| 800 |
+
document.getElementById('audio-voice').textContent = voice.charAt(0).toUpperCase() + voice.slice(1);
|
| 801 |
+
|
| 802 |
+
// Update audio info safely without innerHTML
|
| 803 |
+
// Clear existing content
|
| 804 |
+
audioInfo.textContent = '';
|
| 805 |
+
|
| 806 |
+
// Create and append icon element
|
| 807 |
+
const icon = document.createElement('i');
|
| 808 |
+
icon.className = 'fas fa-check-circle text-success me-1';
|
| 809 |
+
audioInfo.appendChild(icon);
|
| 810 |
+
|
| 811 |
+
// Create info text with auto-combine details
|
| 812 |
+
let infoText = `Generated successfully • ${sizeKB} KB • ${format.toUpperCase()}`;
|
| 813 |
+
|
| 814 |
+
if (metadata.autoCombineUsed && metadata.chunksCount > 1) {
|
| 815 |
+
infoText += ` • Auto-combined ${metadata.chunksCount} chunks`;
|
| 816 |
+
|
| 817 |
+
// Add a special badge for auto-combine
|
| 818 |
+
const badge = document.createElement('span');
|
| 819 |
+
badge.className = 'badge bg-primary ms-2';
|
| 820 |
+
badge.innerHTML = '<i class="fas fa-magic me-1"></i>Auto-combined';
|
| 821 |
+
audioInfo.appendChild(document.createTextNode(infoText));
|
| 822 |
+
audioInfo.appendChild(badge);
|
| 823 |
+
} else {
|
| 824 |
+
// Create and append text content (safely escaped)
|
| 825 |
+
const textNode = document.createTextNode(infoText);
|
| 826 |
+
audioInfo.appendChild(textNode);
|
| 827 |
+
}
|
| 828 |
+
|
| 829 |
+
// Show result with animation
|
| 830 |
+
audioResult.classList.remove('d-none');
|
| 831 |
+
audioResult.classList.add('fade-in');
|
| 832 |
+
|
| 833 |
+
// Update duration when metadata loads
|
| 834 |
+
audioPlayer.addEventListener('loadedmetadata', function() {
|
| 835 |
+
const duration = Math.round(audioPlayer.duration);
|
| 836 |
+
document.getElementById('audio-duration').textContent = `${duration}s`;
|
| 837 |
+
}, { once: true });
|
| 838 |
+
|
| 839 |
+
// Scroll to result
|
| 840 |
+
audioResult.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
| 841 |
+
}
|
| 842 |
+
|
| 843 |
+
// API Key visibility toggle function
|
| 844 |
+
function toggleApiKeyVisibility() {
|
| 845 |
+
const apiKeyInput = document.getElementById('api-key-input');
|
| 846 |
+
const eyeIcon = document.getElementById('api-key-eye-icon');
|
| 847 |
+
|
| 848 |
+
if (apiKeyInput.type === 'password') {
|
| 849 |
+
apiKeyInput.type = 'text';
|
| 850 |
+
eyeIcon.className = 'fas fa-eye-slash';
|
| 851 |
+
} else {
|
| 852 |
+
apiKeyInput.type = 'password';
|
| 853 |
+
eyeIcon.className = 'fas fa-eye';
|
| 854 |
+
}
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
// Export functions for use in HTML
|
| 858 |
+
window.clearText = clearText;
|
| 859 |
+
window.loadRandomText = loadRandomText;
|
| 860 |
+
window.resetForm = resetForm;
|
| 861 |
+
window.toggleApiKeyVisibility = toggleApiKeyVisibility;
|
static/js/websocket-tts.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* WebSocket TTS Streaming Client
|
| 3 |
+
*
|
| 4 |
+
* Because apparently HTTP requests are so 2023.
|
| 5 |
+
* Now we need real-time streaming for everything.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
class WebSocketTTSClient {
|
| 9 |
+
constructor(options = {}) {
|
| 10 |
+
this.socketUrl = options.socketUrl || window.location.origin;
|
| 11 |
+
this.socket = null;
|
| 12 |
+
this.activeRequests = new Map();
|
| 13 |
+
this.reconnectAttempts = 0;
|
| 14 |
+
this.maxReconnectAttempts = options.maxReconnectAttempts || 5;
|
| 15 |
+
this.reconnectDelay = options.reconnectDelay || 1000;
|
| 16 |
+
this.debug = options.debug || false;
|
| 17 |
+
|
| 18 |
+
// Audio context for seamless playback
|
| 19 |
+
this.audioContext = null;
|
| 20 |
+
this.audioQueue = new Map(); // request_id -> audio chunks
|
| 21 |
+
|
| 22 |
+
// Event handlers
|
| 23 |
+
this.onConnect = options.onConnect || (() => {});
|
| 24 |
+
this.onDisconnect = options.onDisconnect || (() => {});
|
| 25 |
+
this.onError = options.onError || ((error) => console.error('WebSocket error:', error));
|
| 26 |
+
|
| 27 |
+
// Initialize
|
| 28 |
+
this.connect();
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
connect() {
|
| 32 |
+
if (this.socket && this.socket.connected) {
|
| 33 |
+
this.log('Already connected');
|
| 34 |
+
return;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
this.log('Connecting to WebSocket server...');
|
| 38 |
+
|
| 39 |
+
// Initialize Socket.IO connection
|
| 40 |
+
this.socket = io(this.socketUrl, {
|
| 41 |
+
transports: ['websocket', 'polling'],
|
| 42 |
+
reconnection: true,
|
| 43 |
+
reconnectionAttempts: this.maxReconnectAttempts,
|
| 44 |
+
reconnectionDelay: this.reconnectDelay
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
// Set up event handlers
|
| 48 |
+
this.setupEventHandlers();
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
setupEventHandlers() {
|
| 52 |
+
// Connection events
|
| 53 |
+
this.socket.on('connect', () => {
|
| 54 |
+
this.log('Connected to WebSocket server');
|
| 55 |
+
this.reconnectAttempts = 0;
|
| 56 |
+
this.onConnect();
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
this.socket.on('disconnect', (reason) => {
|
| 60 |
+
this.log('Disconnected from WebSocket server:', reason);
|
| 61 |
+
this.onDisconnect(reason);
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
this.socket.on('connect_error', (error) => {
|
| 65 |
+
this.log('Connection error:', error);
|
| 66 |
+
this.reconnectAttempts++;
|
| 67 |
+
this.onError({
|
| 68 |
+
type: 'connection_error',
|
| 69 |
+
message: error.message,
|
| 70 |
+
attempts: this.reconnectAttempts
|
| 71 |
+
});
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
// TTS streaming events
|
| 75 |
+
this.socket.on('connected', (data) => {
|
| 76 |
+
this.log('Session established:', data.session_id);
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
this.socket.on('stream_started', (data) => {
|
| 80 |
+
this.log('Stream started:', data.request_id);
|
| 81 |
+
const request = this.activeRequests.get(data.request_id);
|
| 82 |
+
if (request && request.onStart) {
|
| 83 |
+
request.onStart(data);
|
| 84 |
+
}
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
this.socket.on('audio_chunk', (data) => {
|
| 88 |
+
this.handleAudioChunk(data);
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
this.socket.on('stream_progress', (data) => {
|
| 92 |
+
this.handleProgress(data);
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
this.socket.on('stream_complete', (data) => {
|
| 96 |
+
this.handleStreamComplete(data);
|
| 97 |
+
});
|
| 98 |
+
|
| 99 |
+
this.socket.on('stream_error', (data) => {
|
| 100 |
+
this.handleStreamError(data);
|
| 101 |
+
});
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
/**
|
| 105 |
+
* Generate speech with real-time streaming
|
| 106 |
+
*/
|
| 107 |
+
generateSpeech(text, options = {}) {
|
| 108 |
+
return new Promise((resolve, reject) => {
|
| 109 |
+
if (!this.socket || !this.socket.connected) {
|
| 110 |
+
reject(new Error('WebSocket not connected'));
|
| 111 |
+
return;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
const requestId = this.generateRequestId();
|
| 115 |
+
const audioChunks = [];
|
| 116 |
+
|
| 117 |
+
// Store request info
|
| 118 |
+
this.activeRequests.set(requestId, {
|
| 119 |
+
resolve,
|
| 120 |
+
reject,
|
| 121 |
+
audioChunks,
|
| 122 |
+
options,
|
| 123 |
+
startTime: Date.now(),
|
| 124 |
+
onStart: options.onStart,
|
| 125 |
+
onProgress: options.onProgress,
|
| 126 |
+
onChunk: options.onChunk,
|
| 127 |
+
onComplete: options.onComplete,
|
| 128 |
+
onError: options.onError
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
// Initialize audio queue for this request
|
| 132 |
+
this.audioQueue.set(requestId, []);
|
| 133 |
+
|
| 134 |
+
// Emit generation request
|
| 135 |
+
this.socket.emit('generate_stream', {
|
| 136 |
+
request_id: requestId,
|
| 137 |
+
text: text,
|
| 138 |
+
voice: options.voice || 'alloy',
|
| 139 |
+
format: options.format || 'mp3',
|
| 140 |
+
chunk_size: options.chunkSize || 1024
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
this.log('Requested speech generation:', requestId);
|
| 144 |
+
});
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
handleAudioChunk(data) {
|
| 148 |
+
const request = this.activeRequests.get(data.request_id);
|
| 149 |
+
if (!request) {
|
| 150 |
+
this.log('Received chunk for unknown request:', data.request_id);
|
| 151 |
+
return;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
// Convert hex string back to binary
|
| 155 |
+
const audioData = this.hexToArrayBuffer(data.audio_data);
|
| 156 |
+
|
| 157 |
+
// Store chunk
|
| 158 |
+
request.audioChunks.push({
|
| 159 |
+
index: data.chunk_index,
|
| 160 |
+
data: audioData,
|
| 161 |
+
duration: data.duration,
|
| 162 |
+
format: data.format
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
// Add to audio queue for streaming playback
|
| 166 |
+
const queue = this.audioQueue.get(data.request_id);
|
| 167 |
+
if (queue) {
|
| 168 |
+
queue.push(audioData);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
// Call chunk handler if provided
|
| 172 |
+
if (request.onChunk) {
|
| 173 |
+
request.onChunk({
|
| 174 |
+
chunkIndex: data.chunk_index,
|
| 175 |
+
totalChunks: data.total_chunks,
|
| 176 |
+
audioData: audioData,
|
| 177 |
+
duration: data.duration,
|
| 178 |
+
text: data.chunk_text
|
| 179 |
+
});
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
this.log(`Received chunk ${data.chunk_index + 1}/${data.total_chunks} for request ${data.request_id}`);
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
handleProgress(data) {
|
| 186 |
+
const request = this.activeRequests.get(data.request_id);
|
| 187 |
+
if (request && request.onProgress) {
|
| 188 |
+
request.onProgress({
|
| 189 |
+
progress: data.progress,
|
| 190 |
+
chunksCompleted: data.chunks_completed,
|
| 191 |
+
totalChunks: data.total_chunks,
|
| 192 |
+
status: data.status
|
| 193 |
+
});
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
handleStreamComplete(data) {
|
| 198 |
+
const request = this.activeRequests.get(data.request_id);
|
| 199 |
+
if (!request) {
|
| 200 |
+
this.log('Completion for unknown request:', data.request_id);
|
| 201 |
+
return;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
// Sort chunks by index
|
| 205 |
+
request.audioChunks.sort((a, b) => a.index - b.index);
|
| 206 |
+
|
| 207 |
+
// Combine all audio chunks
|
| 208 |
+
const combinedAudio = this.combineAudioChunks(request.audioChunks);
|
| 209 |
+
|
| 210 |
+
const result = {
|
| 211 |
+
requestId: data.request_id,
|
| 212 |
+
audioData: combinedAudio,
|
| 213 |
+
chunks: request.audioChunks,
|
| 214 |
+
duration: request.audioChunks.reduce((sum, chunk) => sum + chunk.duration, 0),
|
| 215 |
+
generationTime: Date.now() - request.startTime,
|
| 216 |
+
format: request.audioChunks[0]?.format || 'mp3'
|
| 217 |
+
};
|
| 218 |
+
|
| 219 |
+
// Call complete handler
|
| 220 |
+
if (request.onComplete) {
|
| 221 |
+
request.onComplete(result);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
// Resolve promise
|
| 225 |
+
request.resolve(result);
|
| 226 |
+
|
| 227 |
+
// Cleanup
|
| 228 |
+
this.activeRequests.delete(data.request_id);
|
| 229 |
+
this.audioQueue.delete(data.request_id);
|
| 230 |
+
|
| 231 |
+
this.log('Stream completed:', data.request_id);
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
handleStreamError(data) {
|
| 235 |
+
const request = this.activeRequests.get(data.request_id);
|
| 236 |
+
if (!request) {
|
| 237 |
+
this.log('Error for unknown request:', data.request_id);
|
| 238 |
+
return;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
const error = new Error(data.error);
|
| 242 |
+
error.requestId = data.request_id;
|
| 243 |
+
error.timestamp = data.timestamp;
|
| 244 |
+
|
| 245 |
+
// Call error handler
|
| 246 |
+
if (request.onError) {
|
| 247 |
+
request.onError(error);
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
// Reject promise
|
| 251 |
+
request.reject(error);
|
| 252 |
+
|
| 253 |
+
// Cleanup
|
| 254 |
+
this.activeRequests.delete(data.request_id);
|
| 255 |
+
this.audioQueue.delete(data.request_id);
|
| 256 |
+
|
| 257 |
+
this.log('Stream error:', data.request_id, data.error);
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
/**
|
| 261 |
+
* Cancel an active stream
|
| 262 |
+
*/
|
| 263 |
+
cancelStream(requestId) {
|
| 264 |
+
if (!this.socket || !this.socket.connected) {
|
| 265 |
+
throw new Error('WebSocket not connected');
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
this.socket.emit('cancel_stream', { request_id: requestId });
|
| 269 |
+
|
| 270 |
+
// Clean up local state
|
| 271 |
+
const request = this.activeRequests.get(requestId);
|
| 272 |
+
if (request) {
|
| 273 |
+
request.reject(new Error('Stream cancelled by user'));
|
| 274 |
+
this.activeRequests.delete(requestId);
|
| 275 |
+
this.audioQueue.delete(requestId);
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
/**
|
| 280 |
+
* Combine audio chunks into a single buffer
|
| 281 |
+
*/
|
| 282 |
+
combineAudioChunks(chunks) {
|
| 283 |
+
if (chunks.length === 0) return new ArrayBuffer(0);
|
| 284 |
+
|
| 285 |
+
// Calculate total size
|
| 286 |
+
const totalSize = chunks.reduce((sum, chunk) => sum + chunk.data.byteLength, 0);
|
| 287 |
+
|
| 288 |
+
// Create combined buffer
|
| 289 |
+
const combined = new ArrayBuffer(totalSize);
|
| 290 |
+
const view = new Uint8Array(combined);
|
| 291 |
+
|
| 292 |
+
let offset = 0;
|
| 293 |
+
for (const chunk of chunks) {
|
| 294 |
+
view.set(new Uint8Array(chunk.data), offset);
|
| 295 |
+
offset += chunk.data.byteLength;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
return combined;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
/**
|
| 302 |
+
* Play audio directly (experimental streaming playback)
|
| 303 |
+
*/
|
| 304 |
+
async playAudioStream(requestId) {
|
| 305 |
+
if (!this.audioContext) {
|
| 306 |
+
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
const queue = this.audioQueue.get(requestId);
|
| 310 |
+
if (!queue) {
|
| 311 |
+
throw new Error('No audio queue found for request');
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
// This is a simplified version - real implementation would need
|
| 315 |
+
// proper audio decoding and buffering for seamless playback
|
| 316 |
+
this.log('Streaming audio playback not fully implemented yet');
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
/**
|
| 320 |
+
* Utility functions
|
| 321 |
+
*/
|
| 322 |
+
hexToArrayBuffer(hex) {
|
| 323 |
+
const bytes = new Uint8Array(hex.length / 2);
|
| 324 |
+
for (let i = 0; i < hex.length; i += 2) {
|
| 325 |
+
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
| 326 |
+
}
|
| 327 |
+
return bytes.buffer;
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
generateRequestId() {
|
| 331 |
+
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
log(...args) {
|
| 335 |
+
if (this.debug) {
|
| 336 |
+
console.log('[WebSocketTTS]', ...args);
|
| 337 |
+
}
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
/**
|
| 341 |
+
* Get connection status
|
| 342 |
+
*/
|
| 343 |
+
isConnected() {
|
| 344 |
+
return this.socket && this.socket.connected;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
/**
|
| 348 |
+
* Disconnect from server
|
| 349 |
+
*/
|
| 350 |
+
disconnect() {
|
| 351 |
+
if (this.socket) {
|
| 352 |
+
this.socket.disconnect();
|
| 353 |
+
this.socket = null;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
// Clear all active requests
|
| 357 |
+
for (const [requestId, request] of this.activeRequests) {
|
| 358 |
+
request.reject(new Error('Client disconnected'));
|
| 359 |
+
}
|
| 360 |
+
this.activeRequests.clear();
|
| 361 |
+
this.audioQueue.clear();
|
| 362 |
+
}
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
// Export for use
|
| 366 |
+
window.WebSocketTTSClient = WebSocketTTSClient;
|
templates/base.html
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="{{ get_locale() }}">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{% block title %}TTSFM - {{ _('nav.home') }}{% endblock %}</title>
|
| 7 |
+
|
| 8 |
+
<!-- Bootstrap CSS -->
|
| 9 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 10 |
+
|
| 11 |
+
<!-- Font Awesome -->
|
| 12 |
+
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
| 13 |
+
|
| 14 |
+
<!-- Google Fonts -->
|
| 15 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
| 16 |
+
|
| 17 |
+
<!-- Custom CSS -->
|
| 18 |
+
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
|
| 19 |
+
|
| 20 |
+
<!-- Additional Performance Optimizations -->
|
| 21 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 22 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 23 |
+
|
| 24 |
+
<!-- Favicon -->
|
| 25 |
+
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎤</text></svg>">
|
| 26 |
+
|
| 27 |
+
<!-- Meta tags for better SEO and social sharing -->
|
| 28 |
+
<meta name="description" content="TTSFM - A Python client for text-to-speech APIs. Simple to use with support for multiple voices and audio formats.">
|
| 29 |
+
<meta name="keywords" content="text-to-speech, TTS, python, API, voice synthesis, audio generation">
|
| 30 |
+
<meta name="author" content="TTSFM">
|
| 31 |
+
|
| 32 |
+
<!-- Open Graph / Facebook -->
|
| 33 |
+
<meta property="og:type" content="website">
|
| 34 |
+
<meta property="og:url" content="{{ request.url }}">
|
| 35 |
+
<meta property="og:title" content="{% block og_title %}TTSFM - Python Text-to-Speech Client{% endblock %}">
|
| 36 |
+
<meta property="og:description" content="A Python client for text-to-speech APIs. Simple to use with support for multiple voices and audio formats.">
|
| 37 |
+
|
| 38 |
+
<!-- Twitter -->
|
| 39 |
+
<meta property="twitter:card" content="summary">
|
| 40 |
+
<meta property="twitter:url" content="{{ request.url }}">
|
| 41 |
+
<meta property="twitter:title" content="{% block twitter_title %}TTSFM - Python Text-to-Speech Client{% endblock %}">
|
| 42 |
+
<meta property="twitter:description" content="A Python client for text-to-speech APIs. Simple to use with support for multiple voices and audio formats.">
|
| 43 |
+
|
| 44 |
+
{% block extra_css %}{% endblock %}
|
| 45 |
+
|
| 46 |
+
<!-- Language button styling -->
|
| 47 |
+
<style>
|
| 48 |
+
/* Language dropdown button styling */
|
| 49 |
+
#languageDropdown {
|
| 50 |
+
border-color: #6c757d;
|
| 51 |
+
color: #6c757d;
|
| 52 |
+
transition: all 0.2s ease-in-out;
|
| 53 |
+
font-size: 0.875rem;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
#languageDropdown:hover {
|
| 57 |
+
border-color: #495057;
|
| 58 |
+
color: #495057;
|
| 59 |
+
background-color: #f8f9fa;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
#languageDropdown:focus {
|
| 63 |
+
box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.25);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/* Responsive language button */
|
| 67 |
+
@media (max-width: 576px) {
|
| 68 |
+
#languageDropdown {
|
| 69 |
+
font-size: 0.75rem;
|
| 70 |
+
padding: 0.25rem 0.5rem;
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/* Ensure consistent button heights */
|
| 75 |
+
.navbar-nav .btn {
|
| 76 |
+
display: inline-flex;
|
| 77 |
+
align-items: center;
|
| 78 |
+
}
|
| 79 |
+
</style>
|
| 80 |
+
</head>
|
| 81 |
+
<body>
|
| 82 |
+
<!-- Skip to content link for accessibility -->
|
| 83 |
+
<a href="#main-content" class="skip-link">Skip to main content</a>
|
| 84 |
+
|
| 85 |
+
<!-- Clean Navigation -->
|
| 86 |
+
<nav class="navbar navbar-expand-lg fixed-top" style="background-color: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); border-bottom: 1px solid #e5e7eb;">
|
| 87 |
+
<div class="container">
|
| 88 |
+
<a class="navbar-brand" href="{{ url_for('index') }}">
|
| 89 |
+
<i class="fas fa-microphone-alt me-2"></i>
|
| 90 |
+
<span class="fw-bold">TTSFM</span>
|
| 91 |
+
<span class="badge bg-primary ms-2 small">v3.2.2</span>
|
| 92 |
+
</a>
|
| 93 |
+
|
| 94 |
+
<button class="navbar-toggler border-0" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
| 95 |
+
<span class="navbar-toggler-icon"></span>
|
| 96 |
+
</button>
|
| 97 |
+
|
| 98 |
+
<div class="collapse navbar-collapse" id="navbarNav">
|
| 99 |
+
<ul class="navbar-nav me-auto">
|
| 100 |
+
<li class="nav-item">
|
| 101 |
+
<a class="nav-link" href="{{ url_for('index') }}" aria-label="{{ _('nav.home') }}">
|
| 102 |
+
<i class="fas fa-home me-1"></i>{{ _('nav.home') }}
|
| 103 |
+
</a>
|
| 104 |
+
</li>
|
| 105 |
+
<li class="nav-item">
|
| 106 |
+
<a class="nav-link" href="{{ url_for('playground') }}" aria-label="{{ _('nav.playground') }}">
|
| 107 |
+
<i class="fas fa-play me-1"></i>{{ _('nav.playground') }}
|
| 108 |
+
</a>
|
| 109 |
+
</li>
|
| 110 |
+
<li class="nav-item">
|
| 111 |
+
<a class="nav-link" href="{{ url_for('docs') }}" aria-label="{{ _('nav.documentation') }}">
|
| 112 |
+
<i class="fas fa-book me-1"></i>{{ _('nav.documentation') }}
|
| 113 |
+
</a>
|
| 114 |
+
</li>
|
| 115 |
+
</ul>
|
| 116 |
+
|
| 117 |
+
<ul class="navbar-nav">
|
| 118 |
+
<li class="nav-item">
|
| 119 |
+
<span class="navbar-text d-flex align-items-center">
|
| 120 |
+
<span id="status-indicator" class="status-indicator status-offline" aria-hidden="true"></span>
|
| 121 |
+
<span id="status-text" class="small">{{ _('nav.status_checking') }}</span>
|
| 122 |
+
</span>
|
| 123 |
+
</li>
|
| 124 |
+
<li class="nav-item dropdown ms-3">
|
| 125 |
+
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" id="languageDropdown" data-bs-toggle="dropdown" aria-expanded="false" title="{{ _('common.language') }}">
|
| 126 |
+
{% if get_locale() == 'zh' %}🇨🇳 中文{% else %}🇺🇸 English{% endif %}
|
| 127 |
+
</button>
|
| 128 |
+
<ul class="dropdown-menu" aria-labelledby="languageDropdown">
|
| 129 |
+
{% for lang_code, lang_name in get_supported_languages().items() %}
|
| 130 |
+
<li>
|
| 131 |
+
<a class="dropdown-item{% if get_locale() == lang_code %} active{% endif %}"
|
| 132 |
+
href="{{ url_for('set_language', lang_code=lang_code) }}">
|
| 133 |
+
{% if lang_code == 'en' %}🇺🇸{% elif lang_code == 'zh' %}🇨🇳{% endif %} {{ lang_name }}
|
| 134 |
+
</a>
|
| 135 |
+
</li>
|
| 136 |
+
{% endfor %}
|
| 137 |
+
</ul>
|
| 138 |
+
</li>
|
| 139 |
+
<li class="nav-item ms-3">
|
| 140 |
+
<a class="btn btn-outline-primary btn-sm" href="https://github.com/dbccccccc/ttsfm" target="_blank" rel="noopener noreferrer" aria-label="{{ _('nav.github') }}">
|
| 141 |
+
<i class="fab fa-github me-1"></i>{{ _('nav.github') }}
|
| 142 |
+
</a>
|
| 143 |
+
</li>
|
| 144 |
+
</ul>
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
</nav>
|
| 148 |
+
|
| 149 |
+
<!-- Main Content -->
|
| 150 |
+
<main id="main-content" style="padding-top: 76px;">
|
| 151 |
+
{% block content %}{% endblock %}
|
| 152 |
+
</main>
|
| 153 |
+
|
| 154 |
+
<!-- Simplified Footer -->
|
| 155 |
+
<footer class="footer py-3" style="background-color: #f9fafb; border-top: 1px solid #e5e7eb;" role="contentinfo">
|
| 156 |
+
<div class="container">
|
| 157 |
+
<div class="row align-items-center">
|
| 158 |
+
<div class="col-md-6">
|
| 159 |
+
<div class="d-flex align-items-center">
|
| 160 |
+
<i class="fas fa-microphone-alt me-2 text-primary"></i>
|
| 161 |
+
<strong class="text-dark">TTSFM</strong>
|
| 162 |
+
<span class="ms-2 text-muted">v3.2.2</span>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
<div class="col-md-6 text-md-end">
|
| 166 |
+
<small class="text-muted">
|
| 167 |
+
{{ _('home.footer_copyright') }} •
|
| 168 |
+
<a href="{{ url_for('docs') }}" class="text-decoration-none text-muted">{{ _('nav.documentation') }}</a> •
|
| 169 |
+
<a href="https://github.com/dbccccccc/ttsfm" class="text-decoration-none text-muted" target="_blank">{{ _('nav.github') }}</a>
|
| 170 |
+
</small>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
</footer>
|
| 175 |
+
|
| 176 |
+
<!-- Bootstrap JS -->
|
| 177 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
| 178 |
+
|
| 179 |
+
<!-- Internationalization Support -->
|
| 180 |
+
<script src="{{ url_for('static', filename='js/i18n.js') }}"></script>
|
| 181 |
+
|
| 182 |
+
<!-- Enhanced Common JavaScript -->
|
| 183 |
+
<script>
|
| 184 |
+
// Enhanced service status checking
|
| 185 |
+
async function checkStatus() {
|
| 186 |
+
try {
|
| 187 |
+
const response = await fetch('/api/health');
|
| 188 |
+
const data = await response.json();
|
| 189 |
+
|
| 190 |
+
const indicator = document.getElementById('status-indicator');
|
| 191 |
+
const text = document.getElementById('status-text');
|
| 192 |
+
|
| 193 |
+
if (response.ok && data.status === 'healthy') {
|
| 194 |
+
indicator.className = 'status-indicator status-online';
|
| 195 |
+
text.textContent = '{{ _("nav.status_online") }}';
|
| 196 |
+
} else {
|
| 197 |
+
indicator.className = 'status-indicator status-offline';
|
| 198 |
+
text.textContent = '{{ _("nav.status_offline") }}';
|
| 199 |
+
}
|
| 200 |
+
} catch (error) {
|
| 201 |
+
const indicator = document.getElementById('status-indicator');
|
| 202 |
+
const text = document.getElementById('status-text');
|
| 203 |
+
indicator.className = 'status-indicator status-offline';
|
| 204 |
+
text.textContent = '{{ _("nav.status_offline") }}';
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
// Enhanced page initialization
|
| 209 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 210 |
+
// Check status immediately and periodically
|
| 211 |
+
checkStatus();
|
| 212 |
+
setInterval(checkStatus, 30000); // Check every 30 seconds
|
| 213 |
+
|
| 214 |
+
// Initialize tooltips
|
| 215 |
+
if (typeof bootstrap !== 'undefined') {
|
| 216 |
+
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
| 217 |
+
tooltipTriggerList.map(function (tooltipTriggerEl) {
|
| 218 |
+
return new bootstrap.Tooltip(tooltipTriggerEl);
|
| 219 |
+
});
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
// Add smooth scrolling for anchor links
|
| 223 |
+
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
| 224 |
+
anchor.addEventListener('click', function (e) {
|
| 225 |
+
const target = document.querySelector(this.getAttribute('href'));
|
| 226 |
+
if (target) {
|
| 227 |
+
e.preventDefault();
|
| 228 |
+
target.scrollIntoView({
|
| 229 |
+
behavior: 'smooth',
|
| 230 |
+
block: 'start'
|
| 231 |
+
});
|
| 232 |
+
}
|
| 233 |
+
});
|
| 234 |
+
});
|
| 235 |
+
|
| 236 |
+
// Add fade-in animation to main content
|
| 237 |
+
const mainContent = document.querySelector('main');
|
| 238 |
+
if (mainContent) {
|
| 239 |
+
mainContent.classList.add('fade-in');
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
// Add loading states to external links
|
| 243 |
+
document.querySelectorAll('a[target="_blank"]').forEach(link => {
|
| 244 |
+
link.addEventListener('click', function() {
|
| 245 |
+
this.style.opacity = '0.7';
|
| 246 |
+
setTimeout(() => {
|
| 247 |
+
this.style.opacity = '1';
|
| 248 |
+
}, 1000);
|
| 249 |
+
});
|
| 250 |
+
});
|
| 251 |
+
});
|
| 252 |
+
|
| 253 |
+
// Enhanced utility function to show loading state
|
| 254 |
+
function setLoading(button, loading) {
|
| 255 |
+
if (loading) {
|
| 256 |
+
button.classList.add('loading');
|
| 257 |
+
button.disabled = true;
|
| 258 |
+
button.style.cursor = 'wait';
|
| 259 |
+
} else {
|
| 260 |
+
button.classList.remove('loading');
|
| 261 |
+
button.disabled = false;
|
| 262 |
+
button.style.cursor = 'pointer';
|
| 263 |
+
}
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
// Enhanced utility function to show alerts
|
| 267 |
+
function showAlert(message, type = 'info', duration = 5000) {
|
| 268 |
+
const alertDiv = document.createElement('div');
|
| 269 |
+
alertDiv.className = `alert alert-${type} alert-dismissible fade show fade-in`;
|
| 270 |
+
alertDiv.style.position = 'relative';
|
| 271 |
+
alertDiv.style.zIndex = '1050';
|
| 272 |
+
alertDiv.innerHTML = `
|
| 273 |
+
<i class="fas fa-${getAlertIcon(type)} me-2"></i>
|
| 274 |
+
${message}
|
| 275 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
| 276 |
+
`;
|
| 277 |
+
|
| 278 |
+
// Find the best container to insert the alert
|
| 279 |
+
const container = document.querySelector('main .container') || document.querySelector('.container') || document.body;
|
| 280 |
+
if (container) {
|
| 281 |
+
container.insertBefore(alertDiv, container.firstChild);
|
| 282 |
+
|
| 283 |
+
// Auto-dismiss after specified duration
|
| 284 |
+
setTimeout(() => {
|
| 285 |
+
if (alertDiv.parentNode) {
|
| 286 |
+
alertDiv.classList.remove('show');
|
| 287 |
+
setTimeout(() => {
|
| 288 |
+
if (alertDiv.parentNode) {
|
| 289 |
+
alertDiv.remove();
|
| 290 |
+
}
|
| 291 |
+
}, 150);
|
| 292 |
+
}
|
| 293 |
+
}, duration);
|
| 294 |
+
|
| 295 |
+
// Scroll to alert if it's not visible
|
| 296 |
+
alertDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
// Helper function to get appropriate icon for alert type
|
| 301 |
+
function getAlertIcon(type) {
|
| 302 |
+
const icons = {
|
| 303 |
+
'success': 'check-circle',
|
| 304 |
+
'danger': 'exclamation-triangle',
|
| 305 |
+
'warning': 'exclamation-triangle',
|
| 306 |
+
'info': 'info-circle',
|
| 307 |
+
'primary': 'info-circle'
|
| 308 |
+
};
|
| 309 |
+
return icons[type] || 'info-circle';
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
// Enhanced error handling for fetch requests
|
| 313 |
+
async function safeFetch(url, options = {}) {
|
| 314 |
+
try {
|
| 315 |
+
const response = await fetch(url, options);
|
| 316 |
+
if (!response.ok) {
|
| 317 |
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
| 318 |
+
}
|
| 319 |
+
return response;
|
| 320 |
+
} catch (error) {
|
| 321 |
+
console.error('Fetch error:', error);
|
| 322 |
+
showAlert(`Network error: ${error.message}`, 'danger');
|
| 323 |
+
throw error;
|
| 324 |
+
}
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
// Performance monitoring
|
| 328 |
+
window.addEventListener('load', function() {
|
| 329 |
+
// Log page load time
|
| 330 |
+
const loadTime = performance.now();
|
| 331 |
+
console.log(`Page loaded in ${Math.round(loadTime)}ms`);
|
| 332 |
+
|
| 333 |
+
// Check for slow loading resources
|
| 334 |
+
if (loadTime > 3000) {
|
| 335 |
+
console.warn('Page load time is slow. Consider optimizing resources.');
|
| 336 |
+
}
|
| 337 |
+
});
|
| 338 |
+
|
| 339 |
+
// Keyboard shortcuts
|
| 340 |
+
document.addEventListener('keydown', function(e) {
|
| 341 |
+
// Alt + H for home
|
| 342 |
+
if (e.altKey && e.key === 'h') {
|
| 343 |
+
e.preventDefault();
|
| 344 |
+
window.location.href = '{{ url_for("index") }}';
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
// Alt + P for playground
|
| 348 |
+
if (e.altKey && e.key === 'p') {
|
| 349 |
+
e.preventDefault();
|
| 350 |
+
window.location.href = '{{ url_for("playground") }}';
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
// Alt + D for docs
|
| 354 |
+
if (e.altKey && e.key === 'd') {
|
| 355 |
+
e.preventDefault();
|
| 356 |
+
window.location.href = '{{ url_for("docs") }}';
|
| 357 |
+
}
|
| 358 |
+
});
|
| 359 |
+
</script>
|
| 360 |
+
|
| 361 |
+
{% block extra_js %}{% endblock %}
|
| 362 |
+
</body>
|
| 363 |
+
</html>
|
templates/docs.html
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}TTSFM {{ _('docs.title') }}{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block extra_css %}
|
| 6 |
+
<style>
|
| 7 |
+
.code-block {
|
| 8 |
+
background-color: #f8f9fa;
|
| 9 |
+
border: 1px solid #e9ecef;
|
| 10 |
+
border-radius: 0.375rem;
|
| 11 |
+
padding: 1rem;
|
| 12 |
+
margin: 1rem 0;
|
| 13 |
+
overflow-x: auto;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
.endpoint-card {
|
| 17 |
+
border-left: 4px solid #007bff;
|
| 18 |
+
margin-bottom: 2rem;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
.method-badge {
|
| 22 |
+
font-size: 0.75rem;
|
| 23 |
+
padding: 0.25rem 0.5rem;
|
| 24 |
+
border-radius: 0.25rem;
|
| 25 |
+
font-weight: bold;
|
| 26 |
+
margin-right: 0.5rem;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
.method-get { background-color: #28a745; color: white; }
|
| 30 |
+
.method-post { background-color: #007bff; color: white; }
|
| 31 |
+
.method-put { background-color: #ffc107; color: black; }
|
| 32 |
+
.method-delete { background-color: #dc3545; color: white; }
|
| 33 |
+
|
| 34 |
+
.response-example {
|
| 35 |
+
background-color: #f1f3f4;
|
| 36 |
+
border-radius: 0.375rem;
|
| 37 |
+
padding: 1rem;
|
| 38 |
+
margin-top: 1rem;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.toc {
|
| 42 |
+
position: sticky;
|
| 43 |
+
top: 2rem;
|
| 44 |
+
max-height: calc(100vh - 4rem);
|
| 45 |
+
overflow-y: auto;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.toc a {
|
| 49 |
+
color: #6c757d;
|
| 50 |
+
text-decoration: none;
|
| 51 |
+
display: block;
|
| 52 |
+
padding: 0.25rem 0;
|
| 53 |
+
border-left: 2px solid transparent;
|
| 54 |
+
padding-left: 1rem;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.toc a:hover, .toc a.active {
|
| 58 |
+
color: #007bff;
|
| 59 |
+
border-left-color: #007bff;
|
| 60 |
+
}
|
| 61 |
+
</style>
|
| 62 |
+
{% endblock %}
|
| 63 |
+
|
| 64 |
+
{% block content %}
|
| 65 |
+
<div class="container py-5">
|
| 66 |
+
<div class="row">
|
| 67 |
+
<div class="col-12 text-center mb-5">
|
| 68 |
+
<h1 class="display-4 fw-bold">
|
| 69 |
+
<i class="fas fa-book me-3 text-primary"></i>{{ _('docs.title') }}
|
| 70 |
+
</h1>
|
| 71 |
+
<p class="lead text-muted">
|
| 72 |
+
{{ _('docs.subtitle') }}
|
| 73 |
+
</p>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<div class="row">
|
| 78 |
+
<!-- Table of Contents -->
|
| 79 |
+
<div class="col-lg-3">
|
| 80 |
+
<div class="toc">
|
| 81 |
+
<h5 class="fw-bold mb-3">{{ _('docs.contents') }}</h5>
|
| 82 |
+
<a href="#overview">{{ _('docs.overview') }}</a>
|
| 83 |
+
<a href="#authentication">{{ _('docs.authentication') }}</a>
|
| 84 |
+
<a href="#text-validation">{{ _('docs.text_validation') }}</a>
|
| 85 |
+
<a href="#endpoints">{{ _('docs.endpoints') }}</a>
|
| 86 |
+
<a href="#voices">{{ _('docs.voices') }}</a>
|
| 87 |
+
<a href="#formats">{{ _('docs.formats') }}</a>
|
| 88 |
+
<a href="#generate">{{ _('docs.generate') }}</a>
|
| 89 |
+
<a href="#combined">{{ _('docs.combined') }}</a>
|
| 90 |
+
<a href="#status">{{ _('docs.status') }}</a>
|
| 91 |
+
<a href="#errors">{{ _('docs.errors') }}</a>
|
| 92 |
+
<a href="#examples">{{ _('docs.examples') }}</a>
|
| 93 |
+
<a href="#python-package">{{ _('docs.python_package') }}</a>
|
| 94 |
+
<a href="#websocket">WebSocket Streaming</a>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
<!-- Documentation Content -->
|
| 99 |
+
<div class="col-lg-9">
|
| 100 |
+
<!-- Overview -->
|
| 101 |
+
<section id="overview" class="mb-5">
|
| 102 |
+
<h2 class="fw-bold mb-3">{{ _('docs.overview_title') }}</h2>
|
| 103 |
+
<p>
|
| 104 |
+
{{ _('docs.overview_desc') }}
|
| 105 |
+
</p>
|
| 106 |
+
|
| 107 |
+
<div class="alert alert-info">
|
| 108 |
+
<i class="fas fa-info-circle me-2"></i>
|
| 109 |
+
<strong>{{ _('docs.base_url') }}</strong> <code>{{ request.url_root }}api/</code>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
<h4>{{ _('docs.key_features') }}</h4>
|
| 113 |
+
<ul>
|
| 114 |
+
<li><strong>🎤 {{ _('docs.feature_voices') }}</strong></li>
|
| 115 |
+
<li><strong>🎵 {{ _('docs.feature_formats') }}</strong></li>
|
| 116 |
+
<li><strong>🤖 {{ _('docs.feature_openai') }}</strong></li>
|
| 117 |
+
<li><strong>✨ {{ _('docs.feature_auto_combine') }}</strong></li>
|
| 118 |
+
<li><strong>📊 {{ _('docs.feature_validation') }}</strong></li>
|
| 119 |
+
<li><strong>📈 {{ _('docs.feature_monitoring') }}</strong></li>
|
| 120 |
+
</ul>
|
| 121 |
+
|
| 122 |
+
<div class="alert alert-success">
|
| 123 |
+
<i class="fas fa-star me-2"></i>
|
| 124 |
+
<strong>{{ _('docs.new_version') }}</strong> {{ _('docs.new_version_desc') }}
|
| 125 |
+
</div>
|
| 126 |
+
</section>
|
| 127 |
+
|
| 128 |
+
<!-- Authentication -->
|
| 129 |
+
<section id="authentication" class="mb-5">
|
| 130 |
+
<h2 class="fw-bold mb-3">{{ _('docs.authentication_title') }}</h2>
|
| 131 |
+
<p>
|
| 132 |
+
{{ _('docs.authentication_desc') }}
|
| 133 |
+
</p>
|
| 134 |
+
|
| 135 |
+
<div class="code-block">
|
| 136 |
+
<pre><code>Authorization: Bearer YOUR_API_KEY</code></pre>
|
| 137 |
+
</div>
|
| 138 |
+
</section>
|
| 139 |
+
|
| 140 |
+
<!-- Text Validation -->
|
| 141 |
+
<section id="text-validation" class="mb-5">
|
| 142 |
+
<h2 class="fw-bold mb-3">{{ _('docs.text_validation_title') }}</h2>
|
| 143 |
+
<p>
|
| 144 |
+
{{ _('docs.text_validation_desc') }}
|
| 145 |
+
</p>
|
| 146 |
+
|
| 147 |
+
<div class="alert alert-warning">
|
| 148 |
+
<i class="fas fa-exclamation-triangle me-2"></i>
|
| 149 |
+
<strong>{{ _('docs.important') }}</strong> {{ _('docs.text_validation_warning') }}
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
<h4>{{ _('docs.validation_options') }}</h4>
|
| 153 |
+
<ul>
|
| 154 |
+
<li><code>max_length</code>: {{ _('docs.max_length_option') }}</li>
|
| 155 |
+
<li><code>validate_length</code>: {{ _('docs.validate_length_option') }}</li>
|
| 156 |
+
<li><code>preserve_words</code>: {{ _('docs.preserve_words_option') }}</li>
|
| 157 |
+
</ul>
|
| 158 |
+
</section>
|
| 159 |
+
|
| 160 |
+
<!-- API Endpoints -->
|
| 161 |
+
<section id="endpoints" class="mb-5">
|
| 162 |
+
<h2 class="fw-bold mb-3">{{ _('docs.endpoints_title') }}</h2>
|
| 163 |
+
|
| 164 |
+
<!-- Voices Endpoint -->
|
| 165 |
+
<div class="card endpoint-card" id="voices">
|
| 166 |
+
<div class="card-body">
|
| 167 |
+
<h4 class="card-title">
|
| 168 |
+
<span class="method-badge method-get">GET</span>
|
| 169 |
+
/api/voices
|
| 170 |
+
</h4>
|
| 171 |
+
<p class="card-text">{{ _('docs.get_voices_desc') }}</p>
|
| 172 |
+
|
| 173 |
+
<h6>{{ _('docs.response_example') }}</h6>
|
| 174 |
+
<div class="response-example">
|
| 175 |
+
<pre><code>{
|
| 176 |
+
"voices": [
|
| 177 |
+
{
|
| 178 |
+
"id": "alloy",
|
| 179 |
+
"name": "Alloy",
|
| 180 |
+
"description": "Alloy voice"
|
| 181 |
+
},
|
| 182 |
+
{
|
| 183 |
+
"id": "echo",
|
| 184 |
+
"name": "Echo",
|
| 185 |
+
"description": "Echo voice"
|
| 186 |
+
}
|
| 187 |
+
],
|
| 188 |
+
"count": 6
|
| 189 |
+
}</code></pre>
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
<!-- Formats Endpoint -->
|
| 195 |
+
<div class="card endpoint-card" id="formats">
|
| 196 |
+
<div class="card-body">
|
| 197 |
+
<h4 class="card-title">
|
| 198 |
+
<span class="method-badge method-get">GET</span>
|
| 199 |
+
/api/formats
|
| 200 |
+
</h4>
|
| 201 |
+
<p class="card-text">Get available audio formats for speech generation.</p>
|
| 202 |
+
|
| 203 |
+
<h6>Available Formats</h6>
|
| 204 |
+
<p>We support multiple format requests, but internally:</p>
|
| 205 |
+
<ul>
|
| 206 |
+
<li><strong>mp3</strong> - Returns actual MP3 format</li>
|
| 207 |
+
<li><strong>All other formats</strong> (opus, aac, flac, wav, pcm) - Mapped to WAV format</li>
|
| 208 |
+
</ul>
|
| 209 |
+
|
| 210 |
+
<div class="alert alert-info">
|
| 211 |
+
<i class="fas fa-info-circle me-2"></i>
|
| 212 |
+
<strong>Note:</strong> When you request opus, aac, flac, wav, or pcm, you'll receive WAV audio data.
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
+
<h6>{{ _('docs.response_example') }}</h6>
|
| 216 |
+
<div class="response-example">
|
| 217 |
+
<pre><code>{
|
| 218 |
+
"formats": [
|
| 219 |
+
{
|
| 220 |
+
"id": "mp3",
|
| 221 |
+
"name": "MP3",
|
| 222 |
+
"mime_type": "audio/mp3",
|
| 223 |
+
"description": "MP3 audio format"
|
| 224 |
+
},
|
| 225 |
+
{
|
| 226 |
+
"id": "opus",
|
| 227 |
+
"name": "Opus",
|
| 228 |
+
"mime_type": "audio/wav",
|
| 229 |
+
"description": "Returns WAV format"
|
| 230 |
+
},
|
| 231 |
+
{
|
| 232 |
+
"id": "aac",
|
| 233 |
+
"name": "AAC",
|
| 234 |
+
"mime_type": "audio/wav",
|
| 235 |
+
"description": "Returns WAV format"
|
| 236 |
+
},
|
| 237 |
+
{
|
| 238 |
+
"id": "flac",
|
| 239 |
+
"name": "FLAC",
|
| 240 |
+
"mime_type": "audio/wav",
|
| 241 |
+
"description": "Returns WAV format"
|
| 242 |
+
},
|
| 243 |
+
{
|
| 244 |
+
"id": "wav",
|
| 245 |
+
"name": "WAV",
|
| 246 |
+
"mime_type": "audio/wav",
|
| 247 |
+
"description": "WAV audio format"
|
| 248 |
+
},
|
| 249 |
+
{
|
| 250 |
+
"id": "pcm",
|
| 251 |
+
"name": "PCM",
|
| 252 |
+
"mime_type": "audio/wav",
|
| 253 |
+
"description": "Returns WAV format"
|
| 254 |
+
}
|
| 255 |
+
],
|
| 256 |
+
"count": 6
|
| 257 |
+
}</code></pre>
|
| 258 |
+
</div>
|
| 259 |
+
</div>
|
| 260 |
+
</div>
|
| 261 |
+
|
| 262 |
+
<!-- Text Validation Endpoint -->
|
| 263 |
+
<div class="card endpoint-card">
|
| 264 |
+
<div class="card-body">
|
| 265 |
+
<h4 class="card-title">
|
| 266 |
+
<span class="method-badge method-post">POST</span>
|
| 267 |
+
/api/validate-text
|
| 268 |
+
</h4>
|
| 269 |
+
<p class="card-text">{{ _('docs.validate_text_desc') }}</p>
|
| 270 |
+
|
| 271 |
+
<h6>{{ _('docs.request_body') }}</h6>
|
| 272 |
+
<div class="code-block">
|
| 273 |
+
<pre><code>{
|
| 274 |
+
"text": "Your text to validate",
|
| 275 |
+
"max_length": 4096
|
| 276 |
+
}</code></pre>
|
| 277 |
+
</div>
|
| 278 |
+
|
| 279 |
+
<h6>{{ _('docs.response_example') }}</h6>
|
| 280 |
+
<div class="response-example">
|
| 281 |
+
<pre><code>{
|
| 282 |
+
"text_length": 5000,
|
| 283 |
+
"max_length": 4096,
|
| 284 |
+
"is_valid": false,
|
| 285 |
+
"needs_splitting": true,
|
| 286 |
+
"suggested_chunks": 2,
|
| 287 |
+
"chunk_preview": [
|
| 288 |
+
"First chunk preview...",
|
| 289 |
+
"Second chunk preview..."
|
| 290 |
+
]
|
| 291 |
+
}</code></pre>
|
| 292 |
+
</div>
|
| 293 |
+
</div>
|
| 294 |
+
</div>
|
| 295 |
+
|
| 296 |
+
<!-- Generate Speech Endpoint -->
|
| 297 |
+
<div class="card endpoint-card" id="generate">
|
| 298 |
+
<div class="card-body">
|
| 299 |
+
<h4 class="card-title">
|
| 300 |
+
<span class="method-badge method-post">POST</span>
|
| 301 |
+
/api/generate
|
| 302 |
+
</h4>
|
| 303 |
+
<p class="card-text">{{ _('docs.generate_speech_desc') }}</p>
|
| 304 |
+
|
| 305 |
+
<h6>{{ _('docs.request_body') }}</h6>
|
| 306 |
+
<div class="code-block">
|
| 307 |
+
<pre><code>{
|
| 308 |
+
"text": "Hello, world!",
|
| 309 |
+
"voice": "alloy",
|
| 310 |
+
"format": "mp3",
|
| 311 |
+
"instructions": "Speak cheerfully",
|
| 312 |
+
"max_length": 4096,
|
| 313 |
+
"validate_length": true
|
| 314 |
+
}</code></pre>
|
| 315 |
+
</div>
|
| 316 |
+
|
| 317 |
+
<h6>{{ _('docs.parameters') }}</h6>
|
| 318 |
+
<ul>
|
| 319 |
+
<li><code>text</code> ({{ _('docs.required') }}): {{ _('docs.text_param') }}</li>
|
| 320 |
+
<li><code>voice</code> ({{ _('docs.optional') }}): {{ _('docs.voice_param') }}</li>
|
| 321 |
+
<li><code>format</code> ({{ _('docs.optional') }}): {{ _('docs.format_param') }}</li>
|
| 322 |
+
<li><code>instructions</code> ({{ _('docs.optional') }}): {{ _('docs.instructions_param') }}</li>
|
| 323 |
+
<li><code>max_length</code> ({{ _('docs.optional') }}): {{ _('docs.max_length_param') }}</li>
|
| 324 |
+
<li><code>validate_length</code> ({{ _('docs.optional') }}): {{ _('docs.validate_length_param') }}</li>
|
| 325 |
+
</ul>
|
| 326 |
+
|
| 327 |
+
<h6>{{ _('docs.response') }}</h6>
|
| 328 |
+
<p>{{ _('docs.response_audio') }}</p>
|
| 329 |
+
</div>
|
| 330 |
+
</div>
|
| 331 |
+
|
| 332 |
+
</section>
|
| 333 |
+
|
| 334 |
+
<!-- Python Package -->
|
| 335 |
+
<section id="python-package" class="mb-5">
|
| 336 |
+
<h3 class="fw-bold mb-4">
|
| 337 |
+
<i class="fab fa-python me-2 text-warning"></i>{{ _('docs.python_package_title') }}
|
| 338 |
+
</h3>
|
| 339 |
+
|
| 340 |
+
<div class="card">
|
| 341 |
+
<div class="card-body">
|
| 342 |
+
<h5>{{ _('docs.long_text_support') }}</h5>
|
| 343 |
+
<p>{{ _('docs.long_text_desc') }}</p>
|
| 344 |
+
|
| 345 |
+
<div class="code-block">
|
| 346 |
+
<pre><code>from ttsfm import TTSClient, Voice, AudioFormat
|
| 347 |
+
|
| 348 |
+
# Create client
|
| 349 |
+
client = TTSClient()
|
| 350 |
+
|
| 351 |
+
# Generate speech from long text (automatically splits into separate files)
|
| 352 |
+
responses = client.generate_speech_long_text(
|
| 353 |
+
text="Very long text that exceeds 4096 characters...",
|
| 354 |
+
voice=Voice.ALLOY,
|
| 355 |
+
response_format=AudioFormat.MP3,
|
| 356 |
+
max_length=2000,
|
| 357 |
+
preserve_words=True
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
# Save each chunk as separate files
|
| 361 |
+
for i, response in enumerate(responses, 1):
|
| 362 |
+
response.save_to_file(f"part_{i:03d}.mp3")</code></pre>
|
| 363 |
+
</div>
|
| 364 |
+
|
| 365 |
+
<h6 class="mt-4">{{ _('docs.developer_features') }}</h6>
|
| 366 |
+
<ul>
|
| 367 |
+
<li><strong>{{ _('docs.manual_splitting') }}</strong></li>
|
| 368 |
+
<li><strong>{{ _('docs.word_preservation') }}</strong></li>
|
| 369 |
+
<li><strong>{{ _('docs.separate_files') }}</strong></li>
|
| 370 |
+
<li><strong>{{ _('docs.cli_support') }}</strong></li>
|
| 371 |
+
</ul>
|
| 372 |
+
|
| 373 |
+
<div class="alert alert-info">
|
| 374 |
+
<i class="fas fa-info-circle me-2"></i>
|
| 375 |
+
<strong>{{ _('docs.note') }}</strong> {{ _('docs.auto_combine_note') }}
|
| 376 |
+
</div>
|
| 377 |
+
</div>
|
| 378 |
+
</div>
|
| 379 |
+
|
| 380 |
+
<!-- Combined Audio Endpoints -->
|
| 381 |
+
<div class="card endpoint-card" id="combined">
|
| 382 |
+
<div class="card-body">
|
| 383 |
+
<h4 class="card-title">
|
| 384 |
+
<span class="method-badge method-post">POST</span>
|
| 385 |
+
/api/generate-combined
|
| 386 |
+
</h4>
|
| 387 |
+
<p class="card-text">{{ _('docs.combined_audio_desc') }}</p>
|
| 388 |
+
|
| 389 |
+
<h6>{{ _('docs.request_body') }}</h6>
|
| 390 |
+
<div class="code-block">
|
| 391 |
+
<pre><code>{
|
| 392 |
+
"text": "Very long text that exceeds the limit...",
|
| 393 |
+
"voice": "alloy",
|
| 394 |
+
"format": "mp3",
|
| 395 |
+
"instructions": "Optional voice instructions",
|
| 396 |
+
"max_length": 4096,
|
| 397 |
+
"preserve_words": true
|
| 398 |
+
}</code></pre>
|
| 399 |
+
</div>
|
| 400 |
+
|
| 401 |
+
<h6>{{ _('docs.response') }}</h6>
|
| 402 |
+
<p>{{ _('docs.response_combined_audio') }}</p>
|
| 403 |
+
|
| 404 |
+
<h6>{{ _('docs.response_headers') }}</h6>
|
| 405 |
+
<ul>
|
| 406 |
+
<li><code>X-Chunks-Combined</code>: {{ _('docs.chunks_combined_header') }}</li>
|
| 407 |
+
<li><code>X-Original-Text-Length</code>: {{ _('docs.original_text_length_header') }}</li>
|
| 408 |
+
<li><code>X-Audio-Size</code>: {{ _('docs.audio_size_header') }}</li>
|
| 409 |
+
</ul>
|
| 410 |
+
</div>
|
| 411 |
+
</div>
|
| 412 |
+
|
| 413 |
+
<!-- OpenAI Compatible Endpoint with Auto-Combine -->
|
| 414 |
+
<div class="card endpoint-card">
|
| 415 |
+
<div class="card-body">
|
| 416 |
+
<h4 class="card-title">
|
| 417 |
+
<span class="method-badge method-post">POST</span>
|
| 418 |
+
/v1/audio/speech
|
| 419 |
+
</h4>
|
| 420 |
+
<p class="card-text">{{ _('docs.openai_compatible_desc') }}</p>
|
| 421 |
+
|
| 422 |
+
<h6>{{ _('docs.request_body') }}</h6>
|
| 423 |
+
<div class="code-block">
|
| 424 |
+
<pre><code>{
|
| 425 |
+
"model": "gpt-4o-mini-tts",
|
| 426 |
+
"input": "Text of any length...",
|
| 427 |
+
"voice": "alloy",
|
| 428 |
+
"response_format": "mp3",
|
| 429 |
+
"instructions": "Optional voice instructions",
|
| 430 |
+
"speed": 1.0,
|
| 431 |
+
"auto_combine": true,
|
| 432 |
+
"max_length": 4096
|
| 433 |
+
}</code></pre>
|
| 434 |
+
</div>
|
| 435 |
+
|
| 436 |
+
<h6>{{ _('docs.enhanced_parameters') }}</h6>
|
| 437 |
+
<ul>
|
| 438 |
+
<li><strong>auto_combine</strong> (boolean, default: true):
|
| 439 |
+
<ul>
|
| 440 |
+
<li><code>true</code>: {{ _('docs.auto_combine_param') }}</li>
|
| 441 |
+
<li><code>false</code>: {{ _('docs.auto_combine_false') }}</li>
|
| 442 |
+
</ul>
|
| 443 |
+
</li>
|
| 444 |
+
<li><strong>max_length</strong> (integer, default: 4096): {{ _('docs.max_length_chunk_param') }}</li>
|
| 445 |
+
</ul>
|
| 446 |
+
|
| 447 |
+
<h6>{{ _('docs.response_headers') }}</h6>
|
| 448 |
+
<ul>
|
| 449 |
+
<li><code>X-Auto-Combine</code>: {{ _('docs.auto_combine_header') }}</li>
|
| 450 |
+
<li><code>X-Chunks-Combined</code>: {{ _('docs.chunks_combined_response') }}</li>
|
| 451 |
+
<li><code>X-Original-Text-Length</code>: {{ _('docs.original_text_response') }}</li>
|
| 452 |
+
<li><code>X-Audio-Format</code>: {{ _('docs.audio_format_header') }}</li>
|
| 453 |
+
<li><code>X-Audio-Size</code>: {{ _('docs.audio_size_response') }}</li>
|
| 454 |
+
</ul>
|
| 455 |
+
|
| 456 |
+
<h6>{{ _('docs.examples_title') }}</h6>
|
| 457 |
+
<div class="code-block">
|
| 458 |
+
<pre><code># {{ _('docs.short_text_comment') }}
|
| 459 |
+
curl -X POST {{ request.url_root }}v1/audio/speech \
|
| 460 |
+
-H "Content-Type: application/json" \
|
| 461 |
+
-d '{
|
| 462 |
+
"model": "gpt-4o-mini-tts",
|
| 463 |
+
"input": "Hello world!",
|
| 464 |
+
"voice": "alloy"
|
| 465 |
+
}'
|
| 466 |
+
|
| 467 |
+
# {{ _('docs.long_text_auto_comment') }}
|
| 468 |
+
curl -X POST {{ request.url_root }}v1/audio/speech \
|
| 469 |
+
-H "Content-Type: application/json" \
|
| 470 |
+
-d '{
|
| 471 |
+
"model": "gpt-4o-mini-tts",
|
| 472 |
+
"input": "Very long text...",
|
| 473 |
+
"voice": "alloy",
|
| 474 |
+
"auto_combine": true
|
| 475 |
+
}'
|
| 476 |
+
|
| 477 |
+
# {{ _('docs.long_text_no_auto_comment') }}
|
| 478 |
+
curl -X POST {{ request.url_root }}v1/audio/speech \
|
| 479 |
+
-H "Content-Type: application/json" \
|
| 480 |
+
-d '{
|
| 481 |
+
"model": "gpt-4o-mini-tts",
|
| 482 |
+
"input": "Very long text...",
|
| 483 |
+
"voice": "alloy",
|
| 484 |
+
"auto_combine": false
|
| 485 |
+
}'</code></pre>
|
| 486 |
+
</div>
|
| 487 |
+
|
| 488 |
+
<div class="alert alert-info mt-3">
|
| 489 |
+
<i class="fas fa-info-circle me-2"></i>
|
| 490 |
+
<strong>{{ _('docs.audio_combination') }}</strong> {{ _('docs.audio_combination_desc') }}
|
| 491 |
+
</div>
|
| 492 |
+
|
| 493 |
+
<h6 class="mt-4">{{ _('docs.use_cases') }}</h6>
|
| 494 |
+
<ul>
|
| 495 |
+
<li><strong>{{ _('docs.use_case_articles') }}</strong></li>
|
| 496 |
+
<li><strong>{{ _('docs.use_case_audiobooks') }}</strong></li>
|
| 497 |
+
<li><strong>{{ _('docs.use_case_podcasts') }}</strong></li>
|
| 498 |
+
<li><strong>{{ _('docs.use_case_education') }}</strong></li>
|
| 499 |
+
</ul>
|
| 500 |
+
|
| 501 |
+
<h6 class="mt-4">{{ _('docs.example_usage') }}</h6>
|
| 502 |
+
<div class="code-block">
|
| 503 |
+
<pre><code># {{ _('docs.python_example_comment') }}
|
| 504 |
+
import requests
|
| 505 |
+
|
| 506 |
+
response = requests.post(
|
| 507 |
+
"{{ request.url_root }}api/generate-combined",
|
| 508 |
+
json={
|
| 509 |
+
"text": "Your very long text content here...",
|
| 510 |
+
"voice": "nova",
|
| 511 |
+
"format": "mp3",
|
| 512 |
+
"max_length": 2000
|
| 513 |
+
}
|
| 514 |
+
)
|
| 515 |
+
|
| 516 |
+
if response.status_code == 200:
|
| 517 |
+
with open("combined_audio.mp3", "wb") as f:
|
| 518 |
+
f.write(response.content)
|
| 519 |
+
|
| 520 |
+
chunks = response.headers.get('X-Chunks-Combined')
|
| 521 |
+
print(f"Combined {chunks} chunks into single file")</code></pre>
|
| 522 |
+
</div>
|
| 523 |
+
</div>
|
| 524 |
+
</div>
|
| 525 |
+
</section>
|
| 526 |
+
|
| 527 |
+
<!-- WebSocket Streaming -->
|
| 528 |
+
<section id="websocket" class="mb-5">
|
| 529 |
+
<h2 class="mb-4">
|
| 530 |
+
<i class="fas fa-bolt text-warning me-2"></i>WebSocket Streaming
|
| 531 |
+
</h2>
|
| 532 |
+
<p class="lead">
|
| 533 |
+
Real-time audio streaming for enhanced user experience. Get audio chunks as they're generated instead of waiting for the complete file.
|
| 534 |
+
</p>
|
| 535 |
+
|
| 536 |
+
<div class="alert alert-info">
|
| 537 |
+
<i class="fas fa-info-circle me-2"></i>
|
| 538 |
+
WebSocket streaming provides lower perceived latency and real-time progress tracking for TTS generation.
|
| 539 |
+
</div>
|
| 540 |
+
|
| 541 |
+
<h3 class="mt-4">Connection</h3>
|
| 542 |
+
<div class="code-block">
|
| 543 |
+
<pre><code>// JavaScript WebSocket client
|
| 544 |
+
const client = new WebSocketTTSClient({
|
| 545 |
+
socketUrl: '{{ request.url_root[:-1] }}',
|
| 546 |
+
debug: true
|
| 547 |
+
});
|
| 548 |
+
|
| 549 |
+
// Connection events
|
| 550 |
+
client.onConnect = () => console.log('Connected');
|
| 551 |
+
client.onDisconnect = () => console.log('Disconnected');</code></pre>
|
| 552 |
+
</div>
|
| 553 |
+
|
| 554 |
+
<h3 class="mt-4">Streaming TTS Generation</h3>
|
| 555 |
+
<div class="code-block">
|
| 556 |
+
<pre><code>// Generate speech with real-time streaming
|
| 557 |
+
const result = await client.generateSpeech('Hello, WebSocket world!', {
|
| 558 |
+
voice: 'alloy',
|
| 559 |
+
format: 'mp3',
|
| 560 |
+
chunkSize: 1024, // Characters per chunk
|
| 561 |
+
|
| 562 |
+
// Progress callback
|
| 563 |
+
onProgress: (progress) => {
|
| 564 |
+
console.log(`Progress: ${progress.progress}%`);
|
| 565 |
+
console.log(`Chunks: ${progress.chunksCompleted}/${progress.totalChunks}`);
|
| 566 |
+
},
|
| 567 |
+
|
| 568 |
+
// Receive audio chunks in real-time
|
| 569 |
+
onChunk: (chunk) => {
|
| 570 |
+
console.log(`Received chunk ${chunk.chunkIndex + 1}`);
|
| 571 |
+
// Process or play audio chunk immediately
|
| 572 |
+
processAudioChunk(chunk.audioData);
|
| 573 |
+
},
|
| 574 |
+
|
| 575 |
+
// Completion callback
|
| 576 |
+
onComplete: (result) => {
|
| 577 |
+
console.log('Streaming complete!');
|
| 578 |
+
// result.audioData contains the complete audio
|
| 579 |
+
}
|
| 580 |
+
});</code></pre>
|
| 581 |
+
</div>
|
| 582 |
+
|
| 583 |
+
<h3 class="mt-4">WebSocket Events</h3>
|
| 584 |
+
<div class="endpoint-card card">
|
| 585 |
+
<div class="card-body">
|
| 586 |
+
<h5>Client → Server Events</h5>
|
| 587 |
+
<table class="table table-sm">
|
| 588 |
+
<thead>
|
| 589 |
+
<tr>
|
| 590 |
+
<th>Event</th>
|
| 591 |
+
<th>Description</th>
|
| 592 |
+
<th>Payload</th>
|
| 593 |
+
</tr>
|
| 594 |
+
</thead>
|
| 595 |
+
<tbody>
|
| 596 |
+
<tr>
|
| 597 |
+
<td><code>generate_stream</code></td>
|
| 598 |
+
<td>Start TTS generation</td>
|
| 599 |
+
<td><code>{text, voice, format, chunk_size}</code></td>
|
| 600 |
+
</tr>
|
| 601 |
+
<tr>
|
| 602 |
+
<td><code>cancel_stream</code></td>
|
| 603 |
+
<td>Cancel active stream</td>
|
| 604 |
+
<td><code>{request_id}</code></td>
|
| 605 |
+
</tr>
|
| 606 |
+
</tbody>
|
| 607 |
+
</table>
|
| 608 |
+
|
| 609 |
+
<h5 class="mt-4">Server → Client Events</h5>
|
| 610 |
+
<table class="table table-sm">
|
| 611 |
+
<thead>
|
| 612 |
+
<tr>
|
| 613 |
+
<th>Event</th>
|
| 614 |
+
<th>Description</th>
|
| 615 |
+
<th>Payload</th>
|
| 616 |
+
</tr>
|
| 617 |
+
</thead>
|
| 618 |
+
<tbody>
|
| 619 |
+
<tr>
|
| 620 |
+
<td><code>stream_started</code></td>
|
| 621 |
+
<td>Stream initiated</td>
|
| 622 |
+
<td><code>{request_id, timestamp}</code></td>
|
| 623 |
+
</tr>
|
| 624 |
+
<tr>
|
| 625 |
+
<td><code>audio_chunk</code></td>
|
| 626 |
+
<td>Audio chunk ready</td>
|
| 627 |
+
<td><code>{request_id, chunk_index, audio_data, duration}</code></td>
|
| 628 |
+
</tr>
|
| 629 |
+
<tr>
|
| 630 |
+
<td><code>stream_progress</code></td>
|
| 631 |
+
<td>Progress update</td>
|
| 632 |
+
<td><code>{progress, chunks_completed, total_chunks}</code></td>
|
| 633 |
+
</tr>
|
| 634 |
+
<tr>
|
| 635 |
+
<td><code>stream_complete</code></td>
|
| 636 |
+
<td>Generation complete</td>
|
| 637 |
+
<td><code>{request_id, total_chunks, status}</code></td>
|
| 638 |
+
</tr>
|
| 639 |
+
<tr>
|
| 640 |
+
<td><code>stream_error</code></td>
|
| 641 |
+
<td>Error occurred</td>
|
| 642 |
+
<td><code>{request_id, error, timestamp}</code></td>
|
| 643 |
+
</tr>
|
| 644 |
+
</tbody>
|
| 645 |
+
</table>
|
| 646 |
+
</div>
|
| 647 |
+
</div>
|
| 648 |
+
|
| 649 |
+
<h3 class="mt-4">Benefits</h3>
|
| 650 |
+
<ul>
|
| 651 |
+
<li><strong>Real-time feedback:</strong> Users see progress as audio generates</li>
|
| 652 |
+
<li><strong>Lower latency:</strong> First audio chunk arrives quickly</li>
|
| 653 |
+
<li><strong>Cancellable:</strong> Stop generation mid-stream if needed</li>
|
| 654 |
+
<li><strong>Efficient:</strong> Process chunks as they arrive</li>
|
| 655 |
+
</ul>
|
| 656 |
+
|
| 657 |
+
<h3 class="mt-4">Example: Streaming Audio Player</h3>
|
| 658 |
+
<div class="code-block">
|
| 659 |
+
<pre><code>// Create a streaming audio player
|
| 660 |
+
const audioChunks = [];
|
| 661 |
+
let isPlaying = false;
|
| 662 |
+
|
| 663 |
+
const streamingPlayer = await client.generateSpeech(longText, {
|
| 664 |
+
voice: 'nova',
|
| 665 |
+
format: 'mp3',
|
| 666 |
+
|
| 667 |
+
onChunk: (chunk) => {
|
| 668 |
+
// Store chunk
|
| 669 |
+
audioChunks.push(chunk.audioData);
|
| 670 |
+
|
| 671 |
+
// Start playing after first chunk
|
| 672 |
+
if (!isPlaying && audioChunks.length >= 3) {
|
| 673 |
+
startStreamingPlayback(audioChunks);
|
| 674 |
+
isPlaying = true;
|
| 675 |
+
}
|
| 676 |
+
},
|
| 677 |
+
|
| 678 |
+
onComplete: (result) => {
|
| 679 |
+
// Ensure all chunks are played
|
| 680 |
+
finishPlayback(result.audioData);
|
| 681 |
+
}
|
| 682 |
+
});</code></pre>
|
| 683 |
+
</div>
|
| 684 |
+
|
| 685 |
+
<div class="alert alert-success mt-4">
|
| 686 |
+
<h6><i class="fas fa-rocket me-2"></i>Try It Out!</h6>
|
| 687 |
+
<p class="mb-0">
|
| 688 |
+
Experience WebSocket streaming in action at the
|
| 689 |
+
<a href="/websocket-demo" class="alert-link">WebSocket Demo</a> or enable streaming mode in the
|
| 690 |
+
<a href="/playground" class="alert-link">Playground</a>.
|
| 691 |
+
</p>
|
| 692 |
+
</div>
|
| 693 |
+
</section>
|
| 694 |
+
</div>
|
| 695 |
+
</div>
|
| 696 |
+
</div>
|
| 697 |
+
{% endblock %}
|
| 698 |
+
|
| 699 |
+
{% block extra_js %}
|
| 700 |
+
<script>
|
| 701 |
+
// Smooth scrolling for TOC links
|
| 702 |
+
document.querySelectorAll('.toc a').forEach(link => {
|
| 703 |
+
link.addEventListener('click', function(e) {
|
| 704 |
+
e.preventDefault();
|
| 705 |
+
const target = document.querySelector(this.getAttribute('href'));
|
| 706 |
+
if (target) {
|
| 707 |
+
target.scrollIntoView({ behavior: 'smooth' });
|
| 708 |
+
|
| 709 |
+
// Update active link
|
| 710 |
+
document.querySelectorAll('.toc a').forEach(l => l.classList.remove('active'));
|
| 711 |
+
this.classList.add('active');
|
| 712 |
+
}
|
| 713 |
+
});
|
| 714 |
+
});
|
| 715 |
+
|
| 716 |
+
// Highlight current section in TOC
|
| 717 |
+
window.addEventListener('scroll', function() {
|
| 718 |
+
const sections = document.querySelectorAll('section[id]');
|
| 719 |
+
const scrollPos = window.scrollY + 100;
|
| 720 |
+
|
| 721 |
+
sections.forEach(section => {
|
| 722 |
+
const top = section.offsetTop;
|
| 723 |
+
const bottom = top + section.offsetHeight;
|
| 724 |
+
const id = section.getAttribute('id');
|
| 725 |
+
const link = document.querySelector(`.toc a[href="#${id}"]`);
|
| 726 |
+
|
| 727 |
+
if (scrollPos >= top && scrollPos < bottom) {
|
| 728 |
+
document.querySelectorAll('.toc a').forEach(l => l.classList.remove('active'));
|
| 729 |
+
if (link) link.classList.add('active');
|
| 730 |
+
}
|
| 731 |
+
});
|
| 732 |
+
});
|
| 733 |
+
</script>
|
| 734 |
+
{% endblock %}
|
templates/index.html
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}TTSFM - {{ _('home.title') }}{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<!-- Hero Section -->
|
| 7 |
+
<section class="hero-section">
|
| 8 |
+
<div class="container">
|
| 9 |
+
<div class="row align-items-center min-vh-75">
|
| 10 |
+
<div class="col-lg-8 mx-auto text-center">
|
| 11 |
+
<div class="hero-content">
|
| 12 |
+
<div class="badge bg-primary text-white mb-3 px-3 py-2">
|
| 13 |
+
<i class="fas fa-code me-2"></i>Python Package
|
| 14 |
+
</div>
|
| 15 |
+
<h1 class="display-4 fw-bold mb-4">
|
| 16 |
+
{{ _('home.title') }}
|
| 17 |
+
</h1>
|
| 18 |
+
<p class="lead mb-4">
|
| 19 |
+
{{ _('home.subtitle') }}
|
| 20 |
+
</p>
|
| 21 |
+
<div class="d-flex flex-wrap gap-3 justify-content-center">
|
| 22 |
+
<a href="{{ url_for('playground') }}" class="btn btn-primary btn-lg">
|
| 23 |
+
<i class="fas fa-play me-2"></i>{{ _('home.try_demo') }}
|
| 24 |
+
</a>
|
| 25 |
+
<a href="{{ url_for('docs') }}" class="btn btn-outline-secondary btn-lg">
|
| 26 |
+
<i class="fas fa-book me-2"></i>{{ _('home.documentation') }}
|
| 27 |
+
</a>
|
| 28 |
+
<a href="https://github.com/dbccccccc/ttsfm" class="btn btn-outline-secondary btn-lg" target="_blank" rel="noopener noreferrer">
|
| 29 |
+
<i class="fab fa-github me-2"></i>{{ _('home.github') }}
|
| 30 |
+
</a>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
</section>
|
| 37 |
+
|
| 38 |
+
<!-- Features Section -->
|
| 39 |
+
<section class="py-5" style="background-color: #f8fafc;">
|
| 40 |
+
<div class="container">
|
| 41 |
+
<div class="row">
|
| 42 |
+
<div class="col-12 text-center mb-5">
|
| 43 |
+
<h2 class="fw-bold mb-4">{{ _('home.features_title') }}</h2>
|
| 44 |
+
<p class="lead text-muted">
|
| 45 |
+
{{ _('home.features_subtitle') }}
|
| 46 |
+
</p>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
<div class="row g-4">
|
| 51 |
+
<div class="col-lg-3">
|
| 52 |
+
<div class="text-center">
|
| 53 |
+
<div class="feature-icon text-white rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 4rem; height: 4rem; background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);">
|
| 54 |
+
<i class="fas fa-key"></i>
|
| 55 |
+
</div>
|
| 56 |
+
<h5 class="fw-bold">{{ _('home.feature_free_title') }}</h5>
|
| 57 |
+
<p class="text-muted">{{ _('home.feature_free_desc') }}</p>
|
| 58 |
+
</div>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<div class="col-lg-3">
|
| 62 |
+
<div class="text-center">
|
| 63 |
+
<div class="feature-icon text-white rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 4rem; height: 4rem; background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%);">
|
| 64 |
+
<i class="fas fa-magic"></i>
|
| 65 |
+
</div>
|
| 66 |
+
<h5 class="fw-bold">{{ _('home.feature_openai_title') }} <span class="badge bg-success ms-1">v3.2.3</span></h5>
|
| 67 |
+
<p class="text-muted">{{ _('home.feature_openai_desc') }}</p>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<div class="col-lg-3">
|
| 72 |
+
<div class="text-center">
|
| 73 |
+
<div class="feature-icon text-white rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 4rem; height: 4rem; background: linear-gradient(135deg, #059669 0%, #10b981 100%);">
|
| 74 |
+
<i class="fas fa-bolt"></i>
|
| 75 |
+
</div>
|
| 76 |
+
<h5 class="fw-bold">{{ _('home.feature_async_title') }}</h5>
|
| 77 |
+
<p class="text-muted">{{ _('home.feature_async_desc') }}</p>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
<div class="col-lg-3">
|
| 82 |
+
<div class="text-center">
|
| 83 |
+
<div class="feature-icon text-white rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 4rem; height: 4rem; background: linear-gradient(135deg, #6b7280 0%, #9ca3af 100%);">
|
| 84 |
+
<i class="fas fa-microphone-alt"></i>
|
| 85 |
+
</div>
|
| 86 |
+
<h5 class="fw-bold">{{ _('home.feature_voices_title') }} & {{ _('home.feature_formats_title') }}</h5>
|
| 87 |
+
<p class="text-muted">{{ _('home.feature_voices_desc') }} {{ _('home.feature_formats_desc') }}</p>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
</section>
|
| 93 |
+
|
| 94 |
+
<!-- Quick Start Section -->
|
| 95 |
+
<section class="py-5">
|
| 96 |
+
<div class="container">
|
| 97 |
+
<div class="row">
|
| 98 |
+
<div class="col-12 text-center mb-5">
|
| 99 |
+
<h2 class="fw-bold mb-4">{{ _('home.quick_start_title') }}</h2>
|
| 100 |
+
<p class="lead text-muted">
|
| 101 |
+
{{ _('home.subtitle') }}
|
| 102 |
+
</p>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<div class="row g-4">
|
| 107 |
+
<div class="col-lg-6">
|
| 108 |
+
<div class="card h-100">
|
| 109 |
+
<div class="card-body">
|
| 110 |
+
<h5 class="card-title">
|
| 111 |
+
<i class="fas fa-download me-2 text-primary"></i>{{ _('home.installation_title') }}
|
| 112 |
+
</h5>
|
| 113 |
+
<pre class="bg-light p-3 rounded"><code>{{ _('home.installation_code') }}</code></pre>
|
| 114 |
+
<small class="text-muted">Requires Python 3.8+</small>
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
|
| 119 |
+
<div class="col-lg-6">
|
| 120 |
+
<div class="card h-100">
|
| 121 |
+
<div class="card-body">
|
| 122 |
+
<h5 class="card-title">
|
| 123 |
+
<i class="fas fa-play me-2 text-success"></i>{{ _('home.usage_title') }}
|
| 124 |
+
</h5>
|
| 125 |
+
<pre class="bg-light p-3 rounded"><code>from ttsfm import TTSClient, Voice, AudioFormat
|
| 126 |
+
|
| 127 |
+
client = TTSClient()
|
| 128 |
+
response = client.generate_speech(
|
| 129 |
+
text="Hello, world!",
|
| 130 |
+
voice=Voice.ALLOY,
|
| 131 |
+
response_format=AudioFormat.MP3
|
| 132 |
+
)
|
| 133 |
+
response.save_to_file("hello")</code></pre>
|
| 134 |
+
<small class="text-muted">No API keys required</small>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
|
| 140 |
+
<div class="row mt-4">
|
| 141 |
+
<div class="col-12 text-center">
|
| 142 |
+
<div class="d-flex justify-content-center gap-3 flex-wrap">
|
| 143 |
+
<a href="{{ url_for('playground') }}" class="btn btn-primary">
|
| 144 |
+
<i class="fas fa-play me-2"></i>{{ _('home.try_demo') }}
|
| 145 |
+
</a>
|
| 146 |
+
<a href="{{ url_for('docs') }}" class="btn btn-outline-primary">
|
| 147 |
+
<i class="fas fa-book me-2"></i>{{ _('home.documentation') }}
|
| 148 |
+
</a>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
</section>
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
{% endblock %}
|
templates/playground.html
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}TTSFM {{ _('nav.playground') }} - {{ _('playground.title') }}{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<!-- Clean Playground Header -->
|
| 7 |
+
<section class="py-5" style="background-color: white; border-bottom: 1px solid #e5e7eb;">
|
| 8 |
+
<div class="container">
|
| 9 |
+
<div class="row align-items-center">
|
| 10 |
+
<div class="col-lg-8">
|
| 11 |
+
<div class="fade-in">
|
| 12 |
+
<div class="badge bg-primary text-white mb-3 px-3 py-2">
|
| 13 |
+
<i class="fas fa-flask me-2"></i>Demo
|
| 14 |
+
</div>
|
| 15 |
+
<h1 class="display-4 fw-bold mb-3 text-dark">
|
| 16 |
+
<i class="fas fa-play-circle me-3 text-primary"></i>{{ _('playground.title') }}
|
| 17 |
+
</h1>
|
| 18 |
+
<p class="lead mb-4 text-muted">
|
| 19 |
+
{{ _('playground.subtitle') }}
|
| 20 |
+
</p>
|
| 21 |
+
</div>
|
| 22 |
+
</div>
|
| 23 |
+
<div class="col-lg-4 text-center">
|
| 24 |
+
<div class="playground-visual fade-in" style="animation-delay: 0.3s;">
|
| 25 |
+
<div class="playground-icon">
|
| 26 |
+
<i class="fas fa-waveform-lines text-primary"></i>
|
| 27 |
+
<div class="pulse-ring"></div>
|
| 28 |
+
<div class="pulse-ring pulse-ring-delay"></div>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
</section>
|
| 35 |
+
|
| 36 |
+
<div class="container py-5 playground">
|
| 37 |
+
|
| 38 |
+
<div class="row">
|
| 39 |
+
<div class="col-lg-10 mx-auto">
|
| 40 |
+
<div class="card shadow-lg-custom border-0 fade-in">
|
| 41 |
+
<div class="card-header bg-gradient-primary text-white">
|
| 42 |
+
<h4 class="mb-0 d-flex align-items-center">
|
| 43 |
+
<i class="fas fa-microphone me-2"></i>
|
| 44 |
+
{{ _('playground.title') }}
|
| 45 |
+
</h4>
|
| 46 |
+
</div>
|
| 47 |
+
<div class="card-body p-4">
|
| 48 |
+
<form id="tts-form" onsubmit="return false;">
|
| 49 |
+
<!-- Enhanced Text Input -->
|
| 50 |
+
<div class="mb-4">
|
| 51 |
+
<label for="text-input" class="form-label fw-bold d-flex align-items-center">
|
| 52 |
+
<i class="fas fa-edit me-2 text-primary"></i>
|
| 53 |
+
{{ _('playground.text_input_label') }}
|
| 54 |
+
</label>
|
| 55 |
+
<div class="position-relative">
|
| 56 |
+
<textarea
|
| 57 |
+
class="form-control shadow-sm"
|
| 58 |
+
id="text-input"
|
| 59 |
+
rows="4"
|
| 60 |
+
placeholder="{{ _('playground.text_input_placeholder') }}"
|
| 61 |
+
required
|
| 62 |
+
>Hello! This is a test of the TTSFM text-to-speech system.</textarea>
|
| 63 |
+
<div class="position-absolute top-0 end-0 p-2">
|
| 64 |
+
<button type="button" class="btn btn-sm btn-outline-secondary" id="clear-text-btn" title="Clear text">
|
| 65 |
+
<i class="fas fa-times"></i>
|
| 66 |
+
</button>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
<div class="form-text d-flex justify-content-between align-items-center">
|
| 70 |
+
<div class="d-flex align-items-center gap-3">
|
| 71 |
+
<span class="text-muted">
|
| 72 |
+
<i class="fas fa-keyboard me-1"></i>
|
| 73 |
+
<span id="char-count">0</span> {{ _('playground.character_count') }}
|
| 74 |
+
</span>
|
| 75 |
+
<span id="length-status" class=""></span>
|
| 76 |
+
<span id="auto-combine-status" class="badge bg-success d-none">
|
| 77 |
+
<i class="fas fa-magic me-1"></i>{{ _('playground.max_length_warning') }}
|
| 78 |
+
</span>
|
| 79 |
+
<span class="text-muted small">
|
| 80 |
+
<i class="fas fa-lightbulb me-1"></i>
|
| 81 |
+
Tip: Use Ctrl+Enter to generate
|
| 82 |
+
</span>
|
| 83 |
+
</div>
|
| 84 |
+
<div class="btn-group" role="group">
|
| 85 |
+
<button type="button" class="btn btn-sm btn-outline-primary" id="validate-text-btn">
|
| 86 |
+
<i class="fas fa-check me-1"></i>{{ _('common.validate') if _('common.validate') != 'common.validate' else 'Validate' }}
|
| 87 |
+
</button>
|
| 88 |
+
<button type="button" class="btn btn-sm btn-outline-secondary" id="random-text-btn">
|
| 89 |
+
<i class="fas fa-dice me-1"></i>{{ _('playground.random_text') }}
|
| 90 |
+
</button>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
<div id="validation-result" class="mt-2 d-none"></div>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<div class="row">
|
| 97 |
+
<!-- Enhanced Voice Selection -->
|
| 98 |
+
<div class="col-md-6 mb-4">
|
| 99 |
+
<label for="voice-select" class="form-label fw-bold d-flex align-items-center">
|
| 100 |
+
<i class="fas fa-microphone me-2 text-primary"></i>
|
| 101 |
+
{{ _('playground.voice_label') }}
|
| 102 |
+
</label>
|
| 103 |
+
<select class="form-select shadow-sm" id="voice-select" required>
|
| 104 |
+
<option value="">{{ _('common.loading_voices') }}</option>
|
| 105 |
+
</select>
|
| 106 |
+
<div class="form-text">
|
| 107 |
+
<span>{{ _('common.choose_voice') }}</span>
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
<!-- Enhanced Format Selection -->
|
| 112 |
+
<div class="col-md-6 mb-4">
|
| 113 |
+
<label for="format-select" class="form-label fw-bold d-flex align-items-center">
|
| 114 |
+
<i class="fas fa-file-audio me-2 text-primary"></i>
|
| 115 |
+
{{ _('playground.format_label') }}
|
| 116 |
+
</label>
|
| 117 |
+
<select class="form-select shadow-sm" id="format-select" required>
|
| 118 |
+
<option value="">{{ _('common.loading_formats') }}</option>
|
| 119 |
+
</select>
|
| 120 |
+
<div class="form-text">
|
| 121 |
+
<span>{{ _('common.select_format') }}</span>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
<!-- Advanced Options -->
|
| 127 |
+
<div class="row">
|
| 128 |
+
<div class="col-md-6 mb-4">
|
| 129 |
+
<label for="max-length-input" class="form-label fw-bold">
|
| 130 |
+
<i class="fas fa-ruler me-2"></i>{{ _('common.max_length') }}
|
| 131 |
+
</label>
|
| 132 |
+
<input
|
| 133 |
+
type="number"
|
| 134 |
+
class="form-control"
|
| 135 |
+
id="max-length-input"
|
| 136 |
+
value="4096"
|
| 137 |
+
min="100"
|
| 138 |
+
max="10000"
|
| 139 |
+
>
|
| 140 |
+
<div class="form-text">
|
| 141 |
+
{{ _('playground.max_length_description') }}
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
|
| 145 |
+
<div class="col-md-6 mb-4">
|
| 146 |
+
<label class="form-label fw-bold">
|
| 147 |
+
<i class="fas fa-cog me-2"></i>{{ _('common.options') }}
|
| 148 |
+
</label>
|
| 149 |
+
<div class="form-check">
|
| 150 |
+
<input class="form-check-input" type="checkbox" id="validate-length-check" checked>
|
| 151 |
+
<label class="form-check-label" for="validate-length-check">
|
| 152 |
+
{{ _('playground.enable_length_validation') }}
|
| 153 |
+
</label>
|
| 154 |
+
</div>
|
| 155 |
+
<div class="form-check">
|
| 156 |
+
<input class="form-check-input" type="checkbox" id="auto-combine-check" checked>
|
| 157 |
+
<label class="form-check-label" for="auto-combine-check">
|
| 158 |
+
<span class="fw-bold text-primary">{{ _('playground.auto_combine_long_text') }}</span>
|
| 159 |
+
<i class="fas fa-info-circle ms-1" data-bs-toggle="tooltip"
|
| 160 |
+
title="{{ _('playground.auto_combine_tooltip') }}"></i>
|
| 161 |
+
</label>
|
| 162 |
+
<div class="form-text small">
|
| 163 |
+
<i class="fas fa-magic me-1"></i>
|
| 164 |
+
{{ _('playground.auto_combine_description') }}
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
|
| 170 |
+
<!-- Instructions (Optional) -->
|
| 171 |
+
<div class="mb-4">
|
| 172 |
+
<label for="instructions-input" class="form-label fw-bold">
|
| 173 |
+
<i class="fas fa-magic me-2"></i>{{ _('playground.instructions_label') }}
|
| 174 |
+
</label>
|
| 175 |
+
<input
|
| 176 |
+
type="text"
|
| 177 |
+
class="form-control"
|
| 178 |
+
id="instructions-input"
|
| 179 |
+
placeholder="{{ _('playground.instructions_placeholder') }}"
|
| 180 |
+
>
|
| 181 |
+
<div class="form-text">
|
| 182 |
+
{{ _('playground.instructions_description') }}
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
|
| 186 |
+
<!-- API Key (Optional) -->
|
| 187 |
+
<div class="mb-4" id="api-key-section">
|
| 188 |
+
<label for="api-key-input" class="form-label fw-bold">
|
| 189 |
+
<i class="fas fa-key me-2"></i>{{ _('playground.api_key_optional') }}
|
| 190 |
+
</label>
|
| 191 |
+
<div class="input-group">
|
| 192 |
+
<input
|
| 193 |
+
type="password"
|
| 194 |
+
class="form-control"
|
| 195 |
+
id="api-key-input"
|
| 196 |
+
placeholder="{{ _('playground.api_key_placeholder') }}"
|
| 197 |
+
>
|
| 198 |
+
<button class="btn btn-outline-secondary" type="button" id="toggle-api-key-visibility">
|
| 199 |
+
<i class="fas fa-eye" id="api-key-eye-icon"></i>
|
| 200 |
+
</button>
|
| 201 |
+
</div>
|
| 202 |
+
<div class="form-text">
|
| 203 |
+
<i class="fas fa-info-circle me-1"></i>
|
| 204 |
+
{{ _('playground.api_key_description') }}
|
| 205 |
+
</div>
|
| 206 |
+
</div>
|
| 207 |
+
|
| 208 |
+
<!-- Enhanced Generate Button -->
|
| 209 |
+
<div class="text-center mb-4">
|
| 210 |
+
<div class="d-grid gap-2 d-md-block">
|
| 211 |
+
<button type="submit" class="btn btn-primary btn-lg px-4 py-3" id="generate-btn">
|
| 212 |
+
<span class="btn-text">
|
| 213 |
+
<i class="fas fa-magic me-2"></i>{{ _('playground.generate_speech') }}
|
| 214 |
+
</span>
|
| 215 |
+
<span class="loading-spinner">
|
| 216 |
+
<i class="fas fa-spinner fa-spin me-2"></i>{{ _('playground.generating') }}
|
| 217 |
+
</span>
|
| 218 |
+
</button>
|
| 219 |
+
<button type="button" class="btn btn-outline-secondary btn-lg ms-md-3" id="reset-form-btn">
|
| 220 |
+
<i class="fas fa-redo me-2"></i>{{ _('common.reset') }}
|
| 221 |
+
</button>
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
</form>
|
| 225 |
+
|
| 226 |
+
<!-- Enhanced Audio Player -->
|
| 227 |
+
<div id="audio-result" class="d-none">
|
| 228 |
+
<div class="border-top pt-4 mt-4">
|
| 229 |
+
<div class="d-flex align-items-center justify-content-between mb-3">
|
| 230 |
+
<h5 class="mb-0 d-flex align-items-center">
|
| 231 |
+
<i class="fas fa-volume-up me-2 text-success"></i>
|
| 232 |
+
{{ _('playground.audio_player_title') }}
|
| 233 |
+
<span class="badge bg-success ms-2">
|
| 234 |
+
<i class="fas fa-check me-1"></i>Ready
|
| 235 |
+
</span>
|
| 236 |
+
</h5>
|
| 237 |
+
<div class="btn-group" role="group">
|
| 238 |
+
<button type="button" class="btn btn-sm btn-outline-primary" id="replay-btn" title="Replay audio">
|
| 239 |
+
<i class="fas fa-redo"></i>
|
| 240 |
+
</button>
|
| 241 |
+
<button type="button" class="btn btn-sm btn-outline-secondary" id="share-btn" title="Share audio">
|
| 242 |
+
<i class="fas fa-share"></i>
|
| 243 |
+
</button>
|
| 244 |
+
</div>
|
| 245 |
+
</div>
|
| 246 |
+
|
| 247 |
+
<div class="audio-player-container bg-light rounded p-3 mb-3">
|
| 248 |
+
<audio controls class="audio-player w-100" id="audio-player" preload="metadata">
|
| 249 |
+
Your browser does not support the audio element.
|
| 250 |
+
</audio>
|
| 251 |
+
<div class="audio-controls mt-2 d-flex justify-content-between align-items-center">
|
| 252 |
+
<div class="audio-info">
|
| 253 |
+
<span id="audio-info" class="text-muted small"></span>
|
| 254 |
+
</div>
|
| 255 |
+
<div class="audio-actions">
|
| 256 |
+
<button type="button" class="btn btn-success btn-sm" id="download-btn">
|
| 257 |
+
<i class="fas fa-download me-1"></i>{{ _('playground.download_audio') }}
|
| 258 |
+
</button>
|
| 259 |
+
</div>
|
| 260 |
+
</div>
|
| 261 |
+
</div>
|
| 262 |
+
|
| 263 |
+
<div class="audio-stats row text-center">
|
| 264 |
+
<div class="col-md-3 col-6">
|
| 265 |
+
<div class="stat-item">
|
| 266 |
+
<i class="fas fa-clock text-primary"></i>
|
| 267 |
+
<div class="stat-value" id="audio-duration">--</div>
|
| 268 |
+
<div class="stat-label">{{ _('playground.duration') }}</div>
|
| 269 |
+
</div>
|
| 270 |
+
</div>
|
| 271 |
+
<div class="col-md-3 col-6">
|
| 272 |
+
<div class="stat-item">
|
| 273 |
+
<i class="fas fa-file text-info"></i>
|
| 274 |
+
<div class="stat-value" id="audio-size">--</div>
|
| 275 |
+
<div class="stat-label">{{ _('playground.file_size') }}</div>
|
| 276 |
+
</div>
|
| 277 |
+
</div>
|
| 278 |
+
<div class="col-md-3 col-6">
|
| 279 |
+
<div class="stat-item">
|
| 280 |
+
<i class="fas fa-microphone text-warning"></i>
|
| 281 |
+
<div class="stat-value" id="audio-voice">--</div>
|
| 282 |
+
<div class="stat-label">{{ _('playground.voice') }}</div>
|
| 283 |
+
</div>
|
| 284 |
+
</div>
|
| 285 |
+
<div class="col-md-3 col-6">
|
| 286 |
+
<div class="stat-item">
|
| 287 |
+
<i class="fas fa-music text-success"></i>
|
| 288 |
+
<div class="stat-value" id="audio-format">--</div>
|
| 289 |
+
<div class="stat-label">{{ _('playground.format') }}</div>
|
| 290 |
+
</div>
|
| 291 |
+
</div>
|
| 292 |
+
</div>
|
| 293 |
+
</div>
|
| 294 |
+
</div>
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
</div>
|
| 302 |
+
{% endblock %}
|
| 303 |
+
|
| 304 |
+
{% block extra_js %}
|
| 305 |
+
<!-- Socket.IO for WebSocket support -->
|
| 306 |
+
<script src="https://cdn.socket.io/4.6.0/socket.io.min.js"></script>
|
| 307 |
+
<!-- WebSocket TTS Client -->
|
| 308 |
+
<script src="{{ url_for('static', filename='js/websocket-tts.js') }}"></script>
|
| 309 |
+
<!-- Enhanced Playground JavaScript with WebSocket Support -->
|
| 310 |
+
<script src="{{ url_for('static', filename='js/playground-enhanced-fixed.js') }}"></script>
|
| 311 |
+
<script>
|
| 312 |
+
// Additional playground-specific functionality
|
| 313 |
+
console.log('TTSFM Enhanced Playground with WebSocket support loaded successfully!');
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
</script>
|
| 317 |
+
{% endblock %}
|
templates/websocket_demo.html
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}{{ _('websocket.title', 'WebSocket Streaming Demo') }} - TTSFM{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="container mt-5">
|
| 7 |
+
<div class="row">
|
| 8 |
+
<div class="col-lg-10 mx-auto">
|
| 9 |
+
<h1 class="text-center mb-4">
|
| 10 |
+
<i class="fas fa-bolt text-warning"></i>
|
| 11 |
+
{{ _('websocket.title', 'WebSocket Streaming Demo') }}
|
| 12 |
+
</h1>
|
| 13 |
+
|
| 14 |
+
<!-- Connection Status -->
|
| 15 |
+
<div class="alert alert-info" id="connection-status">
|
| 16 |
+
<i class="fas fa-plug me-2"></i>
|
| 17 |
+
<span id="status-text">Connecting to WebSocket server...</span>
|
| 18 |
+
</div>
|
| 19 |
+
|
| 20 |
+
<!-- Input Form -->
|
| 21 |
+
<div class="card shadow-sm mb-4">
|
| 22 |
+
<div class="card-body">
|
| 23 |
+
<h5 class="card-title">{{ _('playground.generate_speech', 'Generate Speech') }}</h5>
|
| 24 |
+
|
| 25 |
+
<form id="streaming-form">
|
| 26 |
+
<div class="mb-3">
|
| 27 |
+
<label for="text-input" class="form-label">
|
| 28 |
+
{{ _('playground.text_input', 'Text to Convert') }}
|
| 29 |
+
</label>
|
| 30 |
+
<textarea
|
| 31 |
+
class="form-control"
|
| 32 |
+
id="text-input"
|
| 33 |
+
rows="4"
|
| 34 |
+
maxlength="4096"
|
| 35 |
+
placeholder="{{ _('playground.text_placeholder', 'Enter your text here...') }}"
|
| 36 |
+
>Experience the future of text-to-speech with real-time WebSocket streaming! This innovative feature delivers audio chunks as they're generated, providing a more responsive and engaging user experience.</textarea>
|
| 37 |
+
<div class="form-text">
|
| 38 |
+
<i class="fas fa-info-circle me-1"></i>
|
| 39 |
+
Streaming will split text into chunks for real-time delivery
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<div class="row">
|
| 44 |
+
<div class="col-md-6 mb-3">
|
| 45 |
+
<label for="voice-select" class="form-label">
|
| 46 |
+
{{ _('playground.voice', 'Voice') }}
|
| 47 |
+
</label>
|
| 48 |
+
<select class="form-select" id="voice-select">
|
| 49 |
+
<option value="alloy">Alloy</option>
|
| 50 |
+
<option value="echo">Echo</option>
|
| 51 |
+
<option value="fable">Fable</option>
|
| 52 |
+
<option value="onyx">Onyx</option>
|
| 53 |
+
<option value="nova">Nova</option>
|
| 54 |
+
<option value="shimmer">Shimmer</option>
|
| 55 |
+
</select>
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
<div class="col-md-6 mb-3">
|
| 59 |
+
<label for="format-select" class="form-label">
|
| 60 |
+
{{ _('playground.format', 'Audio Format') }}
|
| 61 |
+
</label>
|
| 62 |
+
<select class="form-select" id="format-select">
|
| 63 |
+
<option value="mp3">MP3</option>
|
| 64 |
+
<option value="wav">WAV</option>
|
| 65 |
+
<option value="opus">OPUS</option>
|
| 66 |
+
</select>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
| 71 |
+
<button type="submit" class="btn btn-primary" id="stream-btn">
|
| 72 |
+
<i class="fas fa-bolt me-2"></i>
|
| 73 |
+
Start Streaming
|
| 74 |
+
</button>
|
| 75 |
+
<button type="button" class="btn btn-danger" id="cancel-btn" style="display: none;">
|
| 76 |
+
<i class="fas fa-stop me-2"></i>
|
| 77 |
+
Cancel
|
| 78 |
+
</button>
|
| 79 |
+
</div>
|
| 80 |
+
</form>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<!-- Progress Section -->
|
| 85 |
+
<div class="card shadow-sm mb-4" id="progress-section" style="display: none;">
|
| 86 |
+
<div class="card-body">
|
| 87 |
+
<h5 class="card-title">Streaming Progress</h5>
|
| 88 |
+
|
| 89 |
+
<div class="progress mb-3" style="height: 25px;">
|
| 90 |
+
<div
|
| 91 |
+
class="progress-bar progress-bar-striped progress-bar-animated"
|
| 92 |
+
id="progress-bar"
|
| 93 |
+
role="progressbar"
|
| 94 |
+
style="width: 0%"
|
| 95 |
+
>
|
| 96 |
+
<span id="progress-text">0%</span>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<div class="row text-center">
|
| 101 |
+
<div class="col-md-4">
|
| 102 |
+
<h6>Chunks Received</h6>
|
| 103 |
+
<p class="h4"><span id="chunks-received">0</span> / <span id="total-chunks">0</span></p>
|
| 104 |
+
</div>
|
| 105 |
+
<div class="col-md-4">
|
| 106 |
+
<h6>Data Transferred</h6>
|
| 107 |
+
<p class="h4" id="data-transferred">0 KB</p>
|
| 108 |
+
</div>
|
| 109 |
+
<div class="col-md-4">
|
| 110 |
+
<h6>Generation Time</h6>
|
| 111 |
+
<p class="h4" id="generation-time">0.0s</p>
|
| 112 |
+
</div>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
<!-- Audio Chunks Display -->
|
| 118 |
+
<div class="card shadow-sm mb-4" id="chunks-section" style="display: none;">
|
| 119 |
+
<div class="card-body">
|
| 120 |
+
<h5 class="card-title">Audio Chunks</h5>
|
| 121 |
+
<div id="chunks-container" class="row g-2">
|
| 122 |
+
<!-- Chunks will be added here dynamically -->
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
<!-- Final Audio Player -->
|
| 128 |
+
<div class="card shadow-sm" id="audio-section" style="display: none;">
|
| 129 |
+
<div class="card-body">
|
| 130 |
+
<h5 class="card-title">Generated Audio</h5>
|
| 131 |
+
<audio id="audio-player" controls class="w-100"></audio>
|
| 132 |
+
<div class="mt-2">
|
| 133 |
+
<button class="btn btn-success" id="download-btn">
|
| 134 |
+
<i class="fas fa-download me-2"></i>
|
| 135 |
+
Download Audio
|
| 136 |
+
</button>
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
|
| 141 |
+
<!-- Info Section -->
|
| 142 |
+
<div class="card shadow-sm mt-4">
|
| 143 |
+
<div class="card-body">
|
| 144 |
+
<h5 class="card-title">
|
| 145 |
+
<i class="fas fa-info-circle text-info me-2"></i>
|
| 146 |
+
About WebSocket Streaming
|
| 147 |
+
</h5>
|
| 148 |
+
<p>
|
| 149 |
+
This demo showcases real-time audio streaming using WebSockets. Instead of waiting
|
| 150 |
+
for the entire audio to be generated, you receive chunks as they're processed,
|
| 151 |
+
providing immediate feedback and a more responsive experience.
|
| 152 |
+
</p>
|
| 153 |
+
<ul>
|
| 154 |
+
<li><strong>Lower Perceived Latency:</strong> Start receiving audio before generation completes</li>
|
| 155 |
+
<li><strong>Progress Tracking:</strong> Real-time updates on generation progress</li>
|
| 156 |
+
<li><strong>Cancellable:</strong> Stop generation mid-stream if needed</li>
|
| 157 |
+
<li><strong>Efficient:</strong> Stream chunks as they're ready, no waiting</li>
|
| 158 |
+
</ul>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
<!-- Include Socket.IO -->
|
| 166 |
+
<script src="https://cdn.socket.io/4.6.0/socket.io.min.js"></script>
|
| 167 |
+
<!-- Include our WebSocket client -->
|
| 168 |
+
<script src="{{ url_for('static', filename='js/websocket-tts.js') }}"></script>
|
| 169 |
+
|
| 170 |
+
<script>
|
| 171 |
+
// Initialize WebSocket client
|
| 172 |
+
let wsClient = null;
|
| 173 |
+
let currentRequestId = null;
|
| 174 |
+
let startTime = null;
|
| 175 |
+
|
| 176 |
+
// Initialize on page load
|
| 177 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 178 |
+
// Create WebSocket client
|
| 179 |
+
wsClient = new WebSocketTTSClient({
|
| 180 |
+
debug: true,
|
| 181 |
+
onConnect: () => {
|
| 182 |
+
updateConnectionStatus('connected');
|
| 183 |
+
},
|
| 184 |
+
onDisconnect: () => {
|
| 185 |
+
updateConnectionStatus('disconnected');
|
| 186 |
+
},
|
| 187 |
+
onError: (error) => {
|
| 188 |
+
updateConnectionStatus('error');
|
| 189 |
+
showError(`Connection error: ${error.message}`);
|
| 190 |
+
}
|
| 191 |
+
});
|
| 192 |
+
|
| 193 |
+
// Form submission
|
| 194 |
+
document.getElementById('streaming-form').addEventListener('submit', handleStreamingSubmit);
|
| 195 |
+
|
| 196 |
+
// Cancel button
|
| 197 |
+
document.getElementById('cancel-btn').addEventListener('click', handleCancel);
|
| 198 |
+
});
|
| 199 |
+
|
| 200 |
+
function updateConnectionStatus(status) {
|
| 201 |
+
const statusEl = document.getElementById('connection-status');
|
| 202 |
+
const statusText = document.getElementById('status-text');
|
| 203 |
+
|
| 204 |
+
statusEl.className = 'alert';
|
| 205 |
+
|
| 206 |
+
switch(status) {
|
| 207 |
+
case 'connected':
|
| 208 |
+
statusEl.classList.add('alert-success');
|
| 209 |
+
statusText.innerHTML = '<i class="fas fa-check-circle me-2"></i>Connected to WebSocket server';
|
| 210 |
+
break;
|
| 211 |
+
case 'disconnected':
|
| 212 |
+
statusEl.classList.add('alert-warning');
|
| 213 |
+
statusText.innerHTML = '<i class="fas fa-exclamation-triangle me-2"></i>Disconnected from server';
|
| 214 |
+
break;
|
| 215 |
+
case 'error':
|
| 216 |
+
statusEl.classList.add('alert-danger');
|
| 217 |
+
statusText.innerHTML = '<i class="fas fa-times-circle me-2"></i>Connection error';
|
| 218 |
+
break;
|
| 219 |
+
default:
|
| 220 |
+
statusEl.classList.add('alert-info');
|
| 221 |
+
statusText.innerHTML = '<i class="fas fa-plug me-2"></i>Connecting...';
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
async function handleStreamingSubmit(e) {
|
| 226 |
+
e.preventDefault();
|
| 227 |
+
|
| 228 |
+
if (!wsClient || !wsClient.isConnected()) {
|
| 229 |
+
showError('WebSocket not connected. Please refresh the page.');
|
| 230 |
+
return;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
// Get form values
|
| 234 |
+
const text = document.getElementById('text-input').value.trim();
|
| 235 |
+
const voice = document.getElementById('voice-select').value;
|
| 236 |
+
const format = document.getElementById('format-select').value;
|
| 237 |
+
|
| 238 |
+
if (!text) {
|
| 239 |
+
showError('Please enter some text to convert.');
|
| 240 |
+
return;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
// Reset UI
|
| 244 |
+
resetUI();
|
| 245 |
+
|
| 246 |
+
// Show progress section
|
| 247 |
+
document.getElementById('progress-section').style.display = 'block';
|
| 248 |
+
document.getElementById('chunks-section').style.display = 'block';
|
| 249 |
+
document.getElementById('stream-btn').disabled = true;
|
| 250 |
+
document.getElementById('cancel-btn').style.display = 'inline-block';
|
| 251 |
+
|
| 252 |
+
startTime = Date.now();
|
| 253 |
+
|
| 254 |
+
try {
|
| 255 |
+
const result = await wsClient.generateSpeech(text, {
|
| 256 |
+
voice: voice,
|
| 257 |
+
format: format,
|
| 258 |
+
chunkSize: 512, // Smaller chunks for more updates
|
| 259 |
+
onStart: (data) => {
|
| 260 |
+
currentRequestId = data.request_id;
|
| 261 |
+
console.log('Stream started:', data);
|
| 262 |
+
},
|
| 263 |
+
onProgress: (progress) => {
|
| 264 |
+
updateProgress(progress);
|
| 265 |
+
},
|
| 266 |
+
onChunk: (chunk) => {
|
| 267 |
+
handleAudioChunk(chunk);
|
| 268 |
+
},
|
| 269 |
+
onComplete: (result) => {
|
| 270 |
+
handleStreamComplete(result);
|
| 271 |
+
},
|
| 272 |
+
onError: (error) => {
|
| 273 |
+
showError(`Streaming error: ${error.message}`);
|
| 274 |
+
}
|
| 275 |
+
});
|
| 276 |
+
|
| 277 |
+
console.log('Streaming completed:', result);
|
| 278 |
+
|
| 279 |
+
} catch (error) {
|
| 280 |
+
showError(`Failed to generate speech: ${error.message}`);
|
| 281 |
+
resetUI();
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
function updateProgress(progress) {
|
| 286 |
+
const progressBar = document.getElementById('progress-bar');
|
| 287 |
+
const progressText = document.getElementById('progress-text');
|
| 288 |
+
const chunksReceived = document.getElementById('chunks-received');
|
| 289 |
+
const totalChunks = document.getElementById('total-chunks');
|
| 290 |
+
const generationTime = document.getElementById('generation-time');
|
| 291 |
+
|
| 292 |
+
progressBar.style.width = `${progress.progress}%`;
|
| 293 |
+
progressText.textContent = `${progress.progress}%`;
|
| 294 |
+
chunksReceived.textContent = progress.chunksCompleted;
|
| 295 |
+
totalChunks.textContent = progress.totalChunks;
|
| 296 |
+
|
| 297 |
+
if (startTime) {
|
| 298 |
+
const elapsed = (Date.now() - startTime) / 1000;
|
| 299 |
+
generationTime.textContent = `${elapsed.toFixed(1)}s`;
|
| 300 |
+
}
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
function handleAudioChunk(chunk) {
|
| 304 |
+
const container = document.getElementById('chunks-container');
|
| 305 |
+
|
| 306 |
+
// Create chunk visualization
|
| 307 |
+
const chunkEl = document.createElement('div');
|
| 308 |
+
chunkEl.className = 'col-auto';
|
| 309 |
+
chunkEl.innerHTML = `
|
| 310 |
+
<div class="badge bg-primary p-2" title="Chunk ${chunk.chunkIndex + 1}">
|
| 311 |
+
<i class="fas fa-music me-1"></i>
|
| 312 |
+
${chunk.chunkIndex + 1}
|
| 313 |
+
<small class="d-block">${(chunk.audioData.byteLength / 1024).toFixed(1)}KB</small>
|
| 314 |
+
</div>
|
| 315 |
+
`;
|
| 316 |
+
|
| 317 |
+
container.appendChild(chunkEl);
|
| 318 |
+
|
| 319 |
+
// Update data transferred
|
| 320 |
+
const currentData = parseFloat(document.getElementById('data-transferred').textContent);
|
| 321 |
+
const newData = currentData + (chunk.audioData.byteLength / 1024);
|
| 322 |
+
document.getElementById('data-transferred').textContent = `${newData.toFixed(1)} KB`;
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
function handleStreamComplete(result) {
|
| 326 |
+
// Create blob from combined audio
|
| 327 |
+
const blob = new Blob([result.audioData], { type: `audio/${result.format}` });
|
| 328 |
+
const url = URL.createObjectURL(blob);
|
| 329 |
+
|
| 330 |
+
// Set up audio player
|
| 331 |
+
const audioPlayer = document.getElementById('audio-player');
|
| 332 |
+
audioPlayer.src = url;
|
| 333 |
+
|
| 334 |
+
// Show audio section
|
| 335 |
+
document.getElementById('audio-section').style.display = 'block';
|
| 336 |
+
|
| 337 |
+
// Set up download button
|
| 338 |
+
document.getElementById('download-btn').onclick = () => {
|
| 339 |
+
const a = document.createElement('a');
|
| 340 |
+
a.href = url;
|
| 341 |
+
a.download = `tts_stream_${Date.now()}.${result.format}`;
|
| 342 |
+
a.click();
|
| 343 |
+
};
|
| 344 |
+
|
| 345 |
+
// Update final stats
|
| 346 |
+
document.getElementById('generation-time').textContent = `${(result.generationTime / 1000).toFixed(2)}s`;
|
| 347 |
+
|
| 348 |
+
// Reset buttons
|
| 349 |
+
document.getElementById('stream-btn').disabled = false;
|
| 350 |
+
document.getElementById('cancel-btn').style.display = 'none';
|
| 351 |
+
|
| 352 |
+
// Update progress bar to success
|
| 353 |
+
const progressBar = document.getElementById('progress-bar');
|
| 354 |
+
progressBar.classList.remove('progress-bar-animated');
|
| 355 |
+
progressBar.classList.add('bg-success');
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
function handleCancel() {
|
| 359 |
+
if (currentRequestId) {
|
| 360 |
+
wsClient.cancelStream(currentRequestId);
|
| 361 |
+
showInfo('Stream cancelled');
|
| 362 |
+
resetUI();
|
| 363 |
+
}
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
function resetUI() {
|
| 367 |
+
document.getElementById('progress-section').style.display = 'none';
|
| 368 |
+
document.getElementById('chunks-section').style.display = 'none';
|
| 369 |
+
document.getElementById('audio-section').style.display = 'none';
|
| 370 |
+
document.getElementById('stream-btn').disabled = false;
|
| 371 |
+
document.getElementById('cancel-btn').style.display = 'none';
|
| 372 |
+
document.getElementById('chunks-container').innerHTML = '';
|
| 373 |
+
document.getElementById('progress-bar').style.width = '0%';
|
| 374 |
+
document.getElementById('progress-bar').className = 'progress-bar progress-bar-striped progress-bar-animated';
|
| 375 |
+
document.getElementById('data-transferred').textContent = '0 KB';
|
| 376 |
+
currentRequestId = null;
|
| 377 |
+
startTime = null;
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
function showError(message) {
|
| 381 |
+
console.error(message);
|
| 382 |
+
// You could add a toast notification here
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
function showInfo(message) {
|
| 386 |
+
console.info(message);
|
| 387 |
+
// You could add a toast notification here
|
| 388 |
+
}
|
| 389 |
+
</script>
|
| 390 |
+
{% endblock %}
|
translations/en.json
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"nav": {
|
| 3 |
+
"home": "Home",
|
| 4 |
+
"playground": "Playground",
|
| 5 |
+
"documentation": "Documentation",
|
| 6 |
+
"github": "GitHub",
|
| 7 |
+
"status_checking": "Checking...",
|
| 8 |
+
"status_online": "Online",
|
| 9 |
+
"status_offline": "Offline"
|
| 10 |
+
},
|
| 11 |
+
"common": {
|
| 12 |
+
"loading": "Loading...",
|
| 13 |
+
"error": "Error",
|
| 14 |
+
"success": "Success",
|
| 15 |
+
"warning": "Warning",
|
| 16 |
+
"info": "Info",
|
| 17 |
+
"close": "Close",
|
| 18 |
+
"save": "Save",
|
| 19 |
+
"cancel": "Cancel",
|
| 20 |
+
"confirm": "Confirm",
|
| 21 |
+
"download": "Download",
|
| 22 |
+
"upload": "Upload",
|
| 23 |
+
"generate": "Generate",
|
| 24 |
+
"play": "Play",
|
| 25 |
+
"stop": "Stop",
|
| 26 |
+
"pause": "Pause",
|
| 27 |
+
"resume": "Resume",
|
| 28 |
+
"clear": "Clear",
|
| 29 |
+
"reset": "Reset",
|
| 30 |
+
"copy": "Copy",
|
| 31 |
+
"copied": "Copied!",
|
| 32 |
+
"language": "Language",
|
| 33 |
+
"english": "English",
|
| 34 |
+
"chinese": "中文",
|
| 35 |
+
"validate": "Validate",
|
| 36 |
+
"options": "Options",
|
| 37 |
+
"max_length": "Max Length",
|
| 38 |
+
"tip": "Tip",
|
| 39 |
+
"choose_voice": "Choose from available voices",
|
| 40 |
+
"select_format": "Select your preferred audio format",
|
| 41 |
+
"loading_voices": "Loading voices...",
|
| 42 |
+
"loading_formats": "Loading formats...",
|
| 43 |
+
"ctrl_enter_tip": "Use Ctrl+Enter to generate",
|
| 44 |
+
"auto_combine_enabled": "Auto-combine enabled"
|
| 45 |
+
},
|
| 46 |
+
"home": {
|
| 47 |
+
"title": "Free Text-to-Speech for Python",
|
| 48 |
+
"subtitle": "Generate high-quality speech from text using the free openai.fm service. No API keys, no registration - just install and start creating audio.",
|
| 49 |
+
"try_demo": "Try Demo",
|
| 50 |
+
"documentation": "Documentation",
|
| 51 |
+
"github": "GitHub",
|
| 52 |
+
"features_title": "Key Features",
|
| 53 |
+
"features_subtitle": "Simple, free, and powerful text-to-speech for Python developers.",
|
| 54 |
+
"feature_free_title": "Completely Free",
|
| 55 |
+
"feature_free_desc": "No API keys or registration required. Uses the free openai.fm service.",
|
| 56 |
+
"feature_voices_title": "11 Voices",
|
| 57 |
+
"feature_voices_desc": "All OpenAI-compatible voices available for different use cases.",
|
| 58 |
+
"feature_formats_title": "6 Audio Formats",
|
| 59 |
+
"feature_formats_desc": "MP3, WAV, OPUS, AAC, FLAC, and PCM support for any application.",
|
| 60 |
+
"feature_docker_title": "Docker Ready",
|
| 61 |
+
"feature_docker_desc": "One-command deployment with web interface and API endpoints.",
|
| 62 |
+
"feature_openai_title": "OpenAI Compatible",
|
| 63 |
+
"feature_openai_desc": "Drop-in replacement for OpenAI's TTS API with auto-combine for long text.",
|
| 64 |
+
"feature_async_title": "Async & Sync",
|
| 65 |
+
"feature_async_desc": "Both asyncio and synchronous clients for maximum flexibility.",
|
| 66 |
+
"quick_start_title": "Quick Start",
|
| 67 |
+
"installation_title": "Installation",
|
| 68 |
+
"installation_code": "pip install ttsfm",
|
| 69 |
+
"usage_title": "Basic Usage",
|
| 70 |
+
"docker_title": "Docker Deployment",
|
| 71 |
+
"docker_desc": "Run TTSFM with web interface:",
|
| 72 |
+
"api_title": "OpenAI-Compatible API",
|
| 73 |
+
"api_desc": "Use with OpenAI Python client:",
|
| 74 |
+
"footer_copyright": "© 2024 dbcccc"
|
| 75 |
+
},
|
| 76 |
+
"playground": {
|
| 77 |
+
"title": "Interactive TTS Playground",
|
| 78 |
+
"subtitle": "Test different voices and audio formats in real-time",
|
| 79 |
+
"text_input_label": "Text to Convert",
|
| 80 |
+
"text_input_placeholder": "Enter the text you want to convert to speech...",
|
| 81 |
+
"voice_label": "Voice",
|
| 82 |
+
"format_label": "Audio Format",
|
| 83 |
+
"instructions_label": "Voice Instructions (Optional)",
|
| 84 |
+
"instructions_placeholder": "Additional instructions for voice generation...",
|
| 85 |
+
"character_count": "characters",
|
| 86 |
+
"max_length_warning": "Text exceeds maximum length. It will be automatically split and combined.",
|
| 87 |
+
"generate_speech": "Generate Speech",
|
| 88 |
+
"generating": "Generating...",
|
| 89 |
+
"download_audio": "Download Audio",
|
| 90 |
+
"audio_player_title": "Generated Audio",
|
| 91 |
+
"file_size": "File Size",
|
| 92 |
+
"duration": "Duration",
|
| 93 |
+
"format": "Format",
|
| 94 |
+
"voice": "Voice",
|
| 95 |
+
"chunks_combined": "Chunks Combined",
|
| 96 |
+
"random_text": "Random Text",
|
| 97 |
+
"clear_text": "Clear Text",
|
| 98 |
+
"max_length_description": "Maximum characters per request (default: 4096)",
|
| 99 |
+
"enable_length_validation": "Enable length validation",
|
| 100 |
+
"auto_combine_long_text": "Auto-combine long text",
|
| 101 |
+
"auto_combine_tooltip": "Automatically split long text and combine audio chunks into a single file",
|
| 102 |
+
"auto_combine_description": "Automatically handles text longer than the limit",
|
| 103 |
+
"instructions_description": "Provide optional instructions for voice modulation",
|
| 104 |
+
"api_key_optional": "API Key (Optional)",
|
| 105 |
+
"api_key_placeholder": "Enter your API key if required",
|
| 106 |
+
"api_key_description": "Only required if API key protection is enabled on the server",
|
| 107 |
+
"sample_texts": {
|
| 108 |
+
"welcome": "Welcome to TTSFM! This is a free text-to-speech service that converts your text into high-quality audio using advanced AI technology.",
|
| 109 |
+
"story": "Once upon a time, in a digital world far away, there lived a small Python package that could transform any text into beautiful speech. This package was called TTSFM, and it brought joy to developers everywhere.",
|
| 110 |
+
"technical": "TTSFM is a Python client for text-to-speech APIs that provides both synchronous and asynchronous interfaces. It supports multiple voices and audio formats, making it perfect for various applications.",
|
| 111 |
+
"multilingual": "TTSFM supports multiple languages and voices, allowing you to create diverse audio content for global audiences. The service is completely free and requires no API keys.",
|
| 112 |
+
"long": "This is a longer text sample designed to test the auto-combine feature of TTSFM. When text exceeds the maximum length limit, TTSFM automatically splits it into smaller chunks, generates audio for each chunk, and then seamlessly combines them into a single audio file. This process is completely transparent to the user and ensures that you can convert text of any length without worrying about technical limitations. The resulting audio maintains consistent quality and natural flow throughout the entire content."
|
| 113 |
+
},
|
| 114 |
+
"error_messages": {
|
| 115 |
+
"empty_text": "Please enter some text to convert.",
|
| 116 |
+
"generation_failed": "Failed to generate speech. Please try again.",
|
| 117 |
+
"network_error": "Network error. Please check your connection and try again.",
|
| 118 |
+
"invalid_format": "Invalid audio format selected.",
|
| 119 |
+
"invalid_voice": "Invalid voice selected.",
|
| 120 |
+
"text_too_long": "Text is too long. Please reduce the length or enable auto-combine.",
|
| 121 |
+
"server_error": "Server error. Please try again later."
|
| 122 |
+
},
|
| 123 |
+
"success_messages": {
|
| 124 |
+
"generation_complete": "Speech generated successfully!",
|
| 125 |
+
"text_copied": "Text copied to clipboard!",
|
| 126 |
+
"download_started": "Download started!"
|
| 127 |
+
}
|
| 128 |
+
},
|
| 129 |
+
"docs": {
|
| 130 |
+
"title": "API Documentation",
|
| 131 |
+
"subtitle": "Complete reference for the TTSFM Text-to-Speech API. Free, simple, and powerful.",
|
| 132 |
+
"contents": "Contents",
|
| 133 |
+
"overview": "Overview",
|
| 134 |
+
"authentication": "Authentication",
|
| 135 |
+
"text_validation": "Text Validation",
|
| 136 |
+
"endpoints": "API Endpoints",
|
| 137 |
+
"voices": "Voices",
|
| 138 |
+
"formats": "Audio Formats",
|
| 139 |
+
"generate": "Generate Speech",
|
| 140 |
+
"combined": "Combined Audio",
|
| 141 |
+
"status": "Status & Health",
|
| 142 |
+
"errors": "Error Handling",
|
| 143 |
+
"examples": "Code Examples",
|
| 144 |
+
"python_package": "Python Package",
|
| 145 |
+
"overview_title": "Overview",
|
| 146 |
+
"overview_desc": "The TTSFM API provides a modern, OpenAI-compatible interface for text-to-speech generation. It supports multiple voices, audio formats, and includes advanced features like text length validation and intelligent auto-combine functionality.",
|
| 147 |
+
"base_url": "Base URL:",
|
| 148 |
+
"key_features": "Key Features",
|
| 149 |
+
"feature_voices": "11 different voice options - Choose from alloy, echo, nova, and more",
|
| 150 |
+
"feature_formats": "Multiple audio formats - MP3, WAV, OPUS, AAC, FLAC, PCM support",
|
| 151 |
+
"feature_openai": "OpenAI compatibility - Drop-in replacement for OpenAI's TTS API",
|
| 152 |
+
"feature_auto_combine": "Auto-combine feature - Automatically handles long text (>4096 chars) by splitting and combining audio",
|
| 153 |
+
"feature_validation": "Text length validation - Smart validation with configurable limits",
|
| 154 |
+
"feature_monitoring": "Real-time monitoring - Status endpoints and health checks",
|
| 155 |
+
"new_version": "New in v3.2.3:",
|
| 156 |
+
"new_version_desc": "Enhanced `/v1/audio/speech` endpoint with intelligent auto-combine feature. Streamlined web interface with clean, user-friendly design and automatic long-text handling!",
|
| 157 |
+
"authentication_title": "Authentication",
|
| 158 |
+
"authentication_desc": "Currently, the API supports optional API key authentication. If configured, include your API key in the request headers.",
|
| 159 |
+
"text_validation_title": "Text Length Validation",
|
| 160 |
+
"text_validation_desc": "TTSFM includes built-in text length validation to ensure compatibility with TTS models. The default maximum length is 4096 characters, but this can be customized.",
|
| 161 |
+
"important": "Important:",
|
| 162 |
+
"text_validation_warning": "Text exceeding the maximum length will be rejected unless validation is disabled or the text is split into chunks.",
|
| 163 |
+
"validation_options": "Validation Options",
|
| 164 |
+
"max_length_option": "Maximum allowed characters (default: 4096)",
|
| 165 |
+
"validate_length_option": "Enable/disable validation (default: true)",
|
| 166 |
+
"preserve_words_option": "Avoid splitting words when chunking (default: true)",
|
| 167 |
+
"endpoints_title": "API Endpoints",
|
| 168 |
+
"get_voices_desc": "Get list of available voices.",
|
| 169 |
+
"get_formats_desc": "Get list of supported audio formats.",
|
| 170 |
+
"validate_text_desc": "Validate text length and get splitting suggestions.",
|
| 171 |
+
"generate_speech_desc": "Generate speech from text.",
|
| 172 |
+
"response_example": "Response Example:",
|
| 173 |
+
"request_body": "Request Body:",
|
| 174 |
+
"parameters": "Parameters:",
|
| 175 |
+
"text_param": "Text to convert to speech",
|
| 176 |
+
"voice_param": "Voice ID (default: \"alloy\")",
|
| 177 |
+
"format_param": "Audio format (default: \"mp3\")",
|
| 178 |
+
"instructions_param": "Voice modulation instructions",
|
| 179 |
+
"max_length_param": "Maximum text length (default: 4096)",
|
| 180 |
+
"validate_length_param": "Enable validation (default: true)",
|
| 181 |
+
"response": "Response:",
|
| 182 |
+
"response_audio": "Returns audio file with appropriate Content-Type header.",
|
| 183 |
+
"response_combined_audio": "Returns a single audio file containing all chunks combined seamlessly.",
|
| 184 |
+
"required": "required",
|
| 185 |
+
"optional": "optional",
|
| 186 |
+
"python_package_title": "Python Package",
|
| 187 |
+
"long_text_support": "Long Text Support",
|
| 188 |
+
"long_text_desc": "The TTSFM Python package includes built-in long text splitting functionality for developers who need fine-grained control:",
|
| 189 |
+
"developer_features": "Developer Features:",
|
| 190 |
+
"manual_splitting": "Manual Splitting: Full control over text chunking for advanced use cases",
|
| 191 |
+
"word_preservation": "Word Preservation: Maintains word boundaries for natural speech",
|
| 192 |
+
"separate_files": "Separate Files: Each chunk saved as individual audio file",
|
| 193 |
+
"cli_support": "CLI Support: Use `--split-long-text` flag for command-line usage",
|
| 194 |
+
"note": "Note:",
|
| 195 |
+
"auto_combine_note": "For web users, the auto-combine feature in `/v1/audio/speech` is recommended as it automatically handles long text and returns a single seamless audio file.",
|
| 196 |
+
"combined_audio_desc": "Generate a single combined audio file from long text. Automatically splits text into chunks, generates speech for each chunk, and combines them into one seamless audio file.",
|
| 197 |
+
"response_headers": "Response Headers:",
|
| 198 |
+
"chunks_combined_header": "Number of chunks that were combined",
|
| 199 |
+
"original_text_length_header": "Original text length in characters",
|
| 200 |
+
"audio_size_header": "Final audio file size in bytes",
|
| 201 |
+
"openai_compatible_desc": "Enhanced OpenAI-compatible endpoint with auto-combine feature. Automatically handles long text by splitting and combining audio chunks when needed.",
|
| 202 |
+
"enhanced_parameters": "Enhanced Parameters:",
|
| 203 |
+
"auto_combine_param": "Automatically split long text and combine audio chunks into a single file",
|
| 204 |
+
"auto_combine_false": "Return error if text exceeds max_length (standard OpenAI behavior)",
|
| 205 |
+
"max_length_chunk_param": "Maximum characters per chunk when splitting",
|
| 206 |
+
"auto_combine_header": "Whether auto-combine was enabled (true/false)",
|
| 207 |
+
"chunks_combined_response": "Number of audio chunks combined (1 for short text)",
|
| 208 |
+
"original_text_response": "Original text length (for long text processing)",
|
| 209 |
+
"audio_format_header": "Audio format of the response",
|
| 210 |
+
"audio_size_response": "Audio file size in bytes",
|
| 211 |
+
"short_text_comment": "Short text (works normally)",
|
| 212 |
+
"long_text_auto_comment": "Long text with auto-combine (default)",
|
| 213 |
+
"long_text_no_auto_comment": "Long text without auto-combine (will error)",
|
| 214 |
+
"audio_combination": "Audio Combination:",
|
| 215 |
+
"audio_combination_desc": "Uses advanced audio processing (PyDub) when available, with intelligent fallbacks for different environments. Supports all audio formats.",
|
| 216 |
+
"use_cases": "Use Cases:",
|
| 217 |
+
"use_case_articles": "Long Articles: Convert blog posts or articles to single audio files",
|
| 218 |
+
"use_case_audiobooks": "Audiobooks: Generate chapters as single audio files",
|
| 219 |
+
"use_case_podcasts": "Podcasts: Create podcast episodes from scripts",
|
| 220 |
+
"use_case_education": "Educational Content: Convert learning materials to audio",
|
| 221 |
+
"example_usage": "Example Usage:",
|
| 222 |
+
"python_example_comment": "Python example"
|
| 223 |
+
}
|
| 224 |
+
}
|
translations/zh.json
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"nav": {
|
| 3 |
+
"home": "首页",
|
| 4 |
+
"playground": "试用平台",
|
| 5 |
+
"documentation": "文档",
|
| 6 |
+
"github": "GitHub",
|
| 7 |
+
"status_checking": "检查中...",
|
| 8 |
+
"status_online": "在线",
|
| 9 |
+
"status_offline": "离线"
|
| 10 |
+
},
|
| 11 |
+
"common": {
|
| 12 |
+
"loading": "加载中...",
|
| 13 |
+
"error": "错误",
|
| 14 |
+
"success": "成功",
|
| 15 |
+
"warning": "警告",
|
| 16 |
+
"info": "信息",
|
| 17 |
+
"close": "关闭",
|
| 18 |
+
"save": "保存",
|
| 19 |
+
"cancel": "取消",
|
| 20 |
+
"confirm": "确认",
|
| 21 |
+
"download": "下载",
|
| 22 |
+
"upload": "上传",
|
| 23 |
+
"generate": "生成",
|
| 24 |
+
"play": "播放",
|
| 25 |
+
"stop": "停止",
|
| 26 |
+
"pause": "暂停",
|
| 27 |
+
"resume": "继续",
|
| 28 |
+
"clear": "清除",
|
| 29 |
+
"reset": "重置",
|
| 30 |
+
"copy": "复制",
|
| 31 |
+
"copied": "已复制!",
|
| 32 |
+
"language": "语言",
|
| 33 |
+
"english": "English",
|
| 34 |
+
"chinese": "中文",
|
| 35 |
+
"validate": "验证",
|
| 36 |
+
"options": "选项",
|
| 37 |
+
"max_length": "最大长度",
|
| 38 |
+
"tip": "提示",
|
| 39 |
+
"choose_voice": "从可用声音中选择",
|
| 40 |
+
"select_format": "选择您偏好的音频格式",
|
| 41 |
+
"loading_voices": "加载声音中...",
|
| 42 |
+
"loading_formats": "加载格式中...",
|
| 43 |
+
"ctrl_enter_tip": "使用 Ctrl+Enter 生成",
|
| 44 |
+
"auto_combine_enabled": "自动合并已启用"
|
| 45 |
+
},
|
| 46 |
+
"home": {
|
| 47 |
+
"title": "免费的Python文本转语音",
|
| 48 |
+
"subtitle": "使用免费的openai.fm服务从文本生成高质量语音。无需API密钥,无需注册 - 只需安装即可开始创建音频。",
|
| 49 |
+
"try_demo": "试用演示",
|
| 50 |
+
"documentation": "文档",
|
| 51 |
+
"github": "GitHub",
|
| 52 |
+
"features_title": "主要特性",
|
| 53 |
+
"features_subtitle": "简单、免费且强大的Python开发者文本转语音工具。",
|
| 54 |
+
"feature_free_title": "完全免费",
|
| 55 |
+
"feature_free_desc": "无需API密钥或注册。使用免费的openai.fm服务。",
|
| 56 |
+
"feature_voices_title": "11种声音",
|
| 57 |
+
"feature_voices_desc": "提供所有OpenAI兼容的声音,适用于不同使用场景。",
|
| 58 |
+
"feature_formats_title": "6种音频格式",
|
| 59 |
+
"feature_formats_desc": "支持MP3、WAV、OPUS、AAC、FLAC和PCM格式,适用于任何应用。",
|
| 60 |
+
"feature_docker_title": "Docker就绪",
|
| 61 |
+
"feature_docker_desc": "一键部署,包含Web界面和API端点。",
|
| 62 |
+
"feature_openai_title": "OpenAI兼容",
|
| 63 |
+
"feature_openai_desc": "OpenAI TTS API的直接替代品,支持长文本自动合并。",
|
| 64 |
+
"feature_async_title": "异步和同步",
|
| 65 |
+
"feature_async_desc": "提供asyncio和同步客户端,最大化灵活性。",
|
| 66 |
+
"quick_start_title": "快速开始",
|
| 67 |
+
"installation_title": "安装",
|
| 68 |
+
"installation_code": "pip install ttsfm",
|
| 69 |
+
"usage_title": "基本用法",
|
| 70 |
+
"docker_title": "Docker部署",
|
| 71 |
+
"docker_desc": "运行带有Web界面的TTSFM:",
|
| 72 |
+
"api_title": "OpenAI兼容API",
|
| 73 |
+
"api_desc": "与OpenAI Python客户端一起使用:",
|
| 74 |
+
"footer_copyright": "© 2024 dbcccc"
|
| 75 |
+
},
|
| 76 |
+
"playground": {
|
| 77 |
+
"title": "交互式TTS试用平台",
|
| 78 |
+
"subtitle": "实时测试不同的声音和音频格式",
|
| 79 |
+
"text_input_label": "要转换的文本",
|
| 80 |
+
"text_input_placeholder": "输入您想要转换为语音的文本...",
|
| 81 |
+
"voice_label": "声音",
|
| 82 |
+
"format_label": "音频格式",
|
| 83 |
+
"instructions_label": "声音指令(可选)",
|
| 84 |
+
"instructions_placeholder": "语音生成的额外指令...",
|
| 85 |
+
"character_count": "字符",
|
| 86 |
+
"max_length_warning": "文本超过最大长度。将自动分割并合并。",
|
| 87 |
+
"generate_speech": "生成语音",
|
| 88 |
+
"generating": "生成中...",
|
| 89 |
+
"download_audio": "下载音频",
|
| 90 |
+
"audio_player_title": "生成的音频",
|
| 91 |
+
"file_size": "文件大小",
|
| 92 |
+
"duration": "时长",
|
| 93 |
+
"format": "格式",
|
| 94 |
+
"voice": "声音",
|
| 95 |
+
"chunks_combined": "合并片段",
|
| 96 |
+
"random_text": "随机文本",
|
| 97 |
+
"clear_text": "清除文本",
|
| 98 |
+
"max_length_description": "每个请求的最大字符数(默认:4096)",
|
| 99 |
+
"enable_length_validation": "启用长度验证",
|
| 100 |
+
"auto_combine_long_text": "自动合并长文本",
|
| 101 |
+
"auto_combine_tooltip": "自动分割长文本并将音频片段合并为单个文件",
|
| 102 |
+
"auto_combine_description": "自动处理超过限制的文本",
|
| 103 |
+
"instructions_description": "为声音调制提供可选指令",
|
| 104 |
+
"api_key_optional": "API密钥(可选)",
|
| 105 |
+
"api_key_placeholder": "如果需要,请输入您的API密钥",
|
| 106 |
+
"api_key_description": "仅在服务器启用API密钥保护时需要",
|
| 107 |
+
"sample_texts": {
|
| 108 |
+
"welcome": "欢迎使用TTSFM!这是一个免费的文本转语音服务,使用先进的AI技术将您的文本转换为高质量音频。",
|
| 109 |
+
"story": "很久很久以前,在一个遥远的数字世界里,住着一个小小的Python包,它能够将任何文本转换成美妙的语音。这个包叫做TTSFM,它为世界各地的开发者带来了快乐。",
|
| 110 |
+
"technical": "TTSFM是一个用于文本转语音API的Python客户端,提供同步和异步接口。它支持多种声音和音频格式,非常适合各种应用。",
|
| 111 |
+
"multilingual": "TTSFM支持多种语言和声音,让您能够为全球受众创建多样化的音频内容。该服务完全免费,无需API密钥。",
|
| 112 |
+
"long": "这是一个较长的文本示例,用于测试TTSFM的自动合并功能。当文本超过最大长度限制时,TTSFM会自动将其分割成较小的片段,为每个片段生成音频,然后无缝地将它们合并成一个音频文件。这个过程对用户完全透明,确保您可以转换任何长度的文本,而无需担心技术限制。生成的音频在整个内容中保持一致的质量和自然的流畅性。"
|
| 113 |
+
},
|
| 114 |
+
"error_messages": {
|
| 115 |
+
"empty_text": "请输入要转换的文本。",
|
| 116 |
+
"generation_failed": "语音生成失败。请重试。",
|
| 117 |
+
"network_error": "网络错误。请检查您的连接并重试。",
|
| 118 |
+
"invalid_format": "选择的音频格式无效。",
|
| 119 |
+
"invalid_voice": "选择的声音无效。",
|
| 120 |
+
"text_too_long": "文本太长。请减少长度或启用自动合并。",
|
| 121 |
+
"server_error": "服务器错误。请稍后重试。"
|
| 122 |
+
},
|
| 123 |
+
"success_messages": {
|
| 124 |
+
"generation_complete": "语音生成成功!",
|
| 125 |
+
"text_copied": "文本已复制到剪贴板!",
|
| 126 |
+
"download_started": "下载已开始!"
|
| 127 |
+
}
|
| 128 |
+
},
|
| 129 |
+
"docs": {
|
| 130 |
+
"title": "API文档",
|
| 131 |
+
"subtitle": "TTSFM文本转语音API的完整参考。免费、简单且强大。",
|
| 132 |
+
"contents": "目录",
|
| 133 |
+
"overview": "概述",
|
| 134 |
+
"authentication": "身份验证",
|
| 135 |
+
"text_validation": "文本验证",
|
| 136 |
+
"endpoints": "API端点",
|
| 137 |
+
"voices": "声音",
|
| 138 |
+
"formats": "音频格式",
|
| 139 |
+
"generate": "生成语音",
|
| 140 |
+
"combined": "合并音频",
|
| 141 |
+
"status": "状态和健康检查",
|
| 142 |
+
"errors": "错误处理",
|
| 143 |
+
"examples": "代码示例",
|
| 144 |
+
"python_package": "Python包",
|
| 145 |
+
"overview_title": "概述",
|
| 146 |
+
"overview_desc": "TTSFM API提供现代的、OpenAI兼容的文本转语音生成接口。它支持多种声音、音频格式,并包含高级功能,如文本长度验证和智能自动合并功能。",
|
| 147 |
+
"base_url": "基础URL:",
|
| 148 |
+
"key_features": "主要特性",
|
| 149 |
+
"feature_voices": "11种不同的声音选项 - 从alloy、echo、nova等中选择",
|
| 150 |
+
"feature_formats": "多种音频格式 - 支持MP3、WAV、OPUS、AAC、FLAC、PCM",
|
| 151 |
+
"feature_openai": "OpenAI兼容性 - OpenAI TTS API的直接替代品",
|
| 152 |
+
"feature_auto_combine": "自动合并功能 - 自动处理长文本(>4096字符),通过分割和合并音频",
|
| 153 |
+
"feature_validation": "文本长度验证 - 智能验证,可配置限制",
|
| 154 |
+
"feature_monitoring": "实时监控 - 状态端点和健康检查",
|
| 155 |
+
"new_version": "v3.2.3新功能:",
|
| 156 |
+
"new_version_desc": "增强的`/v1/audio/speech`端点,具有智能自动合并功能。简化的Web界面,设计简洁、用户友好,自动处理长文本!",
|
| 157 |
+
"authentication_title": "身份验证",
|
| 158 |
+
"authentication_desc": "目前,API支持可选的API密钥身份验证。如果已配置,请在请求头中包含您的API密钥。",
|
| 159 |
+
"text_validation_title": "文本长度验证",
|
| 160 |
+
"text_validation_desc": "TTSFM包含内置的文本长度验证,以确保与TTS模型的兼容性。默认最大长度为4096个字符,但可以自定义。",
|
| 161 |
+
"important": "重要:",
|
| 162 |
+
"text_validation_warning": "超过最大长度的文本将被拒绝,除非禁用验证或将文本分割成块。",
|
| 163 |
+
"validation_options": "验证选项",
|
| 164 |
+
"max_length_option": "允许的最大字符数(默认:4096)",
|
| 165 |
+
"validate_length_option": "启用/禁用验证(默认:true)",
|
| 166 |
+
"preserve_words_option": "分块时避免分割单词(默认:true)",
|
| 167 |
+
"endpoints_title": "API端点",
|
| 168 |
+
"get_voices_desc": "获取可用声音列表。",
|
| 169 |
+
"get_formats_desc": "获取支持的音频格式列表。",
|
| 170 |
+
"validate_text_desc": "验证文本长度并获取分割建议。",
|
| 171 |
+
"generate_speech_desc": "从文本生成语音。",
|
| 172 |
+
"response_example": "响应示例:",
|
| 173 |
+
"request_body": "请求体:",
|
| 174 |
+
"parameters": "参数:",
|
| 175 |
+
"text_param": "要转换为语音的文本",
|
| 176 |
+
"voice_param": "声音ID(默认:\"alloy\")",
|
| 177 |
+
"format_param": "音频格式(默认:\"mp3\")",
|
| 178 |
+
"instructions_param": "声音调制指令",
|
| 179 |
+
"max_length_param": "最大文本长度(默认:4096)",
|
| 180 |
+
"validate_length_param": "启用验证(默认:true)",
|
| 181 |
+
"response": "响应:",
|
| 182 |
+
"response_audio": "返回带有适当Content-Type头的音频文件。",
|
| 183 |
+
"response_combined_audio": "返回包含所有块无缝合并的单个音频文件。",
|
| 184 |
+
"required": "必需",
|
| 185 |
+
"optional": "可选",
|
| 186 |
+
"python_package_title": "Python包",
|
| 187 |
+
"long_text_support": "长文本支持",
|
| 188 |
+
"long_text_desc": "TTSFM Python包包含内置的长文本分割功能,为需要精细控制的开发者提供支持:",
|
| 189 |
+
"developer_features": "开发者功能:",
|
| 190 |
+
"manual_splitting": "手动分割:对高级用例的文本分块进行完全控制",
|
| 191 |
+
"word_preservation": "单词保护:维护单词边界以获得自然语音",
|
| 192 |
+
"separate_files": "单独文件:每个块保存为单独的音频文件",
|
| 193 |
+
"cli_support": "CLI支持:使用`--split-long-text`标志进行命令行使用",
|
| 194 |
+
"note": "注意:",
|
| 195 |
+
"auto_combine_note": "对于Web用户,建议使用`/v1/audio/speech`中的自动合并功能,因为它会自动处理长文本并返回单个无缝音频文件。",
|
| 196 |
+
"combined_audio_desc": "从长文本生成单个合并的音频文件。自动将文本分割成块,为每个块生成语音,并将它们合并成一个无缝的音频文件。",
|
| 197 |
+
"response_headers": "响应头:",
|
| 198 |
+
"chunks_combined_header": "合并的块数",
|
| 199 |
+
"original_text_length_header": "原始文本长度(字符数)",
|
| 200 |
+
"audio_size_header": "最终音频文件大小(字节)",
|
| 201 |
+
"openai_compatible_desc": "增强的OpenAI兼容端点,具有自动合并功能。在需要时自动处理长文本,通过分割和合并音频块。",
|
| 202 |
+
"enhanced_parameters": "增强参数:",
|
| 203 |
+
"auto_combine_param": "自动分割长文本并将音频块合并为单个文件",
|
| 204 |
+
"auto_combine_false": "如果文本超过max_length则返回错误(标准OpenAI行为)",
|
| 205 |
+
"max_length_chunk_param": "分割时每个块的最大字符数",
|
| 206 |
+
"auto_combine_header": "是否启用了自动合并(true/false)",
|
| 207 |
+
"chunks_combined_response": "合并的音频块数(短文本为1)",
|
| 208 |
+
"original_text_response": "原始文本长度(用于长文本处理)",
|
| 209 |
+
"audio_format_header": "响应的音频格式",
|
| 210 |
+
"audio_size_response": "音频文件大小(字节)",
|
| 211 |
+
"short_text_comment": "短文本(正常工作)",
|
| 212 |
+
"long_text_auto_comment": "带自动合并的长文本(默认)",
|
| 213 |
+
"long_text_no_auto_comment": "不带自动合并的长文本(将出错)",
|
| 214 |
+
"audio_combination": "音频合并:",
|
| 215 |
+
"audio_combination_desc": "在可用时使用高级音频处理(PyDub),在不同环境中具有智能回退。支持所有音频格式。",
|
| 216 |
+
"use_cases": "使用场景:",
|
| 217 |
+
"use_case_articles": "长文章:将博客文章或文章转换为单个音频文件",
|
| 218 |
+
"use_case_audiobooks": "有声书:将章节生成为单个音频文件",
|
| 219 |
+
"use_case_podcasts": "播客:从脚本创建播客剧集",
|
| 220 |
+
"use_case_education": "教育内容:将学习材料转换为音频",
|
| 221 |
+
"example_usage": "使用示例:",
|
| 222 |
+
"python_example_comment": "Python示例"
|
| 223 |
+
}
|
| 224 |
+
}
|
websocket_handler.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
WebSocket handler for real-time TTS streaming.
|
| 3 |
+
|
| 4 |
+
Because apparently waiting 2 seconds for audio generation is too much for modern users.
|
| 5 |
+
At least this will make it FEEL faster.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
import uuid
|
| 12 |
+
import time
|
| 13 |
+
from typing import Optional, Dict, Any
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
|
| 16 |
+
from flask_socketio import SocketIO, emit, disconnect
|
| 17 |
+
from flask import request
|
| 18 |
+
|
| 19 |
+
from ttsfm import TTSClient, Voice, AudioFormat, TTSException
|
| 20 |
+
from ttsfm.utils import split_text_by_length, estimate_audio_duration
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class WebSocketTTSHandler:
|
| 26 |
+
"""
|
| 27 |
+
Handles WebSocket connections for streaming TTS generation.
|
| 28 |
+
|
| 29 |
+
Because your users can't wait 2 seconds for a complete response.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
def __init__(self, socketio: SocketIO, tts_client: TTSClient):
|
| 33 |
+
self.socketio = socketio
|
| 34 |
+
self.tts_client = tts_client
|
| 35 |
+
self.active_sessions: Dict[str, Dict[str, Any]] = {}
|
| 36 |
+
|
| 37 |
+
# Register WebSocket events
|
| 38 |
+
self._register_events()
|
| 39 |
+
|
| 40 |
+
def _register_events(self):
|
| 41 |
+
"""Register all WebSocket event handlers."""
|
| 42 |
+
|
| 43 |
+
@self.socketio.on('connect')
|
| 44 |
+
def handle_connect():
|
| 45 |
+
"""Handle new WebSocket connection."""
|
| 46 |
+
session_id = request.sid
|
| 47 |
+
self.active_sessions[session_id] = {
|
| 48 |
+
'connected_at': datetime.now(),
|
| 49 |
+
'request_count': 0,
|
| 50 |
+
'last_request': None
|
| 51 |
+
}
|
| 52 |
+
logger.info(f"WebSocket client connected: {session_id}")
|
| 53 |
+
emit('connected', {'session_id': session_id, 'status': 'ready'})
|
| 54 |
+
|
| 55 |
+
@self.socketio.on('disconnect')
|
| 56 |
+
def handle_disconnect():
|
| 57 |
+
"""Handle WebSocket disconnection."""
|
| 58 |
+
session_id = request.sid
|
| 59 |
+
if session_id in self.active_sessions:
|
| 60 |
+
del self.active_sessions[session_id]
|
| 61 |
+
logger.info(f"WebSocket client disconnected: {session_id}")
|
| 62 |
+
|
| 63 |
+
@self.socketio.on('generate_stream')
|
| 64 |
+
def handle_generate_stream(data):
|
| 65 |
+
"""
|
| 66 |
+
Handle streaming TTS generation request.
|
| 67 |
+
|
| 68 |
+
Expected data format:
|
| 69 |
+
{
|
| 70 |
+
'text': str,
|
| 71 |
+
'voice': str,
|
| 72 |
+
'format': str,
|
| 73 |
+
'chunk_size': int (optional, default 1024 chars),
|
| 74 |
+
'instructions': str (optional, voice modulation instructions)
|
| 75 |
+
}
|
| 76 |
+
"""
|
| 77 |
+
session_id = request.sid
|
| 78 |
+
request_id = data.get('request_id', str(uuid.uuid4()))
|
| 79 |
+
|
| 80 |
+
# Update session info
|
| 81 |
+
if session_id in self.active_sessions:
|
| 82 |
+
self.active_sessions[session_id]['request_count'] += 1
|
| 83 |
+
self.active_sessions[session_id]['last_request'] = datetime.now()
|
| 84 |
+
|
| 85 |
+
# Emit acknowledgment
|
| 86 |
+
emit('stream_started', {
|
| 87 |
+
'request_id': request_id,
|
| 88 |
+
'timestamp': time.time()
|
| 89 |
+
})
|
| 90 |
+
|
| 91 |
+
# Start async generation
|
| 92 |
+
self.socketio.start_background_task(
|
| 93 |
+
self._generate_stream,
|
| 94 |
+
session_id,
|
| 95 |
+
request_id,
|
| 96 |
+
data
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
@self.socketio.on('cancel_stream')
|
| 100 |
+
def handle_cancel_stream(data):
|
| 101 |
+
"""Handle stream cancellation request."""
|
| 102 |
+
request_id = data.get('request_id')
|
| 103 |
+
session_id = request.sid
|
| 104 |
+
|
| 105 |
+
# In a real implementation, you'd track and cancel the actual generation
|
| 106 |
+
logger.info(f"Stream cancellation requested: {request_id}")
|
| 107 |
+
emit('stream_cancelled', {'request_id': request_id})
|
| 108 |
+
|
| 109 |
+
def _generate_stream(self, session_id: str, request_id: str, data: Dict[str, Any]):
|
| 110 |
+
"""
|
| 111 |
+
Generate TTS audio in chunks and stream to client.
|
| 112 |
+
|
| 113 |
+
This is where the magic happens. And by magic, I mean
|
| 114 |
+
chunking text and pretending it's real-time.
|
| 115 |
+
"""
|
| 116 |
+
try:
|
| 117 |
+
# Extract parameters
|
| 118 |
+
text = data.get('text', '')
|
| 119 |
+
voice = data.get('voice', 'alloy')
|
| 120 |
+
format_str = data.get('format', 'mp3')
|
| 121 |
+
chunk_size = data.get('chunk_size', 1024)
|
| 122 |
+
instructions = data.get('instructions', None) # Voice instructions support!
|
| 123 |
+
|
| 124 |
+
if not text:
|
| 125 |
+
self._emit_error(session_id, request_id, "No text provided")
|
| 126 |
+
return
|
| 127 |
+
|
| 128 |
+
# Convert string parameters to enums
|
| 129 |
+
try:
|
| 130 |
+
voice_enum = Voice(voice.lower())
|
| 131 |
+
format_enum = AudioFormat(format_str.lower())
|
| 132 |
+
except ValueError as e:
|
| 133 |
+
self._emit_error(session_id, request_id, f"Invalid parameter: {str(e)}")
|
| 134 |
+
return
|
| 135 |
+
|
| 136 |
+
# Split text into chunks for "streaming" effect
|
| 137 |
+
chunks = split_text_by_length(text, chunk_size, preserve_words=True)
|
| 138 |
+
total_chunks = len(chunks)
|
| 139 |
+
|
| 140 |
+
logger.info(f"Starting stream generation: {request_id} with {total_chunks} chunks")
|
| 141 |
+
|
| 142 |
+
# Emit initial progress
|
| 143 |
+
self.socketio.emit('stream_progress', {
|
| 144 |
+
'request_id': request_id,
|
| 145 |
+
'progress': 0,
|
| 146 |
+
'total_chunks': total_chunks,
|
| 147 |
+
'status': 'processing'
|
| 148 |
+
}, room=session_id)
|
| 149 |
+
|
| 150 |
+
# Process each chunk
|
| 151 |
+
for i, chunk in enumerate(chunks):
|
| 152 |
+
# Check if client is still connected
|
| 153 |
+
if session_id not in self.active_sessions:
|
| 154 |
+
logger.warning(f"Client disconnected during generation: {session_id}")
|
| 155 |
+
break
|
| 156 |
+
|
| 157 |
+
try:
|
| 158 |
+
# Generate audio for chunk
|
| 159 |
+
start_time = time.time()
|
| 160 |
+
response = self.tts_client.generate_speech(
|
| 161 |
+
text=chunk,
|
| 162 |
+
voice=voice_enum,
|
| 163 |
+
response_format=format_enum,
|
| 164 |
+
instructions=instructions, # Pass voice instructions!
|
| 165 |
+
validate_length=False # We already chunked it
|
| 166 |
+
)
|
| 167 |
+
generation_time = time.time() - start_time
|
| 168 |
+
|
| 169 |
+
# Emit chunk data
|
| 170 |
+
chunk_data = {
|
| 171 |
+
'request_id': request_id,
|
| 172 |
+
'chunk_index': i,
|
| 173 |
+
'total_chunks': total_chunks,
|
| 174 |
+
'audio_data': response.audio_data.hex(), # Convert bytes to hex string
|
| 175 |
+
'format': format_enum.value,
|
| 176 |
+
'duration': response.duration,
|
| 177 |
+
'generation_time': generation_time,
|
| 178 |
+
'chunk_text': chunk[:50] + '...' if len(chunk) > 50 else chunk
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
self.socketio.emit('audio_chunk', chunk_data, room=session_id)
|
| 182 |
+
|
| 183 |
+
# Emit progress update
|
| 184 |
+
progress = int(((i + 1) / total_chunks) * 100)
|
| 185 |
+
self.socketio.emit('stream_progress', {
|
| 186 |
+
'request_id': request_id,
|
| 187 |
+
'progress': progress,
|
| 188 |
+
'total_chunks': total_chunks,
|
| 189 |
+
'chunks_completed': i + 1,
|
| 190 |
+
'status': 'processing'
|
| 191 |
+
}, room=session_id)
|
| 192 |
+
|
| 193 |
+
# Small delay to prevent overwhelming the client
|
| 194 |
+
# (and to make it feel more "real-time")
|
| 195 |
+
self.socketio.sleep(0.1)
|
| 196 |
+
|
| 197 |
+
except Exception as e:
|
| 198 |
+
logger.error(f"Error generating chunk {i}: {str(e)}")
|
| 199 |
+
self._emit_error(session_id, request_id, f"Chunk {i} generation failed: {str(e)}")
|
| 200 |
+
# Continue with next chunk instead of failing completely
|
| 201 |
+
continue
|
| 202 |
+
|
| 203 |
+
# Emit completion
|
| 204 |
+
self.socketio.emit('stream_complete', {
|
| 205 |
+
'request_id': request_id,
|
| 206 |
+
'total_chunks': total_chunks,
|
| 207 |
+
'status': 'completed',
|
| 208 |
+
'timestamp': time.time()
|
| 209 |
+
}, room=session_id)
|
| 210 |
+
|
| 211 |
+
logger.info(f"Stream generation completed: {request_id}")
|
| 212 |
+
|
| 213 |
+
except Exception as e:
|
| 214 |
+
logger.error(f"Stream generation failed: {str(e)}")
|
| 215 |
+
self._emit_error(session_id, request_id, str(e))
|
| 216 |
+
|
| 217 |
+
def _emit_error(self, session_id: str, request_id: str, error_message: str):
|
| 218 |
+
"""Emit error to specific session."""
|
| 219 |
+
self.socketio.emit('stream_error', {
|
| 220 |
+
'request_id': request_id,
|
| 221 |
+
'error': error_message,
|
| 222 |
+
'timestamp': time.time()
|
| 223 |
+
}, room=session_id)
|
| 224 |
+
|
| 225 |
+
def get_active_sessions_count(self) -> int:
|
| 226 |
+
"""Get count of active WebSocket sessions."""
|
| 227 |
+
return len(self.active_sessions)
|
| 228 |
+
|
| 229 |
+
def get_session_info(self, session_id: str) -> Optional[Dict[str, Any]]:
|
| 230 |
+
"""Get information about a specific session."""
|
| 231 |
+
return self.active_sessions.get(session_id)
|