Compare commits
11
Commits
v0.1.0
..
b92d3dbc00
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b92d3dbc00 | ||
|
|
b92973cdc9 | ||
|
|
be6c2ad34e | ||
|
|
23b962a90e | ||
|
|
9b2a1d1531 | ||
|
|
9c35f780db | ||
|
|
7bedb222d1 | ||
|
|
4e1820d45b | ||
|
|
1b84a6c9ee | ||
|
|
4c93638250 | ||
|
|
45591a4e3a |
@@ -0,0 +1,9 @@
|
|||||||
|
*.exe
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
tts_api_architecture.html
|
||||||
|
代码审查报告.md
|
||||||
|
fix_list.md
|
||||||
+4
-12
@@ -9,19 +9,9 @@
|
|||||||
BYTEDANCE_TTS_API_KEY=your_api_key_here
|
BYTEDANCE_TTS_API_KEY=your_api_key_here
|
||||||
|
|
||||||
# 资源信息ID(决定使用1.0还是2.0模型)
|
# 资源信息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
|
BYTEDANCE_TTS_RESOURCE_ID=seed-tts-1.0
|
||||||
|
|
||||||
# 发音人(音色)ID,具体参考火山引擎音色列表
|
# 发音人(音色)ID
|
||||||
# 注意:1.0音色只能搭配 seed-tts-1.0 Resource ID
|
|
||||||
# 2.0音色只能搭配 seed-tts-2.0 Resource ID
|
|
||||||
BYTEDANCE_TTS_SPEAKER=your_speaker_id_here
|
BYTEDANCE_TTS_SPEAKER=your_speaker_id_here
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
@@ -32,8 +22,10 @@ BYTEDANCE_TTS_SPEAKER=your_speaker_id_here
|
|||||||
BYTEDANCE_TTS_TIMEOUT=30s
|
BYTEDANCE_TTS_TIMEOUT=30s
|
||||||
|
|
||||||
# OpenAI兼容接口的API密钥(可选)
|
# OpenAI兼容接口的API密钥(可选)
|
||||||
# 配置后,客户端请求需要携带 Authorization: Bearer <OPENAI_TTS_API_KEY>
|
|
||||||
OPENAI_TTS_API_KEY=your_openai_compatible_key_here
|
OPENAI_TTS_API_KEY=your_openai_compatible_key_here
|
||||||
|
|
||||||
|
# CORS 跨域白名单(逗号分隔,开发环境可设 *)
|
||||||
|
# ALLOWED_ORIGINS=https://example.com,https://app.example.com
|
||||||
|
|
||||||
# 服务监听端口,默认8080
|
# 服务监听端口,默认8080
|
||||||
PORT=8080
|
PORT=8080
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
name: Go CI/CD Deploy to Baota
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main, master ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: 拉取代码
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: 安装 Go 环境
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: 1.22
|
|
||||||
|
|
||||||
- name: 编译 Go 项目
|
|
||||||
run: |
|
|
||||||
# 静态编译,无依赖,适合 Linux 部署
|
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app main.go
|
|
||||||
|
|
||||||
- name: 部署到宝塔项目目录并重启服务
|
|
||||||
run: |
|
|
||||||
# 1. 停止旧进程
|
|
||||||
pkill -f Volcano-Engine-TTS-UI || true
|
|
||||||
|
|
||||||
# 2. 复制新程序到项目目录
|
|
||||||
cp ./app /www/wwwroot/Volcano-Engine-TTS-UI/
|
|
||||||
|
|
||||||
# 3. 赋予执行权限
|
|
||||||
chmod +x /www/wwwroot/Volcano-Engine-TTS-UI/app
|
|
||||||
|
|
||||||
# 4. 启动新进程(后台运行)
|
|
||||||
nohup /www/wwwroot/Volcano-Engine-TTS-UI/app > /www/wwwroot/Volcano-Engine-TTS-UI/app.log 2>&1 &
|
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
FROM golang:1.23-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o tts-api .
|
||||||
|
|
||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
RUN apk --no-cache add ca-certificates tzdata \
|
||||||
|
&& addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/tts-api .
|
||||||
|
COPY --from=builder /app/health.html .
|
||||||
|
|
||||||
|
RUN chown -R appuser:appgroup /app
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["./tts-api"]
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package volcano
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/volcano-tts/tts-api/common"
|
||||||
|
"github.com/volcano-tts/tts-api/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HTTPClient struct {
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPClient() *HTTPClient {
|
||||||
|
return &HTTPClient{
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: common.DefaultTimeout,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
MaxIdleConnsPerHost: 20,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPClient) PostStream(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
|
||||||
|
}
|
||||||
|
for key, value := range headers {
|
||||||
|
req.Header.Set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
return h.client.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertSpeedToSpeechRate(speed float64) int {
|
||||||
|
if speed <= 0.5 {
|
||||||
|
return -50
|
||||||
|
}
|
||||||
|
if speed >= 2.0 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return int((speed - 1.0) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Synthesis(config *dto.ByteDanceTTSConfig, httpClient *HTTPClient, text string, speed float64) (*dto.SynthesisResult, error) {
|
||||||
|
reqID := uuid.NewString()
|
||||||
|
speechRate := convertSpeedToSpeechRate(speed)
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"user": map[string]interface{}{
|
||||||
|
"uid": "uid",
|
||||||
|
},
|
||||||
|
"namespace": "BidirectionalTTS",
|
||||||
|
"req_params": map[string]interface{}{
|
||||||
|
"text": text,
|
||||||
|
"speaker": config.Speaker,
|
||||||
|
"audio_params": map[string]interface{}{
|
||||||
|
"format": "wav",
|
||||||
|
"sample_rate": 24000,
|
||||||
|
"speech_rate": speechRate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := map[string]string{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Api-Resource-Id": config.ResourceId,
|
||||||
|
"X-Api-Request-Id": reqID,
|
||||||
|
"X-Api-Key": config.ApiKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyStr, err := json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("JSON marshal fail: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := httpClient.PostStream(config.URL, headers, bodyStr, config.Timeout)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("http post fail: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to read error response body: %v", err)
|
||||||
|
} else {
|
||||||
|
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), 8*1024*1024)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Bytes()
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var v3Resp dto.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 len(audioData) == 0 {
|
||||||
|
return nil, fmt.Errorf("no audio data received")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.SynthesisResult{AudioData: audioData, ReqID: reqID}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultPort = "8080"
|
||||||
|
DefaultTimeout = 30 * time.Second
|
||||||
|
MaxTextLength = 5000
|
||||||
|
MinSpeed = 0.25
|
||||||
|
MaxSpeed = 4.0
|
||||||
|
DefaultSpeed = 1.0
|
||||||
|
MaxRequestBodySize = 1024 * 1024
|
||||||
|
RateLimitRequests = 100
|
||||||
|
RateLimitWindow = time.Minute
|
||||||
|
MaxResponseTimes = 100
|
||||||
|
MaxErrors = 10
|
||||||
|
MaxConcurrentRequests = 10
|
||||||
|
CleanupInterval = time.Hour
|
||||||
|
MaxModelNameLength = 64
|
||||||
|
MaxRateLimiterEntries = 100000
|
||||||
|
)
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/volcano-tts/tts-api/adapter/volcano"
|
||||||
|
"github.com/volcano-tts/tts-api/common"
|
||||||
|
"github.com/volcano-tts/tts-api/dto"
|
||||||
|
"github.com/volcano-tts/tts-api/middleware"
|
||||||
|
"github.com/volcano-tts/tts-api/service"
|
||||||
|
"github.com/volcano-tts/tts-api/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
var volcanoClient *volcano.HTTPClient
|
||||||
|
|
||||||
|
func InitController() {
|
||||||
|
volcanoClient = volcano.NewHTTPClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !middleware.ValidateAPIKey(r) {
|
||||||
|
middleware.SendJSONError(w, http.StatusUnauthorized, "Invalid API key provided.", "invalid_request_error", "invalid_api_key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if setting.TTSConfigErr != nil {
|
||||||
|
middleware.SendJSONError(w, http.StatusServiceUnavailable, "TTS service configuration error. Please check environment variables and restart the service.", "configuration_error", "service_unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case middleware.ConcurrencySem <- struct{}{}:
|
||||||
|
defer func() { <-middleware.ConcurrencySem }()
|
||||||
|
default:
|
||||||
|
log.Printf("警告: 已达到最大并发请求数限制,拒绝请求 - 客户端IP: %s", middleware.GetClientIP(r))
|
||||||
|
middleware.SendJSONError(w, http.StatusServiceUnavailable, "Server is busy, maximum concurrent requests reached. Please try again later.", "concurrency_limit_error", "max_concurrent_requests")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientIP := middleware.GetClientIP(r)
|
||||||
|
if !middleware.GlobalRateLimiter.Allow(clientIP) {
|
||||||
|
log.Printf("警告: 已超过IP速率限制,拒绝请求 - 客户端IP: %s", clientIP)
|
||||||
|
middleware.SendJSONError(w, http.StatusTooManyRequests, "Rate limit exceeded. Please try again later.", "rate_limit_error", "rate_limit_exceeded")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, common.MaxRequestBodySize)
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "request body too large") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.OpenAITTSRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Model != "" {
|
||||||
|
if len(req.Model) > common.MaxModelNameLength {
|
||||||
|
http.Error(w, fmt.Sprintf("Model name too long (max %d characters)", common.MaxModelNameLength), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(req.Model, "\x00\n\r\t") {
|
||||||
|
http.Error(w, "Model name contains invalid characters", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Input == "" {
|
||||||
|
http.Error(w, "Input text is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Input) > common.MaxTextLength {
|
||||||
|
http.Error(w, fmt.Sprintf("Input text too long (max %d characters)", common.MaxTextLength), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
speed := req.Speed
|
||||||
|
if speed <= 0 {
|
||||||
|
speed = common.DefaultSpeed
|
||||||
|
}
|
||||||
|
if speed < common.MinSpeed {
|
||||||
|
speed = common.MinSpeed
|
||||||
|
}
|
||||||
|
if speed > common.MaxSpeed {
|
||||||
|
speed = common.MaxSpeed
|
||||||
|
}
|
||||||
|
|
||||||
|
ttsStart := time.Now()
|
||||||
|
result, err := volcano.Synthesis(&setting.TTSConfig, volcanoClient, req.Input, speed)
|
||||||
|
duration := time.Since(ttsStart)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
service.GlobalStats.AddRequest(false, duration, err.Error())
|
||||||
|
http.Error(w, "TTS synthesis failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
service.GlobalStats.AddRequest(true, duration, "")
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "audio/wav")
|
||||||
|
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(result.AudioData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HealthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if setting.TTSConfigErr != nil {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalRequests, successfulRequests, failedRequests, totalResponseTime, recentResponseTimes, lastErrors := service.GlobalStats.GetSnapshot()
|
||||||
|
|
||||||
|
var errorRate float64
|
||||||
|
if totalRequests > 0 {
|
||||||
|
errorRate = float64(failedRequests) / float64(totalRequests) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
var avgResponseTime float64
|
||||||
|
if totalRequests > 0 {
|
||||||
|
avgResponseTime = totalResponseTime.Seconds() * 1000 / float64(totalRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
envCheckStatus := setting.CheckEnvironmentVariables()
|
||||||
|
allEnvVarsSet := envCheckStatus["all_required_vars_set"].(bool)
|
||||||
|
|
||||||
|
status := "ok"
|
||||||
|
if !allEnvVarsSet {
|
||||||
|
status = "configuration_error"
|
||||||
|
}
|
||||||
|
|
||||||
|
response := dto.HealthResponse{
|
||||||
|
Status: status,
|
||||||
|
Service: "ByteDance TTS to OpenAI API Adapter",
|
||||||
|
Version: "2.0.0 (v3 API)",
|
||||||
|
Uptime: fmt.Sprintf("%.0f seconds", time.Since(startTime).Seconds()),
|
||||||
|
StartTime: startTime.Format(time.RFC3339),
|
||||||
|
Memory: service.GetMemoryInfo(),
|
||||||
|
APIStats: dto.APIStatsResponse{
|
||||||
|
TotalRequests: int(totalRequests),
|
||||||
|
SuccessfulRequests: successfulRequests,
|
||||||
|
FailedRequests: failedRequests,
|
||||||
|
ErrorRatePercent: fmt.Sprintf("%.2f", errorRate),
|
||||||
|
AvgResponseTimeMs: fmt.Sprintf("%.2f", avgResponseTime),
|
||||||
|
RecentResponseTimesMs: recentResponseTimes,
|
||||||
|
},
|
||||||
|
Errors: dto.ErrorResponse{
|
||||||
|
RecentErrorsCount: len(lastErrors),
|
||||||
|
},
|
||||||
|
ConfigStatus: dto.ConfigStatusResponse{
|
||||||
|
AllRequiredVarsSet: allEnvVarsSet,
|
||||||
|
ConfigError: setting.TTSConfigErr != nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
var startTime time.Time
|
||||||
|
|
||||||
|
func SetStartTime(t time.Time) {
|
||||||
|
startTime = t
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
services:
|
||||||
|
tts-api:
|
||||||
|
build: .
|
||||||
|
container_name: tts-api
|
||||||
|
ports:
|
||||||
|
- "${PORT:-8080}:8080"
|
||||||
|
environment:
|
||||||
|
- BYTEDANCE_TTS_API_KEY=${BYTEDANCE_TTS_API_KEY}
|
||||||
|
- BYTEDANCE_TTS_RESOURCE_ID=${BYTEDANCE_TTS_RESOURCE_ID}
|
||||||
|
- BYTEDANCE_TTS_SPEAKER=${BYTEDANCE_TTS_SPEAKER}
|
||||||
|
- BYTEDANCE_TTS_TIMEOUT=${BYTEDANCE_TTS_TIMEOUT:-30s}
|
||||||
|
- OPENAI_TTS_API_KEY=${OPENAI_TTS_API_KEY:-}
|
||||||
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
|
||||||
|
- PORT=8080
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
type HealthResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Uptime string `json:"uptime"`
|
||||||
|
StartTime string `json:"start_time"`
|
||||||
|
Memory map[string]interface{} `json:"memory"`
|
||||||
|
APIStats APIStatsResponse `json:"api_stats"`
|
||||||
|
Errors ErrorResponse `json:"errors"`
|
||||||
|
ConfigStatus ConfigStatusResponse `json:"config_status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIStatsResponse struct {
|
||||||
|
TotalRequests int `json:"total_requests"`
|
||||||
|
SuccessfulRequests int64 `json:"successful_requests"`
|
||||||
|
FailedRequests int64 `json:"failed_requests"`
|
||||||
|
ErrorRatePercent string `json:"error_rate_percent"`
|
||||||
|
AvgResponseTimeMs string `json:"avg_response_time_ms"`
|
||||||
|
RecentResponseTimesMs []float64 `json:"recent_response_times_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorResponse struct {
|
||||||
|
RecentErrorsCount int `json:"recent_errors_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigStatusResponse struct {
|
||||||
|
AllRequiredVarsSet bool `json:"all_required_vars_set"`
|
||||||
|
ConfigError bool `json:"config_error"`
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type OpenAITTSRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Input string `json:"input"`
|
||||||
|
Voice string `json:"voice"`
|
||||||
|
ResponseFormat string `json:"response_format,omitempty"`
|
||||||
|
Speed float64 `json:"speed,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 *V3Usage `json:"usage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type V3Usage struct {
|
||||||
|
TextWords int `json:"text_words"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ByteDanceTTSConfig struct {
|
||||||
|
ApiKey string
|
||||||
|
ResourceId string
|
||||||
|
Speaker string
|
||||||
|
URL string
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type SynthesisResult struct {
|
||||||
|
AudioData []byte
|
||||||
|
ReqID string
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
module bytedance-tts-openai-adapter
|
module github.com/volcano-tts/tts-api
|
||||||
|
|
||||||
go 1.19
|
go 1.19
|
||||||
|
|
||||||
|
|||||||
+492
@@ -0,0 +1,492 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TTS 服务监控</title>
|
||||||
|
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||||
|
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #e0e0e0;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
#app {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
background: linear-gradient(90deg, #00d4ff, #7b2ff7);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.header .version {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.refresh-btn {
|
||||||
|
background: linear-gradient(90deg, #00d4ff, #7b2ff7);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 15px;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
.refresh-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.refresh-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.card-title .dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #00d4ff;
|
||||||
|
}
|
||||||
|
.card-title .dot.error {
|
||||||
|
background: #ff4757;
|
||||||
|
}
|
||||||
|
.card-title .dot.warning {
|
||||||
|
background: #ffa502;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2.5em;
|
||||||
|
font-weight: bold;
|
||||||
|
background: linear-gradient(90deg, #00d4ff, #7b2ff7);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
.info-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.info-label {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.info-value {
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
}
|
||||||
|
.info-value.success {
|
||||||
|
color: #2ed573;
|
||||||
|
}
|
||||||
|
.info-value.error {
|
||||||
|
color: #ff4757;
|
||||||
|
}
|
||||||
|
.info-value.warning {
|
||||||
|
color: #ffa502;
|
||||||
|
}
|
||||||
|
.chart-container {
|
||||||
|
height: 120px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
flex: 1;
|
||||||
|
background: linear-gradient(180deg, #7b2ff7, #00d4ff);
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
min-height: 2px;
|
||||||
|
transition: height 0.3s ease;
|
||||||
|
}
|
||||||
|
.error-list {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.error-item {
|
||||||
|
background: rgba(255, 71, 87, 0.1);
|
||||||
|
border-left: 3px solid #ff4757;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.error-time {
|
||||||
|
color: #888;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.error-box {
|
||||||
|
background: rgba(255, 71, 87, 0.1);
|
||||||
|
border: 1px solid rgba(255, 71, 87, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
color: #ff4757;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(-10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.uptime {
|
||||||
|
font-size: 1.5em;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2ed573;
|
||||||
|
}
|
||||||
|
.progress-ring {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.progress-ring circle {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 8;
|
||||||
|
}
|
||||||
|
.progress-ring .bg {
|
||||||
|
stroke: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.progress-ring .progress {
|
||||||
|
stroke: url(#gradient);
|
||||||
|
stroke-linecap: round;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
transform-origin: center;
|
||||||
|
transition: stroke-dashoffset 0.5s ease;
|
||||||
|
}
|
||||||
|
.progress-text {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.memory-stat {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.memory-stat .value {
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #00d4ff;
|
||||||
|
}
|
||||||
|
.memory-stat .label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.no-errors {
|
||||||
|
text-align: center;
|
||||||
|
color: #2ed573;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div class="header">
|
||||||
|
<h1>TTS 服务监控</h1>
|
||||||
|
<div class="version">{{ healthData.service || 'Loading...' }} - {{ healthData.version || '' }}</div>
|
||||||
|
<button class="refresh-btn" @click="fetchHealth(true)" :disabled="loading">
|
||||||
|
{{ loading ? '刷新中...' : '刷新数据' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="error-box">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="healthData.status">
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">
|
||||||
|
<span class="dot" :class="{ error: healthData.status !== 'ok' }"></span>
|
||||||
|
服务状态
|
||||||
|
</div>
|
||||||
|
<div class="stat-value">{{ healthData.status === 'ok' ? '正常运行' : '配置错误' }}</div>
|
||||||
|
<div class="stat-label">运行时长: {{ formatUptime(healthData.uptime) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">
|
||||||
|
<span class="dot"></span>
|
||||||
|
请求统计
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 30px;">
|
||||||
|
<div>
|
||||||
|
<div class="stat-value">{{ healthData.api_stats?.total_requests || 0 }}</div>
|
||||||
|
<div class="stat-label">总请求数</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value" style="color: #2ed573">{{ healthData.api_stats?.successful_requests || 0 }}</div>
|
||||||
|
<div class="stat-label">成功</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value" style="color: #ff4757">{{ healthData.api_stats?.failed_requests || 0 }}</div>
|
||||||
|
<div class="stat-label">失败</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">
|
||||||
|
<span class="dot" :class="{ warning: errorRate > 10 }"></span>
|
||||||
|
错误率
|
||||||
|
</div>
|
||||||
|
<div class="stat-value" :style="{ color: errorRate > 10 ? '#ff4757' : '#2ed573' }">
|
||||||
|
{{ healthData.api_stats?.error_rate_percent || '0' }}%
|
||||||
|
</div>
|
||||||
|
<div class="stat-label">平均响应: {{ healthData.api_stats?.avg_response_time_ms || '0' }} ms</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">
|
||||||
|
<span class="dot"></span>
|
||||||
|
配置状态
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">环境变量</span>
|
||||||
|
<span class="info-value" :class="healthData.config_status?.all_required_vars_set ? 'success' : 'error'">
|
||||||
|
{{ healthData.config_status?.all_required_vars_set ? '已配置' : '未配置' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">配置状态</span>
|
||||||
|
<span class="info-value" :class="healthData.config_status?.config_error ? 'error' : 'success'">
|
||||||
|
{{ healthData.config_status?.config_error ? '异常' : '正常' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">启动时间</span>
|
||||||
|
<span class="info-value">{{ healthData.start_time || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">
|
||||||
|
<span class="dot"></span>
|
||||||
|
内存使用
|
||||||
|
</div>
|
||||||
|
<div class="memory-stat">
|
||||||
|
<div>
|
||||||
|
<div class="value">{{ formatBytes(healthData.memory?.heap_alloc) }}</div>
|
||||||
|
<div class="label">Heap Alloc</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="value">{{ formatBytes(healthData.memory?.heap_inuse) }}</div>
|
||||||
|
<div class="label">Heap Inuse</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="value">{{ healthData.memory?.goroutines || 0 }}</div>
|
||||||
|
<div class="label">Goroutines</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">
|
||||||
|
<span class="dot"></span>
|
||||||
|
响应时间趋势
|
||||||
|
</div>
|
||||||
|
<div class="chart-container">
|
||||||
|
<div v-for="(time, index) in chartData" :key="index" class="bar"
|
||||||
|
:style="{ height: Math.max(2, (time / maxResponseTime) * 100) + '%' }"
|
||||||
|
:title="time.toFixed(1) + 'ms'">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">
|
||||||
|
<span class="dot" :class="{ error: recentErrorsCount > 0 }"></span>
|
||||||
|
错误记录 ({{ recentErrorsCount }})
|
||||||
|
</div>
|
||||||
|
<div v-if="recentErrorsCount === 0" class="no-errors">
|
||||||
|
暂无错误记录
|
||||||
|
</div>
|
||||||
|
<div v-else class="error-list">
|
||||||
|
<div class="error-item">
|
||||||
|
<div class="error-time">检测到 {{ recentErrorsCount }} 条最近错误,详细信息请查看服务器日志</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading && !healthData.status" class="loading">
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const { createApp, ref, computed, onMounted } = Vue;
|
||||||
|
|
||||||
|
createApp({
|
||||||
|
setup() {
|
||||||
|
const healthData = ref({});
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref(null);
|
||||||
|
const isAutoRefresh = ref(false);
|
||||||
|
|
||||||
|
const deepUpdate = (target, source) => {
|
||||||
|
for (const key of Object.keys(source)) {
|
||||||
|
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||||
|
if (!target[key]) target[key] = {};
|
||||||
|
deepUpdate(target[key], source[key]);
|
||||||
|
} else {
|
||||||
|
target[key] = source[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchHealth = async (showLoading = false) => {
|
||||||
|
if (showLoading) {
|
||||||
|
loading.value = true;
|
||||||
|
}
|
||||||
|
const oldError = error.value;
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/health');
|
||||||
|
deepUpdate(healthData.value, response.data);
|
||||||
|
if (response.data.config_status?.all_required_vars_set !== false) {
|
||||||
|
error.value = null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e.response && e.response.data) {
|
||||||
|
deepUpdate(healthData.value, e.response.data);
|
||||||
|
if (e.response.status === 503) {
|
||||||
|
error.value = '服务配置异常,请检查环境变量配置';
|
||||||
|
} else {
|
||||||
|
error.value = '服务异常: ' + (e.message || '未知错误');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error.value = '无法获取服务状态: ' + (e.message || '未知错误');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorRate = computed(() => {
|
||||||
|
return parseFloat(healthData.value.api_stats?.error_rate_percent || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const recentErrorsCount = computed(() => {
|
||||||
|
return healthData.value.errors?.recent_errors_count || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = computed(() => {
|
||||||
|
return healthData.value.api_stats?.recent_response_times_ms || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxResponseTime = computed(() => {
|
||||||
|
const times = chartData.value;
|
||||||
|
if (times.length === 0) return 100;
|
||||||
|
return Math.max(...times, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatBytes = (bytes) => {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatUptime = (seconds) => {
|
||||||
|
if (!seconds) return '-';
|
||||||
|
const s = parseInt(seconds);
|
||||||
|
const d = Math.floor(s / 86400);
|
||||||
|
const h = Math.floor((s % 86400) / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
if (d > 0) return `${d}天 ${h}小时`;
|
||||||
|
if (h > 0) return `${h}小时 ${m}分钟`;
|
||||||
|
return `${m}分钟`;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchHealth(true);
|
||||||
|
setInterval(() => fetchHealth(false), 10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
healthData,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchHealth,
|
||||||
|
errorRate,
|
||||||
|
recentErrorsCount,
|
||||||
|
chartData,
|
||||||
|
maxResponseTime,
|
||||||
|
formatBytes,
|
||||||
|
formatUptime
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}).mount('#app');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/volcano-tts/tts-api/common"
|
||||||
|
"github.com/volcano-tts/tts-api/controller"
|
||||||
|
"github.com/volcano-tts/tts-api/middleware"
|
||||||
|
"github.com/volcano-tts/tts-api/router"
|
||||||
|
"github.com/volcano-tts/tts-api/service"
|
||||||
|
"github.com/volcano-tts/tts-api/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
|
log.SetPrefix("[TTS-Server] ")
|
||||||
|
|
||||||
|
middleware.InitAPIKeys()
|
||||||
|
middleware.InitCORSConfig()
|
||||||
|
middleware.InitRateLimiter()
|
||||||
|
setting.CheckStaticFiles()
|
||||||
|
service.InitStats()
|
||||||
|
controller.InitController()
|
||||||
|
|
||||||
|
setting.TTSConfigErr = setting.InitTTSConfig()
|
||||||
|
if setting.TTSConfigErr != nil {
|
||||||
|
log.Printf("警告: 配置初始化失败: %v", setting.TTSConfigErr)
|
||||||
|
log.Printf("服务将继续运行,但TTS功能不可用,请检查环境变量配置")
|
||||||
|
} else {
|
||||||
|
log.Printf("配置初始化成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.SetStartTime(time.Now())
|
||||||
|
|
||||||
|
r := router.Setup()
|
||||||
|
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = common.DefaultPort
|
||||||
|
}
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: ":" + port,
|
||||||
|
Handler: r,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 120 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
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")
|
||||||
|
|
||||||
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("Server failed to start: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-quit
|
||||||
|
log.Println("Shutting down server...")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := server.Shutdown(ctx); err != nil {
|
||||||
|
log.Printf("Server forced to shutdown: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("Server exited gracefully")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var validAPIKeys []string
|
||||||
|
|
||||||
|
func InitAPIKeys() {
|
||||||
|
apiKey := os.Getenv("OPENAI_TTS_API_KEY")
|
||||||
|
if apiKey != "" {
|
||||||
|
validAPIKeys = strings.Split(apiKey, ",")
|
||||||
|
for i, k := range validAPIKeys {
|
||||||
|
validAPIKeys[i] = strings.TrimSpace(k)
|
||||||
|
}
|
||||||
|
log.Printf("已配置 %d 个有效的API密钥", len(validAPIKeys))
|
||||||
|
} else {
|
||||||
|
log.Println("警告: OPENAI_TTS_API_KEY 环境变量未设置,将允许所有请求")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateAPIKey(r *http.Request) bool {
|
||||||
|
if len(validAPIKeys) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
for _, validKey := range validAPIKeys {
|
||||||
|
if subtle.ConstantTimeCompare([]byte(token), []byte(validKey)) == 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendJSONError(w http.ResponseWriter, statusCode int, message string, errType string, code string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"message": message,
|
||||||
|
"type": errType,
|
||||||
|
"code": code,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
allowedOrigins []string
|
||||||
|
allowAllOrigins bool
|
||||||
|
corsMaxAgeHeader = "86400"
|
||||||
|
)
|
||||||
|
|
||||||
|
func normalizeOrigin(origin string) string {
|
||||||
|
origin = strings.TrimSpace(origin)
|
||||||
|
origin = strings.TrimRight(origin, "/")
|
||||||
|
return strings.ToLower(origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitCORSConfig() {
|
||||||
|
origins := os.Getenv("ALLOWED_ORIGINS")
|
||||||
|
if origins == "" {
|
||||||
|
log.Println("警告: ALLOWED_ORIGINS 环境变量未设置")
|
||||||
|
log.Println("出于安全考虑,跨域请求将被拒绝。如需开放跨域请配置 ALLOWED_ORIGINS")
|
||||||
|
log.Println("开发环境可设置 ALLOWED_ORIGINS=* 允许所有来源(不可与凭据共用)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(origins, ",")
|
||||||
|
for _, p := range parts {
|
||||||
|
o := strings.TrimSpace(p)
|
||||||
|
if o == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if o == "*" {
|
||||||
|
allowAllOrigins = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allowedOrigins = append(allowedOrigins, normalizeOrigin(o))
|
||||||
|
}
|
||||||
|
|
||||||
|
if allowAllOrigins {
|
||||||
|
log.Println("警告: ALLOWED_ORIGINS=*,将允许所有来源跨域请求(不携带凭据)")
|
||||||
|
}
|
||||||
|
if len(allowedOrigins) > 0 {
|
||||||
|
log.Printf("已配置 %d 个允许的跨域来源白名单", len(allowedOrigins))
|
||||||
|
for _, o := range allowedOrigins {
|
||||||
|
log.Printf(" - %s", o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidOrigin(origin string) bool {
|
||||||
|
if origin == "" || origin == "null" || origin == "nil" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 使用小写比较,避免大小写问题
|
||||||
|
lowerOrigin := strings.ToLower(origin)
|
||||||
|
if !strings.HasPrefix(lowerOrigin, "http://") && !strings.HasPrefix(lowerOrigin, "https://") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchOrigin(origin string) (string, bool) {
|
||||||
|
if !isValidOrigin(origin) {
|
||||||
|
log.Printf("[CORS] Origin %q 验证失败", origin)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if allowAllOrigins {
|
||||||
|
log.Printf("[CORS] Origin %q 匹配 allowAllOrigins", origin)
|
||||||
|
return "*", true
|
||||||
|
}
|
||||||
|
normalized := normalizeOrigin(origin)
|
||||||
|
log.Printf("[CORS] 检查 origin %q (normalized: %q) 对比白名单: %v", origin, normalized, allowedOrigins)
|
||||||
|
for _, allowed := range allowedOrigins {
|
||||||
|
if allowed == normalized {
|
||||||
|
log.Printf("[CORS] Origin %q 匹配白名单 %q", origin, allowed)
|
||||||
|
return origin, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("[CORS] Origin %q 未匹配任何白名单", origin)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func CORS(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
|
||||||
|
if origin != "" {
|
||||||
|
allowOrigin, matched := matchOrigin(origin)
|
||||||
|
if matched {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", allowOrigin)
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
w.Header().Set("Access-Control-Expose-Headers", "X-Request-Id")
|
||||||
|
w.Header().Set("Access-Control-Max-Age", corsMaxAgeHeader)
|
||||||
|
if allowOrigin != "*" {
|
||||||
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
}
|
||||||
|
vary := w.Header().Get("Vary")
|
||||||
|
if vary == "" {
|
||||||
|
w.Header().Set("Vary", "Origin")
|
||||||
|
} else if !strings.Contains(vary, "Origin") {
|
||||||
|
w.Header().Set("Vary", vary+", Origin")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("CORS拦截: 来源=%q 路径=%s 方法=%s 客户端IP=%s",
|
||||||
|
origin, r.URL.Path, r.Method, GetClientIP(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type statusRecorder struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rec *statusRecorder) WriteHeader(code int) {
|
||||||
|
rec.statusCode = code
|
||||||
|
rec.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Logger(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/health" {
|
||||||
|
start := time.Now()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
log.Printf("%s %s %s %v", r.Method, r.RequestURI, r.RemoteAddr, time.Since(start))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
rec := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK}
|
||||||
|
next.ServeHTTP(rec, r)
|
||||||
|
duration := time.Since(start)
|
||||||
|
|
||||||
|
log.Printf("%s %s %s %d %v", r.Method, r.RequestURI, r.RemoteAddr, rec.statusCode, duration)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/volcano-tts/tts-api/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RateLimiter struct {
|
||||||
|
requests map[string][]time.Time
|
||||||
|
mutex sync.Mutex
|
||||||
|
limit int
|
||||||
|
window time.Duration
|
||||||
|
lastCleanup time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
GlobalRateLimiter *RateLimiter
|
||||||
|
ConcurrencySem chan struct{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func InitRateLimiter() {
|
||||||
|
GlobalRateLimiter = &RateLimiter{
|
||||||
|
requests: make(map[string][]time.Time),
|
||||||
|
limit: common.RateLimitRequests,
|
||||||
|
window: common.RateLimitWindow,
|
||||||
|
}
|
||||||
|
ConcurrencySem = make(chan struct{}, common.MaxConcurrentRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *RateLimiter) Allow(key string) bool {
|
||||||
|
rl.mutex.Lock()
|
||||||
|
defer rl.mutex.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
cutoff := now.Add(-rl.window)
|
||||||
|
|
||||||
|
if now.Sub(rl.lastCleanup) > common.CleanupInterval {
|
||||||
|
rl.cleanup()
|
||||||
|
rl.lastCleanup = now
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamps := rl.requests[key]
|
||||||
|
valid := make([]time.Time, 0, len(timestamps))
|
||||||
|
for _, ts := range timestamps {
|
||||||
|
if ts.After(cutoff) {
|
||||||
|
valid = append(valid, ts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(valid) >= rl.limit {
|
||||||
|
rl.requests[key] = valid
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
valid = append(valid, now)
|
||||||
|
rl.requests[key] = valid
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rl.requests) > common.MaxRateLimiterEntries {
|
||||||
|
log.Printf("警告: 限流器条目数 %d 超过上限 %d,触发强制清理", len(rl.requests), common.MaxRateLimiterEntries)
|
||||||
|
for k := range rl.requests {
|
||||||
|
if len(rl.requests) <= common.MaxRateLimiterEntries/2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
delete(rl.requests, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetClientIP(r *http.Request) string {
|
||||||
|
xForwardedFor := r.Header.Get("X-Forwarded-For")
|
||||||
|
if xForwardedFor != "" {
|
||||||
|
ips := strings.Split(xForwardedFor, ",")
|
||||||
|
if len(ips) > 0 {
|
||||||
|
ip := strings.TrimSpace(ips[0])
|
||||||
|
if ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
xRealIP := strings.TrimSpace(r.Header.Get("X-Real-IP"))
|
||||||
|
if xRealIP != "" {
|
||||||
|
return xRealIP
|
||||||
|
}
|
||||||
|
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SecurityHeaders(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("X-Frame-Options", "DENY")
|
||||||
|
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||||
|
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
|
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/v1/") || r.URL.Path == "/health" {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/volcano-tts/tts-api/controller"
|
||||||
|
"github.com/volcano-tts/tts-api/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Setup() *mux.Router {
|
||||||
|
r := mux.NewRouter()
|
||||||
|
|
||||||
|
r.Use(middleware.CORS)
|
||||||
|
r.Use(middleware.SecurityHeaders)
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
|
||||||
|
r.HandleFunc("/v1/audio/speech", controller.OpenaiTTSHandler).Methods("POST", "OPTIONS")
|
||||||
|
r.HandleFunc("/health", controller.HealthHandler).Methods("GET")
|
||||||
|
r.HandleFunc("/dashboard", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.ServeFile(w, r, "health.html")
|
||||||
|
}).Methods("GET")
|
||||||
|
r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||||
|
}).Methods("GET")
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/volcano-tts/tts-api/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Stats struct {
|
||||||
|
totalRequests int64
|
||||||
|
successfulRequests int64
|
||||||
|
failedRequests int64
|
||||||
|
totalResponseTime time.Duration
|
||||||
|
recentResponseTimes []float64
|
||||||
|
responseTimesIndex int
|
||||||
|
lastErrors []string
|
||||||
|
errorsIndex int
|
||||||
|
mutex sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
var GlobalStats *Stats
|
||||||
|
|
||||||
|
func InitStats() {
|
||||||
|
GlobalStats = &Stats{
|
||||||
|
recentResponseTimes: make([]float64, common.MaxResponseTimes),
|
||||||
|
lastErrors: make([]string, common.MaxErrors),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stats) AddRequest(success bool, responseTime time.Duration, errMsg string) {
|
||||||
|
s.mutex.Lock()
|
||||||
|
defer s.mutex.Unlock()
|
||||||
|
|
||||||
|
s.totalRequests++
|
||||||
|
s.totalResponseTime += responseTime
|
||||||
|
|
||||||
|
s.recentResponseTimes[s.responseTimesIndex] = responseTime.Seconds() * 1000
|
||||||
|
s.responseTimesIndex = (s.responseTimesIndex + 1) % common.MaxResponseTimes
|
||||||
|
|
||||||
|
if success {
|
||||||
|
s.successfulRequests++
|
||||||
|
} else {
|
||||||
|
s.failedRequests++
|
||||||
|
if errMsg != "" {
|
||||||
|
errInfo := fmt.Sprintf("%s: %s", time.Now().Format(time.RFC3339), errMsg)
|
||||||
|
s.lastErrors[s.errorsIndex] = errInfo
|
||||||
|
s.errorsIndex = (s.errorsIndex + 1) % common.MaxErrors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stats) GetSnapshot() (totalRequests int64, successfulRequests int64, failedRequests int64,
|
||||||
|
totalResponseTime time.Duration, recentResponseTimes []float64, lastErrors []string) {
|
||||||
|
s.mutex.RLock()
|
||||||
|
defer s.mutex.RUnlock()
|
||||||
|
|
||||||
|
totalRequests = s.totalRequests
|
||||||
|
successfulRequests = s.successfulRequests
|
||||||
|
failedRequests = s.failedRequests
|
||||||
|
totalResponseTime = s.totalResponseTime
|
||||||
|
|
||||||
|
recentResponseTimes = make([]float64, 0, common.MaxResponseTimes)
|
||||||
|
for _, t := range s.recentResponseTimes {
|
||||||
|
if t > 0 {
|
||||||
|
recentResponseTimes = append(recentResponseTimes, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErrors = make([]string, 0, common.MaxErrors)
|
||||||
|
for _, e := range s.lastErrors {
|
||||||
|
if e != "" {
|
||||||
|
lastErrors = append(lastErrors, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetMemoryInfo() map[string]interface{} {
|
||||||
|
var m runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&m)
|
||||||
|
return map[string]interface{}{
|
||||||
|
"total_alloc": m.TotalAlloc,
|
||||||
|
"heap_alloc": m.HeapAlloc,
|
||||||
|
"heap_inuse": m.HeapInuse,
|
||||||
|
"goroutines": runtime.NumGoroutine(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/volcano-tts/tts-api/common"
|
||||||
|
"github.com/volcano-tts/tts-api/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
TTSConfig dto.ByteDanceTTSConfig
|
||||||
|
TTSConfigErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
func InitTTSConfig() error {
|
||||||
|
apiKey := os.Getenv("BYTEDANCE_TTS_API_KEY")
|
||||||
|
resourceId := os.Getenv("BYTEDANCE_TTS_RESOURCE_ID")
|
||||||
|
speaker := os.Getenv("BYTEDANCE_TTS_SPEAKER")
|
||||||
|
|
||||||
|
missingVars := []string{}
|
||||||
|
if apiKey == "" {
|
||||||
|
missingVars = append(missingVars, "BYTEDANCE_TTS_API_KEY")
|
||||||
|
}
|
||||||
|
if resourceId == "" {
|
||||||
|
missingVars = append(missingVars, "BYTEDANCE_TTS_RESOURCE_ID")
|
||||||
|
}
|
||||||
|
if speaker == "" {
|
||||||
|
missingVars = append(missingVars, "BYTEDANCE_TTS_SPEAKER")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(missingVars) > 0 {
|
||||||
|
return fmt.Errorf("缺少必需的环境变量: %v", missingVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := "https://openspeech.bytedance.com/api/v3/tts/unidirectional"
|
||||||
|
|
||||||
|
timeout := common.DefaultTimeout
|
||||||
|
if timeoutStr := os.Getenv("BYTEDANCE_TTS_TIMEOUT"); timeoutStr != "" {
|
||||||
|
if parsedTimeout, err := time.ParseDuration(timeoutStr); err == nil {
|
||||||
|
timeout = parsedTimeout
|
||||||
|
} else {
|
||||||
|
log.Printf("无效的超时设置 '%s',使用默认值: %v", timeoutStr, timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TTSConfig = dto.ByteDanceTTSConfig{
|
||||||
|
ApiKey: apiKey,
|
||||||
|
ResourceId: resourceId,
|
||||||
|
Speaker: speaker,
|
||||||
|
URL: url,
|
||||||
|
Timeout: timeout,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckEnvironmentVariables() map[string]interface{} {
|
||||||
|
requiredVars := map[string]bool{
|
||||||
|
"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{}
|
||||||
|
for varName, isSet := range requiredVars {
|
||||||
|
if !isSet {
|
||||||
|
missingVars = append(missingVars, varName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
optionalVars := map[string]bool{
|
||||||
|
"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{}{
|
||||||
|
"all_required_vars_set": len(missingVars) == 0,
|
||||||
|
"missing_required_vars": missingVars,
|
||||||
|
"required_vars_set": requiredVars,
|
||||||
|
"optional_vars_set": optionalVars,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckStaticFiles() {
|
||||||
|
if _, err := os.Stat("health.html"); os.IsNotExist(err) {
|
||||||
|
log.Println("警告: health.html 不存在,/dashboard 路由将返回 404")
|
||||||
|
}
|
||||||
|
}
|
||||||
-778
@@ -1,778 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/gorilla/mux"
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
MAX_CONCURRENT_REQUESTS = 10
|
|
||||||
)
|
|
||||||
|
|
||||||
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 {
|
|
||||||
Model string `json:"model"`
|
|
||||||
Input string `json:"input"`
|
|
||||||
Voice string `json:"voice"`
|
|
||||||
ResponseFormat string `json:"response_format,omitempty"`
|
|
||||||
Speed float64 `json:"speed,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ByteDanceTTSConfig struct {
|
|
||||||
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
|
|
||||||
lastCleanup time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanupInterval = time.Hour
|
|
||||||
|
|
||||||
type Stats struct {
|
|
||||||
totalRequests int64
|
|
||||||
successfulRequests int64
|
|
||||||
failedRequests int64
|
|
||||||
totalResponseTime time.Duration
|
|
||||||
recentResponseTimes []float64
|
|
||||||
responseTimesIndex int
|
|
||||||
lastErrors []string
|
|
||||||
errorsIndex int
|
|
||||||
mutex sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
VALID_API_KEYS []string
|
|
||||||
ttsConfig ByteDanceTTSConfig
|
|
||||||
ttsConfigErr error
|
|
||||||
globalHTTPClient *http.Client
|
|
||||||
apiStats *Stats
|
|
||||||
rateLimiter *RateLimiter
|
|
||||||
concurrencySem chan struct{}
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
globalHTTPClient = &http.Client{
|
|
||||||
Timeout: DEFAULT_TIMEOUT,
|
|
||||||
Transport: &http.Transport{
|
|
||||||
MaxIdleConns: 100,
|
|
||||||
MaxIdleConnsPerHost: 10,
|
|
||||||
IdleConnTimeout: 90 * time.Second,
|
|
||||||
TLSHandshakeTimeout: 10 * time.Second,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
apiStats = &Stats{
|
|
||||||
recentResponseTimes: make([]float64, MAX_RESPONSE_TIMES),
|
|
||||||
lastErrors: make([]string, MAX_ERRORS),
|
|
||||||
}
|
|
||||||
|
|
||||||
rateLimiter = &RateLimiter{
|
|
||||||
requests: make(map[string][]time.Time),
|
|
||||||
limit: RATE_LIMIT_REQUESTS,
|
|
||||||
window: RATE_LIMIT_WINDOW,
|
|
||||||
}
|
|
||||||
|
|
||||||
concurrencySem = make(chan struct{}, MAX_CONCURRENT_REQUESTS)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rl *RateLimiter) Allow(key string) bool {
|
|
||||||
rl.mutex.Lock()
|
|
||||||
defer rl.mutex.Unlock()
|
|
||||||
|
|
||||||
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 {
|
|
||||||
if ts.After(cutoff) {
|
|
||||||
valid = append(valid, ts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(valid) >= rl.limit {
|
|
||||||
rl.requests[key] = valid
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
valid = append(valid, now)
|
|
||||||
rl.requests[key] = valid
|
|
||||||
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")
|
|
||||||
speaker := os.Getenv("BYTEDANCE_TTS_SPEAKER")
|
|
||||||
|
|
||||||
missingVars := []string{}
|
|
||||||
|
|
||||||
if apiKey == "" {
|
|
||||||
missingVars = append(missingVars, "BYTEDANCE_TTS_API_KEY")
|
|
||||||
}
|
|
||||||
if resourceId == "" {
|
|
||||||
missingVars = append(missingVars, "BYTEDANCE_TTS_RESOURCE_ID")
|
|
||||||
}
|
|
||||||
if speaker == "" {
|
|
||||||
missingVars = append(missingVars, "BYTEDANCE_TTS_SPEAKER")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(missingVars) > 0 {
|
|
||||||
return fmt.Errorf("缺少必需的环境变量: %v", missingVars)
|
|
||||||
}
|
|
||||||
|
|
||||||
url := "https://openspeech.bytedance.com/api/v3/tts/unidirectional"
|
|
||||||
|
|
||||||
timeout := DEFAULT_TIMEOUT
|
|
||||||
if timeoutStr := os.Getenv("BYTEDANCE_TTS_TIMEOUT"); timeoutStr != "" {
|
|
||||||
if parsedTimeout, err := time.ParseDuration(timeoutStr); err == nil {
|
|
||||||
timeout = parsedTimeout
|
|
||||||
} else {
|
|
||||||
log.Printf("无效的超时设置 '%s',使用默认值: %v", timeoutStr, timeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ttsConfig = ByteDanceTTSConfig{
|
|
||||||
ApiKey: apiKey,
|
|
||||||
ResourceId: resourceId,
|
|
||||||
Speaker: speaker,
|
|
||||||
URL: url,
|
|
||||||
Timeout: timeout,
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func initAPIKeys() {
|
|
||||||
apiKey := os.Getenv("OPENAI_TTS_API_KEY")
|
|
||||||
if apiKey != "" {
|
|
||||||
VALID_API_KEYS = strings.Split(apiKey, ",")
|
|
||||||
for i, k := range VALID_API_KEYS {
|
|
||||||
VALID_API_KEYS[i] = strings.TrimSpace(k)
|
|
||||||
}
|
|
||||||
log.Printf("已配置 %d 个有效的API密钥", len(VALID_API_KEYS))
|
|
||||||
} else {
|
|
||||||
log.Println("警告: OPENAI_TTS_API_KEY 环境变量未设置,将允许所有请求")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkEnvironmentVariables() map[string]interface{} {
|
|
||||||
requiredVars := map[string]bool{
|
|
||||||
"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{}
|
|
||||||
for varName, isSet := range requiredVars {
|
|
||||||
if !isSet {
|
|
||||||
missingVars = append(missingVars, varName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
optionalVars := map[string]bool{
|
|
||||||
"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{}{
|
|
||||||
"all_required_vars_set": len(missingVars) == 0,
|
|
||||||
"missing_required_vars": missingVars,
|
|
||||||
"required_vars_set": requiredVars,
|
|
||||||
"optional_vars_set": optionalVars,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
for key, value := range headers {
|
|
||||||
req.Header.Set(key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
|
|
||||||
return globalHTTPClient.Do(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
speechRate := convertSpeedToSpeechRate(speed)
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
log.Printf("JSON marshal fail: %v", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
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 len(audioData) == 0 {
|
|
||||||
return nil, fmt.Errorf("no audio data received")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &SynthesisResult{AudioData: audioData, ReqID: reqID}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateAPIKey(r *http.Request) bool {
|
|
||||||
if len(VALID_API_KEYS) == 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
authHeader := r.Header.Get("Authorization")
|
|
||||||
if authHeader == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
|
||||||
for _, validKey := range VALID_API_KEYS {
|
|
||||||
if token == validKey {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func getClientIP(r *http.Request) string {
|
|
||||||
xForwardedFor := r.Header.Get("X-Forwarded-For")
|
|
||||||
if xForwardedFor != "" {
|
|
||||||
ips := strings.Split(xForwardedFor, ",")
|
|
||||||
if len(ips) > 0 {
|
|
||||||
return strings.TrimSpace(ips[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
xRealIP := r.Header.Get("X-Real-IP")
|
|
||||||
if xRealIP != "" {
|
|
||||||
return xRealIP
|
|
||||||
}
|
|
||||||
|
|
||||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
||||||
if err != nil {
|
|
||||||
return r.RemoteAddr
|
|
||||||
}
|
|
||||||
return host
|
|
||||||
}
|
|
||||||
|
|
||||||
func openaiTTSHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !validateAPIKey(r) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"error": map[string]interface{}{
|
|
||||||
"message": "Invalid API key provided.",
|
|
||||||
"type": "invalid_request_error",
|
|
||||||
"code": "invalid_api_key",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
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{}{
|
|
||||||
"error": map[string]interface{}{
|
|
||||||
"message": "Rate limit exceeded. Please try again later.",
|
|
||||||
"type": "rate_limit_error",
|
|
||||||
"code": "rate_limit_exceeded",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
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 {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
var req OpenAITTSRequest
|
|
||||||
if err := json.Unmarshal(body, &req); err != nil {
|
|
||||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Input == "" {
|
|
||||||
http.Error(w, "Input text is required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(req.Input) > MAX_TEXT_LENGTH {
|
|
||||||
http.Error(w, fmt.Sprintf("Input text too long (max %d characters)", MAX_TEXT_LENGTH), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
speed := req.Speed
|
|
||||||
if speed <= 0 {
|
|
||||||
speed = DEFAULT_SPEED
|
|
||||||
}
|
|
||||||
if speed < MIN_SPEED {
|
|
||||||
speed = MIN_SPEED
|
|
||||||
}
|
|
||||||
if speed > MAX_SPEED {
|
|
||||||
speed = MAX_SPEED
|
|
||||||
}
|
|
||||||
|
|
||||||
ttsStart := time.Now()
|
|
||||||
result, err := synthesis(req.Input, speed)
|
|
||||||
duration := time.Since(ttsStart)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
addRequestStats(false, duration, err.Error())
|
|
||||||
http.Error(w, "TTS synthesis failed", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
addRequestStats(true, duration, "")
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "audio/wav")
|
|
||||||
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(result.AudioData)
|
|
||||||
}
|
|
||||||
|
|
||||||
func addRequestStats(success bool, responseTime time.Duration, errMsg string) {
|
|
||||||
apiStats.mutex.Lock()
|
|
||||||
defer apiStats.mutex.Unlock()
|
|
||||||
|
|
||||||
apiStats.totalRequests++
|
|
||||||
apiStats.totalResponseTime += responseTime
|
|
||||||
|
|
||||||
apiStats.recentResponseTimes[apiStats.responseTimesIndex] = responseTime.Seconds() * 1000
|
|
||||||
apiStats.responseTimesIndex = (apiStats.responseTimesIndex + 1) % MAX_RESPONSE_TIMES
|
|
||||||
|
|
||||||
if success {
|
|
||||||
apiStats.successfulRequests++
|
|
||||||
} else {
|
|
||||||
apiStats.failedRequests++
|
|
||||||
if errMsg != "" {
|
|
||||||
errInfo := fmt.Sprintf("%s: %s", time.Now().Format(time.RFC3339), errMsg)
|
|
||||||
apiStats.lastErrors[apiStats.errorsIndex] = errInfo
|
|
||||||
apiStats.errorsIndex = (apiStats.errorsIndex + 1) % MAX_ERRORS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getMemoryInfo() map[string]interface{} {
|
|
||||||
var m runtime.MemStats
|
|
||||||
runtime.ReadMemStats(&m)
|
|
||||||
return map[string]interface{}{
|
|
||||||
"total_alloc": m.TotalAlloc,
|
|
||||||
"heap_alloc": m.HeapAlloc,
|
|
||||||
"heap_inuse": m.HeapInuse,
|
|
||||||
"goroutines": runtime.NumGoroutine(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func healthHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
if ttsConfigErr != nil {
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
} else {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
apiStats.mutex.RLock()
|
|
||||||
totalRequests := apiStats.totalRequests
|
|
||||||
successfulRequests := apiStats.successfulRequests
|
|
||||||
failedRequests := apiStats.failedRequests
|
|
||||||
totalResponseTime := apiStats.totalResponseTime
|
|
||||||
recentResponseTimes := make([]float64, 0, MAX_RESPONSE_TIMES)
|
|
||||||
for _, t := range apiStats.recentResponseTimes {
|
|
||||||
if t > 0 {
|
|
||||||
recentResponseTimes = append(recentResponseTimes, t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastErrors := make([]string, 0, MAX_ERRORS)
|
|
||||||
for _, e := range apiStats.lastErrors {
|
|
||||||
if e != "" {
|
|
||||||
lastErrors = append(lastErrors, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
apiStats.mutex.RUnlock()
|
|
||||||
|
|
||||||
var errorRate float64
|
|
||||||
if totalRequests > 0 {
|
|
||||||
errorRate = float64(failedRequests) / float64(totalRequests) * 100
|
|
||||||
}
|
|
||||||
|
|
||||||
var avgResponseTime float64
|
|
||||||
if totalRequests > 0 {
|
|
||||||
avgResponseTime = totalResponseTime.Seconds() * 1000 / float64(totalRequests)
|
|
||||||
}
|
|
||||||
|
|
||||||
envCheckStatus := checkEnvironmentVariables()
|
|
||||||
allEnvVarsSet := envCheckStatus["all_required_vars_set"].(bool)
|
|
||||||
|
|
||||||
status := "ok"
|
|
||||||
if !allEnvVarsSet {
|
|
||||||
status = "configuration_error"
|
|
||||||
}
|
|
||||||
|
|
||||||
response := map[string]interface{}{
|
|
||||||
"status": status,
|
|
||||||
"service": "ByteDance TTS to OpenAI API Adapter",
|
|
||||||
"version": "2.0.0 (v3 API)",
|
|
||||||
"uptime": fmt.Sprintf("%.0f seconds", time.Since(startTime).Seconds()),
|
|
||||||
"start_time": startTime.Format(time.RFC3339),
|
|
||||||
"memory": getMemoryInfo(),
|
|
||||||
"api_stats": map[string]interface{}{
|
|
||||||
"total_requests": totalRequests,
|
|
||||||
"successful_requests": successfulRequests,
|
|
||||||
"failed_requests": failedRequests,
|
|
||||||
"error_rate_percent": fmt.Sprintf("%.2f", errorRate),
|
|
||||||
"avg_response_time_ms": fmt.Sprintf("%.2f", avgResponseTime),
|
|
||||||
"recent_response_times_ms": recentResponseTimes,
|
|
||||||
},
|
|
||||||
"errors": map[string]interface{}{
|
|
||||||
"recent_errors_count": len(lastErrors),
|
|
||||||
},
|
|
||||||
"config_status": map[string]interface{}{
|
|
||||||
"all_required_vars_set": allEnvVarsSet,
|
|
||||||
"config_error": ttsConfigErr != nil,
|
|
||||||
"config_error_message": fmt.Sprintf("%v", ttsConfigErr),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
json.NewEncoder(w).Encode(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
var startTime time.Time
|
|
||||||
|
|
||||||
type statusRecorder struct {
|
|
||||||
http.ResponseWriter
|
|
||||||
statusCode int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rec *statusRecorder) WriteHeader(code int) {
|
|
||||||
rec.statusCode = code
|
|
||||||
rec.ResponseWriter.WriteHeader(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func corsMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
||||||
|
|
||||||
if r.Method == http.MethodOptions {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
startTime = time.Now()
|
|
||||||
|
|
||||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
|
||||||
log.SetPrefix("[TTS-Server] ")
|
|
||||||
|
|
||||||
initAPIKeys()
|
|
||||||
|
|
||||||
ttsConfigErr = initTTSConfig()
|
|
||||||
if ttsConfigErr != nil {
|
|
||||||
log.Printf("警告: 配置初始化失败: %v", ttsConfigErr)
|
|
||||||
log.Printf("服务将继续运行,但TTS功能不可用,请检查环境变量配置")
|
|
||||||
} else {
|
|
||||||
log.Printf("配置初始化成功")
|
|
||||||
}
|
|
||||||
|
|
||||||
router := mux.NewRouter()
|
|
||||||
|
|
||||||
router.Use(corsMiddleware)
|
|
||||||
|
|
||||||
router.Use(func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path == "/health" {
|
|
||||||
start := time.Now()
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
log.Printf("%s %s %s %v", r.Method, r.RequestURI, r.RemoteAddr, time.Since(start))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
rec := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK}
|
|
||||||
next.ServeHTTP(rec, r)
|
|
||||||
duration := time.Since(start)
|
|
||||||
|
|
||||||
log.Printf("%s %s %s %d %v", r.Method, r.RequestURI, r.RemoteAddr, rec.statusCode, duration)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
router.HandleFunc("/v1/audio/speech", openaiTTSHandler).Methods("POST", "OPTIONS")
|
|
||||||
router.HandleFunc("/health", healthHandler).Methods("GET")
|
|
||||||
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.Redirect(w, r, "/health", http.StatusFound)
|
|
||||||
}).Methods("GET")
|
|
||||||
|
|
||||||
port := os.Getenv("PORT")
|
|
||||||
if port == "" {
|
|
||||||
port = DEFAULT_PORT
|
|
||||||
}
|
|
||||||
|
|
||||||
server := &http.Server{
|
|
||||||
Addr: ":" + port,
|
|
||||||
Handler: router,
|
|
||||||
ReadTimeout: 15 * time.Second,
|
|
||||||
WriteTimeout: 15 * time.Second,
|
|
||||||
IdleTimeout: 60 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
quit := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
<-quit
|
|
||||||
log.Println("Shutting down server...")
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if err := server.Shutdown(ctx); err != nil {
|
|
||||||
log.Printf("Server forced to shutdown: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Println("Server exited gracefully")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user