From 3304707abefcc85107b66f485dcc6323e928edd7 Mon Sep 17 00:00:00 2001 From: sun <3371392206@qq.com> Date: Sun, 17 May 2026 18:08:06 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E5=8D=87=E7=BA=A7=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E9=80=82=E9=85=8D=E7=81=AB=E5=B1=B1=E5=BC=95=E6=93=8E?= =?UTF-8?q?TTS=20v3=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增: - 添加.env.example环境变量配置示例 - 重构鉴权逻辑,支持新旧两种鉴权模式 - 适配v3版API接口,实现流式响应 - 完善README文档和配置说明 移除: - 删除原Linux启动脚本start_linux.sh 优化: - 重构代码结构,适配新的API参数格式 - 优化日志输出和错误处理 --- .env.example | 56 +++++++++ README.md | 299 ++++++++++++++++++++++++++++++++----------------- start_linux.sh | 292 ----------------------------------------------- tts_server.go | 273 +++++++++++++++++++++++++++++--------------- 4 files changed, 434 insertions(+), 486 deletions(-) create mode 100644 .env.example delete mode 100644 start_linux.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9253c6b --- /dev/null +++ b/.env.example @@ -0,0 +1,56 @@ +# ByteDance TTS v3 API 全兼容配置示例 +# 将此文件复制为 .env 并填入实际配置 + +# ========================================== +# 必需的环境变量 +# ========================================== + +# 鉴权模式(必要参数) +# - legacy: 旧版鉴权,使用 AppID + AccessKey(v1资产升级推荐) +# - new: 新版鉴权,使用 ApiKey(新控制台用户推荐) +BYTEDANCE_TTS_AUTH_MODE=legacy + +# 资源信息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 + +# ========================================== +# 鉴权参数(根据 AUTH_MODE 选择配置) +# ========================================== + +# ---------- legacy 模式需要配置 ---------- +# 火山引擎旧版控制台获取的 AppID +BYTEDANCE_TTS_APP_ID=your_app_id_here + +# 火山引擎旧版控制台获取的 Access Key +BYTEDANCE_TTS_ACCESS_KEY=your_access_key_here + +# ---------- new 模式需要配置 ---------- +# 火山引擎新版控制台获取的 API Key +BYTEDANCE_TTS_API_KEY=your_api_key_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..8c30319 100644 --- a/README.md +++ b/README.md @@ -1,139 +1,232 @@ -# 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(单向流式) +- ✅ 支持两种鉴权模式:Legacy(AppID+AccessKey) 和 New(API Key) +- ✅ 支持多种发音人和模型版本 +- ✅ 内置速率限制和统计功能 +- ✅ 支持配置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_AUTH_MODE` | 鉴权模式:`legacy` 或 `new` | `legacy` | +| `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声音类型 +### 鉴权参数(二选一) -### 可选的环境变量 +**Legacy 模式(旧版控制台):** +- `BYTEDANCE_TTS_APP_ID` - 应用ID +- `BYTEDANCE_TTS_ACCESS_KEY` - 访问密钥 -- `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) +**New 模式(新版控制台):** +- `BYTEDANCE_TTS_API_KEY` - API密钥 -### 环境变量使用示例 +### 可选参数 -```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 +| 变量名 | 说明 | 默认值 | +|--------|------|--------| +| `BYTEDANCE_TTS_TIMEOUT` | 请求超时时间 | `30s` | +| `OPENAI_TTS_API_KEY` | OpenAI兼容接口的API密钥(逗号分隔支持多个) | 无 | +| `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字符版 | + +**注意:** 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`、`mp3`、`opus`、`aac`、`flac` +- `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 +``` + +### 统计信息 + +```bash +curl http://localhost:8080/stats +``` + +## 支持的发音人 + +具体发音人列表请参考火山引擎官方文档: +- 1.0音色:https://www.volcengine.com/docs/6561/97454 +- 2.0音色:https://www.volcengine.com/docs/6561/1340515 + +## 常见问题 + +### 1. 如何获取鉴权信息? + +- 登录火山引擎控制台 +- 进入"语音合成"服务 +- 在应用管理中创建应用并获取AppID和AccessKey(Legacy模式) +- 或在新版控制台获取API Key(New模式) + +### 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..5bc60ab 100644 --- a/tts_server.go +++ b/tts_server.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "bytes" "context" "encoding/base64" @@ -36,13 +37,20 @@ const ( MAX_ERRORS = 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,12 +62,14 @@ type OpenAITTSRequest struct { } type ByteDanceTTSConfig struct { - AppID string - BearerToken string - Cluster string - URL string - VoiceType string - Timeout time.Duration + AuthMode string + AppID string + AccessKey string + ApiKey string + ResourceId string + Speaker string + URL string + Timeout time.Duration } type RateLimiter struct { @@ -138,33 +148,46 @@ func (rl *RateLimiter) Allow(key string) bool { } func initTTSConfig() error { + authMode := os.Getenv("BYTEDANCE_TTS_AUTH_MODE") 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") + accessKey := os.Getenv("BYTEDANCE_TTS_ACCESS_KEY") + 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 authMode == "" { + missingVars = append(missingVars, "BYTEDANCE_TTS_AUTH_MODE") + } else if authMode != "legacy" && authMode != "new" { + return fmt.Errorf("BYTEDANCE_TTS_AUTH_MODE 无效,必须是 'legacy' (旧版 AppID+AccessKey) 或 'new' (新版 ApiKey)") } - 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 speaker == "" { + missingVars = append(missingVars, "BYTEDANCE_TTS_SPEAKER") } - if voiceType == "" { - missingVars = append(missingVars, "BYTEDANCE_TTS_VOICE_TYPE") + + if authMode == "legacy" { + if appID == "" { + missingVars = append(missingVars, "BYTEDANCE_TTS_APP_ID") + } + if accessKey == "" { + missingVars = append(missingVars, "BYTEDANCE_TTS_ACCESS_KEY") + } + } else if authMode == "new" { + if apiKey == "" { + missingVars = append(missingVars, "BYTEDANCE_TTS_API_KEY") + } } 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 +199,14 @@ func initTTSConfig() error { } ttsConfig = ByteDanceTTSConfig{ - AppID: appID, - BearerToken: bearerToken, - Cluster: cluster, - URL: url, - VoiceType: voiceType, - Timeout: timeout, + AuthMode: authMode, + AppID: appID, + AccessKey: accessKey, + ApiKey: apiKey, + ResourceId: resourceId, + Speaker: speaker, + URL: url, + Timeout: timeout, } return nil @@ -201,11 +226,19 @@ func initAPIKeys() { } func checkEnvironmentVariables() map[string]interface{} { + authMode := os.Getenv("BYTEDANCE_TTS_AUTH_MODE") + 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_AUTH_MODE": authMode != "", + "BYTEDANCE_TTS_RESOURCE_ID": os.Getenv("BYTEDANCE_TTS_RESOURCE_ID") != "", + "BYTEDANCE_TTS_SPEAKER": os.Getenv("BYTEDANCE_TTS_SPEAKER") != "", + } + + if authMode == "legacy" { + requiredVars["BYTEDANCE_TTS_APP_ID"] = os.Getenv("BYTEDANCE_TTS_APP_ID") != "" + requiredVars["BYTEDANCE_TTS_ACCESS_KEY"] = os.Getenv("BYTEDANCE_TTS_ACCESS_KEY") != "" + } else if authMode == "new" { + requiredVars["BYTEDANCE_TTS_API_KEY"] = os.Getenv("BYTEDANCE_TTS_API_KEY") != "" } missingVars := []string{} @@ -216,10 +249,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 +262,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 @@ -240,50 +272,65 @@ func httpPost(url string, headers map[string]string, body []byte, timeout time.D } client := globalHTTPClient - if timeout != 0 && timeout != ttsConfig.Timeout { - client = &http.Client{Timeout: timeout} + if timeout != 0 { + client = &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + }, + } } - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() + return client.Do(req) +} - retBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err +func convertSpeedToSpeechRate(speed float64) int { + if speed <= 0.5 { + return -50 } - return retBody, nil + if speed >= 2.0 { + return 100 + } + return int((speed - 1.0) * 100) } func synthesis(text string, speed float64) ([]byte, 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 := map[string]string{ + "Content-Type": "application/json", + "Connection": "keep-alive", + "X-Api-Resource-Id": ttsConfig.ResourceId, + "X-Api-Request-Id": reqID, + } - headers := make(map[string]string) - headers["Content-Type"] = "application/json" - headers["Authorization"] = fmt.Sprintf("Bearer;%s", ttsConfig.BearerToken) + if ttsConfig.AuthMode == "legacy" { + headers["X-Api-App-Id"] = ttsConfig.AppID + headers["X-Api-Access-Key"] = ttsConfig.AccessKey + } else { + headers["X-Api-Key"] = ttsConfig.ApiKey + } bodyStr, err := json.Marshal(params) if err != nil { @@ -291,30 +338,69 @@ 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) + } + 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 audioData, nil } func validateAPIKey(r *http.Request) bool { @@ -522,7 +608,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(), @@ -629,9 +715,14 @@ 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("Auth Mode: %s", ttsConfig.AuthMode) + 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) From 92c6b8b7013dde7ef41a0ea655549195daae9a27 Mon Sep 17 00:00:00 2001 From: sun <3371392206@qq.com> Date: Sun, 17 May 2026 18:51:43 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(tts=20server):=20=E4=BC=98=E5=8C=96TTS?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E9=85=8D=E7=BD=AE=E9=94=99=E8=AF=AF=E5=A4=84?= =?UTF-8?q?=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 将初始化TTS配置的致命错误改为警告,服务继续运行 2. 在TTS请求和健康检查中加入配置错误判断,返回对应错误信息 3. 在健康检查详情中添加配置错误状态和提示信息 --- tts_server.go | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/tts_server.go b/tts_server.go index 5bc60ab..f7009c0 100644 --- a/tts_server.go +++ b/tts_server.go @@ -94,6 +94,7 @@ type Stats struct { var ( VALID_API_KEYS []string ttsConfig ByteDanceTTSConfig + ttsConfigErr error globalHTTPClient *http.Client apiStats *Stats rateLimiter *RateLimiter @@ -466,6 +467,19 @@ 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) { w.Header().Set("Content-Type", "application/json") @@ -566,7 +580,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 @@ -625,6 +644,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), }, } @@ -666,8 +687,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() From 08c529c3758df45500d36b63f0ea0c9636ec06b9 Mon Sep 17 00:00:00 2001 From: sun <3371392206@qq.com> Date: Sun, 17 May 2026 22:27:13 +0800 Subject: [PATCH 3/5] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=E6=97=A7?= =?UTF-8?q?=E7=89=88=E9=89=B4=E6=9D=83=E6=A8=A1=E5=BC=8F=EF=BC=8C=E4=BB=85?= =?UTF-8?q?=E4=BF=9D=E7=95=99API=20Key=E9=89=B4=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 简化了配置逻辑,删除了Legacy鉴权相关的环境变量、代码判断分支和文档说明,统一使用API Key进行鉴权,降低配置复杂度 --- .env.example | 23 +++-------------------- README.md | 18 ++++-------------- tts_server.go | 49 ++++--------------------------------------------- 3 files changed, 11 insertions(+), 79 deletions(-) diff --git a/.env.example b/.env.example index 9253c6b..a1e9875 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,12 @@ -# ByteDance TTS v3 API 全兼容配置示例 +# ByteDance TTS v3 API 配置示例 # 将此文件复制为 .env 并填入实际配置 # ========================================== # 必需的环境变量 # ========================================== -# 鉴权模式(必要参数) -# - legacy: 旧版鉴权,使用 AppID + AccessKey(v1资产升级推荐) -# - new: 新版鉴权,使用 ApiKey(新控制台用户推荐) -BYTEDANCE_TTS_AUTH_MODE=legacy +# 火山引擎新版控制台获取的 API Key +BYTEDANCE_TTS_API_KEY=your_api_key_here # 资源信息ID(决定使用1.0还是2.0模型) # 语音合成模型: @@ -26,21 +24,6 @@ BYTEDANCE_TTS_RESOURCE_ID=seed-tts-1.0 # 2.0音色只能搭配 seed-tts-2.0 Resource ID BYTEDANCE_TTS_SPEAKER=your_speaker_id_here -# ========================================== -# 鉴权参数(根据 AUTH_MODE 选择配置) -# ========================================== - -# ---------- legacy 模式需要配置 ---------- -# 火山引擎旧版控制台获取的 AppID -BYTEDANCE_TTS_APP_ID=your_app_id_here - -# 火山引擎旧版控制台获取的 Access Key -BYTEDANCE_TTS_ACCESS_KEY=your_access_key_here - -# ---------- new 模式需要配置 ---------- -# 火山引擎新版控制台获取的 API Key -BYTEDANCE_TTS_API_KEY=your_api_key_here - # ========================================== # 可选的环境变量 # ========================================== diff --git a/README.md b/README.md index 8c30319..0243681 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ - ✅ 完全兼容OpenAI `/v1/audio/speech` API接口 - ✅ 支持火山引擎TTS v3 API(单向流式) -- ✅ 支持两种鉴权模式:Legacy(AppID+AccessKey) 和 New(API Key) +- ✅ 支持API Key鉴权方式 - ✅ 支持多种发音人和模型版本 - ✅ 内置速率限制和统计功能 - ✅ 支持配置API密钥验证 @@ -61,19 +61,10 @@ tts_server.exe | 变量名 | 说明 | 示例 | |--------|------|------| -| `BYTEDANCE_TTS_AUTH_MODE` | 鉴权模式:`legacy` 或 `new` | `legacy` | +| `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` | -### 鉴权参数(二选一) - -**Legacy 模式(旧版控制台):** -- `BYTEDANCE_TTS_APP_ID` - 应用ID -- `BYTEDANCE_TTS_ACCESS_KEY` - 访问密钥 - -**New 模式(新版控制台):** -- `BYTEDANCE_TTS_API_KEY` - API密钥 - ### 可选参数 | 变量名 | 说明 | 默认值 | @@ -154,10 +145,9 @@ curl http://localhost:8080/stats ### 1. 如何获取鉴权信息? -- 登录火山引擎控制台 +- 登录火山引擎新版控制台 - 进入"语音合成"服务 -- 在应用管理中创建应用并获取AppID和AccessKey(Legacy模式) -- 或在新版控制台获取API Key(New模式) +- 创建应用并获取API Key ### 2. 端口被占用怎么办? diff --git a/tts_server.go b/tts_server.go index f7009c0..8ddc4e5 100644 --- a/tts_server.go +++ b/tts_server.go @@ -62,9 +62,6 @@ type OpenAITTSRequest struct { } type ByteDanceTTSConfig struct { - AuthMode string - AppID string - AccessKey string ApiKey string ResourceId string Speaker string @@ -149,21 +146,15 @@ func (rl *RateLimiter) Allow(key string) bool { } func initTTSConfig() error { - authMode := os.Getenv("BYTEDANCE_TTS_AUTH_MODE") - appID := os.Getenv("BYTEDANCE_TTS_APP_ID") - accessKey := os.Getenv("BYTEDANCE_TTS_ACCESS_KEY") apiKey := os.Getenv("BYTEDANCE_TTS_API_KEY") resourceId := os.Getenv("BYTEDANCE_TTS_RESOURCE_ID") speaker := os.Getenv("BYTEDANCE_TTS_SPEAKER") missingVars := []string{} - if authMode == "" { - missingVars = append(missingVars, "BYTEDANCE_TTS_AUTH_MODE") - } else if authMode != "legacy" && authMode != "new" { - return fmt.Errorf("BYTEDANCE_TTS_AUTH_MODE 无效,必须是 'legacy' (旧版 AppID+AccessKey) 或 'new' (新版 ApiKey)") + if apiKey == "" { + missingVars = append(missingVars, "BYTEDANCE_TTS_API_KEY") } - if resourceId == "" { missingVars = append(missingVars, "BYTEDANCE_TTS_RESOURCE_ID") } @@ -171,19 +162,6 @@ func initTTSConfig() error { missingVars = append(missingVars, "BYTEDANCE_TTS_SPEAKER") } - if authMode == "legacy" { - if appID == "" { - missingVars = append(missingVars, "BYTEDANCE_TTS_APP_ID") - } - if accessKey == "" { - missingVars = append(missingVars, "BYTEDANCE_TTS_ACCESS_KEY") - } - } else if authMode == "new" { - if apiKey == "" { - missingVars = append(missingVars, "BYTEDANCE_TTS_API_KEY") - } - } - if len(missingVars) > 0 { return fmt.Errorf("缺少必需的环境变量: %v", missingVars) } @@ -200,9 +178,6 @@ func initTTSConfig() error { } ttsConfig = ByteDanceTTSConfig{ - AuthMode: authMode, - AppID: appID, - AccessKey: accessKey, ApiKey: apiKey, ResourceId: resourceId, Speaker: speaker, @@ -227,21 +202,12 @@ func initAPIKeys() { } func checkEnvironmentVariables() map[string]interface{} { - authMode := os.Getenv("BYTEDANCE_TTS_AUTH_MODE") - requiredVars := map[string]bool{ - "BYTEDANCE_TTS_AUTH_MODE": authMode != "", + "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") != "", } - if authMode == "legacy" { - requiredVars["BYTEDANCE_TTS_APP_ID"] = os.Getenv("BYTEDANCE_TTS_APP_ID") != "" - requiredVars["BYTEDANCE_TTS_ACCESS_KEY"] = os.Getenv("BYTEDANCE_TTS_ACCESS_KEY") != "" - } else if authMode == "new" { - requiredVars["BYTEDANCE_TTS_API_KEY"] = os.Getenv("BYTEDANCE_TTS_API_KEY") != "" - } - missingVars := []string{} for varName, isSet := range requiredVars { if !isSet { @@ -324,13 +290,7 @@ func synthesis(text string, speed float64) ([]byte, error) { "Connection": "keep-alive", "X-Api-Resource-Id": ttsConfig.ResourceId, "X-Api-Request-Id": reqID, - } - - if ttsConfig.AuthMode == "legacy" { - headers["X-Api-App-Id"] = ttsConfig.AppID - headers["X-Api-Access-Key"] = ttsConfig.AccessKey - } else { - headers["X-Api-Key"] = ttsConfig.ApiKey + "X-Api-Key": ttsConfig.ApiKey, } bodyStr, err := json.Marshal(params) @@ -745,7 +705,6 @@ func main() { 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("Auth Mode: %s", ttsConfig.AuthMode) log.Printf("Resource ID: %s", ttsConfig.ResourceId) log.Printf("Speaker: %s", ttsConfig.Speaker) From 00460771c42e97f2b7857596190a2bdcf74669bd Mon Sep 17 00:00:00 2001 From: sun <3371392206@qq.com> Date: Sun, 17 May 2026 23:42:10 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E9=99=90=E6=B5=81=E4=BF=9D=E6=8A=A4=E5=B9=B6=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增全局并发请求限制,最多同时处理10个TTS请求 - 更新健康检查接口,新增配置检查和统计信息返回 - 调整响应格式文档,仅保留wav格式支持说明 - 补充限流机制说明和触发后的响应示例 - 优化日志输出,限流时打印中文警告日志 --- README.md | 41 +++++++++++++++++++++++++++++++++++------ tts_server.go | 44 +++++++++++++++++++++++++++++++++----------- 2 files changed, 68 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 0243681..bd61807 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - ✅ 支持多种发音人和模型版本 - ✅ 内置速率限制和统计功能 - ✅ 支持配置API密钥验证 +- ✅ 并发限制:最多同时处理10个请求(保护上游API) - ✅ 跨平台支持(Windows/Linux/macOS) ## 文件说明 @@ -111,7 +112,7 @@ tts_server.exe - `model` - 模型名称(OpenAI兼容,实际不影响) - `input` - 要合成的文本 - `voice` - 发音人(OpenAI兼容,实际不影响) -- `response_format` - 输出格式:`wav`、`mp3`、`opus`、`aac`、`flac` +- `response_format` - 输出格式:仅支持 `wav` - `speed` - 语速:0.25 ~ 4.0 **示例调用:** @@ -123,18 +124,46 @@ curl -X POST "http://localhost:8080/v1/audio/speech" \ -o output.wav ``` -### 健康检查 +### 健康检查(含统计信息) ```bash curl http://localhost:8080/health ``` -### 统计信息 +返回包含:服务状态、请求统计、错误记录、配置检查结果 -```bash -curl http://localhost:8080/stats +## 限流机制 + +为保护上游火山引擎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` + ## 支持的发音人 具体发音人列表请参考火山引擎官方文档: @@ -173,7 +202,7 @@ OPENAI_TTS_API_KEY=sk-key1,sk-key2,sk-key3 服务启动后会输出详细日志,包括: - 服务启动信息 -- 鉴权模式和配置状态 +- 配置状态 - 请求统计信息 - 错误详情 diff --git a/tts_server.go b/tts_server.go index 8ddc4e5..177af75 100644 --- a/tts_server.go +++ b/tts_server.go @@ -24,17 +24,18 @@ 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 V3TTSResponse struct { @@ -95,6 +96,7 @@ var ( globalHTTPClient *http.Client apiStats *Stats rateLimiter *RateLimiter + concurrencySem chan struct{} ) func init() { @@ -118,6 +120,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 { @@ -442,6 +446,7 @@ func openaiTTSHandler(w http.ResponseWriter, r *http.Request) { 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{}{ @@ -454,6 +459,23 @@ 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) From f3197338d476ed3dc3c724eff528daf1ee4755fd Mon Sep 17 00:00:00 2001 From: sun <3371392206@qq.com> Date: Mon, 18 May 2026 23:13:34 +0800 Subject: [PATCH 5/5] =?UTF-8?q?refactor(tts):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E9=99=90=E6=B5=81=E5=99=A8=E6=B8=85=E7=90=86=E5=92=8C=E5=A4=9A?= =?UTF-8?q?=E9=A1=B9=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 为限流器增加定期清理过期请求记录的功能,减少内存占用 2. 重构httpPostStream使用context超时管理,复用全局http客户端 3. 调整synthesis函数返回结果结构,新增请求ID字段 4. 优化请求体读取错误处理,细化错误响应 5. 为TTS响应添加X-Request-Id响应头 --- tts_server.go | 75 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 52 insertions(+), 23 deletions(-) diff --git a/tts_server.go b/tts_server.go index 177af75..d3e7f07 100644 --- a/tts_server.go +++ b/tts_server.go @@ -71,12 +71,15 @@ type ByteDanceTTSConfig struct { } 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 @@ -131,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 { @@ -149,6 +157,23 @@ 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 { apiKey := os.Getenv("BYTEDANCE_TTS_API_KEY") resourceId := os.Getenv("BYTEDANCE_TTS_RESOURCE_ID") @@ -242,20 +267,12 @@ func httpPostStream(url string, headers map[string]string, body []byte, timeout req.Header.Set(key, value) } - client := globalHTTPClient - if timeout != 0 { - client = &http.Client{ - Timeout: timeout, - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - }, - } - } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() - return client.Do(req) + req = req.WithContext(ctx) + + return globalHTTPClient.Do(req) } func convertSpeedToSpeechRate(speed float64) int { @@ -268,7 +285,12 @@ func convertSpeedToSpeechRate(speed float64) int { return int((speed - 1.0) * 100) } -func synthesis(text string, speed float64) ([]byte, error) { +type SynthesisResult struct { + AudioData []byte + ReqID string +} + +func synthesis(text string, speed float64) (*SynthesisResult, error) { reqID := uuid.NewString() speechRate := convertSpeedToSpeechRate(speed) @@ -336,6 +358,8 @@ func synthesis(text string, speed float64) ([]byte, error) { if v3Resp.Usage != nil { log.Printf("TTS synthesis completed, usage: %+v", v3Resp.Usage) } + for scanner.Scan() { + } break } @@ -365,7 +389,7 @@ func synthesis(text string, speed float64) ([]byte, error) { return nil, fmt.Errorf("no audio data received") } - return audioData, nil + return &SynthesisResult{AudioData: audioData, ReqID: reqID}, nil } func validateAPIKey(r *http.Request) bool { @@ -478,7 +502,11 @@ func openaiTTSHandler(w http.ResponseWriter, r *http.Request) { 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 } @@ -510,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 { @@ -522,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) {