NitinBot001 commited on
Commit
64cfce9
·
verified ·
1 Parent(s): 4489dc1

Upload 28 files

Browse files
.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
- title: TTSFM LAGECY
3
- emoji: 🦀
4
- colorFrom: gray
5
- colorTo: pink
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ [![Docker Pulls](https://img.shields.io/docker/pulls/dbcccc/ttsfm?style=flat-square&logo=docker)](https://hub.docker.com/r/dbcccc/ttsfm)
4
+ [![License](https://img.shields.io/github/license/dbccccccc/ttsfm?style=flat-square)](LICENSE)
5
+ [![GitHub Stars](https://img.shields.io/github/stars/dbccccccc/ttsfm?style=social)](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
+ [![Star History Chart](https://api.star-history.com/svg?repos=dbccccccc/ttsfm&type=Date)](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