Merge pull request 'test' (#1) from test into main
Go CI/CD Deploy to Baota / build-and-deploy (push) Has been cancelled

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
sun
2026-05-18 23:29:21 +08:00
4 changed files with 500 additions and 515 deletions
+39
View File
@@ -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
+215 -103
View File
@@ -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
View File
@@ -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
View File
@@ -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)