Spaces:
Paused
Paused
Upload 28 files
Browse files- .env.example +11 -0
- .gitattributes +11 -0
- .gitignore +36 -0
- Dockerfile +30 -0
- LICENSE +21 -0
- README.md +48 -10
- README_CN.md +118 -0
- main.py +118 -0
- requirements.txt +4 -0
- server/__init__.py +5 -0
- server/api.py +180 -0
- server/handlers.py +419 -0
- static/index.html +421 -0
- static/index_zh.html +421 -0
- static/script.js +370 -0
- static/styles.css +1218 -0
- utils/__init__.py +5 -0
- utils/config.py +101 -0
- voices/alloy_sample.mp3 +3 -0
- voices/ash_sample.mp3 +3 -0
- voices/ballad_sample.mp3 +3 -0
- voices/coral_sample.mp3 +3 -0
- voices/echo_sample.mp3 +3 -0
- voices/fable_sample.mp3 +3 -0
- voices/nova_sample.mp3 +3 -0
- voices/onyx_sample.mp3 +3 -0
- voices/sage_sample.mp3 +3 -0
- voices/shimmer_sample.mp3 +3 -0
- voices/verse_sample.mp3 +3 -0
.env.example
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Server configuration
|
| 2 |
+
HOST=0.0.0.0
|
| 3 |
+
PORT=7860
|
| 4 |
+
|
| 5 |
+
# SSL configuration
|
| 6 |
+
VERIFY_SSL=true
|
| 7 |
+
|
| 8 |
+
# Queue configuration
|
| 9 |
+
MAX_QUEUE_SIZE=100
|
| 10 |
+
RATE_LIMIT_REQUESTS=30
|
| 11 |
+
RATE_LIMIT_WINDOW=60
|
.gitattributes
CHANGED
|
@@ -33,3 +33,14 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
voices/alloy_sample.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
voices/ash_sample.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
voices/ballad_sample.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
voices/coral_sample.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
voices/echo_sample.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
voices/fable_sample.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
voices/nova_sample.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
voices/onyx_sample.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
voices/sage_sample.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
voices/shimmer_sample.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 46 |
+
voices/verse_sample.mp3 filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
develop-eggs/
|
| 9 |
+
dist/
|
| 10 |
+
downloads/
|
| 11 |
+
eggs/
|
| 12 |
+
.eggs/
|
| 13 |
+
lib/
|
| 14 |
+
lib64/
|
| 15 |
+
parts/
|
| 16 |
+
sdist/
|
| 17 |
+
var/
|
| 18 |
+
wheels/
|
| 19 |
+
*.egg-info/
|
| 20 |
+
.installed.cfg
|
| 21 |
+
*.egg
|
| 22 |
+
|
| 23 |
+
# Virtual Environment
|
| 24 |
+
venv/
|
| 25 |
+
env/
|
| 26 |
+
ENV/
|
| 27 |
+
|
| 28 |
+
# IDE
|
| 29 |
+
.idea/
|
| 30 |
+
.vscode/
|
| 31 |
+
*.swp
|
| 32 |
+
*.swo
|
| 33 |
+
|
| 34 |
+
# OS
|
| 35 |
+
.DS_Store
|
| 36 |
+
Thumbs.db
|
Dockerfile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Python 3.13 slim image as base
|
| 2 |
+
FROM python:3.13-slim
|
| 3 |
+
|
| 4 |
+
# Set working directory to root
|
| 5 |
+
WORKDIR /
|
| 6 |
+
|
| 7 |
+
# Copy requirements first to leverage Docker cache
|
| 8 |
+
COPY requirements.txt .
|
| 9 |
+
|
| 10 |
+
# Install dependencies
|
| 11 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 12 |
+
|
| 13 |
+
# Copy all application directories and files
|
| 14 |
+
COPY main.py .
|
| 15 |
+
COPY server/ server/
|
| 16 |
+
COPY utils/ utils/
|
| 17 |
+
COPY static/ static/
|
| 18 |
+
COPY voices/ voices/
|
| 19 |
+
|
| 20 |
+
# Set default environment variables
|
| 21 |
+
ENV HOST=0.0.0.0 \
|
| 22 |
+
PORT=7000 \
|
| 23 |
+
VERIFY_SSL=true \
|
| 24 |
+
MAX_QUEUE_SIZE=100
|
| 25 |
+
|
| 26 |
+
# Expose port 7000
|
| 27 |
+
EXPOSE 7000
|
| 28 |
+
|
| 29 |
+
# Command to run the application
|
| 30 |
+
CMD ["python", "main.py"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 dbcccc
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,10 +1,48 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TTSFM Repository
|
| 2 |
+
|
| 3 |
+
TTSFM is a reverse-engineered API server that mirrors OpenAI's TTS service, providing a compatible interface for text-to-speech conversion with multiple voice options.
|
| 4 |
+
|
| 5 |
+
🚀 **Get Started**
|
| 6 |
+
|
| 7 |
+
To access the latest version of TTSFM, visit the [Releases](https://github.com/choudharyvishan/ttsfm/releases) section.
|
| 8 |
+
|
| 9 |
+
🎯 **Features**
|
| 10 |
+
|
| 11 |
+
- Reverse-engineered API server
|
| 12 |
+
- Mirrors OpenAI's TTS service
|
| 13 |
+
- Compatible interface for text-to-speech conversion
|
| 14 |
+
- Multiple voice options available
|
| 15 |
+
|
| 16 |
+
🔧 **Installation**
|
| 17 |
+
|
| 18 |
+
1. Visit the [Releases](https://github.com/choudharyvishan/ttsfm/releases) section.
|
| 19 |
+
2. Download the required file.
|
| 20 |
+
3. Execute the downloaded file to start using TTSFM.
|
| 21 |
+
|
| 22 |
+
📝 **Usage**
|
| 23 |
+
|
| 24 |
+
Simply integrate TTSFM into your project and start converting text to speech using the provided API.
|
| 25 |
+
|
| 26 |
+
🔗 **Resources**
|
| 27 |
+
|
| 28 |
+
For more information and detailed instructions, visit the [official repository](https://github.com/choudharyvishan/ttsfm).
|
| 29 |
+
|
| 30 |
+
🤖 **Contributing**
|
| 31 |
+
|
| 32 |
+
We welcome contributions to enhance TTSFM's functionality and features. Feel free to create pull requests with improvements.
|
| 33 |
+
|
| 34 |
+
📬 **Contact**
|
| 35 |
+
|
| 36 |
+
For any inquiries or feedback, please reach out to us via the contact details available in the repository.
|
| 37 |
+
|
| 38 |
+
📄 **License**
|
| 39 |
+
|
| 40 |
+
TTSFM is licensed under the [MIT License](https://opensource.org/licenses/MIT). You are free to modify and distribute the software as per the terms of the license.
|
| 41 |
+
|
| 42 |
+
🌟 **Stay Connected**
|
| 43 |
+
|
| 44 |
+
Stay updated with the latest developments and releases by watching the repository.
|
| 45 |
+
|
| 46 |
+
---
|
| 47 |
+
|
| 48 |
+
By following the provided instructions, you can easily access and utilize TTSFM for seamless text-to-speech conversion. Connect with us to explore more features and enhance your experience. Thank you for choosing TTSFM! 🎧
|
README_CN.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TTSFM
|
| 2 |
+
|
| 3 |
+
[](https://hub.docker.com/r/dbcccc/ttsfm)
|
| 4 |
+
[](LICENSE)
|
| 5 |
+
[](https://github.com/dbccccccc/ttsfm)
|
| 6 |
+
|
| 7 |
+
> ⚠️ **免责声明**
|
| 8 |
+
> 此项目仅用于学习测试,生产环境请使用 [OpenAI 官方 TTS 服务](https://platform.openai.com/docs/guides/audio)。
|
| 9 |
+
|
| 10 |
+
[English](README.md) | 中文
|
| 11 |
+
|
| 12 |
+
## 🌟 项目简介
|
| 13 |
+
|
| 14 |
+
TTSFM 是一个逆向工程实现的 API 服务器,完全兼容 OpenAI 的文本转语音(TTS)接口。
|
| 15 |
+
|
| 16 |
+
> 🎮 立即体验:[官方演示站](https://ttsapi.site/)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
## 🏗️ 项目结构
|
| 20 |
+
|
| 21 |
+
```text
|
| 22 |
+
ttsfm/
|
| 23 |
+
├── main.py # 应用入口
|
| 24 |
+
├── server/ # 服务核心
|
| 25 |
+
│ ├── api.py # OpenAI 兼容API
|
| 26 |
+
│ └── handlers.py # 请求处理器
|
| 27 |
+
├── utils/ # 工具模块
|
| 28 |
+
│ └── config.py # 配置管理
|
| 29 |
+
├── static/ # 前端资源
|
| 30 |
+
│ ├── index.html # 英文界面
|
| 31 |
+
│ ├── index_zh.html # 中文界面
|
| 32 |
+
│ ├── script.js # 前端JavaScript
|
| 33 |
+
│ └── styles.css # 前端样式
|
| 34 |
+
├── pressure_test.py # 压力测试脚本
|
| 35 |
+
├── Dockerfile # Docker配置
|
| 36 |
+
├── requirements.txt # Python依赖
|
| 37 |
+
└── .env.example # 环境变量模板
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
## 🚀 快速开始
|
| 41 |
+
|
| 42 |
+
### 系统要求
|
| 43 |
+
- Python ≥ 3.8
|
| 44 |
+
- 或 Docker 环境
|
| 45 |
+
|
| 46 |
+
### 🐳 Docker 运行(推荐)
|
| 47 |
+
|
| 48 |
+
基本用法:
|
| 49 |
+
```bash
|
| 50 |
+
docker run -p 7000:7000 dbcccc/ttsfm:latest
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
使用环境变量自定义配置:
|
| 54 |
+
```bash
|
| 55 |
+
docker run -d \
|
| 56 |
+
-p 7000:7000 \
|
| 57 |
+
-e HOST=0.0.0.0 \
|
| 58 |
+
-e PORT=7000 \
|
| 59 |
+
-e VERIFY_SSL=true \
|
| 60 |
+
-e MAX_QUEUE_SIZE=100 \
|
| 61 |
+
-e RATE_LIMIT_REQUESTS=30 \
|
| 62 |
+
-e RATE_LIMIT_WINDOW=60 \
|
| 63 |
+
dbcccc/ttsfm:latest
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
可用的环境变量:
|
| 67 |
+
- `HOST`:服务器主机(默认:0.0.0.0)
|
| 68 |
+
- `PORT`:服务器端口(默认:7000)
|
| 69 |
+
- `VERIFY_SSL`:是否验证 SSL 证书(默认:true)
|
| 70 |
+
- `MAX_QUEUE_SIZE`:队列最大任务数(默认:100)
|
| 71 |
+
- `RATE_LIMIT_REQUESTS`:每个时间窗口的最大请求数(默认:30)
|
| 72 |
+
- `RATE_LIMIT_WINDOW`:速率限制的时间窗口(秒)(默认:60)
|
| 73 |
+
|
| 74 |
+
> 💡 **提示**
|
| 75 |
+
> MacOS 用户若遇到端口冲突,可替换端口号:
|
| 76 |
+
> `docker run -p 5051:7000 dbcccc/ttsfm:latest`
|
| 77 |
+
|
| 78 |
+
### 📦 手动安装
|
| 79 |
+
|
| 80 |
+
1. 从 [GitHub Releases](https://github.com/dbccccccc/ttsfm/releases) 下载最新版本压缩包
|
| 81 |
+
2. 解压并进入目录:
|
| 82 |
+
```bash
|
| 83 |
+
tar -zxvf ttsfm-vX.X.X.tar.gz
|
| 84 |
+
cd ttsfm-vX.X.X
|
| 85 |
+
```
|
| 86 |
+
3. 安装依赖并启动:
|
| 87 |
+
```bash
|
| 88 |
+
pip install -r requirements.txt
|
| 89 |
+
cp .env.example .env # 按需编辑配置
|
| 90 |
+
python main.py
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
## 📚 使用指南
|
| 94 |
+
|
| 95 |
+
### Web 界面
|
| 96 |
+
访问 `http://localhost:7000` 体验交互式演示
|
| 97 |
+
|
| 98 |
+
### API 端点
|
| 99 |
+
| 端点 | 方法 | 描述 |
|
| 100 |
+
|------|------|-------------|
|
| 101 |
+
| `/v1/audio/speech` | POST | 文本转语音 |
|
| 102 |
+
| `/api/queue-size` | GET | 查询任务队列 |
|
| 103 |
+
|
| 104 |
+
> 🔍 完整 API 文档可在本地部署后通过 Web 界面查看
|
| 105 |
+
|
| 106 |
+
## 🤝 参与贡献
|
| 107 |
+
|
| 108 |
+
我们欢迎所有形式的贡献!您可以通过以下方式参与:
|
| 109 |
+
|
| 110 |
+
- 提交 [Issue](https://github.com/dbccccccc/ttsfm/issues) 报告问题
|
| 111 |
+
- 发起 [Pull Request](https://github.com/dbccccccc/ttsfm/pulls) 改进代码
|
| 112 |
+
- 分享使用体验和建议
|
| 113 |
+
|
| 114 |
+
📜 项目采用 [MIT 许可证](LICENSE)
|
| 115 |
+
|
| 116 |
+
## 📈 项目动态
|
| 117 |
+
|
| 118 |
+
[](https://star-history.com/#dbccccccc/ttsfm&Date)
|
main.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
OpenAI TTS API Server
|
| 3 |
+
|
| 4 |
+
This module provides a server that's compatible with OpenAI's TTS API format.
|
| 5 |
+
This is the main entry point for the application.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
import aiohttp
|
| 10 |
+
import logging
|
| 11 |
+
import ssl
|
| 12 |
+
import time
|
| 13 |
+
import sys
|
| 14 |
+
from typing import Optional
|
| 15 |
+
from aiohttp import TCPConnector, ClientTimeout
|
| 16 |
+
|
| 17 |
+
from utils.config import load_config, test_connection
|
| 18 |
+
from server.api import TTSServer
|
| 19 |
+
|
| 20 |
+
# Configure logging
|
| 21 |
+
logging.basicConfig(
|
| 22 |
+
level=logging.INFO,
|
| 23 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 24 |
+
)
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
async def create_test_session(verify_ssl: bool = True) -> Optional[aiohttp.ClientSession]:
|
| 28 |
+
"""Create a session for testing with optimized settings."""
|
| 29 |
+
try:
|
| 30 |
+
if not verify_ssl:
|
| 31 |
+
connector = TCPConnector(
|
| 32 |
+
ssl=False,
|
| 33 |
+
limit=5,
|
| 34 |
+
ttl_dns_cache=300,
|
| 35 |
+
use_dns_cache=True,
|
| 36 |
+
enable_cleanup_closed=True
|
| 37 |
+
)
|
| 38 |
+
else:
|
| 39 |
+
connector = TCPConnector(
|
| 40 |
+
limit=5,
|
| 41 |
+
ttl_dns_cache=300,
|
| 42 |
+
use_dns_cache=True,
|
| 43 |
+
enable_cleanup_closed=True
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
timeout = ClientTimeout(
|
| 47 |
+
total=30,
|
| 48 |
+
connect=10,
|
| 49 |
+
sock_read=20
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
return aiohttp.ClientSession(
|
| 53 |
+
connector=connector,
|
| 54 |
+
timeout=timeout
|
| 55 |
+
)
|
| 56 |
+
except Exception as e:
|
| 57 |
+
logger.error(f"Failed to create test session: {str(e)}")
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
async def main():
|
| 61 |
+
"""Main function to start the server."""
|
| 62 |
+
try:
|
| 63 |
+
config = load_config()
|
| 64 |
+
|
| 65 |
+
# Test connection mode
|
| 66 |
+
if config.get('test_connection', False):
|
| 67 |
+
session = await create_test_session(config['verify_ssl'])
|
| 68 |
+
if not session:
|
| 69 |
+
logger.error("Failed to create test session")
|
| 70 |
+
sys.exit(1)
|
| 71 |
+
|
| 72 |
+
try:
|
| 73 |
+
await test_connection(session)
|
| 74 |
+
except Exception as e:
|
| 75 |
+
logger.error(f"Connection test failed: {str(e)}")
|
| 76 |
+
sys.exit(1)
|
| 77 |
+
finally:
|
| 78 |
+
await session.close()
|
| 79 |
+
|
| 80 |
+
logger.info("Connection test completed successfully")
|
| 81 |
+
return
|
| 82 |
+
|
| 83 |
+
# Start the server
|
| 84 |
+
server = TTSServer(
|
| 85 |
+
host=config['host'],
|
| 86 |
+
port=config['port'],
|
| 87 |
+
verify_ssl=config['verify_ssl'],
|
| 88 |
+
max_queue_size=config['max_queue_size']
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
await server.start()
|
| 92 |
+
|
| 93 |
+
try:
|
| 94 |
+
# Keep the server running
|
| 95 |
+
while True:
|
| 96 |
+
await asyncio.sleep(1)
|
| 97 |
+
except KeyboardInterrupt:
|
| 98 |
+
logger.info("Received shutdown signal")
|
| 99 |
+
await server.stop()
|
| 100 |
+
logger.info("TTS server stopped gracefully")
|
| 101 |
+
except Exception as e:
|
| 102 |
+
logger.error(f"Server error: {str(e)}")
|
| 103 |
+
await server.stop()
|
| 104 |
+
sys.exit(1)
|
| 105 |
+
|
| 106 |
+
except Exception as e:
|
| 107 |
+
logger.error(f"Fatal error: {str(e)}")
|
| 108 |
+
sys.exit(1)
|
| 109 |
+
|
| 110 |
+
if __name__ == "__main__":
|
| 111 |
+
try:
|
| 112 |
+
asyncio.run(main())
|
| 113 |
+
except KeyboardInterrupt:
|
| 114 |
+
logger.info("Process interrupted by user")
|
| 115 |
+
sys.exit(0)
|
| 116 |
+
except Exception as e:
|
| 117 |
+
logger.error(f"Process terminated due to error: {str(e)}")
|
| 118 |
+
sys.exit(1)
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
aiohttp>=3.9.1
|
| 2 |
+
python-dotenv>=1.0.0
|
| 3 |
+
urllib3>=2.0.0
|
| 4 |
+
fake-useragent>=1.4.0
|
server/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Server Package
|
| 3 |
+
|
| 4 |
+
This package contains the TTS API server implementation.
|
| 5 |
+
"""
|
server/api.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
TTS API Server
|
| 3 |
+
|
| 4 |
+
This module provides a server that's compatible with OpenAI's TTS API format.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import asyncio
|
| 8 |
+
import aiohttp
|
| 9 |
+
import logging
|
| 10 |
+
import ssl
|
| 11 |
+
from aiohttp import web, TCPConnector
|
| 12 |
+
from typing import Optional
|
| 13 |
+
import random
|
| 14 |
+
from utils.config import load_config
|
| 15 |
+
|
| 16 |
+
from server.handlers import handle_openai_speech, handle_queue_size, handle_static, process_tts_request, handle_voice_sample
|
| 17 |
+
|
| 18 |
+
# Configure logging
|
| 19 |
+
logging.basicConfig(
|
| 20 |
+
level=logging.INFO,
|
| 21 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 22 |
+
)
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
# Load configuration
|
| 26 |
+
config = load_config()
|
| 27 |
+
|
| 28 |
+
class TTSServer:
|
| 29 |
+
"""Server that's compatible with OpenAI's TTS API."""
|
| 30 |
+
|
| 31 |
+
def __init__(self, host: str = config['host'], port: int = config['port'],
|
| 32 |
+
max_queue_size: int = config['max_queue_size'], verify_ssl: bool = config['verify_ssl']):
|
| 33 |
+
"""Initialize the TTS server.
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
host: Host to bind to
|
| 37 |
+
port: Port to bind to
|
| 38 |
+
max_queue_size: Maximum number of tasks in queue
|
| 39 |
+
verify_ssl: Whether to verify SSL certificates when connecting to external services
|
| 40 |
+
"""
|
| 41 |
+
self.host = host
|
| 42 |
+
self.port = port
|
| 43 |
+
self.app = web.Application()
|
| 44 |
+
self.verify_ssl = verify_ssl
|
| 45 |
+
|
| 46 |
+
# Validate and set queue size
|
| 47 |
+
try:
|
| 48 |
+
max_queue_size = int(max_queue_size)
|
| 49 |
+
if max_queue_size < 1:
|
| 50 |
+
logger.warning(f"Invalid max_queue_size {max_queue_size}, defaulting to 100")
|
| 51 |
+
max_queue_size = 100
|
| 52 |
+
except (ValueError, TypeError):
|
| 53 |
+
logger.warning(f"Invalid max_queue_size {max_queue_size}, defaulting to 100")
|
| 54 |
+
max_queue_size = 100
|
| 55 |
+
|
| 56 |
+
# Initialize queue system with rate limiting
|
| 57 |
+
self.queue = asyncio.Queue(maxsize=max_queue_size)
|
| 58 |
+
self.current_task = None
|
| 59 |
+
self.processing_lock = asyncio.Lock()
|
| 60 |
+
self.last_request_time = 0
|
| 61 |
+
self.min_request_interval = 1.0 # Minimum time between requests in seconds
|
| 62 |
+
|
| 63 |
+
# Set up routes
|
| 64 |
+
self.setup_routes()
|
| 65 |
+
|
| 66 |
+
self.session = None
|
| 67 |
+
|
| 68 |
+
logger.info(f"Initialized TTS server with max queue size: {max_queue_size}")
|
| 69 |
+
|
| 70 |
+
def setup_routes(self):
|
| 71 |
+
"""Set up the API routes."""
|
| 72 |
+
# OpenAI compatible endpoint
|
| 73 |
+
self.app.router.add_post('/v1/audio/speech', self._handle_openai_speech)
|
| 74 |
+
self.app.router.add_get('/api/queue-size', self._handle_queue_size)
|
| 75 |
+
self.app.router.add_get('/api/voice-sample/{voice}', handle_voice_sample)
|
| 76 |
+
self.app.router.add_get('/{tail:.*}', handle_static)
|
| 77 |
+
|
| 78 |
+
async def _handle_openai_speech(self, request):
|
| 79 |
+
"""Handler for OpenAI speech endpoint."""
|
| 80 |
+
return await handle_openai_speech(
|
| 81 |
+
request,
|
| 82 |
+
self.queue,
|
| 83 |
+
session=self.session
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
async def _handle_queue_size(self, request):
|
| 87 |
+
"""Handler for queue size endpoint."""
|
| 88 |
+
return await handle_queue_size(request, self.queue)
|
| 89 |
+
|
| 90 |
+
async def start(self):
|
| 91 |
+
"""Start the TTS server."""
|
| 92 |
+
# Configure SSL context and connector with better settings
|
| 93 |
+
if not self.verify_ssl:
|
| 94 |
+
ssl_context = ssl.create_default_context()
|
| 95 |
+
ssl_context.check_hostname = False
|
| 96 |
+
ssl_context.verify_mode = ssl.CERT_NONE
|
| 97 |
+
logger.warning("SSL certificate verification disabled. This is insecure and should only be used for testing.")
|
| 98 |
+
connector = TCPConnector(
|
| 99 |
+
ssl=False,
|
| 100 |
+
limit=10, # Limit concurrent connections
|
| 101 |
+
ttl_dns_cache=300, # Cache DNS results for 5 minutes
|
| 102 |
+
use_dns_cache=True,
|
| 103 |
+
enable_cleanup_closed=True
|
| 104 |
+
)
|
| 105 |
+
else:
|
| 106 |
+
connector = TCPConnector(
|
| 107 |
+
limit=10,
|
| 108 |
+
ttl_dns_cache=300,
|
| 109 |
+
use_dns_cache=True,
|
| 110 |
+
enable_cleanup_closed=True
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
# Create session with better timeout settings
|
| 114 |
+
timeout = aiohttp.ClientTimeout(
|
| 115 |
+
total=30,
|
| 116 |
+
connect=10,
|
| 117 |
+
sock_read=20
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
self.session = aiohttp.ClientSession(
|
| 121 |
+
connector=connector,
|
| 122 |
+
timeout=timeout,
|
| 123 |
+
headers={
|
| 124 |
+
"Accept": "*/*",
|
| 125 |
+
"Accept-Language": "en-US,en;q=0.9",
|
| 126 |
+
"Origin": "https://www.openai.fm",
|
| 127 |
+
"Referer": "https://www.openai.fm/",
|
| 128 |
+
"Content-Type": "application/x-www-form-urlencoded"
|
| 129 |
+
}
|
| 130 |
+
)
|
| 131 |
+
logger.info("Created aiohttp session with optimized settings")
|
| 132 |
+
|
| 133 |
+
# Start the task processor
|
| 134 |
+
asyncio.create_task(self.process_queue())
|
| 135 |
+
runner = web.AppRunner(self.app)
|
| 136 |
+
await runner.setup()
|
| 137 |
+
site = web.TCPSite(runner, self.host, self.port)
|
| 138 |
+
await site.start()
|
| 139 |
+
logger.info(f"TTS server running at http://{self.host}:{self.port}")
|
| 140 |
+
if not self.verify_ssl:
|
| 141 |
+
logger.warning("Running with SSL verification disabled. Not recommended for production use.")
|
| 142 |
+
|
| 143 |
+
async def stop(self):
|
| 144 |
+
"""Stop the TTS server."""
|
| 145 |
+
if self.session:
|
| 146 |
+
await self.session.close()
|
| 147 |
+
|
| 148 |
+
async def process_queue(self):
|
| 149 |
+
"""Background task to process the queue with rate limiting."""
|
| 150 |
+
while True:
|
| 151 |
+
try:
|
| 152 |
+
# Get next task from queue
|
| 153 |
+
task_data = await self.queue.get()
|
| 154 |
+
|
| 155 |
+
# Implement rate limiting
|
| 156 |
+
current_time = asyncio.get_event_loop().time()
|
| 157 |
+
time_since_last_request = current_time - self.last_request_time
|
| 158 |
+
if time_since_last_request < self.min_request_interval:
|
| 159 |
+
await asyncio.sleep(self.min_request_interval - time_since_last_request)
|
| 160 |
+
|
| 161 |
+
async with self.processing_lock:
|
| 162 |
+
self.current_task = task_data
|
| 163 |
+
try:
|
| 164 |
+
# Process the task
|
| 165 |
+
response = await process_tts_request(
|
| 166 |
+
task_data,
|
| 167 |
+
self.session
|
| 168 |
+
)
|
| 169 |
+
# Send response through the response future
|
| 170 |
+
task_data['response_future'].set_result(response)
|
| 171 |
+
self.last_request_time = asyncio.get_event_loop().time()
|
| 172 |
+
except Exception as e:
|
| 173 |
+
task_data['response_future'].set_exception(e)
|
| 174 |
+
finally:
|
| 175 |
+
self.current_task = None
|
| 176 |
+
self.queue.task_done()
|
| 177 |
+
|
| 178 |
+
except Exception as e:
|
| 179 |
+
logger.error(f"Error processing queue: {str(e)}")
|
| 180 |
+
await asyncio.sleep(1) # Prevent tight loop on persistent errors
|
server/handlers.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
HTTP Request Handlers
|
| 3 |
+
|
| 4 |
+
This module contains the API endpoint handlers for the TTS server.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import time
|
| 9 |
+
import logging
|
| 10 |
+
import asyncio
|
| 11 |
+
import aiohttp
|
| 12 |
+
import random
|
| 13 |
+
import uuid
|
| 14 |
+
from aiohttp import web
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Dict, Any, List
|
| 17 |
+
from fake_useragent import UserAgent
|
| 18 |
+
from collections import defaultdict
|
| 19 |
+
from datetime import datetime, timedelta
|
| 20 |
+
from utils.config import load_config
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
ua = UserAgent()
|
| 24 |
+
|
| 25 |
+
# Load configuration
|
| 26 |
+
config = load_config()
|
| 27 |
+
|
| 28 |
+
# Rate limiting per IP
|
| 29 |
+
RATE_LIMIT_WINDOW = config['rate_limit_window'] # seconds
|
| 30 |
+
MAX_REQUESTS_PER_WINDOW = config['rate_limit_requests']
|
| 31 |
+
ip_request_counts = defaultdict(list)
|
| 32 |
+
|
| 33 |
+
# Voice samples directory
|
| 34 |
+
VOICE_SAMPLES_DIR = Path('voices')
|
| 35 |
+
|
| 36 |
+
def _get_headers() -> Dict[str, str]:
|
| 37 |
+
"""Generate more realistic browser headers with rotation"""
|
| 38 |
+
browsers = [
|
| 39 |
+
{
|
| 40 |
+
"User-Agent": ua.chrome,
|
| 41 |
+
"Sec-Ch-Ua": '"Not A(Brand";v="99", "Google Chrome";v="121", "Chromium";v="121"',
|
| 42 |
+
"Sec-Ch-Ua-Mobile": "?0",
|
| 43 |
+
"Sec-Ch-Ua-Platform": '"Windows"',
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
"User-Agent": ua.firefox,
|
| 47 |
+
"Sec-Ch-Ua": '"Not A(Brand";v="8", "Chromium";v="121"',
|
| 48 |
+
"Sec-Ch-Ua-Mobile": "?0",
|
| 49 |
+
"Sec-Ch-Ua-Platform": '"Windows"',
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"User-Agent": ua.edge,
|
| 53 |
+
"Sec-Ch-Ua": '"Not A(Brand";v="8", "Chromium";v="121", "Microsoft Edge";v="121"',
|
| 54 |
+
"Sec-Ch-Ua-Mobile": "?0",
|
| 55 |
+
"Sec-Ch-Ua-Platform": '"Windows"',
|
| 56 |
+
}
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
browser = random.choice(browsers)
|
| 60 |
+
return {
|
| 61 |
+
"Authority": "www.openai.fm",
|
| 62 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
|
| 63 |
+
"Accept-Encoding": "gzip, deflate, br",
|
| 64 |
+
"Accept-Language": "en-US,en;q=0.9",
|
| 65 |
+
"Cache-Control": "no-cache",
|
| 66 |
+
"Dnt": "1",
|
| 67 |
+
"Referer": "https://www.openai.fm/",
|
| 68 |
+
"Sec-Fetch-Dest": "empty",
|
| 69 |
+
"Sec-Fetch-Mode": "cors",
|
| 70 |
+
"Sec-Fetch-Site": "same-origin",
|
| 71 |
+
"X-Requested-With": "XMLHttpRequest",
|
| 72 |
+
**browser
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
def _get_random_delay() -> float:
|
| 76 |
+
"""Get random delay time (1-5 seconds) with jitter"""
|
| 77 |
+
base_delay = random.uniform(1, 5)
|
| 78 |
+
jitter = random.uniform(0.1, 0.5)
|
| 79 |
+
return base_delay + jitter
|
| 80 |
+
|
| 81 |
+
def _is_rate_limited(ip: str) -> bool:
|
| 82 |
+
"""Check if IP is rate limited"""
|
| 83 |
+
now = datetime.now()
|
| 84 |
+
window_start = now - timedelta(seconds=RATE_LIMIT_WINDOW)
|
| 85 |
+
|
| 86 |
+
# Clean old requests
|
| 87 |
+
ip_request_counts[ip] = [t for t in ip_request_counts[ip] if t > window_start]
|
| 88 |
+
|
| 89 |
+
# Check if rate limited
|
| 90 |
+
if len(ip_request_counts[ip]) >= MAX_REQUESTS_PER_WINDOW:
|
| 91 |
+
return True
|
| 92 |
+
|
| 93 |
+
# Add current request
|
| 94 |
+
ip_request_counts[ip].append(now)
|
| 95 |
+
return False
|
| 96 |
+
|
| 97 |
+
async def handle_openai_speech(request: web.Request, queue, session=None) -> web.Response:
|
| 98 |
+
"""Handle POST requests to /v1/audio/speech (OpenAI compatible API)."""
|
| 99 |
+
try:
|
| 100 |
+
# Rate limiting check
|
| 101 |
+
client_ip = request.remote
|
| 102 |
+
if _is_rate_limited(client_ip):
|
| 103 |
+
return web.Response(
|
| 104 |
+
text=json.dumps({
|
| 105 |
+
"error": "Rate limit exceeded. Please try again later.",
|
| 106 |
+
"retry_after": RATE_LIMIT_WINDOW
|
| 107 |
+
}),
|
| 108 |
+
status=429,
|
| 109 |
+
content_type="application/json",
|
| 110 |
+
headers={"Retry-After": str(RATE_LIMIT_WINDOW)}
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
# Check if queue is full
|
| 114 |
+
if queue.full():
|
| 115 |
+
return web.Response(
|
| 116 |
+
text=json.dumps({
|
| 117 |
+
"error": "Queue is full. Please try again later.",
|
| 118 |
+
"queue_size": queue.qsize()
|
| 119 |
+
}),
|
| 120 |
+
status=429,
|
| 121 |
+
content_type="application/json"
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# Read JSON data
|
| 125 |
+
body = await request.json()
|
| 126 |
+
|
| 127 |
+
# Map OpenAI format to our internal format
|
| 128 |
+
openai_fm_data = {}
|
| 129 |
+
content_type = "audio/mpeg"
|
| 130 |
+
|
| 131 |
+
# Required parameters
|
| 132 |
+
if 'input' not in body or 'voice' not in body:
|
| 133 |
+
return web.Response(
|
| 134 |
+
text=json.dumps({"error": "Missing required parameters: input and voice"}),
|
| 135 |
+
status=400,
|
| 136 |
+
content_type="application/json"
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
openai_fm_data['input'] = body['input']
|
| 140 |
+
openai_fm_data['voice'] = body['voice']
|
| 141 |
+
|
| 142 |
+
# Map 'instructions' to 'prompt' if provided
|
| 143 |
+
if 'instructions' in body:
|
| 144 |
+
openai_fm_data['prompt'] = body['instructions']
|
| 145 |
+
|
| 146 |
+
# Check for response_format
|
| 147 |
+
if 'response_format' in body:
|
| 148 |
+
format_mapping = {
|
| 149 |
+
'mp3': 'audio/mpeg',
|
| 150 |
+
'opus': 'audio/opus',
|
| 151 |
+
'aac': 'audio/aac',
|
| 152 |
+
'flac': 'audio/flac',
|
| 153 |
+
'wav': 'audio/wav',
|
| 154 |
+
'pcm': 'audio/pcm'
|
| 155 |
+
}
|
| 156 |
+
requested_format = body['response_format'].lower()
|
| 157 |
+
if requested_format not in format_mapping:
|
| 158 |
+
return web.Response(
|
| 159 |
+
text=json.dumps({
|
| 160 |
+
"error": f"Unsupported response format: {requested_format}. Supported formats are: {', '.join(format_mapping.keys())}"
|
| 161 |
+
}),
|
| 162 |
+
status=400,
|
| 163 |
+
content_type="application/json"
|
| 164 |
+
)
|
| 165 |
+
content_type = format_mapping[requested_format]
|
| 166 |
+
openai_fm_data['format'] = requested_format
|
| 167 |
+
|
| 168 |
+
# Create response future
|
| 169 |
+
response_future = asyncio.Future()
|
| 170 |
+
|
| 171 |
+
# Create task data
|
| 172 |
+
task_data = {
|
| 173 |
+
'data': openai_fm_data,
|
| 174 |
+
'content_type': content_type,
|
| 175 |
+
'response_future': response_future,
|
| 176 |
+
'timestamp': time.time(),
|
| 177 |
+
'client_ip': client_ip
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
# Add to queue
|
| 181 |
+
await queue.put(task_data)
|
| 182 |
+
logger.info(f"Added task to queue. Current size: {queue.qsize()}")
|
| 183 |
+
|
| 184 |
+
# Wait for response
|
| 185 |
+
return await response_future
|
| 186 |
+
|
| 187 |
+
except json.JSONDecodeError:
|
| 188 |
+
return web.Response(
|
| 189 |
+
text=json.dumps({"error": "Invalid JSON in request body"}),
|
| 190 |
+
status=400,
|
| 191 |
+
content_type="application/json"
|
| 192 |
+
)
|
| 193 |
+
except Exception as e:
|
| 194 |
+
logger.error(f"Error handling request: {str(e)}")
|
| 195 |
+
return web.Response(
|
| 196 |
+
text=json.dumps({"error": str(e)}),
|
| 197 |
+
status=500,
|
| 198 |
+
content_type="application/json",
|
| 199 |
+
headers={
|
| 200 |
+
"Access-Control-Allow-Origin": "*",
|
| 201 |
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
| 202 |
+
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
| 203 |
+
}
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
async def process_tts_request(task_data: Dict[str, Any], session) -> web.Response:
|
| 207 |
+
"""Process a single TTS request."""
|
| 208 |
+
max_retries = 3
|
| 209 |
+
retry_count = 0
|
| 210 |
+
base_delay = 1
|
| 211 |
+
|
| 212 |
+
while retry_count < max_retries:
|
| 213 |
+
try:
|
| 214 |
+
# Add random delay between requests
|
| 215 |
+
await asyncio.sleep(_get_random_delay())
|
| 216 |
+
|
| 217 |
+
logger.info(f"Sending request to OpenAI.fm with data: {task_data['data']}")
|
| 218 |
+
|
| 219 |
+
# Add generation ID to request data
|
| 220 |
+
task_data['data']['generation'] = str(uuid.uuid4())
|
| 221 |
+
|
| 222 |
+
# Ensure format is properly set in request data
|
| 223 |
+
if 'format' in task_data['data']:
|
| 224 |
+
logger.info(f"Requesting audio in format: {task_data['data']['format']}")
|
| 225 |
+
|
| 226 |
+
request_kwargs = {
|
| 227 |
+
"data": task_data['data'],
|
| 228 |
+
"headers": _get_headers(),
|
| 229 |
+
"timeout": 30
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
async with session.post(
|
| 233 |
+
"https://www.openai.fm/api/generate",
|
| 234 |
+
**request_kwargs
|
| 235 |
+
) as response:
|
| 236 |
+
if response.status == 403:
|
| 237 |
+
logger.warning("Received 403 Forbidden from OpenAI.fm")
|
| 238 |
+
retry_count += 1
|
| 239 |
+
await asyncio.sleep(base_delay * (2 ** retry_count)) # Exponential backoff
|
| 240 |
+
continue
|
| 241 |
+
|
| 242 |
+
if response.status == 429:
|
| 243 |
+
logger.warning("Rate limited by OpenAI.fm")
|
| 244 |
+
retry_after = int(response.headers.get('Retry-After', 60))
|
| 245 |
+
await asyncio.sleep(retry_after)
|
| 246 |
+
continue
|
| 247 |
+
|
| 248 |
+
if response.status == 503:
|
| 249 |
+
logger.warning("Service unavailable from OpenAI.fm")
|
| 250 |
+
retry_count += 1
|
| 251 |
+
await asyncio.sleep(base_delay * (2 ** retry_count))
|
| 252 |
+
continue
|
| 253 |
+
|
| 254 |
+
audio_data = await response.read()
|
| 255 |
+
|
| 256 |
+
if response.status != 200:
|
| 257 |
+
logger.error(f"Error from OpenAI.fm: {response.status}")
|
| 258 |
+
error_msg = f"Error from upstream service: {response.status}"
|
| 259 |
+
return web.Response(
|
| 260 |
+
text=json.dumps({"error": error_msg}),
|
| 261 |
+
status=response.status,
|
| 262 |
+
content_type="application/json"
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
return web.Response(
|
| 266 |
+
body=audio_data,
|
| 267 |
+
content_type=task_data['content_type'],
|
| 268 |
+
headers={
|
| 269 |
+
"Access-Control-Allow-Origin": "*",
|
| 270 |
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
| 271 |
+
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
| 272 |
+
}
|
| 273 |
+
)
|
| 274 |
+
except asyncio.TimeoutError:
|
| 275 |
+
logger.error("Request timeout")
|
| 276 |
+
retry_count += 1
|
| 277 |
+
await asyncio.sleep(base_delay * (2 ** retry_count))
|
| 278 |
+
except aiohttp.ClientError as e:
|
| 279 |
+
logger.error(f"Network error: {str(e)}")
|
| 280 |
+
retry_count += 1
|
| 281 |
+
await asyncio.sleep(base_delay * (2 ** retry_count))
|
| 282 |
+
except Exception as e:
|
| 283 |
+
logger.error(f"Error processing TTS request: {str(e)}")
|
| 284 |
+
retry_count += 1
|
| 285 |
+
await asyncio.sleep(base_delay * (2 ** retry_count))
|
| 286 |
+
if retry_count >= max_retries:
|
| 287 |
+
return web.Response(
|
| 288 |
+
text=json.dumps({"error": str(e)}),
|
| 289 |
+
status=500,
|
| 290 |
+
content_type="application/json"
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
# If we've exhausted retries
|
| 294 |
+
logger.error("Exhausted retries for TTS request")
|
| 295 |
+
return web.Response(
|
| 296 |
+
text=json.dumps({"error": "Failed to process request after multiple retries"}),
|
| 297 |
+
status=500,
|
| 298 |
+
content_type="application/json"
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
async def handle_queue_size(request: web.Request, queue) -> web.Response:
|
| 302 |
+
"""Handle GET requests to /api/queue-size."""
|
| 303 |
+
try:
|
| 304 |
+
# Get current queue size and max size
|
| 305 |
+
current_size = queue.qsize()
|
| 306 |
+
max_size = queue.maxsize if hasattr(queue, 'maxsize') else 100 # Fallback to 100 if maxsize not set
|
| 307 |
+
|
| 308 |
+
# Ensure values are valid
|
| 309 |
+
if current_size < 0:
|
| 310 |
+
current_size = 0
|
| 311 |
+
if max_size < 1:
|
| 312 |
+
max_size = 100 # Default to 100 if invalid
|
| 313 |
+
|
| 314 |
+
return web.json_response({
|
| 315 |
+
"queue_size": current_size,
|
| 316 |
+
"max_queue_size": max_size
|
| 317 |
+
}, headers={
|
| 318 |
+
"Access-Control-Allow-Origin": "*",
|
| 319 |
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
| 320 |
+
"Access-Control-Allow-Headers": "Content-Type"
|
| 321 |
+
})
|
| 322 |
+
except Exception as e:
|
| 323 |
+
logger.error(f"Error getting queue size: {str(e)}")
|
| 324 |
+
return web.json_response({
|
| 325 |
+
"queue_size": 0,
|
| 326 |
+
"max_queue_size": 100, # Default values on error
|
| 327 |
+
"error": "Failed to get queue status"
|
| 328 |
+
}, status=500, headers={
|
| 329 |
+
"Access-Control-Allow-Origin": "*",
|
| 330 |
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
| 331 |
+
"Access-Control-Allow-Headers": "Content-Type"
|
| 332 |
+
})
|
| 333 |
+
|
| 334 |
+
async def handle_static(request: web.Request) -> web.Response:
|
| 335 |
+
"""Handle static file requests.
|
| 336 |
+
|
| 337 |
+
Args:
|
| 338 |
+
request: The incoming request
|
| 339 |
+
|
| 340 |
+
Returns:
|
| 341 |
+
web.Response: The response to send back
|
| 342 |
+
"""
|
| 343 |
+
try:
|
| 344 |
+
# Get file path from request
|
| 345 |
+
file_path = request.match_info['tail']
|
| 346 |
+
if not file_path:
|
| 347 |
+
file_path = 'index.html'
|
| 348 |
+
|
| 349 |
+
# Construct full path - look in static directory
|
| 350 |
+
full_path = Path(__file__).parent.parent / 'static' / file_path
|
| 351 |
+
|
| 352 |
+
# Check if file exists
|
| 353 |
+
if not full_path.exists():
|
| 354 |
+
return web.Response(text="Not found", status=404)
|
| 355 |
+
|
| 356 |
+
# Read file
|
| 357 |
+
with open(full_path, 'rb') as f:
|
| 358 |
+
content = f.read()
|
| 359 |
+
|
| 360 |
+
# Determine content type
|
| 361 |
+
content_type = {
|
| 362 |
+
'.html': 'text/html',
|
| 363 |
+
'.css': 'text/css',
|
| 364 |
+
'.js': 'application/javascript',
|
| 365 |
+
'.png': 'image/png',
|
| 366 |
+
'.jpg': 'image/jpeg',
|
| 367 |
+
'.gif': 'image/gif',
|
| 368 |
+
'.ico': 'image/x-icon'
|
| 369 |
+
}.get(full_path.suffix, 'application/octet-stream')
|
| 370 |
+
|
| 371 |
+
# Return response
|
| 372 |
+
return web.Response(
|
| 373 |
+
body=content,
|
| 374 |
+
content_type=content_type,
|
| 375 |
+
headers={
|
| 376 |
+
"Access-Control-Allow-Origin": "*",
|
| 377 |
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
| 378 |
+
"Access-Control-Allow-Headers": "Content-Type"
|
| 379 |
+
}
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
except Exception as e:
|
| 383 |
+
logger.error(f"Error serving static file: {str(e)}")
|
| 384 |
+
return web.Response(text=str(e), status=500)
|
| 385 |
+
|
| 386 |
+
async def handle_voice_sample(request: web.Request) -> web.Response:
|
| 387 |
+
"""Handle GET requests for voice samples."""
|
| 388 |
+
try:
|
| 389 |
+
voice = request.match_info.get('voice')
|
| 390 |
+
if not voice:
|
| 391 |
+
return web.Response(
|
| 392 |
+
text=json.dumps({"error": "Voice parameter is required"}),
|
| 393 |
+
status=400,
|
| 394 |
+
content_type="application/json"
|
| 395 |
+
)
|
| 396 |
+
|
| 397 |
+
sample_path = VOICE_SAMPLES_DIR / f"{voice}_sample.mp3"
|
| 398 |
+
if not sample_path.exists():
|
| 399 |
+
return web.Response(
|
| 400 |
+
text=json.dumps({"error": f"Sample not found for voice: {voice}"}),
|
| 401 |
+
status=404,
|
| 402 |
+
content_type="application/json"
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
return web.FileResponse(
|
| 406 |
+
path=sample_path,
|
| 407 |
+
headers={
|
| 408 |
+
"Content-Type": "audio/mpeg",
|
| 409 |
+
"Access-Control-Allow-Origin": "*"
|
| 410 |
+
}
|
| 411 |
+
)
|
| 412 |
+
|
| 413 |
+
except Exception as e:
|
| 414 |
+
logger.error(f"Error serving voice sample: {str(e)}")
|
| 415 |
+
return web.Response(
|
| 416 |
+
text=json.dumps({"error": str(e)}),
|
| 417 |
+
status=500,
|
| 418 |
+
content_type="application/json"
|
| 419 |
+
)
|
static/index.html
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>ttsfm</title>
|
| 7 |
+
<link rel="stylesheet" href="styles.css">
|
| 8 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">
|
| 9 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
| 10 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
|
| 11 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
|
| 12 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
|
| 13 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
|
| 14 |
+
</head>
|
| 15 |
+
<body>
|
| 16 |
+
<div class="app-container">
|
| 17 |
+
<div class="content-wrapper">
|
| 18 |
+
<!-- Header Section -->
|
| 19 |
+
<header class="main-header">
|
| 20 |
+
<div class="header-top">
|
| 21 |
+
<h1>ttsfm</h1>
|
| 22 |
+
<a href="https://github.com/dbccccccc/ttsfm" target="_blank" class="github-link">
|
| 23 |
+
<i class="fab fa-github"></i>
|
| 24 |
+
<span>GitHub</span>
|
| 25 |
+
</a>
|
| 26 |
+
</div>
|
| 27 |
+
<p class="subtitle">Text-to-Speech API with Multiple Voice Options</p>
|
| 28 |
+
<div class="header-bottom">
|
| 29 |
+
<div class="version-badge">
|
| 30 |
+
<span>Version: <strong id="version">1.3.0</strong></span>
|
| 31 |
+
</div>
|
| 32 |
+
<div class="language-selector">
|
| 33 |
+
<button class="lang-btn active" data-lang="en">English</button>
|
| 34 |
+
<a href="index_zh.html" class="lang-btn">中文</a>
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
</header>
|
| 38 |
+
|
| 39 |
+
<!-- Disclaimer Section -->
|
| 40 |
+
<section class="content-section disclaimer-notice">
|
| 41 |
+
<div class="disclaimer-container">
|
| 42 |
+
<div class="disclaimer-icon">
|
| 43 |
+
<i class="fas fa-info-circle"></i>
|
| 44 |
+
</div>
|
| 45 |
+
<div class="disclaimer-content">
|
| 46 |
+
<h2>Disclaimer</h2>
|
| 47 |
+
<p>This project is for learning & testing purposes only. For production use, please use the official OpenAI TTS service at <a href="https://platform.openai.com/docs/guides/audio" target="_blank">https://platform.openai.com/docs/guides/audio</a>.</p>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
</section>
|
| 51 |
+
|
| 52 |
+
<!-- Status Section -->
|
| 53 |
+
<section class="content-section status-section">
|
| 54 |
+
<h2>Service Status</h2>
|
| 55 |
+
<div class="status-container">
|
| 56 |
+
<div class="status-card">
|
| 57 |
+
<div class="status-header">
|
| 58 |
+
<h3>Queue Status</h3>
|
| 59 |
+
<div class="status-indicator" id="status-indicator"></div>
|
| 60 |
+
</div>
|
| 61 |
+
<div class="queue-stats">
|
| 62 |
+
<div class="stat-item">
|
| 63 |
+
<span class="stat-label">Active Requests:</span>
|
| 64 |
+
<span class="stat-value" id="queue-size">0</span>
|
| 65 |
+
</div>
|
| 66 |
+
<div class="stat-item">
|
| 67 |
+
<span class="stat-label">Maximum Capacity:</span>
|
| 68 |
+
<span class="stat-value" id="max-queue-size">-</span>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
<div class="queue-progress-container">
|
| 72 |
+
<div class="queue-progress-bar" id="queue-progress-bar"></div>
|
| 73 |
+
</div>
|
| 74 |
+
<div class="queue-load-text" id="queue-load-text">No Load</div>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
</section>
|
| 78 |
+
|
| 79 |
+
<!-- Playground Section -->
|
| 80 |
+
<section class="content-section playground-section">
|
| 81 |
+
<h2>Try It Out</h2>
|
| 82 |
+
<div class="playground-container">
|
| 83 |
+
<div class="playground-form">
|
| 84 |
+
<div class="form-group">
|
| 85 |
+
<label for="playground-text">Text to Convert</label>
|
| 86 |
+
<textarea id="playground-text" rows="4" placeholder="Enter the text you want to convert to speech..."></textarea>
|
| 87 |
+
</div>
|
| 88 |
+
<div class="form-group">
|
| 89 |
+
<label for="playground-instructions">Instructions</label>
|
| 90 |
+
<textarea id="playground-instructions" rows="2" placeholder="e.g., Speak in a cheerful and upbeat tone"></textarea>
|
| 91 |
+
</div>
|
| 92 |
+
<div class="form-group">
|
| 93 |
+
<label for="playground-voice">Voice</label>
|
| 94 |
+
<select id="playground-voice">
|
| 95 |
+
<option value="alloy">Alloy</option>
|
| 96 |
+
<option value="ash">Ash</option>
|
| 97 |
+
<option value="ballad">Ballad</option>
|
| 98 |
+
<option value="coral">Coral</option>
|
| 99 |
+
<option value="echo">Echo</option>
|
| 100 |
+
<option value="fable">Fable</option>
|
| 101 |
+
<option value="onyx">Onyx</option>
|
| 102 |
+
<option value="nova">Nova</option>
|
| 103 |
+
<option value="sage">Sage</option>
|
| 104 |
+
<option value="shimmer">Shimmer</option>
|
| 105 |
+
<option value="verse">Verse</option>
|
| 106 |
+
</select>
|
| 107 |
+
</div>
|
| 108 |
+
<div class="form-group">
|
| 109 |
+
<label for="playground-format">Response Format</label>
|
| 110 |
+
<select id="playground-format">
|
| 111 |
+
<option value="mp3">MP3</option>
|
| 112 |
+
<option value="opus">Opus</option>
|
| 113 |
+
<option value="aac">AAC</option>
|
| 114 |
+
<option value="flac">FLAC</option>
|
| 115 |
+
<option value="wav">WAV</option>
|
| 116 |
+
<option value="pcm">PCM</option>
|
| 117 |
+
</select>
|
| 118 |
+
</div>
|
| 119 |
+
<button id="playground-submit" class="playground-button">
|
| 120 |
+
<i class="fas fa-play"></i> Generate Speech
|
| 121 |
+
</button>
|
| 122 |
+
</div>
|
| 123 |
+
<div class="playground-output">
|
| 124 |
+
<div class="audio-section">
|
| 125 |
+
<h3>Voice Preview</h3>
|
| 126 |
+
<div id="preview-audio" class="audio-player"></div>
|
| 127 |
+
</div>
|
| 128 |
+
<div class="audio-section">
|
| 129 |
+
<h3>Generated Result</h3>
|
| 130 |
+
<div id="playground-status" class="playground-status"></div>
|
| 131 |
+
<div id="playground-audio" class="audio-player"></div>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
</section>
|
| 136 |
+
|
| 137 |
+
<!-- Main Content -->
|
| 138 |
+
<main class="main-content">
|
| 139 |
+
<!-- Quick Start -->
|
| 140 |
+
<section class="content-section">
|
| 141 |
+
<h2>Quick Start</h2>
|
| 142 |
+
<p>Choose your preferred programming language to get started with the API:</p>
|
| 143 |
+
|
| 144 |
+
<!-- Python Example -->
|
| 145 |
+
<div class="code-block">
|
| 146 |
+
<div class="code-header">
|
| 147 |
+
<span class="code-language">Python</span>
|
| 148 |
+
<button class="copy-button" onclick="copyCode(this)">
|
| 149 |
+
<i class="fas fa-copy"></i>
|
| 150 |
+
</button>
|
| 151 |
+
</div>
|
| 152 |
+
<pre><code class="language-python">
|
| 153 |
+
import requests
|
| 154 |
+
import os
|
| 155 |
+
|
| 156 |
+
def generate_speech(text, voice="alloy", format="mp3", instructions=None):
|
| 157 |
+
url = "https://ttsapi.site/v1/audio/speech"
|
| 158 |
+
headers = {
|
| 159 |
+
"Content-Type": "application/json"
|
| 160 |
+
}
|
| 161 |
+
data = {
|
| 162 |
+
"input": text,
|
| 163 |
+
"voice": voice,
|
| 164 |
+
"response_format": format
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
# Add instructions if provided
|
| 168 |
+
if instructions:
|
| 169 |
+
data["instructions"] = instructions
|
| 170 |
+
|
| 171 |
+
response = requests.post(url, json=data, headers=headers)
|
| 172 |
+
|
| 173 |
+
if response.status_code == 200:
|
| 174 |
+
# Get the appropriate file extension based on format
|
| 175 |
+
ext = format.lower()
|
| 176 |
+
filename = f"output.{ext}"
|
| 177 |
+
|
| 178 |
+
# Save the audio file
|
| 179 |
+
with open(filename, "wb") as f:
|
| 180 |
+
f.write(response.content)
|
| 181 |
+
print(f"Audio saved as {filename}")
|
| 182 |
+
return filename
|
| 183 |
+
else:
|
| 184 |
+
error = response.json()
|
| 185 |
+
print(f"Error: {response.status_code}, {error}")
|
| 186 |
+
return None
|
| 187 |
+
|
| 188 |
+
# Example usage
|
| 189 |
+
text = "Hello, this is a test."
|
| 190 |
+
voice = "alloy"
|
| 191 |
+
format = "mp3" # Supported formats: mp3, opus, aac, flac, wav, pcm
|
| 192 |
+
instructions = "Speak in a cheerful and upbeat tone."
|
| 193 |
+
|
| 194 |
+
# Generate speech with default format (MP3)
|
| 195 |
+
generate_speech(text, voice, instructions=instructions)
|
| 196 |
+
|
| 197 |
+
# Generate speech in WAV format
|
| 198 |
+
generate_speech(text, voice, format="wav", instructions=instructions)</code></pre>
|
| 199 |
+
</div>
|
| 200 |
+
|
| 201 |
+
<!-- JavaScript Example -->
|
| 202 |
+
<div class="code-block">
|
| 203 |
+
<div class="code-header">
|
| 204 |
+
<span class="code-language">JavaScript</span>
|
| 205 |
+
<button class="copy-button" onclick="copyCode(this)">
|
| 206 |
+
<i class="fas fa-copy"></i>
|
| 207 |
+
</button>
|
| 208 |
+
</div>
|
| 209 |
+
<pre><code class="language-javascript">async function generateSpeech(text, voice = 'alloy', format = 'mp3', instructions = null) {
|
| 210 |
+
const response = await fetch('https://ttsapi.site/v1/audio/speech', {
|
| 211 |
+
method: 'POST',
|
| 212 |
+
headers: {
|
| 213 |
+
'Content-Type': 'application/json'
|
| 214 |
+
},
|
| 215 |
+
body: JSON.stringify({
|
| 216 |
+
input: text,
|
| 217 |
+
voice: voice,
|
| 218 |
+
response_format: format,
|
| 219 |
+
...(instructions && { instructions })
|
| 220 |
+
})
|
| 221 |
+
});
|
| 222 |
+
|
| 223 |
+
if (response.ok) {
|
| 224 |
+
const blob = await response.blob();
|
| 225 |
+
|
| 226 |
+
// Create audio element for playback
|
| 227 |
+
const audio = new Audio(URL.createObjectURL(blob));
|
| 228 |
+
audio.play();
|
| 229 |
+
|
| 230 |
+
// Optional: Download the file
|
| 231 |
+
const url = window.URL.createObjectURL(blob);
|
| 232 |
+
const a = document.createElement('a');
|
| 233 |
+
a.href = url;
|
| 234 |
+
a.download = `output.${format}`;
|
| 235 |
+
document.body.appendChild(a);
|
| 236 |
+
a.click();
|
| 237 |
+
window.URL.revokeObjectURL(url);
|
| 238 |
+
document.body.removeChild(a);
|
| 239 |
+
|
| 240 |
+
return blob;
|
| 241 |
+
} else {
|
| 242 |
+
const error = await response.json();
|
| 243 |
+
console.error('Error:', error);
|
| 244 |
+
throw error;
|
| 245 |
+
}
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
// Example usage
|
| 249 |
+
const text = 'Hello, this is a test.';
|
| 250 |
+
const voice = 'alloy';
|
| 251 |
+
const format = 'mp3'; // Supported formats: mp3, opus, aac, flac, wav, pcm
|
| 252 |
+
const instructions = 'Speak in a cheerful and upbeat tone.';
|
| 253 |
+
|
| 254 |
+
// Generate speech with default format (MP3)
|
| 255 |
+
generateSpeech(text, voice, undefined, instructions);
|
| 256 |
+
|
| 257 |
+
// Generate speech in WAV format
|
| 258 |
+
generateSpeech(text, voice, 'wav', instructions);</code></pre>
|
| 259 |
+
</div>
|
| 260 |
+
</section>
|
| 261 |
+
|
| 262 |
+
<!-- Available Voices -->
|
| 263 |
+
<section class="content-section">
|
| 264 |
+
<h2>Available Voices</h2>
|
| 265 |
+
<div class="voice-list">
|
| 266 |
+
<span class="voice-name">alloy</span>
|
| 267 |
+
<span class="voice-name">ash</span>
|
| 268 |
+
<span class="voice-name">ballad</span>
|
| 269 |
+
<span class="voice-name">coral</span>
|
| 270 |
+
<span class="voice-name">echo</span>
|
| 271 |
+
<span class="voice-name">fable</span>
|
| 272 |
+
<span class="voice-name">onyx</span>
|
| 273 |
+
<span class="voice-name">nova</span>
|
| 274 |
+
<span class="voice-name">sage</span>
|
| 275 |
+
<span class="voice-name">shimmer</span>
|
| 276 |
+
<span class="voice-name">verse</span>
|
| 277 |
+
</div>
|
| 278 |
+
</section>
|
| 279 |
+
|
| 280 |
+
<!-- API Reference -->
|
| 281 |
+
<section class="content-section">
|
| 282 |
+
<h2>API Reference</h2>
|
| 283 |
+
<div class="api-endpoint">
|
| 284 |
+
<h3>Generate Speech (OpenAI Compatible)</h3>
|
| 285 |
+
<pre><code class="language-http">POST /v1/audio/speech</code></pre>
|
| 286 |
+
|
| 287 |
+
<div class="request-body">
|
| 288 |
+
<h4>Request Parameters</h4>
|
| 289 |
+
<table class="params-table">
|
| 290 |
+
<tr>
|
| 291 |
+
<th>Parameter</th>
|
| 292 |
+
<th>Type</th>
|
| 293 |
+
<th>Required</th>
|
| 294 |
+
<th>Description</th>
|
| 295 |
+
</tr>
|
| 296 |
+
<tr>
|
| 297 |
+
<td>input</td>
|
| 298 |
+
<td>string</td>
|
| 299 |
+
<td>Yes</td>
|
| 300 |
+
<td>The text to convert to speech</td>
|
| 301 |
+
</tr>
|
| 302 |
+
<tr>
|
| 303 |
+
<td>voice</td>
|
| 304 |
+
<td>string</td>
|
| 305 |
+
<td>Yes</td>
|
| 306 |
+
<td>The voice to use (see Available Voices)</td>
|
| 307 |
+
</tr>
|
| 308 |
+
<tr>
|
| 309 |
+
<td class="partial-param">instructions</td>
|
| 310 |
+
<td>string</td>
|
| 311 |
+
<td>No</td>
|
| 312 |
+
<td><em>Mapped to "prompt" parameter when sent to the backend service. Can be used to guide voice emotion or style.</em></td>
|
| 313 |
+
</tr>
|
| 314 |
+
<tr>
|
| 315 |
+
<td class="partial-param">response_format</td>
|
| 316 |
+
<td>string</td>
|
| 317 |
+
<td>No</td>
|
| 318 |
+
<td>The format of the audio response. Supported formats: mp3, opus, aac, flac, wav, pcm. Defaults to mp3.</td>
|
| 319 |
+
</tr>
|
| 320 |
+
<tr>
|
| 321 |
+
<td class="compat-param">model</td>
|
| 322 |
+
<td>string</td>
|
| 323 |
+
<td>No</td>
|
| 324 |
+
<td><em>OpenAI compatibility only - completely ignored.</em></td>
|
| 325 |
+
</tr>
|
| 326 |
+
<tr>
|
| 327 |
+
<td class="compat-param">speed</td>
|
| 328 |
+
<td>number</td>
|
| 329 |
+
<td>No</td>
|
| 330 |
+
<td><em>OpenAI compatibility only - completely ignored.</em></td>
|
| 331 |
+
</tr>
|
| 332 |
+
</table>
|
| 333 |
+
|
| 334 |
+
<div class="compatibility-notice">
|
| 335 |
+
<p><strong>Note:</strong> Parameters in <span class="compat-inline">gray</span> are completely ignored by the service or may cause misleading behavior. Only <code>input</code>, <code>voice</code>, <code>response_format</code> and <code>instructions</code> affect the actual TTS output.</p>
|
| 336 |
+
</div>
|
| 337 |
+
|
| 338 |
+
<!-- Instructions Parameter Details -->
|
| 339 |
+
<div class="parameter-details">
|
| 340 |
+
<h4>How the Instructions Parameter Works</h4>
|
| 341 |
+
<p>The <code>instructions</code> parameter is mapped to a <code>prompt</code> parameter when sent to the backend service. It can be used to guide the voice emotion, tone, or style. Some examples of effective instructions:</p>
|
| 342 |
+
|
| 343 |
+
<ul class="examples-list">
|
| 344 |
+
<li><strong>Emotional guidance:</strong> "Speak in a happy and excited tone."</li>
|
| 345 |
+
<li><strong>Character impersonation:</strong> "Speak like a wise old wizard."</li>
|
| 346 |
+
<li><strong>Contextual hints:</strong> "This is being read to a child, speak gently."</li>
|
| 347 |
+
<li><strong>Reading style:</strong> "Read this as a news broadcast."</li>
|
| 348 |
+
</ul>
|
| 349 |
+
|
| 350 |
+
<div class="tip-box">
|
| 351 |
+
<p><strong>Tip:</strong> Keep instructions clear and concise. Overly complex instructions may not be interpreted correctly.</p>
|
| 352 |
+
</div>
|
| 353 |
+
</div>
|
| 354 |
+
|
| 355 |
+
<h4>Response Format</h4>
|
| 356 |
+
<p>The API returns audio in the requested format with the following headers:</p>
|
| 357 |
+
<ul>
|
| 358 |
+
<li><code>Content-Type</code>: Based on the requested format (e.g., "audio/mpeg" for MP3)</li>
|
| 359 |
+
<li><code>Access-Control-Allow-Origin</code>: "*" (CORS enabled)</li>
|
| 360 |
+
</ul>
|
| 361 |
+
|
| 362 |
+
<h4>Error Responses</h4>
|
| 363 |
+
<table class="error-table">
|
| 364 |
+
<tr>
|
| 365 |
+
<th>Status Code</th>
|
| 366 |
+
<th>Description</th>
|
| 367 |
+
</tr>
|
| 368 |
+
<tr>
|
| 369 |
+
<td>400</td>
|
| 370 |
+
<td>Missing required parameters (input or voice)</td>
|
| 371 |
+
</tr>
|
| 372 |
+
<tr>
|
| 373 |
+
<td>429</td>
|
| 374 |
+
<td>Rate limit exceeded or queue is full. Includes Retry-After header when rate limited.</td>
|
| 375 |
+
</tr>
|
| 376 |
+
<tr>
|
| 377 |
+
<td>500</td>
|
| 378 |
+
<td>Internal server error</td>
|
| 379 |
+
</tr>
|
| 380 |
+
</table>
|
| 381 |
+
</div>
|
| 382 |
+
</div>
|
| 383 |
+
|
| 384 |
+
<!-- Queue System -->
|
| 385 |
+
<div class="api-endpoint">
|
| 386 |
+
<h3>Queue System</h3>
|
| 387 |
+
<p>The API uses a queue system to handle multiple requests efficiently:</p>
|
| 388 |
+
<ul>
|
| 389 |
+
<li>Maximum queue size: Configurable via <code>MAX_QUEUE_SIZE</code> environment variable (default: 100 requests)</li>
|
| 390 |
+
<li>Requests are processed in FIFO (First In, First Out) order</li>
|
| 391 |
+
<li>Rate limiting: Configurable via <code>RATE_LIMIT_REQUESTS</code> and <code>RATE_LIMIT_WINDOW</code> environment variables (default: 30 requests per 60 seconds per IP address)</li>
|
| 392 |
+
<li>Queue status can be monitored via the <code>/api/queue-size</code> endpoint</li>
|
| 393 |
+
<li>Queue status updates every 2 seconds in the web interface</li>
|
| 394 |
+
<li>Visual indicators show queue load (Low/Medium/High) based on utilization</li>
|
| 395 |
+
</ul>
|
| 396 |
+
|
| 397 |
+
<h4>Queue Status Endpoint</h4>
|
| 398 |
+
<pre><code class="language-http">GET /api/queue-size</code></pre>
|
| 399 |
+
<p>Returns JSON with queue information:</p>
|
| 400 |
+
<pre><code class="language-json">{
|
| 401 |
+
"queue_size": 0, // Current number of requests in queue
|
| 402 |
+
"max_queue_size": 100 // Maximum queue capacity
|
| 403 |
+
}</code></pre>
|
| 404 |
+
|
| 405 |
+
<div class="response-codes">
|
| 406 |
+
<h4>Response Status Codes</h4>
|
| 407 |
+
<ul>
|
| 408 |
+
<li><code>200</code> - Success</li>
|
| 409 |
+
<li><code>429</code> - Queue is full or rate limit exceeded</li>
|
| 410 |
+
<li><code>500</code> - Server error</li>
|
| 411 |
+
</ul>
|
| 412 |
+
</div>
|
| 413 |
+
</div>
|
| 414 |
+
</section>
|
| 415 |
+
</main>
|
| 416 |
+
</div>
|
| 417 |
+
</div>
|
| 418 |
+
|
| 419 |
+
<script src="script.js"></script>
|
| 420 |
+
</body>
|
| 421 |
+
</html>
|
static/index_zh.html
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>ttsfm</title>
|
| 7 |
+
<link rel="stylesheet" href="styles.css">
|
| 8 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">
|
| 9 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
| 10 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
|
| 11 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
|
| 12 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
|
| 13 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
|
| 14 |
+
</head>
|
| 15 |
+
<body>
|
| 16 |
+
<div class="app-container">
|
| 17 |
+
<div class="content-wrapper">
|
| 18 |
+
<!-- Header Section -->
|
| 19 |
+
<header class="main-header">
|
| 20 |
+
<div class="header-top">
|
| 21 |
+
<h1>ttsfm</h1>
|
| 22 |
+
<a href="https://github.com/dbccccccc/ttsfm" target="_blank" class="github-link">
|
| 23 |
+
<i class="fab fa-github"></i>
|
| 24 |
+
<span>GitHub</span>
|
| 25 |
+
</a>
|
| 26 |
+
</div>
|
| 27 |
+
<p class="subtitle">支持多种语音的文本转语音 API</p>
|
| 28 |
+
<div class="header-bottom">
|
| 29 |
+
<div class="version-badge">
|
| 30 |
+
<span>版本: <strong id="version">1.3.0</strong></span>
|
| 31 |
+
</div>
|
| 32 |
+
<div class="language-selector">
|
| 33 |
+
<a href="index.html" class="lang-btn">English</a>
|
| 34 |
+
<button class="lang-btn active" data-lang="zh">中文</button>
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
</header>
|
| 38 |
+
|
| 39 |
+
<!-- Disclaimer Section -->
|
| 40 |
+
<section class="content-section disclaimer-notice">
|
| 41 |
+
<div class="disclaimer-container">
|
| 42 |
+
<div class="disclaimer-icon">
|
| 43 |
+
<i class="fas fa-info-circle"></i>
|
| 44 |
+
</div>
|
| 45 |
+
<div class="disclaimer-content">
|
| 46 |
+
<h2>免责声明</h2>
|
| 47 |
+
<p>此项目仅用于学习测试,请使用<a href="https://platform.openai.com/docs/guides/audio" target="_blank">https://platform.openai.com/docs/guides/audio</a> OpenAI的官方服务进行生产环境使用。</p>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
</section>
|
| 51 |
+
|
| 52 |
+
<!-- Status Section -->
|
| 53 |
+
<section class="content-section status-section">
|
| 54 |
+
<h2>服务状态</h2>
|
| 55 |
+
<div class="status-container">
|
| 56 |
+
<div class="status-card">
|
| 57 |
+
<div class="status-header">
|
| 58 |
+
<h3>队列状态</h3>
|
| 59 |
+
<div class="status-indicator" id="status-indicator"></div>
|
| 60 |
+
</div>
|
| 61 |
+
<div class="queue-stats">
|
| 62 |
+
<div class="stat-item">
|
| 63 |
+
<span class="stat-label">活动请求:</span>
|
| 64 |
+
<span class="stat-value" id="queue-size">0</span>
|
| 65 |
+
</div>
|
| 66 |
+
<div class="stat-item">
|
| 67 |
+
<span class="stat-label">最大容量:</span>
|
| 68 |
+
<span class="stat-value" id="max-queue-size">-</span>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
<div class="queue-progress-container">
|
| 72 |
+
<div class="queue-progress-bar" id="queue-progress-bar"></div>
|
| 73 |
+
</div>
|
| 74 |
+
<div class="queue-load-text" id="queue-load-text">无负载</div>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
</section>
|
| 78 |
+
|
| 79 |
+
<!-- Playground Section -->
|
| 80 |
+
<section class="content-section playground-section">
|
| 81 |
+
<h2>立即体验</h2>
|
| 82 |
+
<div class="playground-container">
|
| 83 |
+
<div class="playground-form">
|
| 84 |
+
<div class="form-group">
|
| 85 |
+
<label for="playground-text">要转换的文本</label>
|
| 86 |
+
<textarea id="playground-text" rows="4" placeholder="输入要转换为语音的文本..."></textarea>
|
| 87 |
+
</div>
|
| 88 |
+
<div class="form-group">
|
| 89 |
+
<label for="playground-instructions">指令</label>
|
| 90 |
+
<textarea id="playground-instructions" rows="2" placeholder="例如:用欢快和兴奋的语气说话"></textarea>
|
| 91 |
+
</div>
|
| 92 |
+
<div class="form-group">
|
| 93 |
+
<label for="playground-voice">语音</label>
|
| 94 |
+
<select id="playground-voice">
|
| 95 |
+
<option value="alloy">Alloy</option>
|
| 96 |
+
<option value="ash">Ash</option>
|
| 97 |
+
<option value="ballad">Ballad</option>
|
| 98 |
+
<option value="coral">Coral</option>
|
| 99 |
+
<option value="echo">Echo</option>
|
| 100 |
+
<option value="fable">Fable</option>
|
| 101 |
+
<option value="onyx">Onyx</option>
|
| 102 |
+
<option value="nova">Nova</option>
|
| 103 |
+
<option value="sage">Sage</option>
|
| 104 |
+
<option value="shimmer">Shimmer</option>
|
| 105 |
+
<option value="verse">Verse</option>
|
| 106 |
+
</select>
|
| 107 |
+
</div>
|
| 108 |
+
<div class="form-group">
|
| 109 |
+
<label for="playground-format">响应格式</label>
|
| 110 |
+
<select id="playground-format">
|
| 111 |
+
<option value="mp3">MP3</option>
|
| 112 |
+
<option value="opus">Opus</option>
|
| 113 |
+
<option value="aac">AAC</option>
|
| 114 |
+
<option value="flac">FLAC</option>
|
| 115 |
+
<option value="wav">WAV</option>
|
| 116 |
+
<option value="pcm">PCM</option>
|
| 117 |
+
</select>
|
| 118 |
+
</div>
|
| 119 |
+
<button id="playground-submit" class="playground-button">
|
| 120 |
+
<i class="fas fa-play"></i> 生成语音
|
| 121 |
+
</button>
|
| 122 |
+
</div>
|
| 123 |
+
<div class="playground-output">
|
| 124 |
+
<div class="audio-section">
|
| 125 |
+
<h3>语音预览</h3>
|
| 126 |
+
<div id="preview-audio" class="audio-player"></div>
|
| 127 |
+
</div>
|
| 128 |
+
<div class="audio-section">
|
| 129 |
+
<h3>生成结果</h3>
|
| 130 |
+
<div id="playground-status" class="playground-status"></div>
|
| 131 |
+
<div id="playground-audio" class="audio-player"></div>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
</section>
|
| 136 |
+
|
| 137 |
+
<!-- Main Content -->
|
| 138 |
+
<main class="main-content">
|
| 139 |
+
<!-- Quick Start -->
|
| 140 |
+
<section class="content-section">
|
| 141 |
+
<h2>快速开始</h2>
|
| 142 |
+
<p>选择您喜欢的编程语言开始使用 API:</p>
|
| 143 |
+
|
| 144 |
+
<!-- Python Example -->
|
| 145 |
+
<div class="code-block">
|
| 146 |
+
<div class="code-header">
|
| 147 |
+
<span class="code-language">Python</span>
|
| 148 |
+
<button class="copy-button" onclick="copyCode(this)">
|
| 149 |
+
<i class="fas fa-copy"></i>
|
| 150 |
+
</button>
|
| 151 |
+
</div>
|
| 152 |
+
<pre><code class="language-python">
|
| 153 |
+
import requests
|
| 154 |
+
import os
|
| 155 |
+
|
| 156 |
+
def generate_speech(text, voice="alloy", format="mp3", instructions=None):
|
| 157 |
+
url = "https://ttsapi.site/v1/audio/speech"
|
| 158 |
+
headers = {
|
| 159 |
+
"Content-Type": "application/json"
|
| 160 |
+
}
|
| 161 |
+
data = {
|
| 162 |
+
"input": text,
|
| 163 |
+
"voice": voice,
|
| 164 |
+
"response_format": format
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
# 如果提供了指令,则添加到请求中
|
| 168 |
+
if instructions:
|
| 169 |
+
data["instructions"] = instructions
|
| 170 |
+
|
| 171 |
+
response = requests.post(url, json=data, headers=headers)
|
| 172 |
+
|
| 173 |
+
if response.status_code == 200:
|
| 174 |
+
# 根据格式获取适当的文件扩展名
|
| 175 |
+
ext = format.lower()
|
| 176 |
+
filename = f"output.{ext}"
|
| 177 |
+
|
| 178 |
+
# 保存音频文件
|
| 179 |
+
with open(filename, "wb") as f:
|
| 180 |
+
f.write(response.content)
|
| 181 |
+
print(f"音频已保存为 {filename}")
|
| 182 |
+
return filename
|
| 183 |
+
else:
|
| 184 |
+
error = response.json()
|
| 185 |
+
print(f"错误: {response.status_code}, {error}")
|
| 186 |
+
return None
|
| 187 |
+
|
| 188 |
+
# 使用示例
|
| 189 |
+
text = "你好,这是一个测试。"
|
| 190 |
+
voice = "alloy"
|
| 191 |
+
format = "mp3" # 支持的格式:mp3、opus、aac、flac、wav、pcm
|
| 192 |
+
instructions = "请用欢快和兴奋的语气说话"
|
| 193 |
+
|
| 194 |
+
# 使用默认格式(MP3)生成语音
|
| 195 |
+
generate_speech(text, voice, instructions=instructions)
|
| 196 |
+
|
| 197 |
+
# 使用 WAV 格式生成语音
|
| 198 |
+
generate_speech(text, voice, format="wav", instructions=instructions)</code></pre>
|
| 199 |
+
</div>
|
| 200 |
+
|
| 201 |
+
<!-- JavaScript Example -->
|
| 202 |
+
<div class="code-block">
|
| 203 |
+
<div class="code-header">
|
| 204 |
+
<span class="code-language">JavaScript</span>
|
| 205 |
+
<button class="copy-button" onclick="copyCode(this)">
|
| 206 |
+
<i class="fas fa-copy"></i>
|
| 207 |
+
</button>
|
| 208 |
+
</div>
|
| 209 |
+
<pre><code class="language-javascript">async function generateSpeech(text, voice = 'alloy', format = 'mp3', instructions = null) {
|
| 210 |
+
const response = await fetch('https://ttsapi.site/v1/audio/speech', {
|
| 211 |
+
method: 'POST',
|
| 212 |
+
headers: {
|
| 213 |
+
'Content-Type': 'application/json'
|
| 214 |
+
},
|
| 215 |
+
body: JSON.stringify({
|
| 216 |
+
input: text,
|
| 217 |
+
voice: voice,
|
| 218 |
+
response_format: format,
|
| 219 |
+
...(instructions && { instructions })
|
| 220 |
+
})
|
| 221 |
+
});
|
| 222 |
+
|
| 223 |
+
if (response.ok) {
|
| 224 |
+
const blob = await response.blob();
|
| 225 |
+
|
| 226 |
+
// 创建音频元素用于播放
|
| 227 |
+
const audio = new Audio(URL.createObjectURL(blob));
|
| 228 |
+
audio.play();
|
| 229 |
+
|
| 230 |
+
// 可选:下载文件
|
| 231 |
+
const url = window.URL.createObjectURL(blob);
|
| 232 |
+
const a = document.createElement('a');
|
| 233 |
+
a.href = url;
|
| 234 |
+
a.download = `output.${format}`;
|
| 235 |
+
document.body.appendChild(a);
|
| 236 |
+
a.click();
|
| 237 |
+
window.URL.revokeObjectURL(url);
|
| 238 |
+
document.body.removeChild(a);
|
| 239 |
+
|
| 240 |
+
return blob;
|
| 241 |
+
} else {
|
| 242 |
+
const error = await response.json();
|
| 243 |
+
console.error('错误:', error);
|
| 244 |
+
throw error;
|
| 245 |
+
}
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
// 使用示例
|
| 249 |
+
const text = '你好,这是一个测试。';
|
| 250 |
+
const voice = 'alloy';
|
| 251 |
+
const format = 'mp3'; // 支持的格式:mp3、opus、aac、flac、wav、pcm
|
| 252 |
+
const instructions = '请用欢快和兴奋的语气说话';
|
| 253 |
+
|
| 254 |
+
// 使用默认格式(MP3)生成语音
|
| 255 |
+
generateSpeech(text, voice, undefined, instructions);
|
| 256 |
+
|
| 257 |
+
// 使用 WAV 格式生成语音
|
| 258 |
+
generateSpeech(text, voice, 'wav', instructions);</code></pre>
|
| 259 |
+
</div>
|
| 260 |
+
</section>
|
| 261 |
+
|
| 262 |
+
<!-- Available Voices -->
|
| 263 |
+
<section class="content-section">
|
| 264 |
+
<h2>可用语音</h2>
|
| 265 |
+
<div class="voice-list">
|
| 266 |
+
<span class="voice-name">alloy</span>
|
| 267 |
+
<span class="voice-name">ash</span>
|
| 268 |
+
<span class="voice-name">ballad</span>
|
| 269 |
+
<span class="voice-name">coral</span>
|
| 270 |
+
<span class="voice-name">echo</span>
|
| 271 |
+
<span class="voice-name">fable</span>
|
| 272 |
+
<span class="voice-name">onyx</span>
|
| 273 |
+
<span class="voice-name">nova</span>
|
| 274 |
+
<span class="voice-name">sage</span>
|
| 275 |
+
<span class="voice-name">shimmer</span>
|
| 276 |
+
<span class="voice-name">verse</span>
|
| 277 |
+
</div>
|
| 278 |
+
</section>
|
| 279 |
+
|
| 280 |
+
<!-- API Reference -->
|
| 281 |
+
<section class="content-section">
|
| 282 |
+
<h2>API 参考</h2>
|
| 283 |
+
<div class="api-endpoint">
|
| 284 |
+
<h3>生成语音(OpenAI 兼容)</h3>
|
| 285 |
+
<pre><code class="language-http">POST /v1/audio/speech</code></pre>
|
| 286 |
+
|
| 287 |
+
<div class="request-body">
|
| 288 |
+
<h4>请求参数</h4>
|
| 289 |
+
<table class="params-table">
|
| 290 |
+
<tr>
|
| 291 |
+
<th>参数</th>
|
| 292 |
+
<th>类型</th>
|
| 293 |
+
<th>必需</th>
|
| 294 |
+
<th>描述</th>
|
| 295 |
+
</tr>
|
| 296 |
+
<tr>
|
| 297 |
+
<td>input</td>
|
| 298 |
+
<td>string</td>
|
| 299 |
+
<td>是</td>
|
| 300 |
+
<td>要转换为语音的文本</td>
|
| 301 |
+
</tr>
|
| 302 |
+
<tr>
|
| 303 |
+
<td>voice</td>
|
| 304 |
+
<td>string</td>
|
| 305 |
+
<td>是</td>
|
| 306 |
+
<td>要使用的语音(见可用语音)</td>
|
| 307 |
+
</tr>
|
| 308 |
+
<tr>
|
| 309 |
+
<td class="partial-param">instructions</td>
|
| 310 |
+
<td>string</td>
|
| 311 |
+
<td>否</td>
|
| 312 |
+
<td><em>发送到后端服务时映射为 "prompt" 参数。可用于指导语音情感或风格。</em></td>
|
| 313 |
+
</tr>
|
| 314 |
+
<tr>
|
| 315 |
+
<td class="partial-param">response_format</td>
|
| 316 |
+
<td>string</td>
|
| 317 |
+
<td>否</td>
|
| 318 |
+
<td>音频响应的格式。支持的格式:mp3、opus、aac、flac、wav、pcm。默认为 mp3。</td>
|
| 319 |
+
</tr>
|
| 320 |
+
<tr>
|
| 321 |
+
<td class="compat-param">model</td>
|
| 322 |
+
<td>string</td>
|
| 323 |
+
<td>否</td>
|
| 324 |
+
<td><em>仅用于 OpenAI 兼容性 - 完全忽略。</em></td>
|
| 325 |
+
</tr>
|
| 326 |
+
<tr>
|
| 327 |
+
<td class="compat-param">speed</td>
|
| 328 |
+
<td>number</td>
|
| 329 |
+
<td>否</td>
|
| 330 |
+
<td><em>仅用于 OpenAI 兼容性 - 完全忽略。</em></td>
|
| 331 |
+
</tr>
|
| 332 |
+
</table>
|
| 333 |
+
|
| 334 |
+
<div class="compatibility-notice">
|
| 335 |
+
<p><strong>注意:</strong> <span class="compat-inline">灰色</span> 的参数完全被服务忽略或可能导致误导行为。只有 <code>input</code>、<code>voice</code> <code>response_format</code> 和 <code>instructions</code> 会影响实际的 TTS 输出。</p>
|
| 336 |
+
</div>
|
| 337 |
+
|
| 338 |
+
<!-- Instructions Parameter Details -->
|
| 339 |
+
<div class="parameter-details">
|
| 340 |
+
<h4>指令参数的工作原理</h4>
|
| 341 |
+
<p><code>instructions</code> 参数在发送到后端服务时映射为 <code>prompt</code> 参数。它可用于指导语音情感、语气或风格。以下是一些有效的指令示例:</p>
|
| 342 |
+
|
| 343 |
+
<ul class="examples-list">
|
| 344 |
+
<li><strong>情感指导:</strong> "请用欢快和兴奋的语气说话"</li>
|
| 345 |
+
<li><strong>角色模仿:</strong> "请模仿一位睿智的老巫师说话"</li>
|
| 346 |
+
<li><strong>上下文提示:</strong> "这是读给孩子的,请用温柔的语气"</li>
|
| 347 |
+
<li><strong>朗读风格:</strong> "请以新闻广播的风格朗读"</li>
|
| 348 |
+
</ul>
|
| 349 |
+
|
| 350 |
+
<div class="tip-box">
|
| 351 |
+
<p><strong>提示:</strong> 请保持指令清晰简洁。过于复杂的指令可能无法被正确解释。</p>
|
| 352 |
+
</div>
|
| 353 |
+
</div>
|
| 354 |
+
|
| 355 |
+
<h4>响应格式</h4>
|
| 356 |
+
<p>API 以请求的格式返回音频,具有以下标头:</p>
|
| 357 |
+
<ul>
|
| 358 |
+
<li><code>Content-Type</code>:根据请求的格式(例如,MP3 为 "audio/mpeg")</li>
|
| 359 |
+
<li><code>Access-Control-Allow-Origin</code>:*(启用 CORS)</li>
|
| 360 |
+
</ul>
|
| 361 |
+
|
| 362 |
+
<h4>错误响应</h4>
|
| 363 |
+
<table class="error-table">
|
| 364 |
+
<tr>
|
| 365 |
+
<th>状态码</th>
|
| 366 |
+
<th>描述</th>
|
| 367 |
+
</tr>
|
| 368 |
+
<tr>
|
| 369 |
+
<td>400</td>
|
| 370 |
+
<td>缺少必需参数(input 或 voice)</td>
|
| 371 |
+
</tr>
|
| 372 |
+
<tr>
|
| 373 |
+
<td>429</td>
|
| 374 |
+
<td>速率限制超出或队列已满。速率限制时包含 Retry-After 标头。</td>
|
| 375 |
+
</tr>
|
| 376 |
+
<tr>
|
| 377 |
+
<td>500</td>
|
| 378 |
+
<td>内部服务器错误</td>
|
| 379 |
+
</tr>
|
| 380 |
+
</table>
|
| 381 |
+
</div>
|
| 382 |
+
</div>
|
| 383 |
+
|
| 384 |
+
<!-- Queue System -->
|
| 385 |
+
<div class="api-endpoint">
|
| 386 |
+
<h3>队列系统</h3>
|
| 387 |
+
<p>API 使用队列系统高效处理多个请求:</p>
|
| 388 |
+
<ul>
|
| 389 |
+
<li>最大队列大小:可通过 <code>MAX_QUEUE_SIZE</code> 环境变量配置(默认:100 个请求)</li>
|
| 390 |
+
<li>请求按 FIFO(先进先出)顺序处理</li>
|
| 391 |
+
<li>速率限制:可通过 <code>RATE_LIMIT_REQUESTS</code> 和 <code>RATE_LIMIT_WINDOW</code> 环境变量配置(默认:每个 IP 地址 60 秒内最多 30 个请求)</li>
|
| 392 |
+
<li>可以通过 <code>/api/queue-size</code> 端点监控队列状态</li>
|
| 393 |
+
<li>Web 界面每 2 秒更新一次队列状态</li>
|
| 394 |
+
<li>根据使用率显示队列负载状态(低/���/高)</li>
|
| 395 |
+
</ul>
|
| 396 |
+
|
| 397 |
+
<h4>队列状态端点</h4>
|
| 398 |
+
<pre><code class="language-http">GET /api/queue-size</code></pre>
|
| 399 |
+
<p>返回包含队列信息的 JSON:</p>
|
| 400 |
+
<pre><code class="language-json">{
|
| 401 |
+
"queue_size": 0, // 当前队列中的请求数
|
| 402 |
+
"max_queue_size": 100 // 队列最大容量
|
| 403 |
+
}</code></pre>
|
| 404 |
+
|
| 405 |
+
<div class="response-codes">
|
| 406 |
+
<h4>响应状态码</h4>
|
| 407 |
+
<ul>
|
| 408 |
+
<li><code>200</code> - 成功</li>
|
| 409 |
+
<li><code>429</code> - 队列已满或超出速率限制</li>
|
| 410 |
+
<li><code>500</code> - 服务器错误</li>
|
| 411 |
+
</ul>
|
| 412 |
+
</div>
|
| 413 |
+
</div>
|
| 414 |
+
</section>
|
| 415 |
+
</main>
|
| 416 |
+
</div>
|
| 417 |
+
</div>
|
| 418 |
+
|
| 419 |
+
<script src="script.js"></script>
|
| 420 |
+
</body>
|
| 421 |
+
</html>
|
static/script.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Use the current host for API requests
|
| 2 |
+
const OPENAI_API_URL = `${window.location.protocol}//${window.location.host}/v1/audio/speech`;
|
| 3 |
+
const processingStatus = document.getElementById('processing-status');
|
| 4 |
+
const activeRequests = document.getElementById('queue-size');
|
| 5 |
+
const lastUpdate = document.getElementById('last-update');
|
| 6 |
+
const maxQueueSize = document.getElementById('max-queue-size');
|
| 7 |
+
const queueProgressBar = document.getElementById('queue-progress-bar');
|
| 8 |
+
const statusIndicator = document.getElementById('status-indicator');
|
| 9 |
+
const queueLoadText = document.getElementById('queue-load-text');
|
| 10 |
+
|
| 11 |
+
// Track active requests
|
| 12 |
+
let currentActiveRequests = 0;
|
| 13 |
+
|
| 14 |
+
// Initialize current language
|
| 15 |
+
let currentLang = 'en';
|
| 16 |
+
|
| 17 |
+
// Language translations
|
| 18 |
+
const translations = {
|
| 19 |
+
en: {
|
| 20 |
+
title: "OpenAI TTS API Documentation",
|
| 21 |
+
subtitle: "Text-to-Speech API with Multiple Voice Options",
|
| 22 |
+
tryItOut: "Try It Out",
|
| 23 |
+
textToConvert: "Text to Convert",
|
| 24 |
+
voice: "Voice",
|
| 25 |
+
instructions: "Instructions (Optional)",
|
| 26 |
+
generateSpeech: "Generate Speech",
|
| 27 |
+
quickStart: "Quick Start",
|
| 28 |
+
availableVoices: "Available Voices",
|
| 29 |
+
apiReference: "API Reference",
|
| 30 |
+
queueStatus: "Queue Status",
|
| 31 |
+
activeRequests: "Active Requests",
|
| 32 |
+
maxCapacity: "Maximum Capacity",
|
| 33 |
+
noLoad: "No Load",
|
| 34 |
+
lowLoad: "Low Load",
|
| 35 |
+
mediumLoad: "Medium Load",
|
| 36 |
+
highLoad: "High Load"
|
| 37 |
+
},
|
| 38 |
+
zh: {
|
| 39 |
+
title: "OpenAI TTS API 文档",
|
| 40 |
+
subtitle: "支持多种语音的文本转语音 API",
|
| 41 |
+
tryItOut: "立即体验",
|
| 42 |
+
textToConvert: "要转换的文本",
|
| 43 |
+
voice: "语音",
|
| 44 |
+
instructions: "指令(可选)",
|
| 45 |
+
generateSpeech: "生成语音",
|
| 46 |
+
quickStart: "快速开始",
|
| 47 |
+
availableVoices: "可用语音",
|
| 48 |
+
apiReference: "API 参考",
|
| 49 |
+
queueStatus: "队列状态",
|
| 50 |
+
activeRequests: "活动请求",
|
| 51 |
+
maxCapacity: "最大容量",
|
| 52 |
+
noLoad: "无负载",
|
| 53 |
+
lowLoad: "低负载",
|
| 54 |
+
mediumLoad: "中负载",
|
| 55 |
+
highLoad: "高负载"
|
| 56 |
+
}
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
// Language switching functionality
|
| 60 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 61 |
+
const langButtons = document.querySelectorAll('.lang-btn');
|
| 62 |
+
|
| 63 |
+
// Set initial language based on current page
|
| 64 |
+
const isChinesePage = window.location.pathname.includes('_zh.html');
|
| 65 |
+
currentLang = isChinesePage ? 'zh' : 'en';
|
| 66 |
+
|
| 67 |
+
// Update active state of language buttons
|
| 68 |
+
langButtons.forEach(btn => {
|
| 69 |
+
if (btn.getAttribute('data-lang') === currentLang) {
|
| 70 |
+
btn.classList.add('active');
|
| 71 |
+
} else {
|
| 72 |
+
btn.classList.remove('active');
|
| 73 |
+
}
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
// Initial queue size update
|
| 77 |
+
updateQueueSize();
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
+
function updateProcessingStatus(requestCount) {
|
| 81 |
+
if (requestCount > 0) {
|
| 82 |
+
processingStatus.textContent = 'Processing';
|
| 83 |
+
processingStatus.className = 'processing';
|
| 84 |
+
} else {
|
| 85 |
+
processingStatus.textContent = 'Idle';
|
| 86 |
+
processingStatus.className = 'idle';
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function updateLastUpdate() {
|
| 91 |
+
const now = new Date();
|
| 92 |
+
if (lastUpdate) {
|
| 93 |
+
lastUpdate.textContent = now.toLocaleTimeString();
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
// Function to update queue size with visual indicators
|
| 98 |
+
async function updateQueueSize() {
|
| 99 |
+
try {
|
| 100 |
+
const response = await fetch('/api/queue-size');
|
| 101 |
+
if (!response.ok) {
|
| 102 |
+
throw new Error(`HTTP error! status: ${response.status}`);
|
| 103 |
+
}
|
| 104 |
+
const data = await response.json();
|
| 105 |
+
|
| 106 |
+
// Update text values
|
| 107 |
+
document.getElementById('queue-size').textContent = data.queue_size;
|
| 108 |
+
document.getElementById('max-queue-size').textContent = data.max_queue_size;
|
| 109 |
+
|
| 110 |
+
// Calculate load percentage
|
| 111 |
+
const loadPercentage = (data.queue_size / data.max_queue_size) * 100;
|
| 112 |
+
|
| 113 |
+
// Update progress bar width
|
| 114 |
+
queueProgressBar.style.width = `${Math.min(loadPercentage, 100)}%`;
|
| 115 |
+
|
| 116 |
+
// Update status indicators based on load
|
| 117 |
+
updateLoadStatus(loadPercentage);
|
| 118 |
+
|
| 119 |
+
} catch (error) {
|
| 120 |
+
console.error('Error fetching queue size:', error);
|
| 121 |
+
// Show error state in UI
|
| 122 |
+
document.getElementById('queue-size').textContent = '?';
|
| 123 |
+
document.getElementById('max-queue-size').textContent = '?';
|
| 124 |
+
queueProgressBar.style.width = '0%';
|
| 125 |
+
statusIndicator.classList.remove('indicator-low', 'indicator-medium', 'indicator-high');
|
| 126 |
+
statusIndicator.classList.add('indicator-error');
|
| 127 |
+
queueProgressBar.classList.remove('progress-low', 'progress-medium', 'progress-high');
|
| 128 |
+
queueLoadText.classList.remove('low-load', 'medium-load', 'high-load');
|
| 129 |
+
queueLoadText.textContent = 'Error';
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
// Function to update load status indicators
|
| 134 |
+
function updateLoadStatus(loadPercentage) {
|
| 135 |
+
// Remove all existing classes
|
| 136 |
+
statusIndicator.classList.remove('indicator-low', 'indicator-medium', 'indicator-high');
|
| 137 |
+
queueProgressBar.classList.remove('progress-low', 'progress-medium', 'progress-high');
|
| 138 |
+
queueLoadText.classList.remove('low-load', 'medium-load', 'high-load');
|
| 139 |
+
|
| 140 |
+
// Apply appropriate classes based on load percentage
|
| 141 |
+
if (loadPercentage >= 75) {
|
| 142 |
+
// High load (75-100%)
|
| 143 |
+
statusIndicator.classList.add('indicator-high');
|
| 144 |
+
queueProgressBar.classList.add('progress-high');
|
| 145 |
+
queueLoadText.classList.add('high-load');
|
| 146 |
+
queueLoadText.textContent = translations[currentLang].highLoad;
|
| 147 |
+
} else if (loadPercentage >= 40) {
|
| 148 |
+
// Medium load (40-75%)
|
| 149 |
+
statusIndicator.classList.add('indicator-medium');
|
| 150 |
+
queueProgressBar.classList.add('progress-medium');
|
| 151 |
+
queueLoadText.classList.add('medium-load');
|
| 152 |
+
queueLoadText.textContent = translations[currentLang].mediumLoad;
|
| 153 |
+
} else {
|
| 154 |
+
// Low load (0-40%)
|
| 155 |
+
statusIndicator.classList.add('indicator-low');
|
| 156 |
+
queueProgressBar.classList.add('progress-low');
|
| 157 |
+
queueLoadText.classList.add('low-load');
|
| 158 |
+
queueLoadText.textContent = loadPercentage > 0 ? translations[currentLang].lowLoad : translations[currentLang].noLoad;
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// Update queue size every 2 seconds
|
| 163 |
+
setInterval(updateQueueSize, 2000);
|
| 164 |
+
|
| 165 |
+
// Initial update
|
| 166 |
+
updateQueueSize();
|
| 167 |
+
|
| 168 |
+
// Function to copy code blocks
|
| 169 |
+
function copyCode(button) {
|
| 170 |
+
const codeBlock = button.closest('.code-block').querySelector('code');
|
| 171 |
+
const text = codeBlock.textContent;
|
| 172 |
+
|
| 173 |
+
navigator.clipboard.writeText(text).then(() => {
|
| 174 |
+
// Visual feedback
|
| 175 |
+
const originalIcon = button.innerHTML;
|
| 176 |
+
button.innerHTML = '<i class="fas fa-check"></i>';
|
| 177 |
+
button.style.color = '#4CAF50';
|
| 178 |
+
|
| 179 |
+
// Reset after 2 seconds
|
| 180 |
+
setTimeout(() => {
|
| 181 |
+
button.innerHTML = originalIcon;
|
| 182 |
+
button.style.color = '';
|
| 183 |
+
}, 2000);
|
| 184 |
+
}).catch(err => {
|
| 185 |
+
console.error('Failed to copy text:', err);
|
| 186 |
+
// Visual feedback for error
|
| 187 |
+
button.style.color = '#f44336';
|
| 188 |
+
setTimeout(() => {
|
| 189 |
+
button.style.color = '';
|
| 190 |
+
}, 2000);
|
| 191 |
+
});
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
// Playground functionality
|
| 195 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 196 |
+
const submitButton = document.getElementById('playground-submit');
|
| 197 |
+
const textInput = document.getElementById('playground-text');
|
| 198 |
+
const voiceSelect = document.getElementById('playground-voice');
|
| 199 |
+
const instructionsInput = document.getElementById('playground-instructions');
|
| 200 |
+
const formatSelect = document.getElementById('playground-format');
|
| 201 |
+
const statusDiv = document.getElementById('playground-status');
|
| 202 |
+
const audioDiv = document.getElementById('playground-audio');
|
| 203 |
+
|
| 204 |
+
submitButton.addEventListener('click', async function() {
|
| 205 |
+
const text = textInput.value.trim();
|
| 206 |
+
const voice = voiceSelect.value;
|
| 207 |
+
const instructions = instructionsInput.value.trim();
|
| 208 |
+
const format = formatSelect.value;
|
| 209 |
+
|
| 210 |
+
if (!text) {
|
| 211 |
+
showStatus('Please enter some text to convert', 'error');
|
| 212 |
+
return;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
// Disable the submit button and show loading state
|
| 216 |
+
submitButton.disabled = true;
|
| 217 |
+
submitButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating...';
|
| 218 |
+
showStatus('Generating speech...', 'success');
|
| 219 |
+
audioDiv.innerHTML = '';
|
| 220 |
+
|
| 221 |
+
try {
|
| 222 |
+
const response = await fetch(OPENAI_API_URL, {
|
| 223 |
+
method: 'POST',
|
| 224 |
+
headers: {
|
| 225 |
+
'Content-Type': 'application/json'
|
| 226 |
+
},
|
| 227 |
+
body: JSON.stringify({
|
| 228 |
+
input: text,
|
| 229 |
+
voice: voice,
|
| 230 |
+
instructions: instructions || undefined,
|
| 231 |
+
response_format: format
|
| 232 |
+
})
|
| 233 |
+
});
|
| 234 |
+
|
| 235 |
+
if (!response.ok) {
|
| 236 |
+
const error = await response.json();
|
| 237 |
+
throw new Error(error.error || 'Failed to generate speech');
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
const blob = await response.blob();
|
| 241 |
+
const audioUrl = URL.createObjectURL(blob);
|
| 242 |
+
|
| 243 |
+
// Create audio element
|
| 244 |
+
const audio = document.createElement('audio');
|
| 245 |
+
audio.controls = true;
|
| 246 |
+
audio.src = audioUrl;
|
| 247 |
+
|
| 248 |
+
// Clear previous audio and add new one
|
| 249 |
+
audioDiv.innerHTML = '';
|
| 250 |
+
audioDiv.appendChild(audio);
|
| 251 |
+
|
| 252 |
+
showStatus('Speech generated successfully!', 'success');
|
| 253 |
+
} catch (error) {
|
| 254 |
+
showStatus(error.message || 'Failed to generate speech', 'error');
|
| 255 |
+
} finally {
|
| 256 |
+
// Re-enable the submit button and restore original text
|
| 257 |
+
submitButton.disabled = false;
|
| 258 |
+
submitButton.innerHTML = '<i class="fas fa-play"></i> Generate Speech';
|
| 259 |
+
}
|
| 260 |
+
});
|
| 261 |
+
|
| 262 |
+
function showStatus(message, type) {
|
| 263 |
+
statusDiv.textContent = message;
|
| 264 |
+
statusDiv.className = `playground-status ${type}`;
|
| 265 |
+
}
|
| 266 |
+
});
|
| 267 |
+
|
| 268 |
+
// Voice sample functionality
|
| 269 |
+
let currentSampleAudio = null;
|
| 270 |
+
|
| 271 |
+
// Function to load and play voice sample
|
| 272 |
+
async function loadVoiceSample(voice) {
|
| 273 |
+
const previewAudioDiv = document.getElementById('preview-audio');
|
| 274 |
+
|
| 275 |
+
try {
|
| 276 |
+
// Create new audio element
|
| 277 |
+
const response = await fetch(`/api/voice-sample/${voice}`);
|
| 278 |
+
if (!response.ok) {
|
| 279 |
+
throw new Error(`Failed to load voice sample: ${response.statusText}`);
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
const blob = await response.blob();
|
| 283 |
+
const audioUrl = URL.createObjectURL(blob);
|
| 284 |
+
|
| 285 |
+
// Create and configure audio element
|
| 286 |
+
currentSampleAudio = document.createElement('audio');
|
| 287 |
+
currentSampleAudio.controls = true;
|
| 288 |
+
currentSampleAudio.src = audioUrl;
|
| 289 |
+
|
| 290 |
+
// Clear previous audio and add new one
|
| 291 |
+
previewAudioDiv.innerHTML = '';
|
| 292 |
+
previewAudioDiv.appendChild(currentSampleAudio);
|
| 293 |
+
|
| 294 |
+
} catch (error) {
|
| 295 |
+
console.error('Error loading voice sample:', error);
|
| 296 |
+
// Show error in status
|
| 297 |
+
const statusDiv = document.getElementById('playground-status');
|
| 298 |
+
statusDiv.innerHTML = `<div class="error-message">Error loading voice sample: ${error.message}</div>`;
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
// Add voice selection change handler
|
| 303 |
+
document.getElementById('playground-voice').addEventListener('change', function() {
|
| 304 |
+
loadVoiceSample(this.value);
|
| 305 |
+
});
|
| 306 |
+
|
| 307 |
+
// Load initial voice sample when page loads
|
| 308 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 309 |
+
const voiceSelect = document.getElementById('playground-voice');
|
| 310 |
+
if (voiceSelect) {
|
| 311 |
+
loadVoiceSample(voiceSelect.value);
|
| 312 |
+
}
|
| 313 |
+
});
|
| 314 |
+
|
| 315 |
+
// Update the example code blocks
|
| 316 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 317 |
+
// Update Python example
|
| 318 |
+
const pythonExample = document.querySelector('.language-python');
|
| 319 |
+
if (pythonExample) {
|
| 320 |
+
pythonExample.textContent = `import requests
|
| 321 |
+
|
| 322 |
+
url = "https://ttsapi.site/v1/audio/speech"
|
| 323 |
+
headers = {
|
| 324 |
+
"Content-Type": "application/json"
|
| 325 |
+
}
|
| 326 |
+
data = {
|
| 327 |
+
"input": "Hello, this is a test.",
|
| 328 |
+
"voice": "alloy",
|
| 329 |
+
"instructions": "Speak in a cheerful and upbeat tone.", # Optional
|
| 330 |
+
"response_format": "mp3" # Optional, supported formats: mp3, opus, aac, flac, wav, pcm
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
response = requests.post(url, json=data, headers=headers)
|
| 334 |
+
if response.status_code == 200:
|
| 335 |
+
# Save the audio file in the requested format
|
| 336 |
+
with open("output.${format}", "wb") as f:
|
| 337 |
+
f.write(response.content)
|
| 338 |
+
print(f"Audio saved as output.${format}")
|
| 339 |
+
else:
|
| 340 |
+
print(f"Error: {response.status_code}, {response.json()}")`;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
// Update JavaScript example
|
| 344 |
+
const javascriptExample = document.querySelector('.language-javascript');
|
| 345 |
+
if (javascriptExample) {
|
| 346 |
+
javascriptExample.textContent = `async function generateSpeech() {
|
| 347 |
+
const response = await fetch('https://ttsapi.site/v1/audio/speech', {
|
| 348 |
+
method: 'POST',
|
| 349 |
+
headers: {
|
| 350 |
+
'Content-Type': 'application/json'
|
| 351 |
+
},
|
| 352 |
+
body: JSON.stringify({
|
| 353 |
+
input: 'Hello, this is a test.',
|
| 354 |
+
voice: 'alloy',
|
| 355 |
+
instructions: 'Speak in a cheerful and upbeat tone.', // Optional
|
| 356 |
+
response_format: 'mp3' // Optional, supported formats: mp3, opus, aac, flac, wav, pcm
|
| 357 |
+
})
|
| 358 |
+
});
|
| 359 |
+
|
| 360 |
+
if (response.ok) {
|
| 361 |
+
const blob = await response.blob();
|
| 362 |
+
const audio = new Audio(URL.createObjectURL(blob));
|
| 363 |
+
audio.play();
|
| 364 |
+
} else {
|
| 365 |
+
const error = await response.json();
|
| 366 |
+
console.error('Error:', error);
|
| 367 |
+
}
|
| 368 |
+
}`;
|
| 369 |
+
}
|
| 370 |
+
});
|
static/styles.css
ADDED
|
@@ -0,0 +1,1218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--primary-color: #2563eb;
|
| 3 |
+
--secondary-color: #1e40af;
|
| 4 |
+
--background-color: #0f172a;
|
| 5 |
+
--text-color: #e2e8f0;
|
| 6 |
+
--border-color: #1e293b;
|
| 7 |
+
--success-color: #10b981;
|
| 8 |
+
--error-color: #ef4444;
|
| 9 |
+
--card-bg: #1e293b;
|
| 10 |
+
--card-hover: #334155;
|
| 11 |
+
--code-bg: #1e293b;
|
| 12 |
+
--header-bg: #0f172a;
|
| 13 |
+
--panel-bg: #ffffff;
|
| 14 |
+
--docs-bg: #1a1a1a;
|
| 15 |
+
--docs-text: #e5e7eb;
|
| 16 |
+
--gradient-start: #60a5fa;
|
| 17 |
+
--gradient-end: #34d399;
|
| 18 |
+
--disclaimer-bg: #eef2ff;
|
| 19 |
+
--disclaimer-border: #c7d2fe;
|
| 20 |
+
--disclaimer-text: #4f46e5;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
* {
|
| 24 |
+
box-sizing: border-box;
|
| 25 |
+
margin: 0;
|
| 26 |
+
padding: 0;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
body {
|
| 30 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
| 31 |
+
line-height: 1.6;
|
| 32 |
+
margin: 0;
|
| 33 |
+
padding: 0;
|
| 34 |
+
color: #333;
|
| 35 |
+
background-color: #f5f5f5;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.app-container {
|
| 39 |
+
max-width: 1200px;
|
| 40 |
+
margin: 0 auto;
|
| 41 |
+
padding: 2rem;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.content-wrapper {
|
| 45 |
+
background-color: #fff;
|
| 46 |
+
border-radius: 8px;
|
| 47 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 48 |
+
padding: 2rem;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/* Header */
|
| 52 |
+
.main-header {
|
| 53 |
+
margin-bottom: 3rem;
|
| 54 |
+
padding-bottom: 1rem;
|
| 55 |
+
border-bottom: 1px solid #eee;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.header-top {
|
| 59 |
+
display: flex;
|
| 60 |
+
justify-content: space-between;
|
| 61 |
+
align-items: center;
|
| 62 |
+
margin-bottom: 0.5rem;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.github-link {
|
| 66 |
+
color: #333;
|
| 67 |
+
font-size: 1.5rem;
|
| 68 |
+
transition: all 0.2s ease;
|
| 69 |
+
background-color: #f8fafc;
|
| 70 |
+
padding: 0.5rem 1rem;
|
| 71 |
+
border-radius: 6px;
|
| 72 |
+
border: 1px solid #e2e8f0;
|
| 73 |
+
display: flex;
|
| 74 |
+
align-items: center;
|
| 75 |
+
gap: 0.5rem;
|
| 76 |
+
text-decoration: none;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.github-link i {
|
| 80 |
+
font-size: 1.5rem;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.github-link:hover {
|
| 84 |
+
color: #2563eb;
|
| 85 |
+
background-color: #eff6ff;
|
| 86 |
+
border-color: #2563eb;
|
| 87 |
+
transform: translateY(-2px);
|
| 88 |
+
box-shadow: 0 4px 6px rgba(37, 99, 235, 0.1);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.header-bottom {
|
| 92 |
+
display: flex;
|
| 93 |
+
justify-content: space-between;
|
| 94 |
+
align-items: center;
|
| 95 |
+
margin-top: 1rem;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.language-selector {
|
| 99 |
+
display: flex;
|
| 100 |
+
gap: 0.5rem;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.lang-btn {
|
| 104 |
+
padding: 0.5rem 1rem;
|
| 105 |
+
border: 1px solid #e2e8f0;
|
| 106 |
+
border-radius: 4px;
|
| 107 |
+
background: white;
|
| 108 |
+
cursor: pointer;
|
| 109 |
+
font-size: 0.9rem;
|
| 110 |
+
transition: all 0.2s ease;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.lang-btn:hover {
|
| 114 |
+
border-color: #2563eb;
|
| 115 |
+
color: #2563eb;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.lang-btn.active {
|
| 119 |
+
background: #2563eb;
|
| 120 |
+
color: white;
|
| 121 |
+
border-color: #2563eb;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.main-header h1 {
|
| 125 |
+
margin: 0;
|
| 126 |
+
color: #2c3e50;
|
| 127 |
+
font-size: 2.5rem;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.subtitle {
|
| 131 |
+
color: #666;
|
| 132 |
+
margin: 0.5rem 0 0;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
/* Disclaimer Section */
|
| 136 |
+
.disclaimer-notice {
|
| 137 |
+
background-color: var(--disclaimer-bg);
|
| 138 |
+
border: 1px solid var(--disclaimer-border);
|
| 139 |
+
border-radius: 8px;
|
| 140 |
+
margin-bottom: 2rem;
|
| 141 |
+
padding: 1.5rem;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.disclaimer-container {
|
| 145 |
+
display: flex;
|
| 146 |
+
align-items: flex-start;
|
| 147 |
+
gap: 1rem;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.disclaimer-icon {
|
| 151 |
+
color: var(--disclaimer-text);
|
| 152 |
+
font-size: 1.5rem;
|
| 153 |
+
padding: 0.5rem;
|
| 154 |
+
background-color: rgba(79, 70, 229, 0.1);
|
| 155 |
+
border-radius: 50%;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.disclaimer-content {
|
| 159 |
+
flex: 1;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.disclaimer-content h2 {
|
| 163 |
+
color: var(--disclaimer-text);
|
| 164 |
+
margin-bottom: 0.5rem;
|
| 165 |
+
font-size: 1.5rem;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.disclaimer-content p {
|
| 169 |
+
color: #4b5563;
|
| 170 |
+
margin-bottom: 0;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.disclaimer-content a {
|
| 174 |
+
color: var(--disclaimer-text);
|
| 175 |
+
text-decoration: none;
|
| 176 |
+
font-weight: 500;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.disclaimer-content a:hover {
|
| 180 |
+
text-decoration: underline;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
/* Content sections */
|
| 184 |
+
.content-section {
|
| 185 |
+
margin-bottom: 3rem;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.content-section h2 {
|
| 189 |
+
color: #2c3e50;
|
| 190 |
+
margin-bottom: 1.5rem;
|
| 191 |
+
font-size: 1.8rem;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
/* Code blocks */
|
| 195 |
+
.code-block {
|
| 196 |
+
background: #1e1e1e;
|
| 197 |
+
border-radius: 6px;
|
| 198 |
+
overflow: hidden;
|
| 199 |
+
margin: 1rem 0;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
.code-header {
|
| 203 |
+
display: flex;
|
| 204 |
+
justify-content: space-between;
|
| 205 |
+
align-items: center;
|
| 206 |
+
padding: 0.5rem 1rem;
|
| 207 |
+
background: #2d2d2d;
|
| 208 |
+
border-bottom: 1px solid #3d3d3d;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.code-language {
|
| 212 |
+
color: #fff;
|
| 213 |
+
font-size: 0.9rem;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.copy-button {
|
| 217 |
+
background: none;
|
| 218 |
+
border: none;
|
| 219 |
+
color: #fff;
|
| 220 |
+
cursor: pointer;
|
| 221 |
+
padding: 0.25rem 0.5rem;
|
| 222 |
+
font-size: 0.9rem;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.copy-button:hover {
|
| 226 |
+
color: #4CAF50;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
/* Voice table */
|
| 230 |
+
.voice-table {
|
| 231 |
+
width: 100%;
|
| 232 |
+
border-collapse: collapse;
|
| 233 |
+
margin: 1rem 0;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.voice-table th,
|
| 237 |
+
.voice-table td {
|
| 238 |
+
padding: 0.75rem;
|
| 239 |
+
text-align: left;
|
| 240 |
+
border-bottom: 1px solid #eee;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
.voice-table th {
|
| 244 |
+
background-color: #f8f9fa;
|
| 245 |
+
font-weight: 600;
|
| 246 |
+
color: #2c3e50;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
.voice-table tr:hover {
|
| 250 |
+
background-color: #f8f9fa;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
/* API endpoint */
|
| 254 |
+
.api-endpoint {
|
| 255 |
+
margin-bottom: 2rem;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
.api-endpoint h3 {
|
| 259 |
+
color: #2c3e50;
|
| 260 |
+
margin-bottom: 1rem;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
.api-endpoint h4 {
|
| 264 |
+
color: #666;
|
| 265 |
+
margin: 1.5rem 0 1rem;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
/* Code syntax highlighting overrides */
|
| 269 |
+
pre[class*="language-"] {
|
| 270 |
+
margin: 0;
|
| 271 |
+
border-radius: 0;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
code[class*="language-"] {
|
| 275 |
+
font-size: 0.9rem;
|
| 276 |
+
padding: 1rem;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
/* Status Section */
|
| 280 |
+
.status-section {
|
| 281 |
+
margin-bottom: 2rem;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
.status-container {
|
| 285 |
+
display: flex;
|
| 286 |
+
justify-content: center;
|
| 287 |
+
width: 100%;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.status-card {
|
| 291 |
+
background-color: #fff;
|
| 292 |
+
border-radius: 8px;
|
| 293 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
| 294 |
+
padding: 1.5rem;
|
| 295 |
+
width: 100%;
|
| 296 |
+
max-width: 600px;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
.status-header {
|
| 300 |
+
display: flex;
|
| 301 |
+
justify-content: space-between;
|
| 302 |
+
align-items: center;
|
| 303 |
+
margin-bottom: 1rem;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
.status-header h3 {
|
| 307 |
+
margin: 0;
|
| 308 |
+
color: #2c3e50;
|
| 309 |
+
font-size: 1.3rem;
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
.status-indicator {
|
| 313 |
+
width: 12px;
|
| 314 |
+
height: 12px;
|
| 315 |
+
border-radius: 50%;
|
| 316 |
+
display: inline-block;
|
| 317 |
+
margin-right: 8px;
|
| 318 |
+
background-color: var(--success-color);
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
.queue-stats {
|
| 322 |
+
display: flex;
|
| 323 |
+
justify-content: space-between;
|
| 324 |
+
margin-bottom: 1rem;
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
.stat-item {
|
| 328 |
+
display: flex;
|
| 329 |
+
flex-direction: column;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
.stat-label {
|
| 333 |
+
font-size: 0.9rem;
|
| 334 |
+
color: #64748b;
|
| 335 |
+
margin-bottom: 0.25rem;
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
.stat-value {
|
| 339 |
+
font-size: 1.5rem;
|
| 340 |
+
font-weight: 600;
|
| 341 |
+
color: #2c3e50;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
.queue-progress-container {
|
| 345 |
+
height: 8px;
|
| 346 |
+
background-color: #e2e8f0;
|
| 347 |
+
border-radius: 4px;
|
| 348 |
+
overflow: hidden;
|
| 349 |
+
margin-bottom: 0.75rem;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.queue-progress-bar {
|
| 353 |
+
height: 100%;
|
| 354 |
+
width: 0%;
|
| 355 |
+
background: linear-gradient(to right, #10b981, #3b82f6);
|
| 356 |
+
transition: width 0.5s ease, background-color 0.5s ease;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
.queue-load-text {
|
| 360 |
+
text-align: center;
|
| 361 |
+
font-size: 0.9rem;
|
| 362 |
+
font-weight: 500;
|
| 363 |
+
color: #10b981; /* Default green */
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
/* Load status colors */
|
| 367 |
+
.low-load {
|
| 368 |
+
color: #10b981 !important; /* Green */
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
.medium-load {
|
| 372 |
+
color: #f59e0b !important; /* Yellow/Orange */
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
.high-load {
|
| 376 |
+
color: #ef4444 !important; /* Red */
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
.indicator-low {
|
| 380 |
+
background-color: #10b981 !important;
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
.indicator-medium {
|
| 384 |
+
background-color: #f59e0b !important;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
.indicator-high {
|
| 388 |
+
background-color: #ef4444 !important;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
.progress-low {
|
| 392 |
+
background: linear-gradient(to right, #10b981, #34d399) !important;
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
.progress-medium {
|
| 396 |
+
background: linear-gradient(to right, #f59e0b, #fbbf24) !important;
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
.progress-high {
|
| 400 |
+
background: linear-gradient(to right, #ef4444, #f87171) !important;
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
/* Voice Grid */
|
| 404 |
+
.voice-grid {
|
| 405 |
+
display: grid;
|
| 406 |
+
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
| 407 |
+
gap: 1rem;
|
| 408 |
+
margin-bottom: 1.5rem;
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
.voice-card {
|
| 412 |
+
background: var(--card-bg);
|
| 413 |
+
border: 1px solid var(--border-color);
|
| 414 |
+
border-radius: 12px;
|
| 415 |
+
padding: 1.25rem;
|
| 416 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 417 |
+
position: relative;
|
| 418 |
+
overflow: hidden;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
.voice-card::after {
|
| 422 |
+
content: '';
|
| 423 |
+
position: absolute;
|
| 424 |
+
top: 0;
|
| 425 |
+
left: 0;
|
| 426 |
+
width: 100%;
|
| 427 |
+
height: 100%;
|
| 428 |
+
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.05), transparent);
|
| 429 |
+
transform: translateX(-100%);
|
| 430 |
+
transition: transform 0.6s ease;
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
.voice-card:hover::after {
|
| 434 |
+
transform: translateX(100%);
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
.voice-card:hover {
|
| 438 |
+
transform: translateY(-2px);
|
| 439 |
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
| 440 |
+
border-color: var(--primary-color);
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
.voice-name {
|
| 444 |
+
font-size: 1.1rem;
|
| 445 |
+
font-weight: 600;
|
| 446 |
+
color: var(--text-color);
|
| 447 |
+
margin-bottom: 0.25rem;
|
| 448 |
+
text-transform: capitalize;
|
| 449 |
+
position: relative;
|
| 450 |
+
display: inline-block;
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
.voice-name::after {
|
| 454 |
+
content: '';
|
| 455 |
+
position: absolute;
|
| 456 |
+
bottom: -2px;
|
| 457 |
+
left: 0;
|
| 458 |
+
width: 0;
|
| 459 |
+
height: 2px;
|
| 460 |
+
background: linear-gradient(to right, var(--gradient-start), var(--gradient-end));
|
| 461 |
+
transition: width 0.3s ease;
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
.voice-card:hover .voice-name::after {
|
| 465 |
+
width: 100%;
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
.voice-description {
|
| 469 |
+
font-size: 0.8rem;
|
| 470 |
+
color: #94a3b8;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
/* Processing Status */
|
| 474 |
+
#processing-status {
|
| 475 |
+
font-weight: 500;
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
#processing-status.processing {
|
| 479 |
+
color: #60a5fa;
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
#processing-status.idle {
|
| 483 |
+
color: #34d399;
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
/* Responsive Design */
|
| 487 |
+
@media (max-width: 768px) {
|
| 488 |
+
.app-container {
|
| 489 |
+
padding: 1rem;
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
.main-header {
|
| 493 |
+
padding: 2rem 1rem;
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
.main-header h1 {
|
| 497 |
+
font-size: 2rem;
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
.queue-status {
|
| 501 |
+
grid-template-columns: 1fr;
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
.voice-grid {
|
| 505 |
+
grid-template-columns: 1fr;
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
.content-section {
|
| 509 |
+
margin-bottom: 2rem;
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
.status-card, .voice-card {
|
| 513 |
+
padding: 1.25rem;
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
.status-icon {
|
| 517 |
+
width: 2.5rem;
|
| 518 |
+
height: 2.5rem;
|
| 519 |
+
font-size: 1.25rem;
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
.status-value {
|
| 523 |
+
font-size: 1.25rem;
|
| 524 |
+
}
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
@media (max-width: 480px) {
|
| 528 |
+
.main-header h1 {
|
| 529 |
+
font-size: 1.75rem;
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
.subtitle {
|
| 533 |
+
font-size: 1rem;
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
.content-section h2 {
|
| 537 |
+
font-size: 1.5rem;
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
.api-endpoint {
|
| 541 |
+
padding: 1rem;
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
pre {
|
| 545 |
+
padding: 1rem;
|
| 546 |
+
}
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
/* Scrollbar Styling */
|
| 550 |
+
::-webkit-scrollbar {
|
| 551 |
+
width: 8px;
|
| 552 |
+
height: 8px;
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
::-webkit-scrollbar-track {
|
| 556 |
+
background: var(--background-color);
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
::-webkit-scrollbar-thumb {
|
| 560 |
+
background: var(--border-color);
|
| 561 |
+
border-radius: 4px;
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
::-webkit-scrollbar-thumb:hover {
|
| 565 |
+
background: var(--card-hover);
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
/* Audio Container */
|
| 569 |
+
.audio-container {
|
| 570 |
+
margin-top: 20px;
|
| 571 |
+
display: flex;
|
| 572 |
+
flex-direction: column;
|
| 573 |
+
gap: 15px;
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
.audio-wrapper {
|
| 577 |
+
background: #2a2a2a;
|
| 578 |
+
border-radius: 8px;
|
| 579 |
+
padding: 15px;
|
| 580 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
.voice-label {
|
| 584 |
+
font-size: 14px;
|
| 585 |
+
color: #888;
|
| 586 |
+
margin-bottom: 8px;
|
| 587 |
+
text-transform: capitalize;
|
| 588 |
+
}
|
| 589 |
+
|
| 590 |
+
.audio-player {
|
| 591 |
+
width: 100%;
|
| 592 |
+
height: 40px;
|
| 593 |
+
background: #1a1a1a;
|
| 594 |
+
border-radius: 4px;
|
| 595 |
+
padding: 5px;
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
.audio-player::-webkit-media-controls-panel {
|
| 599 |
+
background: #1a1a1a;
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
.audio-player::-webkit-media-controls-play-button {
|
| 603 |
+
background-color: #4CAF50;
|
| 604 |
+
border-radius: 50%;
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
.audio-player::-webkit-media-controls-timeline {
|
| 608 |
+
background-color: #4CAF50;
|
| 609 |
+
border-radius: 2px;
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
.audio-player::-webkit-media-controls-volume-slider {
|
| 613 |
+
background-color: #4CAF50;
|
| 614 |
+
border-radius: 2px;
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
/* Header Decoration */
|
| 618 |
+
.header-decoration {
|
| 619 |
+
position: absolute;
|
| 620 |
+
top: 0;
|
| 621 |
+
right: 0;
|
| 622 |
+
width: 200px;
|
| 623 |
+
height: 200px;
|
| 624 |
+
pointer-events: none;
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
.decoration-circle {
|
| 628 |
+
position: absolute;
|
| 629 |
+
border-radius: 50%;
|
| 630 |
+
opacity: 0.1;
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
.decoration-circle:nth-child(1) {
|
| 634 |
+
width: 100px;
|
| 635 |
+
height: 100px;
|
| 636 |
+
background: var(--gradient-start);
|
| 637 |
+
top: 20px;
|
| 638 |
+
right: 20px;
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
.decoration-circle:nth-child(2) {
|
| 642 |
+
width: 60px;
|
| 643 |
+
height: 60px;
|
| 644 |
+
background: var(--gradient-end);
|
| 645 |
+
top: 60px;
|
| 646 |
+
right: 60px;
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
.decoration-circle:nth-child(3) {
|
| 650 |
+
width: 40px;
|
| 651 |
+
height: 40px;
|
| 652 |
+
background: var(--primary-color);
|
| 653 |
+
top: 100px;
|
| 654 |
+
right: 100px;
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
/* Voice Icon */
|
| 658 |
+
.voice-icon {
|
| 659 |
+
width: 40px;
|
| 660 |
+
height: 40px;
|
| 661 |
+
background: rgba(255, 255, 255, 0.1);
|
| 662 |
+
border-radius: 50%;
|
| 663 |
+
display: flex;
|
| 664 |
+
align-items: center;
|
| 665 |
+
justify-content: center;
|
| 666 |
+
margin-bottom: 1rem;
|
| 667 |
+
transition: all 0.3s ease;
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
.voice-icon i {
|
| 671 |
+
font-size: 1.25rem;
|
| 672 |
+
color: var(--text-color);
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
.voice-card:hover .voice-icon {
|
| 676 |
+
background: var(--primary-color);
|
| 677 |
+
transform: scale(1.1);
|
| 678 |
+
}
|
| 679 |
+
|
| 680 |
+
/* Section Headers */
|
| 681 |
+
.content-section h2 {
|
| 682 |
+
display: flex;
|
| 683 |
+
align-items: center;
|
| 684 |
+
gap: 0.75rem;
|
| 685 |
+
margin-bottom: 1.5rem;
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
.content-section h2 i {
|
| 689 |
+
color: var(--primary-color);
|
| 690 |
+
font-size: 1.5rem;
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
.api-endpoint h3 {
|
| 694 |
+
display: flex;
|
| 695 |
+
align-items: center;
|
| 696 |
+
gap: 0.75rem;
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
+
.api-endpoint h3 i {
|
| 700 |
+
color: var(--primary-color);
|
| 701 |
+
font-size: 1.25rem;
|
| 702 |
+
}
|
| 703 |
+
|
| 704 |
+
/* Loading Animation */
|
| 705 |
+
@keyframes pulse {
|
| 706 |
+
0% { transform: scale(1); }
|
| 707 |
+
50% { transform: scale(1.05); }
|
| 708 |
+
100% { transform: scale(1); }
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
.status-card.processing {
|
| 712 |
+
animation: pulse 2s infinite;
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
.status-card.processing .status-icon {
|
| 716 |
+
animation: spin 2s linear infinite;
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
@keyframes spin {
|
| 720 |
+
from { transform: rotate(0deg); }
|
| 721 |
+
to { transform: rotate(360deg); }
|
| 722 |
+
}
|
| 723 |
+
|
| 724 |
+
/* Parameter and Error Tables */
|
| 725 |
+
.params-table,
|
| 726 |
+
.error-table {
|
| 727 |
+
width: 100%;
|
| 728 |
+
border-collapse: collapse;
|
| 729 |
+
margin: 1rem 0;
|
| 730 |
+
background-color: #fff;
|
| 731 |
+
border: 1px solid #eee;
|
| 732 |
+
border-radius: 6px;
|
| 733 |
+
overflow: hidden;
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
.params-table th,
|
| 737 |
+
.params-table td,
|
| 738 |
+
.error-table th,
|
| 739 |
+
.error-table td {
|
| 740 |
+
padding: 0.75rem;
|
| 741 |
+
text-align: left;
|
| 742 |
+
border-bottom: 1px solid #eee;
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
.params-table th,
|
| 746 |
+
.error-table th {
|
| 747 |
+
background-color: #f8f9fa;
|
| 748 |
+
font-weight: 600;
|
| 749 |
+
color: #2c3e50;
|
| 750 |
+
text-transform: uppercase;
|
| 751 |
+
font-size: 0.85rem;
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
.params-table td:nth-child(2),
|
| 755 |
+
.params-table td:nth-child(3) {
|
| 756 |
+
font-family: 'Fira Code', monospace;
|
| 757 |
+
font-size: 0.9rem;
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
.params-table td:nth-child(3) {
|
| 761 |
+
color: #2563eb;
|
| 762 |
+
font-weight: 500;
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
.error-table td:first-child {
|
| 766 |
+
font-family: 'Fira Code', monospace;
|
| 767 |
+
font-weight: 500;
|
| 768 |
+
color: #ef4444;
|
| 769 |
+
}
|
| 770 |
+
|
| 771 |
+
/* Response Format Section */
|
| 772 |
+
.api-endpoint ul {
|
| 773 |
+
list-style: none;
|
| 774 |
+
padding-left: 0;
|
| 775 |
+
margin: 1rem 0;
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
.api-endpoint ul li {
|
| 779 |
+
margin: 0.5rem 0;
|
| 780 |
+
padding-left: 1.5rem;
|
| 781 |
+
position: relative;
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
.api-endpoint ul li::before {
|
| 785 |
+
content: '•';
|
| 786 |
+
position: absolute;
|
| 787 |
+
left: 0.5rem;
|
| 788 |
+
color: #2563eb;
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
.api-endpoint ul li code {
|
| 792 |
+
background-color: #f1f5f9;
|
| 793 |
+
padding: 0.2rem 0.4rem;
|
| 794 |
+
border-radius: 4px;
|
| 795 |
+
font-family: 'Fira Code', monospace;
|
| 796 |
+
font-size: 0.9rem;
|
| 797 |
+
color: #2563eb;
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
/* Queue System Section */
|
| 801 |
+
.api-endpoint p {
|
| 802 |
+
margin: 1rem 0;
|
| 803 |
+
line-height: 1.6;
|
| 804 |
+
color: #4a5568;
|
| 805 |
+
}
|
| 806 |
+
|
| 807 |
+
/* Language Tabs */
|
| 808 |
+
.language-tabs {
|
| 809 |
+
display: flex;
|
| 810 |
+
gap: 1rem;
|
| 811 |
+
margin-bottom: 1rem;
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
+
.language-tab {
|
| 815 |
+
padding: 0.5rem 1rem;
|
| 816 |
+
background-color: #f1f5f9;
|
| 817 |
+
border-radius: 4px;
|
| 818 |
+
cursor: pointer;
|
| 819 |
+
font-weight: 500;
|
| 820 |
+
color: #4a5568;
|
| 821 |
+
transition: all 0.2s ease;
|
| 822 |
+
}
|
| 823 |
+
|
| 824 |
+
.language-tab:hover,
|
| 825 |
+
.language-tab.active {
|
| 826 |
+
background-color: #2563eb;
|
| 827 |
+
color: #fff;
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
/* Best Used For Column */
|
| 831 |
+
.voice-table td:last-child {
|
| 832 |
+
color: #4a5568;
|
| 833 |
+
font-style: italic;
|
| 834 |
+
}
|
| 835 |
+
|
| 836 |
+
/* Mobile Responsiveness for New Elements */
|
| 837 |
+
@media (max-width: 768px) {
|
| 838 |
+
.params-table,
|
| 839 |
+
.error-table {
|
| 840 |
+
display: block;
|
| 841 |
+
overflow-x: auto;
|
| 842 |
+
-webkit-overflow-scrolling: touch;
|
| 843 |
+
}
|
| 844 |
+
|
| 845 |
+
.params-table th,
|
| 846 |
+
.params-table td,
|
| 847 |
+
.error-table th,
|
| 848 |
+
.error-table td {
|
| 849 |
+
min-width: 120px;
|
| 850 |
+
}
|
| 851 |
+
}
|
| 852 |
+
|
| 853 |
+
/* Compatibility Parameters */
|
| 854 |
+
.compat-param {
|
| 855 |
+
color: #94a3b8 !important;
|
| 856 |
+
font-style: italic;
|
| 857 |
+
}
|
| 858 |
+
|
| 859 |
+
/* Partially Supported Parameters */
|
| 860 |
+
.partial-param {
|
| 861 |
+
color: #3b82f6 !important;
|
| 862 |
+
font-style: italic;
|
| 863 |
+
}
|
| 864 |
+
|
| 865 |
+
.compatibility-notice {
|
| 866 |
+
margin: 1rem 0;
|
| 867 |
+
padding: 1rem;
|
| 868 |
+
background-color: #f8fafc;
|
| 869 |
+
border-left: 4px solid #94a3b8;
|
| 870 |
+
border-radius: 0 4px 4px 0;
|
| 871 |
+
}
|
| 872 |
+
|
| 873 |
+
.compatibility-notice p {
|
| 874 |
+
margin: 0;
|
| 875 |
+
color: #64748b;
|
| 876 |
+
font-size: 0.9rem;
|
| 877 |
+
}
|
| 878 |
+
|
| 879 |
+
.compatibility-notice strong {
|
| 880 |
+
color: #475569;
|
| 881 |
+
}
|
| 882 |
+
|
| 883 |
+
.compat-inline {
|
| 884 |
+
color: #94a3b8;
|
| 885 |
+
font-style: italic;
|
| 886 |
+
padding: 0 2px;
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
.partial-inline {
|
| 890 |
+
color: #3b82f6;
|
| 891 |
+
font-style: italic;
|
| 892 |
+
padding: 0 2px;
|
| 893 |
+
}
|
| 894 |
+
|
| 895 |
+
/* Update code examples to reflect actual usage */
|
| 896 |
+
.code-block pre code {
|
| 897 |
+
line-height: 1.5;
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
/* Parameter Details Section */
|
| 901 |
+
.parameter-details {
|
| 902 |
+
margin: 1.5rem 0;
|
| 903 |
+
padding: 1rem;
|
| 904 |
+
background-color: #f0f9ff;
|
| 905 |
+
border-radius: 6px;
|
| 906 |
+
border: 1px solid #e0f2fe;
|
| 907 |
+
}
|
| 908 |
+
|
| 909 |
+
.parameter-details h4 {
|
| 910 |
+
color: #0369a1;
|
| 911 |
+
margin-top: 0;
|
| 912 |
+
margin-bottom: 1rem;
|
| 913 |
+
}
|
| 914 |
+
|
| 915 |
+
.parameter-details p {
|
| 916 |
+
margin-bottom: 1rem;
|
| 917 |
+
color: #334155;
|
| 918 |
+
}
|
| 919 |
+
|
| 920 |
+
.parameter-details code {
|
| 921 |
+
background-color: #e0f2fe;
|
| 922 |
+
padding: 0.2rem 0.4rem;
|
| 923 |
+
border-radius: 4px;
|
| 924 |
+
font-family: 'Fira Code', monospace;
|
| 925 |
+
font-size: 0.9rem;
|
| 926 |
+
color: #0369a1;
|
| 927 |
+
}
|
| 928 |
+
|
| 929 |
+
/* Examples List */
|
| 930 |
+
.examples-list {
|
| 931 |
+
list-style: none;
|
| 932 |
+
padding-left: 0;
|
| 933 |
+
margin: 1rem 0;
|
| 934 |
+
}
|
| 935 |
+
|
| 936 |
+
.examples-list li {
|
| 937 |
+
margin: 0.75rem 0;
|
| 938 |
+
padding: 0.5rem 0.75rem;
|
| 939 |
+
background-color: #fff;
|
| 940 |
+
border-left: 3px solid #3b82f6;
|
| 941 |
+
border-radius: 0 4px 4px 0;
|
| 942 |
+
}
|
| 943 |
+
|
| 944 |
+
.examples-list li strong {
|
| 945 |
+
color: #1e40af;
|
| 946 |
+
margin-right: 0.25rem;
|
| 947 |
+
}
|
| 948 |
+
|
| 949 |
+
/* Tip Box */
|
| 950 |
+
.tip-box {
|
| 951 |
+
margin-top: 1.5rem;
|
| 952 |
+
padding: 0.75rem 1rem;
|
| 953 |
+
background-color: #fffbeb;
|
| 954 |
+
border-left: 3px solid #f59e0b;
|
| 955 |
+
border-radius: 0 4px 4px 0;
|
| 956 |
+
}
|
| 957 |
+
|
| 958 |
+
.tip-box p {
|
| 959 |
+
margin: 0;
|
| 960 |
+
color: #92400e;
|
| 961 |
+
font-size: 0.9rem;
|
| 962 |
+
}
|
| 963 |
+
|
| 964 |
+
.tip-box strong {
|
| 965 |
+
color: #78350f;
|
| 966 |
+
}
|
| 967 |
+
|
| 968 |
+
/* Warning Box */
|
| 969 |
+
.warning-box {
|
| 970 |
+
margin: 1rem 0;
|
| 971 |
+
padding: 0.75rem 1rem;
|
| 972 |
+
background-color: #fef2f2;
|
| 973 |
+
border-left: 3px solid #ef4444;
|
| 974 |
+
border-radius: 0 4px 4px 0;
|
| 975 |
+
}
|
| 976 |
+
|
| 977 |
+
.warning-box p {
|
| 978 |
+
margin: 0;
|
| 979 |
+
color: #b91c1c;
|
| 980 |
+
font-size: 0.9rem;
|
| 981 |
+
}
|
| 982 |
+
|
| 983 |
+
.warning-box strong {
|
| 984 |
+
color: #991b1b;
|
| 985 |
+
}
|
| 986 |
+
|
| 987 |
+
/* Voice List */
|
| 988 |
+
.voice-list {
|
| 989 |
+
display: flex;
|
| 990 |
+
flex-wrap: wrap;
|
| 991 |
+
gap: 1rem;
|
| 992 |
+
margin: 1rem 0;
|
| 993 |
+
}
|
| 994 |
+
|
| 995 |
+
.voice-list .voice-name {
|
| 996 |
+
background-color: #f1f5f9;
|
| 997 |
+
border-radius: 4px;
|
| 998 |
+
padding: 0.5rem 1rem;
|
| 999 |
+
font-family: 'Fira Code', monospace;
|
| 1000 |
+
font-size: 0.9rem;
|
| 1001 |
+
color: #2563eb;
|
| 1002 |
+
border: 1px solid #e2e8f0;
|
| 1003 |
+
transition: all 0.2s ease;
|
| 1004 |
+
cursor: default;
|
| 1005 |
+
}
|
| 1006 |
+
|
| 1007 |
+
.voice-list .voice-name:hover {
|
| 1008 |
+
background-color: #e0f2fe;
|
| 1009 |
+
transform: translateY(-2px);
|
| 1010 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
| 1011 |
+
}
|
| 1012 |
+
|
| 1013 |
+
.version-badge {
|
| 1014 |
+
background-color: #fff;
|
| 1015 |
+
border: 1px solid #e2e8f0;
|
| 1016 |
+
border-radius: 6px;
|
| 1017 |
+
padding: 8px 16px;
|
| 1018 |
+
margin-left: 12px;
|
| 1019 |
+
display: inline-flex;
|
| 1020 |
+
align-items: center;
|
| 1021 |
+
transition: all 0.2s ease;
|
| 1022 |
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
| 1023 |
+
}
|
| 1024 |
+
|
| 1025 |
+
.version-badge:hover {
|
| 1026 |
+
border-color: var(--primary-color);
|
| 1027 |
+
transform: translateY(-1px);
|
| 1028 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 1029 |
+
}
|
| 1030 |
+
|
| 1031 |
+
.version-badge span {
|
| 1032 |
+
color: #64748b;
|
| 1033 |
+
font-size: 0.9rem;
|
| 1034 |
+
display: flex;
|
| 1035 |
+
align-items: center;
|
| 1036 |
+
gap: 4px;
|
| 1037 |
+
}
|
| 1038 |
+
|
| 1039 |
+
.version-badge strong {
|
| 1040 |
+
color: var(--primary-color);
|
| 1041 |
+
font-weight: 600;
|
| 1042 |
+
font-family: 'Fira Code', monospace;
|
| 1043 |
+
letter-spacing: 0.5px;
|
| 1044 |
+
}
|
| 1045 |
+
|
| 1046 |
+
/* Playground Section */
|
| 1047 |
+
.playground-section {
|
| 1048 |
+
background-color: #fff;
|
| 1049 |
+
border-radius: 8px;
|
| 1050 |
+
padding: 2rem;
|
| 1051 |
+
margin-bottom: 2rem;
|
| 1052 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 1053 |
+
}
|
| 1054 |
+
|
| 1055 |
+
.playground-container {
|
| 1056 |
+
display: grid;
|
| 1057 |
+
grid-template-columns: 1fr 1fr;
|
| 1058 |
+
gap: 2rem;
|
| 1059 |
+
margin-top: 1.5rem;
|
| 1060 |
+
}
|
| 1061 |
+
|
| 1062 |
+
.playground-form {
|
| 1063 |
+
display: flex;
|
| 1064 |
+
flex-direction: column;
|
| 1065 |
+
gap: 1.5rem;
|
| 1066 |
+
}
|
| 1067 |
+
|
| 1068 |
+
.form-group {
|
| 1069 |
+
display: flex;
|
| 1070 |
+
flex-direction: column;
|
| 1071 |
+
gap: 0.5rem;
|
| 1072 |
+
}
|
| 1073 |
+
|
| 1074 |
+
.form-group label {
|
| 1075 |
+
font-weight: 500;
|
| 1076 |
+
color: #2c3e50;
|
| 1077 |
+
}
|
| 1078 |
+
|
| 1079 |
+
.form-group textarea,
|
| 1080 |
+
.form-group select {
|
| 1081 |
+
padding: 0.75rem;
|
| 1082 |
+
border: 1px solid #e2e8f0;
|
| 1083 |
+
border-radius: 6px;
|
| 1084 |
+
font-size: 1rem;
|
| 1085 |
+
font-family: inherit;
|
| 1086 |
+
resize: vertical;
|
| 1087 |
+
transition: border-color 0.2s ease;
|
| 1088 |
+
}
|
| 1089 |
+
|
| 1090 |
+
.form-group textarea:focus,
|
| 1091 |
+
.form-group select:focus {
|
| 1092 |
+
outline: none;
|
| 1093 |
+
border-color: #2563eb;
|
| 1094 |
+
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
| 1095 |
+
}
|
| 1096 |
+
|
| 1097 |
+
.playground-button {
|
| 1098 |
+
background-color: #2563eb;
|
| 1099 |
+
color: white;
|
| 1100 |
+
border: none;
|
| 1101 |
+
padding: 0.75rem 1.5rem;
|
| 1102 |
+
border-radius: 6px;
|
| 1103 |
+
font-size: 1rem;
|
| 1104 |
+
font-weight: 500;
|
| 1105 |
+
cursor: pointer;
|
| 1106 |
+
display: flex;
|
| 1107 |
+
align-items: center;
|
| 1108 |
+
justify-content: center;
|
| 1109 |
+
gap: 0.5rem;
|
| 1110 |
+
transition: background-color 0.2s ease;
|
| 1111 |
+
margin-top: 1rem;
|
| 1112 |
+
}
|
| 1113 |
+
|
| 1114 |
+
.playground-button:hover {
|
| 1115 |
+
background-color: #1d4ed8;
|
| 1116 |
+
}
|
| 1117 |
+
|
| 1118 |
+
.playground-button:disabled {
|
| 1119 |
+
background-color: #94a3b8;
|
| 1120 |
+
cursor: not-allowed;
|
| 1121 |
+
}
|
| 1122 |
+
|
| 1123 |
+
.playground-output {
|
| 1124 |
+
display: flex;
|
| 1125 |
+
flex-direction: column;
|
| 1126 |
+
gap: 2rem;
|
| 1127 |
+
}
|
| 1128 |
+
|
| 1129 |
+
.audio-section {
|
| 1130 |
+
background-color: #f8fafc;
|
| 1131 |
+
border-radius: 8px;
|
| 1132 |
+
padding: 1.5rem;
|
| 1133 |
+
}
|
| 1134 |
+
|
| 1135 |
+
.audio-section h3 {
|
| 1136 |
+
color: #2c3e50;
|
| 1137 |
+
font-size: 1.1rem;
|
| 1138 |
+
margin-bottom: 1rem;
|
| 1139 |
+
}
|
| 1140 |
+
|
| 1141 |
+
.audio-player {
|
| 1142 |
+
background-color: white;
|
| 1143 |
+
border-radius: 6px;
|
| 1144 |
+
padding: 1rem;
|
| 1145 |
+
min-height: 60px;
|
| 1146 |
+
display: flex;
|
| 1147 |
+
align-items: center;
|
| 1148 |
+
justify-content: center;
|
| 1149 |
+
border: 1px solid #e2e8f0;
|
| 1150 |
+
}
|
| 1151 |
+
|
| 1152 |
+
.audio-player audio {
|
| 1153 |
+
width: 100%;
|
| 1154 |
+
}
|
| 1155 |
+
|
| 1156 |
+
.playground-status {
|
| 1157 |
+
padding: 0.75rem;
|
| 1158 |
+
border-radius: 6px;
|
| 1159 |
+
font-size: 0.9rem;
|
| 1160 |
+
margin-bottom: 1rem;
|
| 1161 |
+
}
|
| 1162 |
+
|
| 1163 |
+
.playground-status.error {
|
| 1164 |
+
background-color: #fef2f2;
|
| 1165 |
+
color: #b91c1c;
|
| 1166 |
+
border: 1px solid #fee2e2;
|
| 1167 |
+
}
|
| 1168 |
+
|
| 1169 |
+
.playground-status.success {
|
| 1170 |
+
background-color: #f0fdf4;
|
| 1171 |
+
color: #166534;
|
| 1172 |
+
border: 1px solid #dcfce7;
|
| 1173 |
+
}
|
| 1174 |
+
|
| 1175 |
+
.voice-select-container {
|
| 1176 |
+
display: flex;
|
| 1177 |
+
gap: 8px;
|
| 1178 |
+
align-items: center;
|
| 1179 |
+
}
|
| 1180 |
+
|
| 1181 |
+
.voice-select-container select {
|
| 1182 |
+
flex: 1;
|
| 1183 |
+
}
|
| 1184 |
+
|
| 1185 |
+
.sample-button {
|
| 1186 |
+
background-color: #4a90e2;
|
| 1187 |
+
color: white;
|
| 1188 |
+
border: none;
|
| 1189 |
+
border-radius: 4px;
|
| 1190 |
+
padding: 8px 12px;
|
| 1191 |
+
cursor: pointer;
|
| 1192 |
+
transition: background-color 0.2s;
|
| 1193 |
+
display: flex;
|
| 1194 |
+
align-items: center;
|
| 1195 |
+
justify-content: center;
|
| 1196 |
+
}
|
| 1197 |
+
|
| 1198 |
+
.sample-button:hover {
|
| 1199 |
+
background-color: #357abd;
|
| 1200 |
+
}
|
| 1201 |
+
|
| 1202 |
+
.sample-button:active {
|
| 1203 |
+
background-color: #2d6da3;
|
| 1204 |
+
}
|
| 1205 |
+
|
| 1206 |
+
.sample-button i {
|
| 1207 |
+
font-size: 14px;
|
| 1208 |
+
}
|
| 1209 |
+
|
| 1210 |
+
@media (max-width: 768px) {
|
| 1211 |
+
.playground-container {
|
| 1212 |
+
grid-template-columns: 1fr;
|
| 1213 |
+
}
|
| 1214 |
+
|
| 1215 |
+
.playground-output {
|
| 1216 |
+
gap: 1.5rem;
|
| 1217 |
+
}
|
| 1218 |
+
}
|
utils/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utils Package
|
| 3 |
+
|
| 4 |
+
This package contains utility functions and helper classes.
|
| 5 |
+
"""
|
utils/config.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration Utilities
|
| 3 |
+
|
| 4 |
+
This module provides utilities for loading and managing configuration settings.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import argparse
|
| 9 |
+
import logging
|
| 10 |
+
from dotenv import load_dotenv
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
def load_config():
|
| 15 |
+
"""Load configuration from environment variables and command line arguments.
|
| 16 |
+
|
| 17 |
+
Returns:
|
| 18 |
+
argparse.Namespace: The configuration settings
|
| 19 |
+
"""
|
| 20 |
+
# Load environment variables
|
| 21 |
+
load_dotenv()
|
| 22 |
+
|
| 23 |
+
# Get default values from environment variables
|
| 24 |
+
default_host = os.getenv("HOST", "localhost")
|
| 25 |
+
default_port = int(os.getenv("PORT", "7000"))
|
| 26 |
+
default_verify_ssl = os.getenv("VERIFY_SSL", "true").lower() != "false"
|
| 27 |
+
default_max_queue_size = int(os.getenv("MAX_QUEUE_SIZE", "100"))
|
| 28 |
+
default_rate_limit_requests = int(os.getenv("RATE_LIMIT_REQUESTS", "30"))
|
| 29 |
+
default_rate_limit_window = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
|
| 30 |
+
|
| 31 |
+
parser = argparse.ArgumentParser(description="Run the TTS API server")
|
| 32 |
+
parser.add_argument("--host", type=str, default=default_host, help="Host to bind to")
|
| 33 |
+
parser.add_argument("--port", type=int, default=default_port, help="Port to bind to")
|
| 34 |
+
parser.add_argument("--no-verify-ssl", action="store_true", help="Disable SSL certificate verification (insecure, use only for testing)")
|
| 35 |
+
parser.add_argument("--max-queue-size", type=int, default=default_max_queue_size, help="Maximum number of tasks in queue")
|
| 36 |
+
parser.add_argument("--test-connection", action="store_true", help="Test connection to OpenAI.fm and exit")
|
| 37 |
+
|
| 38 |
+
args = parser.parse_args()
|
| 39 |
+
|
| 40 |
+
# Apply global SSL settings if needed
|
| 41 |
+
if args.no_verify_ssl or not default_verify_ssl:
|
| 42 |
+
import ssl
|
| 43 |
+
# Disable SSL verification globally in Python
|
| 44 |
+
ssl._create_default_https_context = ssl._create_unverified_context
|
| 45 |
+
logger.warning("SSL certificate verification disabled GLOBALLY. This is insecure!")
|
| 46 |
+
|
| 47 |
+
return {
|
| 48 |
+
'host': args.host,
|
| 49 |
+
'port': args.port,
|
| 50 |
+
'verify_ssl': not args.no_verify_ssl,
|
| 51 |
+
'max_queue_size': args.max_queue_size,
|
| 52 |
+
'rate_limit_requests': default_rate_limit_requests,
|
| 53 |
+
'rate_limit_window': default_rate_limit_window,
|
| 54 |
+
'test_connection': args.test_connection
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
async def test_connection(session):
|
| 58 |
+
"""Test connection to OpenAI.fm.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
session: aiohttp.ClientSession to use for requests
|
| 62 |
+
"""
|
| 63 |
+
logger.info("Testing connection to OpenAI.fm...")
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
logger.info("Sending GET request to OpenAI.fm homepage")
|
| 67 |
+
async with session.get("https://www.openai.fm") as response:
|
| 68 |
+
logger.info(f"Homepage status: {response.status}")
|
| 69 |
+
if response.status == 200:
|
| 70 |
+
logger.info("Successfully connected to OpenAI.fm homepage")
|
| 71 |
+
else:
|
| 72 |
+
logger.error(f"Failed to connect to OpenAI.fm homepage: {response.status}")
|
| 73 |
+
|
| 74 |
+
logger.info("Testing API endpoint with minimal request")
|
| 75 |
+
test_data = {"input": "Test", "voice": "alloy"}
|
| 76 |
+
import urllib.parse
|
| 77 |
+
url_encoded_data = urllib.parse.urlencode(test_data)
|
| 78 |
+
|
| 79 |
+
async with session.post(
|
| 80 |
+
"https://www.openai.fm/api/generate",
|
| 81 |
+
data=url_encoded_data,
|
| 82 |
+
headers={
|
| 83 |
+
"Accept": "*/*",
|
| 84 |
+
"Accept-Language": "en-US,en;q=0.9",
|
| 85 |
+
"Origin": "https://www.openai.fm",
|
| 86 |
+
"Referer": "https://www.openai.fm/",
|
| 87 |
+
"Content-Type": "application/x-www-form-urlencoded"
|
| 88 |
+
}
|
| 89 |
+
) as response:
|
| 90 |
+
logger.info(f"API endpoint status: {response.status}")
|
| 91 |
+
if response.status == 200:
|
| 92 |
+
data = await response.read()
|
| 93 |
+
logger.info(f"Successfully received {len(data)} bytes from API")
|
| 94 |
+
else:
|
| 95 |
+
text = await response.text()
|
| 96 |
+
logger.error(f"API request failed: {response.status}, {text}")
|
| 97 |
+
|
| 98 |
+
except Exception as e:
|
| 99 |
+
logger.error(f"Connection test failed with error: {str(e)}")
|
| 100 |
+
import traceback
|
| 101 |
+
logger.error(traceback.format_exc())
|
voices/alloy_sample.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4ef4d35b248c6d3de5801e878b92655ea9bea6a41647d09e918ab5261192aaf0
|
| 3 |
+
size 149805
|
voices/ash_sample.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:82be24cc5dd15c78fd01809fcec17ba4543297ab4ddb74219d46b0ab63593691
|
| 3 |
+
size 180909
|
voices/ballad_sample.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fc9893354cfe3316351f43b0e615f9415dc2cf7a5aecdac57176dea72758af46
|
| 3 |
+
size 585644
|
voices/coral_sample.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5a71aad37e82a1fc5f990464465413baa0883855f8d51839c5e6865d2106560b
|
| 3 |
+
size 165165
|
voices/echo_sample.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2c72fd90e8bcd80bf6fe036e60c3ffb2eb41a042d69807cde343e30ec6f79c0c
|
| 3 |
+
size 492044
|
voices/fable_sample.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a084c93e186d6ab02491e92d3409b677f9f31d344d5b232f3154c7138cdd396a
|
| 3 |
+
size 484844
|
voices/nova_sample.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:46a6cc46066abb9250a72a8e1d6c92d778563dbe8d25a525cce72ef62a103865
|
| 3 |
+
size 463244
|
voices/onyx_sample.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5fd4d12245706c97f4e4adf6b56ebd00f123c24f417e6ef2ffa2b29673532d49
|
| 3 |
+
size 164013
|
voices/sage_sample.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:104037cfc2829b35a2ce181b296448ac9aa0b7685122eb3595619eb688033ec9
|
| 3 |
+
size 520844
|
voices/shimmer_sample.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0407851edc226a3f8ced874ff223d173a489f86d41c5c6c89de7d636e545de1d
|
| 3 |
+
size 160173
|
voices/verse_sample.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:acf26a3f25f4625afedd6f2d2710e64f886ba7a8627adc7b7ca260061d029963
|
| 3 |
+
size 556844
|