@@ -0,0 +1,39 @@
|
|||||||
|
# ByteDance TTS v3 API 配置示例
|
||||||
|
# 将此文件复制为 .env 并填入实际配置
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 必需的环境变量
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
# 火山引擎新版控制台获取的 API Key
|
||||||
|
BYTEDANCE_TTS_API_KEY=your_api_key_here
|
||||||
|
|
||||||
|
# 资源信息ID(决定使用1.0还是2.0模型)
|
||||||
|
# 语音合成模型:
|
||||||
|
# - seed-tts-1.0: 豆包语音合成模型1.0字符版
|
||||||
|
# - seed-tts-1.0-concurr: 豆包语音合成模型1.0并发版
|
||||||
|
# - seed-tts-2.0: 豆包语音合成模型2.0字符版
|
||||||
|
# 声音复刻模型:
|
||||||
|
# - seed-icl-1.0: 声音复刻1.0字符版
|
||||||
|
# - seed-icl-1.0-concurr: 声音复刻1.0并发版
|
||||||
|
# - seed-icl-2.0: 声音复刻2.0字符版
|
||||||
|
BYTEDANCE_TTS_RESOURCE_ID=seed-tts-1.0
|
||||||
|
|
||||||
|
# 发音人(音色)ID,具体参考火山引擎音色列表
|
||||||
|
# 注意:1.0音色只能搭配 seed-tts-1.0 Resource ID
|
||||||
|
# 2.0音色只能搭配 seed-tts-2.0 Resource ID
|
||||||
|
BYTEDANCE_TTS_SPEAKER=your_speaker_id_here
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 可选的环境变量
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
# 请求超时时间,默认30秒
|
||||||
|
BYTEDANCE_TTS_TIMEOUT=30s
|
||||||
|
|
||||||
|
# OpenAI兼容接口的API密钥(可选)
|
||||||
|
# 配置后,客户端请求需要携带 Authorization: Bearer <OPENAI_TTS_API_KEY>
|
||||||
|
OPENAI_TTS_API_KEY=your_openai_compatible_key_here
|
||||||
|
|
||||||
|
# 服务监听端口,默认8080
|
||||||
|
PORT=8080
|
||||||
@@ -1,139 +1,251 @@
|
|||||||
# ByteDance TTS to OpenAI API Adapter - Linux部署指南
|
# 字节跳动火山引擎TTS v3 API 转 OpenAI 兼容接口
|
||||||
|
|
||||||
## 快速开始
|
## 项目简介
|
||||||
|
|
||||||
这是为Linux环境优化的TTS服务器版本,可以将字节跳动TTS服务适配为OpenAI TTS API格式。
|
本项目将字节跳动火山引擎TTS(文本转语音)v3 API封装为OpenAI兼容的TTS API接口,使原本调用OpenAI TTS服务的应用可以无缝切换到火山引擎TTS服务。
|
||||||
|
|
||||||
|
### 主要特性
|
||||||
|
|
||||||
|
- ✅ 完全兼容OpenAI `/v1/audio/speech` API接口
|
||||||
|
- ✅ 支持火山引擎TTS v3 API(单向流式)
|
||||||
|
- ✅ 支持API Key鉴权方式
|
||||||
|
- ✅ 支持多种发音人和模型版本
|
||||||
|
- ✅ 内置速率限制和统计功能
|
||||||
|
- ✅ 支持配置API密钥验证
|
||||||
|
- ✅ 并发限制:最多同时处理10个请求(保护上游API)
|
||||||
|
- ✅ 跨平台支持(Windows/Linux/macOS)
|
||||||
|
|
||||||
## 文件说明
|
## 文件说明
|
||||||
|
|
||||||
- `tts_server_linux.go` - Linux优化版主程序
|
- `tts_server.go` - 主程序源码
|
||||||
- `start_linux.sh` - Linux启动脚本
|
- `.env.example` - 环境变量配置示例
|
||||||
- `fix_crlf.sh` - 换行符修复工具
|
- `go.mod` / `go.sum` - Go模块依赖
|
||||||
- `go.mod` 和 `go.sum` - Go模块依赖文件
|
|
||||||
|
|
||||||
## 部署步骤
|
## 快速开始
|
||||||
|
|
||||||
### 1. 上传文件到Linux服务器
|
### 前置要求
|
||||||
|
|
||||||
将本文件夹中的所有文件上传到Linux服务器,建议放在`/www/wwwroot/tts-server/`目录。
|
- Go 1.19 或更高版本
|
||||||
|
- 火山引擎账号并开通TTS服务
|
||||||
|
|
||||||
### 2. 修复换行符(重要!)
|
### 1. 编译程序
|
||||||
|
|
||||||
在Linux服务器上,执行以下命令修复可能存在的Windows CRLF换行符问题:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /www/wwwroot/tts-server
|
go build -o tts_server tts_server.go
|
||||||
chmod +x fix_crlf.sh
|
|
||||||
./fix_crlf.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 安装Go环境(如果未安装)
|
### 2. 配置环境变量
|
||||||
|
|
||||||
|
复制 `.env.example` 为 `.env` 并填入你的配置:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 下载Go 1.21.5
|
cp .env.example .env
|
||||||
cd /tmp
|
|
||||||
wget https://go.dev/dl/go1.21.5.linux-amd64.tar.gz
|
|
||||||
|
|
||||||
# 解压到/usr/local
|
|
||||||
sudo rm -rf /usr/local/go
|
|
||||||
sudo tar -C /usr/local -xzf go1.21.5.linux-amd64.tar.gz
|
|
||||||
|
|
||||||
# 设置环境变量
|
|
||||||
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
|
|
||||||
echo 'export GOPROXY=https://goproxy.cn,direct' >> ~/.bashrc
|
|
||||||
source ~/.bashrc
|
|
||||||
|
|
||||||
# 验证安装
|
|
||||||
go version
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. 启动服务
|
编辑 `.env` 文件,填入必要的配置参数。
|
||||||
|
|
||||||
|
### 3. 启动服务
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /www/wwwroot/tts-server
|
# Windows
|
||||||
chmod +x start_linux.sh
|
tts_server.exe
|
||||||
./start_linux.sh start
|
|
||||||
|
# Linux/macOS
|
||||||
|
./tts_server
|
||||||
```
|
```
|
||||||
|
|
||||||
## 服务管理命令
|
服务默认监听 `8080` 端口。
|
||||||
|
|
||||||
- 启动服务:`./start_linux.sh start`
|
|
||||||
- 停止服务:`./start_linux.sh stop`
|
|
||||||
- 重启服务:`./start_linux.sh restart`
|
|
||||||
- 查看状态:`./start_linux.sh status`
|
|
||||||
- 查看日志:`./start_linux.sh logs`
|
|
||||||
|
|
||||||
## 接口使用
|
|
||||||
|
|
||||||
- OpenAI TTS API兼容端点:`http://你的服务器IP:8080/v1/audio/speech`
|
|
||||||
- 健康检查:`http://你的服务器IP:8080/health`
|
|
||||||
|
|
||||||
## API调用示例
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "http://你的服务器IP:8080/v1/audio/speech" \
|
|
||||||
-H "Authorization: Bearer sk-7IBXpzK1YszwGArEMvLGzSdZe93rXVxg4CFBe5KRlqs4dVJO" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"model":"tts-1","input":"你好,这是一个测试文本","voice":"alloy","speed":1.0}' \
|
|
||||||
-o output.wav
|
|
||||||
```
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### 端口被占用
|
|
||||||
|
|
||||||
如果8080端口被占用,可以通过环境变量修改端口:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export PORT=8081 && ./start_linux.sh start
|
|
||||||
```
|
|
||||||
|
|
||||||
### 启动失败
|
|
||||||
|
|
||||||
查看日志文件获取详细信息:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat /www/wwwroot/tts-server/logs/tts-server.log
|
|
||||||
```
|
|
||||||
|
|
||||||
## 环境变量配置
|
## 环境变量配置
|
||||||
|
|
||||||
服务现在支持通过环境变量配置所有参数,推荐使用这种方式而不是直接修改代码。
|
### 必需参数
|
||||||
|
|
||||||
### 必须的环境变量
|
| 变量名 | 说明 | 示例 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `BYTEDANCE_TTS_API_KEY` | 火山引擎新版控制台 API Key | `your_api_key_here` |
|
||||||
|
| `BYTEDANCE_TTS_RESOURCE_ID` | 资源ID,决定模型版本 | `seed-tts-1.0` |
|
||||||
|
| `BYTEDANCE_TTS_SPEAKER` | 发音人(音色)ID | `zh_female_qingxin` |
|
||||||
|
|
||||||
- `BYTEDANCE_TTS_APP_ID` - 字节跳动TTS应用ID
|
### 可选参数
|
||||||
- `BYTEDANCE_TTS_BEARER_TOKEN` - 字节跳动TTS访问令牌
|
|
||||||
- `BYTEDANCE_TTS_CLUSTER` - 字节跳动TTS业务集群
|
|
||||||
- `BYTEDANCE_TTS_VOICE_TYPE` - 字节跳动TTS声音类型
|
|
||||||
|
|
||||||
### 可选的环境变量
|
| 变量名 | 说明 | 默认值 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| `BYTEDANCE_TTS_TIMEOUT` | 请求超时时间 | `30s` |
|
||||||
|
| `OPENAI_TTS_API_KEY` | OpenAI兼容接口的API密钥(逗号分隔支持多个) | 无 |
|
||||||
|
| `PORT` | 服务监听端口 | `8080` |
|
||||||
|
|
||||||
- `BYTEDANCE_TTS_ENDPOINT` - 字节跳动TTS服务端点(默认:https://openspeech.bytedance.com/api/v1/tts)
|
### Resource ID 说明
|
||||||
- `BYTEDANCE_TTS_TIMEOUT` - 字节跳动TTS请求超时时间(默认:30s,支持Go duration格式,如"60s"、"1m")
|
|
||||||
- `OPENAI_TTS_API_KEY` - OpenAI TTS API密钥(未设置时允许任意key)
|
|
||||||
- `PORT` - 服务监听端口(默认:8080)
|
|
||||||
|
|
||||||
### 环境变量使用示例
|
| Resource ID | 模型说明 |
|
||||||
|
|-------------|----------|
|
||||||
|
| `seed-tts-1.0` | 豆包语音合成模型1.0字符版 |
|
||||||
|
| `seed-tts-1.0-concurr` | 豆包语音合成模型1.0并发版 |
|
||||||
|
| `seed-tts-2.0` | 豆包语音合成模型2.0字符版 |
|
||||||
|
| `seed-icl-1.0` | 声音复刻1.0字符版 |
|
||||||
|
| `seed-icl-1.0-concurr` | 声音复刻1.0并发版 |
|
||||||
|
| `seed-icl-2.0` | 声音复刻2.0字符版 |
|
||||||
|
|
||||||
```bash
|
**注意:** 1.0音色只能搭配 `seed-tts-1.0` Resource ID,2.0音色只能搭配 `seed-tts-2.0` Resource ID。
|
||||||
# 启动服务时设置环境变量
|
|
||||||
export BYTEDANCE_TTS_APP_ID=your_app_id
|
## API 使用说明
|
||||||
export BYTEDANCE_TTS_BEARER_TOKEN=your_token
|
|
||||||
export BYTEDANCE_TTS_CLUSTER=your_cluster
|
### OpenAI 兼容接口
|
||||||
export BYTEDANCE_TTS_VOICE_TYPE=your_voice_type
|
|
||||||
export OPENAI_TTS_API_KEY=sk-your_api_key
|
**端点:** `POST /v1/audio/speech`
|
||||||
export PORT=8081
|
|
||||||
./start_linux.sh start
|
**请求头:**
|
||||||
|
- `Content-Type: application/json`
|
||||||
|
- `Authorization: Bearer <你的API密钥>`(如果配置了OPENAI_TTS_API_KEY)
|
||||||
|
|
||||||
|
**请求体:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "tts-1",
|
||||||
|
"input": "你好,这是一个测试文本",
|
||||||
|
"voice": "alloy",
|
||||||
|
"response_format": "wav",
|
||||||
|
"speed": 1.0
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 注意事项
|
**参数说明:**
|
||||||
|
- `model` - 模型名称(OpenAI兼容,实际不影响)
|
||||||
|
- `input` - 要合成的文本
|
||||||
|
- `voice` - 发音人(OpenAI兼容,实际不影响)
|
||||||
|
- `response_format` - 输出格式:仅支持 `wav`
|
||||||
|
- `speed` - 语速:0.25 ~ 4.0
|
||||||
|
|
||||||
1. 确保Go版本至少为1.19
|
**示例调用:**
|
||||||
2. 确保服务器有足够的网络权限访问字节跳动TTS服务
|
|
||||||
3. 必须设置所有必需的环境变量,否则服务会使用默认值并显示警告信息
|
```bash
|
||||||
4. 可以通过设置环境变量而不是直接修改代码来配置服务
|
curl -X POST "http://localhost:8080/v1/audio/speech" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"model":"tts-1","input":"你好,世界","voice":"alloy","speed":1.0}' \
|
||||||
|
-o output.wav
|
||||||
|
```
|
||||||
|
|
||||||
|
### 健康检查(含统计信息)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
```
|
||||||
|
|
||||||
|
返回包含:服务状态、请求统计、错误记录、配置检查结果
|
||||||
|
|
||||||
|
## 限流机制
|
||||||
|
|
||||||
|
为保护上游火山引擎API,服务实现了两层限流保护:
|
||||||
|
|
||||||
|
### 1. 全局并发限制
|
||||||
|
- **限制**:最多同时处理 **10个** TTS请求
|
||||||
|
- **触发**:超过10个并发请求时
|
||||||
|
- **错误码**:`503 Service Unavailable`
|
||||||
|
- **说明**:确保不超过上游API的并发限制
|
||||||
|
|
||||||
|
### 2. IP速率限制
|
||||||
|
- **限制**:每个IP每分钟 **100个** 请求
|
||||||
|
- **触发**:单个IP调用过于频繁
|
||||||
|
- **错误码**:`429 Too Many Requests`
|
||||||
|
- **说明**:防止单个客户端滥用服务
|
||||||
|
|
||||||
|
### 触发限流时的响应
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"message": "Server is busy, maximum concurrent requests reached.",
|
||||||
|
"type": "concurrency_limit_error",
|
||||||
|
"code": "max_concurrent_requests"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 服务器日志
|
||||||
|
触发限流时服务器会输出中文警告日志:
|
||||||
|
- `警告: 已达到最大并发请求数限制,拒绝请求 - 客户端IP: x.x.x.x`
|
||||||
|
- `警告: 已超过IP速率限制,拒绝请求 - 客户端IP: x.x.x.x`
|
||||||
|
|
||||||
|
## 支持的发音人
|
||||||
|
|
||||||
|
具体发音人列表请参考火山引擎官方文档:
|
||||||
|
- 1.0音色:https://www.volcengine.com/docs/6561/97454
|
||||||
|
- 2.0音色:https://www.volcengine.com/docs/6561/1340515
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 1. 如何获取鉴权信息?
|
||||||
|
|
||||||
|
- 登录火山引擎新版控制台
|
||||||
|
- 进入"语音合成"服务
|
||||||
|
- 创建应用并获取API Key
|
||||||
|
|
||||||
|
### 2. 端口被占用怎么办?
|
||||||
|
|
||||||
|
通过环境变量修改端口:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
set PORT=8081 && tts_server.exe
|
||||||
|
|
||||||
|
# Linux/macOS
|
||||||
|
PORT=8081 ./tts_server
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 如何配置多个API密钥?
|
||||||
|
|
||||||
|
使用逗号分隔:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OPENAI_TTS_API_KEY=sk-key1,sk-key2,sk-key3
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 查看日志
|
||||||
|
|
||||||
|
服务启动后会输出详细日志,包括:
|
||||||
|
- 服务启动信息
|
||||||
|
- 配置状态
|
||||||
|
- 请求统计信息
|
||||||
|
- 错误详情
|
||||||
|
|
||||||
|
## 部署建议
|
||||||
|
|
||||||
|
### Linux Systemd 服务
|
||||||
|
|
||||||
|
创建 `/etc/systemd/system/tts-server.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=ByteDance TTS to OpenAI API Adapter
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=www-data
|
||||||
|
WorkingDirectory=/www/wwwroot/tts-server
|
||||||
|
EnvironmentFile=/www/wwwroot/tts-server/.env
|
||||||
|
ExecStart=/www/wwwroot/tts-server/tts_server
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
启动服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable tts-server
|
||||||
|
sudo systemctl start tts-server
|
||||||
|
```
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
本项目采用非商业用途许可协议。您可以免费使用本软件用于非商业目的,但禁止用于任何商业活动。详细条款请参阅项目中的 [LICENSE](LICENSE) 文件。
|
本项目采用非商业用途许可协议。您可以免费使用本软件用于非商业目的,但禁止用于任何商业活动。详细条款请参阅 [LICENSE](LICENSE) 文件。
|
||||||
|
|
||||||
|
## 技术支持
|
||||||
|
|
||||||
|
如有问题,请检查:
|
||||||
|
1. 环境变量配置是否正确
|
||||||
|
2. 网络是否能访问火山引擎TTS服务
|
||||||
|
3. 鉴权信息是否有效
|
||||||
|
4. Resource ID与Speaker是否匹配
|
||||||
|
|||||||
-292
@@ -1,292 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# ByteDance TTS to OpenAI API Adapter 启动脚本 (Linux优化版)
|
|
||||||
# 使用Unix LF换行符,避免Windows CRLF问题
|
|
||||||
|
|
||||||
# 设置Go代理(中国大陆用户推荐)
|
|
||||||
export GOPROXY=https://goproxy.cn,direct
|
|
||||||
|
|
||||||
# 项目目录 - 自动获取当前目录
|
|
||||||
PROJECT_DIR=$(pwd)
|
|
||||||
BINARY_NAME="tts-server"
|
|
||||||
MAIN_FILE="tts_server.go"
|
|
||||||
PID_FILE="$PROJECT_DIR/tts-server.pid"
|
|
||||||
LOG_FILE="$PROJECT_DIR/logs/tts-server.log"
|
|
||||||
|
|
||||||
# 创建日志目录
|
|
||||||
mkdir -p "$PROJECT_DIR/logs"
|
|
||||||
|
|
||||||
# 检查Go环境
|
|
||||||
check_go() {
|
|
||||||
if ! command -v go &> /dev/null; then
|
|
||||||
echo "❌ Go 未安装或未添加到 PATH"
|
|
||||||
echo "请先安装 Go 语言环境"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
echo "✅ Go 环境检查通过: $(go version)"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# 检查依赖
|
|
||||||
check_deps() {
|
|
||||||
echo "正在检查 Go 模块依赖..."
|
|
||||||
cd $PROJECT_DIR
|
|
||||||
if [ ! -f go.mod ]; then
|
|
||||||
echo "❌ 未找到 go.mod 文件"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 下载依赖
|
|
||||||
go mod download
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "❌ Go 依赖下载失败"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Go 依赖检查完成"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# 函数:启动服务
|
|
||||||
start() {
|
|
||||||
echo "🚀 正在启动 TTS API 服务器..."
|
|
||||||
|
|
||||||
# 检查Go环境
|
|
||||||
if ! check_go; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 检查是否已经运行
|
|
||||||
if [ -f $PID_FILE ]; then
|
|
||||||
PID=$(cat $PID_FILE)
|
|
||||||
if ps -p $PID > /dev/null 2>&1; then
|
|
||||||
echo "⚠️ 服务已经在运行中 (PID: $PID)"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 切换到项目目录
|
|
||||||
cd $PROJECT_DIR
|
|
||||||
|
|
||||||
# 检查主程序文件
|
|
||||||
if [ ! -f $MAIN_FILE ]; then
|
|
||||||
echo "❌ 未找到主程序文件: $MAIN_FILE"
|
|
||||||
echo "请确保 $MAIN_FILE 文件存在"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 检查依赖
|
|
||||||
if ! check_deps; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 构建项目
|
|
||||||
echo "🔨 正在构建项目..."
|
|
||||||
go build -o $BINARY_NAME $MAIN_FILE
|
|
||||||
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "❌ 构建失败"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ 构建成功"
|
|
||||||
|
|
||||||
# 启动服务
|
|
||||||
echo "🌟 正在启动服务..."
|
|
||||||
nohup ./$BINARY_NAME > $LOG_FILE 2>&1 &
|
|
||||||
PID=$!
|
|
||||||
echo $PID > $PID_FILE
|
|
||||||
|
|
||||||
# 等待启动
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
# 检查启动状态
|
|
||||||
if ps -p $PID > /dev/null 2>&1; then
|
|
||||||
echo "✅ TTS API 服务器启动成功 (PID: $PID)"
|
|
||||||
echo "📍 端口: 8080"
|
|
||||||
echo "📄 日志文件: $LOG_FILE"
|
|
||||||
echo "🌐 访问地址: http://你的服务器IP:8080"
|
|
||||||
echo "💚 健康检查: http://你的服务器IP:8080/health"
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
echo "❌ 服务启动失败,请检查日志: $LOG_FILE"
|
|
||||||
rm -f $PID_FILE
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 函数:停止服务
|
|
||||||
stop() {
|
|
||||||
echo "🛑 正在停止 TTS API 服务器..."
|
|
||||||
|
|
||||||
if [ -f $PID_FILE ]; then
|
|
||||||
PID=$(cat $PID_FILE)
|
|
||||||
if ps -p $PID > /dev/null 2>&1; then
|
|
||||||
# 优雅关闭(发送 SIGTERM)
|
|
||||||
kill -TERM $PID
|
|
||||||
|
|
||||||
# 等待进程结束
|
|
||||||
for i in {1..10}; do
|
|
||||||
if ! ps -p $PID > /dev/null 2>&1; then
|
|
||||||
echo "✅ 服务已优雅停止 (PID: $PID)"
|
|
||||||
rm -f $PID_FILE
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
# 强制杀死进程
|
|
||||||
echo "⚠️ 进程未响应,强制终止..."
|
|
||||||
kill -KILL $PID
|
|
||||||
echo "✅ 服务已强制停止 (PID: $PID)"
|
|
||||||
rm -f $PID_FILE
|
|
||||||
else
|
|
||||||
echo "⚠️ 服务未运行"
|
|
||||||
rm -f $PID_FILE
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "⚠️ PID文件不存在,服务可能未运行"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 函数:重启服务
|
|
||||||
restart() {
|
|
||||||
echo "🔄 正在重启 TTS API 服务器..."
|
|
||||||
stop
|
|
||||||
sleep 2
|
|
||||||
start
|
|
||||||
}
|
|
||||||
|
|
||||||
# 函数:查看状态
|
|
||||||
status() {
|
|
||||||
echo "📊 TTS API 服务器状态:"
|
|
||||||
echo "================================"
|
|
||||||
|
|
||||||
if [ -f $PID_FILE ]; then
|
|
||||||
PID=$(cat $PID_FILE)
|
|
||||||
if ps -p $PID > /dev/null 2>&1; then
|
|
||||||
echo "🟢 状态: 运行中"
|
|
||||||
echo "🆔 PID: $PID"
|
|
||||||
echo "🕐 运行时间: $(ps -o etime= -p $PID | tr -d ' ')"
|
|
||||||
echo "💾 内存使用: $(ps -o rss= -p $PID | tr -d ' ') KB"
|
|
||||||
echo "🌐 端口: 8080"
|
|
||||||
echo "📄 日志: $LOG_FILE"
|
|
||||||
|
|
||||||
# 检查端口是否监听
|
|
||||||
if command -v netstat &> /dev/null; then
|
|
||||||
if netstat -tlnp 2>/dev/null | grep ":8080" | grep "$PID" > /dev/null; then
|
|
||||||
echo "🔗 端口监听: ✅"
|
|
||||||
else
|
|
||||||
echo "🔗 端口监听: ❌"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "🔴 状态: 未运行(PID文件存在但进程不存在)"
|
|
||||||
rm -f $PID_FILE
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "🔴 状态: 未运行"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "================================"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 函数:查看日志
|
|
||||||
logs() {
|
|
||||||
if [ -f $LOG_FILE ]; then
|
|
||||||
echo "📄 实时日志 (按 Ctrl+C 退出):"
|
|
||||||
echo "================================"
|
|
||||||
tail -f $LOG_FILE
|
|
||||||
else
|
|
||||||
echo "❌ 日志文件不存在: $LOG_FILE"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 函数:查看最近日志
|
|
||||||
lastlog() {
|
|
||||||
if [ -f $LOG_FILE ]; then
|
|
||||||
echo "📄 最近 50 行日志:"
|
|
||||||
echo "================================"
|
|
||||||
tail -n 50 $LOG_FILE
|
|
||||||
else
|
|
||||||
echo "❌ 日志文件不存在: $LOG_FILE"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 函数:测试服务
|
|
||||||
test() {
|
|
||||||
echo "🧪 正在测试 TTS API 服务器..."
|
|
||||||
|
|
||||||
# 检查健康状态
|
|
||||||
echo "1. 健康检查测试..."
|
|
||||||
if command -v curl &> /dev/null; then
|
|
||||||
response=$(curl -s -w "%{http_code}" -o /tmp/health_check.tmp http://localhost:8080/health)
|
|
||||||
if [ "$response" = "200" ]; then
|
|
||||||
echo "✅ 健康检查通过"
|
|
||||||
cat /tmp/health_check.tmp | python3 -m json.tool 2>/dev/null || cat /tmp/health_check.tmp
|
|
||||||
else
|
|
||||||
echo "❌ 健康检查失败 (HTTP: $response)"
|
|
||||||
fi
|
|
||||||
rm -f /tmp/health_check.tmp
|
|
||||||
else
|
|
||||||
echo "⚠️ curl 未安装,跳过健康检查"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# 显示帮助信息
|
|
||||||
usage() {
|
|
||||||
echo "ByteDance TTS to OpenAI API Adapter 管理脚本 (Linux优化版)"
|
|
||||||
echo ""
|
|
||||||
echo "使用方法: $0 {命令}"
|
|
||||||
echo ""
|
|
||||||
echo "可用命令:"
|
|
||||||
echo " start - 启动服务"
|
|
||||||
echo " stop - 停止服务"
|
|
||||||
echo " restart - 重启服务"
|
|
||||||
echo " status - 查看详细状态"
|
|
||||||
echo " logs - 实时查看日志"
|
|
||||||
echo " lastlog - 查看最近日志"
|
|
||||||
echo " test - 测试服务"
|
|
||||||
echo " help - 显示帮助信息"
|
|
||||||
echo ""
|
|
||||||
echo "示例:"
|
|
||||||
echo " ./start_linux.sh start"
|
|
||||||
echo " ./start_linux.sh status"
|
|
||||||
echo " ./start_linux.sh logs"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 主程序逻辑
|
|
||||||
case "$1" in
|
|
||||||
start)
|
|
||||||
start
|
|
||||||
;;
|
|
||||||
stop)
|
|
||||||
stop
|
|
||||||
;;
|
|
||||||
restart)
|
|
||||||
restart
|
|
||||||
;;
|
|
||||||
status)
|
|
||||||
status
|
|
||||||
;;
|
|
||||||
logs)
|
|
||||||
logs
|
|
||||||
;;
|
|
||||||
lastlog)
|
|
||||||
lastlog
|
|
||||||
;;
|
|
||||||
test)
|
|
||||||
test
|
|
||||||
;;
|
|
||||||
help|--help|-h)
|
|
||||||
usage
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "❌ 未知命令: $1"
|
|
||||||
echo ""
|
|
||||||
usage
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
exit $?
|
|
||||||
+246
-120
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
@@ -23,26 +24,34 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DEFAULT_PORT = "8080"
|
DEFAULT_PORT = "8080"
|
||||||
DEFAULT_TIMEOUT = 30 * time.Second
|
DEFAULT_TIMEOUT = 30 * time.Second
|
||||||
MAX_TEXT_LENGTH = 5000
|
MAX_TEXT_LENGTH = 5000
|
||||||
MIN_SPEED = 0.25
|
MIN_SPEED = 0.25
|
||||||
MAX_SPEED = 4.0
|
MAX_SPEED = 4.0
|
||||||
DEFAULT_SPEED = 1.0
|
DEFAULT_SPEED = 1.0
|
||||||
MAX_REQUEST_BODY_SIZE = 1024 * 1024
|
MAX_REQUEST_BODY_SIZE = 1024 * 1024
|
||||||
RATE_LIMIT_REQUESTS = 100
|
RATE_LIMIT_REQUESTS = 100
|
||||||
RATE_LIMIT_WINDOW = time.Minute
|
RATE_LIMIT_WINDOW = time.Minute
|
||||||
MAX_RESPONSE_TIMES = 100
|
MAX_RESPONSE_TIMES = 100
|
||||||
MAX_ERRORS = 10
|
MAX_ERRORS = 10
|
||||||
|
MAX_CONCURRENT_REQUESTS = 10
|
||||||
)
|
)
|
||||||
|
|
||||||
type TTSServResponse struct {
|
type V3TTSResponse struct {
|
||||||
ReqID string `json:"reqid"`
|
ReqID string `json:"reqid"`
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
Message string `json:"Message"`
|
Message string `json:"message"`
|
||||||
Operation string `json:"operation"`
|
Event string `json:"event"`
|
||||||
Sequence int `json:"sequence"`
|
Sequence int `json:"sequence"`
|
||||||
Data string `json:"data"`
|
Data string `json:"data"`
|
||||||
|
Sentence string `json:"sentence,omitempty"`
|
||||||
|
IsFinal bool `json:"is_final"`
|
||||||
|
Usage *Usage `json:"usage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Usage struct {
|
||||||
|
TextWords int `json:"text_words"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpenAITTSRequest struct {
|
type OpenAITTSRequest struct {
|
||||||
@@ -54,21 +63,23 @@ type OpenAITTSRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ByteDanceTTSConfig struct {
|
type ByteDanceTTSConfig struct {
|
||||||
AppID string
|
ApiKey string
|
||||||
BearerToken string
|
ResourceId string
|
||||||
Cluster string
|
Speaker string
|
||||||
URL string
|
URL string
|
||||||
VoiceType string
|
Timeout time.Duration
|
||||||
Timeout time.Duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type RateLimiter struct {
|
type RateLimiter struct {
|
||||||
requests map[string][]time.Time
|
requests map[string][]time.Time
|
||||||
mutex sync.Mutex
|
mutex sync.Mutex
|
||||||
limit int
|
limit int
|
||||||
window time.Duration
|
window time.Duration
|
||||||
|
lastCleanup time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cleanupInterval = time.Hour
|
||||||
|
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
totalRequests int64
|
totalRequests int64
|
||||||
successfulRequests int64
|
successfulRequests int64
|
||||||
@@ -84,9 +95,11 @@ type Stats struct {
|
|||||||
var (
|
var (
|
||||||
VALID_API_KEYS []string
|
VALID_API_KEYS []string
|
||||||
ttsConfig ByteDanceTTSConfig
|
ttsConfig ByteDanceTTSConfig
|
||||||
|
ttsConfigErr error
|
||||||
globalHTTPClient *http.Client
|
globalHTTPClient *http.Client
|
||||||
apiStats *Stats
|
apiStats *Stats
|
||||||
rateLimiter *RateLimiter
|
rateLimiter *RateLimiter
|
||||||
|
concurrencySem chan struct{}
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -110,6 +123,8 @@ func init() {
|
|||||||
limit: RATE_LIMIT_REQUESTS,
|
limit: RATE_LIMIT_REQUESTS,
|
||||||
window: RATE_LIMIT_WINDOW,
|
window: RATE_LIMIT_WINDOW,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
concurrencySem = make(chan struct{}, MAX_CONCURRENT_REQUESTS)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rl *RateLimiter) Allow(key string) bool {
|
func (rl *RateLimiter) Allow(key string) bool {
|
||||||
@@ -119,6 +134,11 @@ func (rl *RateLimiter) Allow(key string) bool {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
cutoff := now.Add(-rl.window)
|
cutoff := now.Add(-rl.window)
|
||||||
|
|
||||||
|
if now.Sub(rl.lastCleanup) > cleanupInterval {
|
||||||
|
rl.cleanup()
|
||||||
|
rl.lastCleanup = now
|
||||||
|
}
|
||||||
|
|
||||||
timestamps := rl.requests[key]
|
timestamps := rl.requests[key]
|
||||||
valid := make([]time.Time, 0, len(timestamps))
|
valid := make([]time.Time, 0, len(timestamps))
|
||||||
for _, ts := range timestamps {
|
for _, ts := range timestamps {
|
||||||
@@ -137,34 +157,45 @@ func (rl *RateLimiter) Allow(key string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rl *RateLimiter) cleanup() {
|
||||||
|
cutoff := time.Now().Add(-rl.window)
|
||||||
|
for k, v := range rl.requests {
|
||||||
|
valid := make([]time.Time, 0, len(v))
|
||||||
|
for _, ts := range v {
|
||||||
|
if ts.After(cutoff) {
|
||||||
|
valid = append(valid, ts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(valid) == 0 {
|
||||||
|
delete(rl.requests, k)
|
||||||
|
} else {
|
||||||
|
rl.requests[k] = valid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func initTTSConfig() error {
|
func initTTSConfig() error {
|
||||||
appID := os.Getenv("BYTEDANCE_TTS_APP_ID")
|
apiKey := os.Getenv("BYTEDANCE_TTS_API_KEY")
|
||||||
bearerToken := os.Getenv("BYTEDANCE_TTS_BEARER_TOKEN")
|
resourceId := os.Getenv("BYTEDANCE_TTS_RESOURCE_ID")
|
||||||
cluster := os.Getenv("BYTEDANCE_TTS_CLUSTER")
|
speaker := os.Getenv("BYTEDANCE_TTS_SPEAKER")
|
||||||
voiceType := os.Getenv("BYTEDANCE_TTS_VOICE_TYPE")
|
|
||||||
|
|
||||||
missingVars := []string{}
|
missingVars := []string{}
|
||||||
if appID == "" {
|
|
||||||
missingVars = append(missingVars, "BYTEDANCE_TTS_APP_ID")
|
if apiKey == "" {
|
||||||
|
missingVars = append(missingVars, "BYTEDANCE_TTS_API_KEY")
|
||||||
}
|
}
|
||||||
if bearerToken == "" {
|
if resourceId == "" {
|
||||||
missingVars = append(missingVars, "BYTEDANCE_TTS_BEARER_TOKEN")
|
missingVars = append(missingVars, "BYTEDANCE_TTS_RESOURCE_ID")
|
||||||
}
|
}
|
||||||
if cluster == "" {
|
if speaker == "" {
|
||||||
missingVars = append(missingVars, "BYTEDANCE_TTS_CLUSTER")
|
missingVars = append(missingVars, "BYTEDANCE_TTS_SPEAKER")
|
||||||
}
|
|
||||||
if voiceType == "" {
|
|
||||||
missingVars = append(missingVars, "BYTEDANCE_TTS_VOICE_TYPE")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(missingVars) > 0 {
|
if len(missingVars) > 0 {
|
||||||
return fmt.Errorf("缺少必需的环境变量: %v", missingVars)
|
return fmt.Errorf("缺少必需的环境变量: %v", missingVars)
|
||||||
}
|
}
|
||||||
|
|
||||||
url := os.Getenv("BYTEDANCE_TTS_ENDPOINT")
|
url := "https://openspeech.bytedance.com/api/v3/tts/unidirectional"
|
||||||
if url == "" {
|
|
||||||
url = "https://openspeech.bytedance.com/api/v1/tts"
|
|
||||||
}
|
|
||||||
|
|
||||||
timeout := DEFAULT_TIMEOUT
|
timeout := DEFAULT_TIMEOUT
|
||||||
if timeoutStr := os.Getenv("BYTEDANCE_TTS_TIMEOUT"); timeoutStr != "" {
|
if timeoutStr := os.Getenv("BYTEDANCE_TTS_TIMEOUT"); timeoutStr != "" {
|
||||||
@@ -176,12 +207,11 @@ func initTTSConfig() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ttsConfig = ByteDanceTTSConfig{
|
ttsConfig = ByteDanceTTSConfig{
|
||||||
AppID: appID,
|
ApiKey: apiKey,
|
||||||
BearerToken: bearerToken,
|
ResourceId: resourceId,
|
||||||
Cluster: cluster,
|
Speaker: speaker,
|
||||||
URL: url,
|
URL: url,
|
||||||
VoiceType: voiceType,
|
Timeout: timeout,
|
||||||
Timeout: timeout,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -202,10 +232,9 @@ func initAPIKeys() {
|
|||||||
|
|
||||||
func checkEnvironmentVariables() map[string]interface{} {
|
func checkEnvironmentVariables() map[string]interface{} {
|
||||||
requiredVars := map[string]bool{
|
requiredVars := map[string]bool{
|
||||||
"BYTEDANCE_TTS_APP_ID": os.Getenv("BYTEDANCE_TTS_APP_ID") != "",
|
"BYTEDANCE_TTS_API_KEY": os.Getenv("BYTEDANCE_TTS_API_KEY") != "",
|
||||||
"BYTEDANCE_TTS_BEARER_TOKEN": os.Getenv("BYTEDANCE_TTS_BEARER_TOKEN") != "",
|
"BYTEDANCE_TTS_RESOURCE_ID": os.Getenv("BYTEDANCE_TTS_RESOURCE_ID") != "",
|
||||||
"BYTEDANCE_TTS_CLUSTER": os.Getenv("BYTEDANCE_TTS_CLUSTER") != "",
|
"BYTEDANCE_TTS_SPEAKER": os.Getenv("BYTEDANCE_TTS_SPEAKER") != "",
|
||||||
"BYTEDANCE_TTS_VOICE_TYPE": os.Getenv("BYTEDANCE_TTS_VOICE_TYPE") != "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
missingVars := []string{}
|
missingVars := []string{}
|
||||||
@@ -216,10 +245,9 @@ func checkEnvironmentVariables() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
|
|
||||||
optionalVars := map[string]bool{
|
optionalVars := map[string]bool{
|
||||||
"BYTEDANCE_TTS_ENDPOINT": os.Getenv("BYTEDANCE_TTS_ENDPOINT") != "",
|
"BYTEDANCE_TTS_TIMEOUT": os.Getenv("BYTEDANCE_TTS_TIMEOUT") != "",
|
||||||
"BYTEDANCE_TTS_TIMEOUT": os.Getenv("BYTEDANCE_TTS_TIMEOUT") != "",
|
"OPENAI_TTS_API_KEY": os.Getenv("OPENAI_TTS_API_KEY") != "",
|
||||||
"OPENAI_TTS_API_KEY": os.Getenv("OPENAI_TTS_API_KEY") != "",
|
"PORT": os.Getenv("PORT") != "",
|
||||||
"PORT": os.Getenv("PORT") != "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
@@ -230,7 +258,7 @@ func checkEnvironmentVariables() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func httpPost(url string, headers map[string]string, body []byte, timeout time.Duration) ([]byte, error) {
|
func httpPostStream(url string, headers map[string]string, body []byte, timeout time.Duration) (*http.Response, error) {
|
||||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(body))
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -239,51 +267,57 @@ func httpPost(url string, headers map[string]string, body []byte, timeout time.D
|
|||||||
req.Header.Set(key, value)
|
req.Header.Set(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
client := globalHTTPClient
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
if timeout != 0 && timeout != ttsConfig.Timeout {
|
defer cancel()
|
||||||
client = &http.Client{Timeout: timeout}
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
req = req.WithContext(ctx)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
retBody, err := io.ReadAll(resp.Body)
|
return globalHTTPClient.Do(req)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return retBody, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func synthesis(text string, speed float64) ([]byte, error) {
|
func convertSpeedToSpeechRate(speed float64) int {
|
||||||
|
if speed <= 0.5 {
|
||||||
|
return -50
|
||||||
|
}
|
||||||
|
if speed >= 2.0 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return int((speed - 1.0) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SynthesisResult struct {
|
||||||
|
AudioData []byte
|
||||||
|
ReqID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func synthesis(text string, speed float64) (*SynthesisResult, error) {
|
||||||
reqID := uuid.NewString()
|
reqID := uuid.NewString()
|
||||||
params := make(map[string]map[string]interface{})
|
|
||||||
params["app"] = make(map[string]interface{})
|
|
||||||
params["app"]["appid"] = ttsConfig.AppID
|
|
||||||
params["app"]["token"] = "access_token"
|
|
||||||
params["app"]["cluster"] = ttsConfig.Cluster
|
|
||||||
|
|
||||||
params["user"] = make(map[string]interface{})
|
speechRate := convertSpeedToSpeechRate(speed)
|
||||||
params["user"]["uid"] = "uid"
|
|
||||||
|
|
||||||
params["audio"] = make(map[string]interface{})
|
params := map[string]interface{}{
|
||||||
params["audio"]["voice_type"] = ttsConfig.VoiceType
|
"user": map[string]interface{}{
|
||||||
params["audio"]["encoding"] = "wav"
|
"uid": "uid",
|
||||||
params["audio"]["speed_ratio"] = speed
|
},
|
||||||
params["audio"]["volume_ratio"] = 1.0
|
"namespace": "BidirectionalTTS",
|
||||||
params["audio"]["pitch_ratio"] = 1.0
|
"req_params": map[string]interface{}{
|
||||||
|
"text": text,
|
||||||
|
"speaker": ttsConfig.Speaker,
|
||||||
|
"audio_params": map[string]interface{}{
|
||||||
|
"format": "wav",
|
||||||
|
"sample_rate": 24000,
|
||||||
|
"speech_rate": speechRate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
params["request"] = make(map[string]interface{})
|
headers := map[string]string{
|
||||||
params["request"]["reqid"] = reqID
|
"Content-Type": "application/json",
|
||||||
params["request"]["text"] = text
|
"Connection": "keep-alive",
|
||||||
params["request"]["text_type"] = "plain"
|
"X-Api-Resource-Id": ttsConfig.ResourceId,
|
||||||
params["request"]["operation"] = "query"
|
"X-Api-Request-Id": reqID,
|
||||||
|
"X-Api-Key": ttsConfig.ApiKey,
|
||||||
headers := make(map[string]string)
|
}
|
||||||
headers["Content-Type"] = "application/json"
|
|
||||||
headers["Authorization"] = fmt.Sprintf("Bearer;%s", ttsConfig.BearerToken)
|
|
||||||
|
|
||||||
bodyStr, err := json.Marshal(params)
|
bodyStr, err := json.Marshal(params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -291,30 +325,71 @@ func synthesis(text string, speed float64) ([]byte, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
synResp, err := httpPost(ttsConfig.URL, headers, bodyStr, ttsConfig.Timeout)
|
resp, err := httpPostStream(ttsConfig.URL, headers, bodyStr, ttsConfig.Timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("http post fail: %v", err)
|
log.Printf("http post fail: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
var respJSON TTSServResponse
|
if resp.StatusCode != http.StatusOK {
|
||||||
err = json.Unmarshal(synResp, &respJSON)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
log.Printf("TTS service error: status=%d, body=%s", resp.StatusCode, string(body))
|
||||||
log.Printf("unmarshal response fail: %v", err)
|
return nil, fmt.Errorf("TTS service error: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var audioData []byte
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Bytes()
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var v3Resp V3TTSResponse
|
||||||
|
if err := json.Unmarshal(line, &v3Resp); err != nil {
|
||||||
|
log.Printf("unmarshal chunk fail: %v, line: %s", err, string(line))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if v3Resp.Code == 20000000 {
|
||||||
|
if v3Resp.Usage != nil {
|
||||||
|
log.Printf("TTS synthesis completed, usage: %+v", v3Resp.Usage)
|
||||||
|
}
|
||||||
|
for scanner.Scan() {
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if v3Resp.Code != 0 {
|
||||||
|
log.Printf("TTS service error: code=%d, message=%s", v3Resp.Code, v3Resp.Message)
|
||||||
|
return nil, fmt.Errorf("TTS service error: %s", v3Resp.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if v3Resp.Data != "" {
|
||||||
|
chunk, err := base64.StdEncoding.DecodeString(v3Resp.Data)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("base64 decode fail: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
audioData = append(audioData, chunk...)
|
||||||
|
} else if v3Resp.Sentence != "" {
|
||||||
|
log.Printf("Received sentence info (sequence %d): %s", v3Resp.Sequence, v3Resp.Sentence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
log.Printf("read stream fail: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if respJSON.Code != 3000 {
|
if len(audioData) == 0 {
|
||||||
log.Printf("TTS service error: code=%d, message=%s", respJSON.Code, respJSON.Message)
|
return nil, fmt.Errorf("no audio data received")
|
||||||
return nil, fmt.Errorf("TTS service error")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
audio, err := base64.StdEncoding.DecodeString(respJSON.Data)
|
return &SynthesisResult{AudioData: audioData, ReqID: reqID}, nil
|
||||||
if err != nil {
|
|
||||||
log.Printf("base64 decode fail: %v", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return audio, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateAPIKey(r *http.Request) bool {
|
func validateAPIKey(r *http.Request) bool {
|
||||||
@@ -380,8 +455,22 @@ func openaiTTSHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ttsConfigErr != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"message": fmt.Sprintf("TTS service configuration error: %v. Please check environment variables and restart the service.", ttsConfigErr),
|
||||||
|
"type": "configuration_error",
|
||||||
|
"code": "service_unavailable",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
clientIP := getClientIP(r)
|
clientIP := getClientIP(r)
|
||||||
if !rateLimiter.Allow(clientIP) {
|
if !rateLimiter.Allow(clientIP) {
|
||||||
|
log.Printf("警告: 已超过IP速率限制,拒绝请求 - 客户端IP: %s", clientIP)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusTooManyRequests)
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
@@ -394,9 +483,30 @@ func openaiTTSHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case concurrencySem <- struct{}{}:
|
||||||
|
defer func() { <-concurrencySem }()
|
||||||
|
default:
|
||||||
|
log.Printf("警告: 已达到最大并发请求数限制,拒绝请求 - 客户端IP: %s", getClientIP(r))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"message": "Server is busy, maximum concurrent requests reached. Please try again later.",
|
||||||
|
"type": "concurrency_limit_error",
|
||||||
|
"code": "max_concurrent_requests",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, MAX_REQUEST_BODY_SIZE))
|
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, MAX_REQUEST_BODY_SIZE))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
|
if strings.Contains(err.Error(), "request body too large") {
|
||||||
|
http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
|
||||||
|
} else {
|
||||||
|
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,7 +538,7 @@ func openaiTTSHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ttsStart := time.Now()
|
ttsStart := time.Now()
|
||||||
audioData, err := synthesis(req.Input, speed)
|
result, err := synthesis(req.Input, speed)
|
||||||
duration := time.Since(ttsStart)
|
duration := time.Since(ttsStart)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -440,9 +550,10 @@ func openaiTTSHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
addRequestStats(true, duration, "")
|
addRequestStats(true, duration, "")
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "audio/wav")
|
w.Header().Set("Content-Type", "audio/wav")
|
||||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(audioData)))
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(result.AudioData)))
|
||||||
|
w.Header().Set("X-Request-Id", result.ReqID)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write(audioData)
|
w.Write(result.AudioData)
|
||||||
}
|
}
|
||||||
|
|
||||||
func addRequestStats(success bool, responseTime time.Duration, errMsg string) {
|
func addRequestStats(success bool, responseTime time.Duration, errMsg string) {
|
||||||
@@ -480,7 +591,12 @@ func getMemoryInfo() map[string]interface{} {
|
|||||||
|
|
||||||
func healthHandler(w http.ResponseWriter, r *http.Request) {
|
func healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
|
if ttsConfigErr != nil {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
apiStats.mutex.RLock()
|
apiStats.mutex.RLock()
|
||||||
totalRequests := apiStats.totalRequests
|
totalRequests := apiStats.totalRequests
|
||||||
@@ -522,7 +638,7 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
response := map[string]interface{}{
|
response := map[string]interface{}{
|
||||||
"status": status,
|
"status": status,
|
||||||
"service": "ByteDance TTS to OpenAI API Adapter",
|
"service": "ByteDance TTS to OpenAI API Adapter",
|
||||||
"version": "1.1.0",
|
"version": "2.0.0 (v3 API)",
|
||||||
"uptime": fmt.Sprintf("%.0f seconds", time.Since(startTime).Seconds()),
|
"uptime": fmt.Sprintf("%.0f seconds", time.Since(startTime).Seconds()),
|
||||||
"start_time": startTime.Format(time.RFC3339),
|
"start_time": startTime.Format(time.RFC3339),
|
||||||
"memory": getMemoryInfo(),
|
"memory": getMemoryInfo(),
|
||||||
@@ -539,6 +655,8 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
},
|
},
|
||||||
"config_status": map[string]interface{}{
|
"config_status": map[string]interface{}{
|
||||||
"all_required_vars_set": allEnvVarsSet,
|
"all_required_vars_set": allEnvVarsSet,
|
||||||
|
"config_error": ttsConfigErr != nil,
|
||||||
|
"config_error_message": fmt.Sprintf("%v", ttsConfigErr),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,8 +698,12 @@ func main() {
|
|||||||
|
|
||||||
initAPIKeys()
|
initAPIKeys()
|
||||||
|
|
||||||
if err := initTTSConfig(); err != nil {
|
ttsConfigErr = initTTSConfig()
|
||||||
log.Fatalf("配置初始化失败: %v", err)
|
if ttsConfigErr != nil {
|
||||||
|
log.Printf("警告: 配置初始化失败: %v", ttsConfigErr)
|
||||||
|
log.Printf("服务将继续运行,但TTS功能不可用,请检查环境变量配置")
|
||||||
|
} else {
|
||||||
|
log.Printf("配置初始化成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
@@ -629,9 +751,13 @@ func main() {
|
|||||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("Starting ByteDance TTS to OpenAI API adapter server on port %s", port)
|
log.Printf("Starting ByteDance TTS to OpenAI API Adapter Server")
|
||||||
|
log.Printf("Listening on port: %s", port)
|
||||||
log.Printf("OpenAI TTS endpoint: http://localhost:%s/v1/audio/speech", port)
|
log.Printf("OpenAI TTS endpoint: http://localhost:%s/v1/audio/speech", port)
|
||||||
log.Printf("Health check: http://localhost:%s/health", port)
|
log.Printf("Health check: http://localhost:%s/health", port)
|
||||||
|
log.Printf("Using ByteDance v3 API: %s", ttsConfig.URL)
|
||||||
|
log.Printf("Resource ID: %s", ttsConfig.ResourceId)
|
||||||
|
log.Printf("Speaker: %s", ttsConfig.Speaker)
|
||||||
|
|
||||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
log.Fatalf("Server failed to start: %v", err)
|
log.Fatalf("Server failed to start: %v", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user