diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a1e9875 --- /dev/null +++ b/.env.example @@ -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=your_openai_compatible_key_here + +# 服务监听端口,默认8080 +PORT=8080 diff --git a/README.md b/README.md index 71dcaa1..bd61807 100644 --- a/README.md +++ b/README.md @@ -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优化版主程序 -- `start_linux.sh` - Linux启动脚本 -- `fix_crlf.sh` - 换行符修复工具 -- `go.mod` 和 `go.sum` - Go模块依赖文件 +- `tts_server.go` - 主程序源码 +- `.env.example` - 环境变量配置示例 +- `go.mod` / `go.sum` - Go模块依赖 -## 部署步骤 +## 快速开始 -### 1. 上传文件到Linux服务器 +### 前置要求 -将本文件夹中的所有文件上传到Linux服务器,建议放在`/www/wwwroot/tts-server/`目录。 +- Go 1.19 或更高版本 +- 火山引擎账号并开通TTS服务 -### 2. 修复换行符(重要!) - -在Linux服务器上,执行以下命令修复可能存在的Windows CRLF换行符问题: +### 1. 编译程序 ```bash -cd /www/wwwroot/tts-server -chmod +x fix_crlf.sh -./fix_crlf.sh +go build -o tts_server tts_server.go ``` -### 3. 安装Go环境(如果未安装) +### 2. 配置环境变量 + +复制 `.env.example` 为 `.env` 并填入你的配置: ```bash -# 下载Go 1.21.5 -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 +cp .env.example .env ``` -### 4. 启动服务 +编辑 `.env` 文件,填入必要的配置参数。 + +### 3. 启动服务 ```bash -cd /www/wwwroot/tts-server -chmod +x start_linux.sh -./start_linux.sh start +# Windows +tts_server.exe + +# Linux/macOS +./tts_server ``` -## 服务管理命令 - -- 启动服务:`./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 -``` +服务默认监听 `8080` 端口。 ## 环境变量配置 -服务现在支持通过环境变量配置所有参数,推荐使用这种方式而不是直接修改代码。 +### 必需参数 -### 必须的环境变量 +| 变量名 | 说明 | 示例 | +|--------|------|------| +| `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) -- `BYTEDANCE_TTS_TIMEOUT` - 字节跳动TTS请求超时时间(默认:30s,支持Go duration格式,如"60s"、"1m") -- `OPENAI_TTS_API_KEY` - OpenAI TTS API密钥(未设置时允许任意key) -- `PORT` - 服务监听端口(默认:8080) +### Resource ID 说明 -### 环境变量使用示例 +| 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 -# 启动服务时设置环境变量 -export BYTEDANCE_TTS_APP_ID=your_app_id -export BYTEDANCE_TTS_BEARER_TOKEN=your_token -export BYTEDANCE_TTS_CLUSTER=your_cluster -export BYTEDANCE_TTS_VOICE_TYPE=your_voice_type -export OPENAI_TTS_API_KEY=sk-your_api_key -export PORT=8081 -./start_linux.sh start +**注意:** 1.0音色只能搭配 `seed-tts-1.0` Resource ID,2.0音色只能搭配 `seed-tts-2.0` Resource ID。 + +## API 使用说明 + +### OpenAI 兼容接口 + +**端点:** `POST /v1/audio/speech` + +**请求头:** +- `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. 必须设置所有必需的环境变量,否则服务会使用默认值并显示警告信息 -4. 可以通过设置环境变量而不是直接修改代码来配置服务 +**示例调用:** + +```bash +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) 文件。 \ No newline at end of file +本项目采用非商业用途许可协议。您可以免费使用本软件用于非商业目的,但禁止用于任何商业活动。详细条款请参阅 [LICENSE](LICENSE) 文件。 + +## 技术支持 + +如有问题,请检查: +1. 环境变量配置是否正确 +2. 网络是否能访问火山引擎TTS服务 +3. 鉴权信息是否有效 +4. Resource ID与Speaker是否匹配 diff --git a/start_linux.sh b/start_linux.sh deleted file mode 100644 index 8822a95..0000000 --- a/start_linux.sh +++ /dev/null @@ -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 $? diff --git a/tts_server.go b/tts_server.go index ceed8dd..d3e7f07 100644 --- a/tts_server.go +++ b/tts_server.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "bytes" "context" "encoding/base64" @@ -23,26 +24,34 @@ import ( ) const ( - DEFAULT_PORT = "8080" - DEFAULT_TIMEOUT = 30 * time.Second - MAX_TEXT_LENGTH = 5000 - MIN_SPEED = 0.25 - MAX_SPEED = 4.0 - DEFAULT_SPEED = 1.0 - MAX_REQUEST_BODY_SIZE = 1024 * 1024 - RATE_LIMIT_REQUESTS = 100 - RATE_LIMIT_WINDOW = time.Minute - MAX_RESPONSE_TIMES = 100 - MAX_ERRORS = 10 + DEFAULT_PORT = "8080" + DEFAULT_TIMEOUT = 30 * time.Second + MAX_TEXT_LENGTH = 5000 + MIN_SPEED = 0.25 + MAX_SPEED = 4.0 + DEFAULT_SPEED = 1.0 + MAX_REQUEST_BODY_SIZE = 1024 * 1024 + RATE_LIMIT_REQUESTS = 100 + RATE_LIMIT_WINDOW = time.Minute + MAX_RESPONSE_TIMES = 100 + MAX_ERRORS = 10 + MAX_CONCURRENT_REQUESTS = 10 ) -type TTSServResponse struct { - ReqID string `json:"reqid"` - Code int `json:"code"` - Message string `json:"Message"` - Operation string `json:"operation"` - Sequence int `json:"sequence"` - Data string `json:"data"` +type V3TTSResponse struct { + ReqID string `json:"reqid"` + Code int `json:"code"` + Message string `json:"message"` + Event string `json:"event"` + Sequence int `json:"sequence"` + 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 { @@ -54,21 +63,23 @@ type OpenAITTSRequest struct { } type ByteDanceTTSConfig struct { - AppID string - BearerToken string - Cluster string - URL string - VoiceType string - Timeout time.Duration + ApiKey string + ResourceId string + Speaker string + URL string + Timeout time.Duration } type RateLimiter struct { - requests map[string][]time.Time - mutex sync.Mutex - limit int - window time.Duration + requests map[string][]time.Time + mutex sync.Mutex + limit int + window time.Duration + lastCleanup time.Time } +const cleanupInterval = time.Hour + type Stats struct { totalRequests int64 successfulRequests int64 @@ -84,9 +95,11 @@ type Stats struct { var ( VALID_API_KEYS []string ttsConfig ByteDanceTTSConfig + ttsConfigErr error globalHTTPClient *http.Client apiStats *Stats rateLimiter *RateLimiter + concurrencySem chan struct{} ) func init() { @@ -110,6 +123,8 @@ func init() { limit: RATE_LIMIT_REQUESTS, window: RATE_LIMIT_WINDOW, } + + concurrencySem = make(chan struct{}, MAX_CONCURRENT_REQUESTS) } func (rl *RateLimiter) Allow(key string) bool { @@ -119,6 +134,11 @@ func (rl *RateLimiter) Allow(key string) bool { now := time.Now() cutoff := now.Add(-rl.window) + if now.Sub(rl.lastCleanup) > cleanupInterval { + rl.cleanup() + rl.lastCleanup = now + } + timestamps := rl.requests[key] valid := make([]time.Time, 0, len(timestamps)) for _, ts := range timestamps { @@ -137,34 +157,45 @@ func (rl *RateLimiter) Allow(key string) bool { 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 { - appID := os.Getenv("BYTEDANCE_TTS_APP_ID") - bearerToken := os.Getenv("BYTEDANCE_TTS_BEARER_TOKEN") - cluster := os.Getenv("BYTEDANCE_TTS_CLUSTER") - voiceType := os.Getenv("BYTEDANCE_TTS_VOICE_TYPE") + apiKey := os.Getenv("BYTEDANCE_TTS_API_KEY") + resourceId := os.Getenv("BYTEDANCE_TTS_RESOURCE_ID") + speaker := os.Getenv("BYTEDANCE_TTS_SPEAKER") missingVars := []string{} - if appID == "" { - missingVars = append(missingVars, "BYTEDANCE_TTS_APP_ID") + + if apiKey == "" { + missingVars = append(missingVars, "BYTEDANCE_TTS_API_KEY") } - if bearerToken == "" { - missingVars = append(missingVars, "BYTEDANCE_TTS_BEARER_TOKEN") + if resourceId == "" { + missingVars = append(missingVars, "BYTEDANCE_TTS_RESOURCE_ID") } - if cluster == "" { - missingVars = append(missingVars, "BYTEDANCE_TTS_CLUSTER") - } - if voiceType == "" { - missingVars = append(missingVars, "BYTEDANCE_TTS_VOICE_TYPE") + if speaker == "" { + missingVars = append(missingVars, "BYTEDANCE_TTS_SPEAKER") } if len(missingVars) > 0 { return fmt.Errorf("缺少必需的环境变量: %v", missingVars) } - url := os.Getenv("BYTEDANCE_TTS_ENDPOINT") - if url == "" { - url = "https://openspeech.bytedance.com/api/v1/tts" - } + url := "https://openspeech.bytedance.com/api/v3/tts/unidirectional" timeout := DEFAULT_TIMEOUT if timeoutStr := os.Getenv("BYTEDANCE_TTS_TIMEOUT"); timeoutStr != "" { @@ -176,12 +207,11 @@ func initTTSConfig() error { } ttsConfig = ByteDanceTTSConfig{ - AppID: appID, - BearerToken: bearerToken, - Cluster: cluster, - URL: url, - VoiceType: voiceType, - Timeout: timeout, + ApiKey: apiKey, + ResourceId: resourceId, + Speaker: speaker, + URL: url, + Timeout: timeout, } return nil @@ -202,10 +232,9 @@ func initAPIKeys() { func checkEnvironmentVariables() map[string]interface{} { requiredVars := map[string]bool{ - "BYTEDANCE_TTS_APP_ID": os.Getenv("BYTEDANCE_TTS_APP_ID") != "", - "BYTEDANCE_TTS_BEARER_TOKEN": os.Getenv("BYTEDANCE_TTS_BEARER_TOKEN") != "", - "BYTEDANCE_TTS_CLUSTER": os.Getenv("BYTEDANCE_TTS_CLUSTER") != "", - "BYTEDANCE_TTS_VOICE_TYPE": os.Getenv("BYTEDANCE_TTS_VOICE_TYPE") != "", + "BYTEDANCE_TTS_API_KEY": os.Getenv("BYTEDANCE_TTS_API_KEY") != "", + "BYTEDANCE_TTS_RESOURCE_ID": os.Getenv("BYTEDANCE_TTS_RESOURCE_ID") != "", + "BYTEDANCE_TTS_SPEAKER": os.Getenv("BYTEDANCE_TTS_SPEAKER") != "", } missingVars := []string{} @@ -216,10 +245,9 @@ func checkEnvironmentVariables() map[string]interface{} { } optionalVars := map[string]bool{ - "BYTEDANCE_TTS_ENDPOINT": os.Getenv("BYTEDANCE_TTS_ENDPOINT") != "", - "BYTEDANCE_TTS_TIMEOUT": os.Getenv("BYTEDANCE_TTS_TIMEOUT") != "", - "OPENAI_TTS_API_KEY": os.Getenv("OPENAI_TTS_API_KEY") != "", - "PORT": os.Getenv("PORT") != "", + "BYTEDANCE_TTS_TIMEOUT": os.Getenv("BYTEDANCE_TTS_TIMEOUT") != "", + "OPENAI_TTS_API_KEY": os.Getenv("OPENAI_TTS_API_KEY") != "", + "PORT": os.Getenv("PORT") != "", } 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)) if err != nil { 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) } - client := globalHTTPClient - if timeout != 0 && timeout != ttsConfig.Timeout { - client = &http.Client{Timeout: timeout} - } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() + req = req.WithContext(ctx) - retBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - return retBody, nil + return globalHTTPClient.Do(req) } -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() - 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{}) - params["user"]["uid"] = "uid" + speechRate := convertSpeedToSpeechRate(speed) - params["audio"] = make(map[string]interface{}) - params["audio"]["voice_type"] = ttsConfig.VoiceType - params["audio"]["encoding"] = "wav" - params["audio"]["speed_ratio"] = speed - params["audio"]["volume_ratio"] = 1.0 - params["audio"]["pitch_ratio"] = 1.0 + params := map[string]interface{}{ + "user": map[string]interface{}{ + "uid": "uid", + }, + "namespace": "BidirectionalTTS", + "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{}) - params["request"]["reqid"] = reqID - params["request"]["text"] = text - params["request"]["text_type"] = "plain" - params["request"]["operation"] = "query" - - headers := make(map[string]string) - headers["Content-Type"] = "application/json" - headers["Authorization"] = fmt.Sprintf("Bearer;%s", ttsConfig.BearerToken) + headers := map[string]string{ + "Content-Type": "application/json", + "Connection": "keep-alive", + "X-Api-Resource-Id": ttsConfig.ResourceId, + "X-Api-Request-Id": reqID, + "X-Api-Key": ttsConfig.ApiKey, + } bodyStr, err := json.Marshal(params) if err != nil { @@ -291,30 +325,71 @@ func synthesis(text string, speed float64) ([]byte, error) { return nil, err } - synResp, err := httpPost(ttsConfig.URL, headers, bodyStr, ttsConfig.Timeout) + resp, err := httpPostStream(ttsConfig.URL, headers, bodyStr, ttsConfig.Timeout) if err != nil { log.Printf("http post fail: %v", err) return nil, err } + defer resp.Body.Close() - var respJSON TTSServResponse - err = json.Unmarshal(synResp, &respJSON) - if err != nil { - log.Printf("unmarshal response fail: %v", err) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + log.Printf("TTS service error: status=%d, body=%s", resp.StatusCode, string(body)) + 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 } - if respJSON.Code != 3000 { - log.Printf("TTS service error: code=%d, message=%s", respJSON.Code, respJSON.Message) - return nil, fmt.Errorf("TTS service error") + if len(audioData) == 0 { + return nil, fmt.Errorf("no audio data received") } - audio, err := base64.StdEncoding.DecodeString(respJSON.Data) - if err != nil { - log.Printf("base64 decode fail: %v", err) - return nil, err - } - return audio, nil + return &SynthesisResult{AudioData: audioData, ReqID: reqID}, nil } func validateAPIKey(r *http.Request) bool { @@ -380,8 +455,22 @@ func openaiTTSHandler(w http.ResponseWriter, r *http.Request) { 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) if !rateLimiter.Allow(clientIP) { + log.Printf("警告: 已超过IP速率限制,拒绝请求 - 客户端IP: %s", clientIP) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusTooManyRequests) json.NewEncoder(w).Encode(map[string]interface{}{ @@ -394,9 +483,30 @@ func openaiTTSHandler(w http.ResponseWriter, r *http.Request) { 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)) 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 } @@ -428,7 +538,7 @@ func openaiTTSHandler(w http.ResponseWriter, r *http.Request) { } ttsStart := time.Now() - audioData, err := synthesis(req.Input, speed) + result, err := synthesis(req.Input, speed) duration := time.Since(ttsStart) if err != nil { @@ -440,9 +550,10 @@ func openaiTTSHandler(w http.ResponseWriter, r *http.Request) { addRequestStats(true, duration, "") 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.Write(audioData) + w.Write(result.AudioData) } 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) { 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() totalRequests := apiStats.totalRequests @@ -522,7 +638,7 @@ func healthHandler(w http.ResponseWriter, r *http.Request) { response := map[string]interface{}{ "status": status, "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()), "start_time": startTime.Format(time.RFC3339), "memory": getMemoryInfo(), @@ -539,6 +655,8 @@ func healthHandler(w http.ResponseWriter, r *http.Request) { }, "config_status": map[string]interface{}{ "all_required_vars_set": allEnvVarsSet, + "config_error": ttsConfigErr != nil, + "config_error_message": fmt.Sprintf("%v", ttsConfigErr), }, } @@ -580,8 +698,12 @@ func main() { initAPIKeys() - if err := initTTSConfig(); err != nil { - log.Fatalf("配置初始化失败: %v", err) + ttsConfigErr = initTTSConfig() + if ttsConfigErr != nil { + log.Printf("警告: 配置初始化失败: %v", ttsConfigErr) + log.Printf("服务将继续运行,但TTS功能不可用,请检查环境变量配置") + } else { + log.Printf("配置初始化成功") } router := mux.NewRouter() @@ -629,9 +751,13 @@ func main() { signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 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("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 { log.Fatalf("Server failed to start: %v", err)