diff --git a/README.md b/README.md index ca2d75e..57fa4f5 100644 --- a/README.md +++ b/README.md @@ -65,10 +65,7 @@ tts-api.exe | 变量名 | 说明 | 默认值 | |--------|------|--------| -| `BYTEDANCE_TTS_MODEL` | `req_params.model` 子模型版本,默认 `seed-icl-2.0` 与 `BYTEDANCE_TTS_RESOURCE_ID` 音色复刻路由对齐 | `seed-icl-2.0` | | `BYTEDANCE_TTS_TIMEOUT` | 请求超时时间 | `30s` | -| `BYTEDANCE_TTS_FORMAT` | 音频格式:`mp3` / `ogg_opus` / `pcm` / `wav` | `mp3` | -| `BYTEDANCE_TTS_SAMPLE_RATE` | 采样率:8000/16000/22050/24000/32000/44100/48000 | `24000` | | `OPENAI_TTS_API_KEY` | OpenAI兼容接口的API密钥(逗号分隔支持多个) | 无 | | `PORT` | 服务监听端口 | `8080` | | `ALLOWED_ORIGINS` | 允许跨域请求的来源(多个用英文逗号分隔;调试可设为 `*`) | 无(不设则拒绝所有跨域) | @@ -90,16 +87,7 @@ tts-api.exe ### 音频格式说明 -本项目使用火山 v3 **HTTP Chunked 单向流式** API([官方文档](https://www.volcengine.com/docs/6561/1598757)),支持以下音频格式: - -| 格式 | Content-Type | 说明 | -|------|-------------|------| -| `mp3` | `audio/mpeg` | 默认格式,API原生支持,推荐使用 | -| `ogg_opus` | `audio/ogg` | OGG Opus格式,API原生支持 | -| `pcm` | `audio/pcm` | 原始PCM数据,API原生支持 | -| `wav` | `audio/wav` | 本端用PCM请求API后封装WAV header(流式场景下API的wav会多次返回header,所以内部用pcm再拼装) | - -> 流式场景下直接请求 wav 格式,API每个chunk都会返回一个完整的 wav header,导致拼接后的音频损坏。因此当用户选择 wav 输出时,适配器自动用 pcm 格式请求 API,最后在客户端拼装标准 44 字节 WAV 文件头。 +本项目按参考实现硬编码请求 `wav` / `24000Hz` 格式,HTTP 响应 `Content-Type` 固定为 `audio/wav`。 ### v3 API 调用说明 @@ -108,14 +96,6 @@ tts-api.exe - **协议**:HTTP Chunked 流式,请求路径 `https://openspeech.bytedance.com/api/v3/tts/unidirectional` - **不再使用业务集群**(`cluster` 字段在 v3 已废弃),改用 `X-Api-Resource-Id` HTTP header 路由模型 - **鉴权 header 只有** `X-Api-Key` 一个,无 `Authorization`,无 app 对象 -- **用量返回**:携带 `X-Control-Require-Usage-Tokens-Return: *` header,合成结束时响应中包含 `usage` 字段 -- **`req_params.model` 字段**:v3 必须显式传子模型版本。可选值: - - `seed-icl-2.0`(默认,与 `X-Api-Resource-Id: seed-icl-2.0` 配套,专用于音色复刻路由,常规音色/复刻音色通用) - - `seed-tts-2.0-standard`(标准合成版,仅在把 `BYTEDANCE_TTS_RESOURCE_ID` 切到 `seed-tts-2.0` 时使用) - - `seed-tts-2.0-expressive`(表现力增强版,配合 `seed-tts-2.0` 资源使用) - - 留空时使用 `seed-icl-2.0` 作为兜底(与默认复刻路由对齐) -- **复刻音色(`S_` 开头的 speaker)默认走 `seed-icl-2.0` 即可**,若用其他资源 ID 需同步调整该字段,否则会返回 `55000000` -- **响应 event 字段**:`TTSSentenceStart`/`TTSSentenceEnd` 标记句子边界,音频数据在默认 event 中返回 ## CORS 跨域配置 @@ -268,7 +248,7 @@ OPENAI_TTS_API_KEY=sk-key1,sk-key2,sk-key3 2. 用控制台的在线体验/调试试一下同一对 `BYTEDANCE_TTS_RESOURCE_ID` + 音色 3. 控制台能合成的组合才是正确的 4. 把控制台显示的**实际资源 ID 字符串**(通常是 `volc.megatts.*` 格式)填到 `BYTEDANCE_TTS_RESOURCE_ID` -5. 如果你用的是**声音复刻**音色(speaker 以 `S_` 开头),确认 `BYTEDANCE_TTS_RESOURCE_ID` 与 `BYTEDANCE_TTS_MODEL` 同属一个资源族(默认两者都用 `seed-icl-2.0`)。**复刻音色把 `model` 误填成 `seed-tts-2.0-standard` 是 55000000 的最常见原因**——`standard` 是 TTS 合成子模型,不属于复刻资源族 +5. 如果你用的是**声音复刻**音色(speaker 以 `S_` 开头),确认 `BYTEDANCE_TTS_RESOURCE_ID` 已在火山控制台开通,并且和音色 ID 在同一个资源族下。55000000 通常是资源族不匹配 ### 5. PowerShell 下 `curl` 命令被解释错 @@ -381,5 +361,5 @@ docker compose up -d 5. ALLOWED_ORIGINS 是否包含前端完整 origin(含 https://) 6. 客户端请求 URL 是否以 https:// 开头 7. 生产环境凭据是否定期轮换(API Key 明文出现在日志/对话中时立刻重置) -8. 复刻音色(speaker 以 `S_` 开头)`BYTEDANCE_TTS_MODEL` 应保持默认 `seed-icl-2.0`,与 `BYTEDANCE_TTS_RESOURCE_ID` 同族;不要误填 `seed-tts-2.0-standard`(那是合成子模型) +8. 复刻音色(speaker 以 `S_` 开头)确保 `BYTEDANCE_TTS_RESOURCE_ID` 在控制台和音色 ID 同族(参考 `seed-icl-2.0` 等表) 9. 音频格式是否匹配客户端解码能力(默认 mp3 兼容性最好) diff --git a/adapter/volcano/volcano.go b/adapter/volcano/volcano.go index c4926a4..3a4fc30 100644 --- a/adapter/volcano/volcano.go +++ b/adapter/volcano/volcano.go @@ -5,7 +5,6 @@ import ( "bytes" "context" "encoding/base64" - "encoding/binary" "encoding/json" "fmt" "io" @@ -19,9 +18,7 @@ import ( // 火山引擎 TTS v3 HTTP 单向流式 API 客户端。 // 官方文档:https://www.volcengine.com/docs/6561/1598757 -// 官方 Go 示例:请求体仅含 req_params;本实现按 commit 4aed966 经验额外带上 -// user.uid 和 namespace="UnidirectionalTTS"(早期用其它 namespace 出现过兼容性 -// 问题,显式指定最稳)。复刻音色场景额外带 req_params.model。 +// 官方 Go 示例:请求体仅含 req_params;严格按单文件参考实现的请求结构。 type HTTPClient struct { client *http.Client @@ -71,7 +68,6 @@ type ttsUser struct { type ttsReqParams struct { Text string `json:"text"` Speaker string `json:"speaker"` - Model string `json:"model"` AudioParams ttsAudioParams `json:"audio_params"` } @@ -94,122 +90,20 @@ func convertSpeedToSpeechRate(speed float64) int { } return rate } - -// resolveAPIFormat 根据用户期望的输出格式决定实际请求火山 API 的格式。 -// 文档明确指出:流式场景下传入 wav 会多次返回 wav header,建议使用 pcm。 -// 因此当用户要 wav 输出时,用 pcm 请求 API,最后由本端拼装完整 wav header。 -func resolveAPIFormat(desiredFormat string) (apiFormat string, needWavHeader bool) { - switch desiredFormat { - case "wav": - return "pcm", true - case "mp3", "ogg_opus", "pcm": - return desiredFormat, false - default: - return "mp3", false - } -} - -// buildWavHeader 构造标准 44 字节 WAV 文件头(16-bit PCM, mono)。 -func buildWavHeader(dataLen int, sampleRate int) []byte { - header := make([]byte, 44) - byteRate := sampleRate * 2 // 16bit * 1channel / 8 * sampleRate - blockAlign := 2 // 16bit / 8 * 1channel - - copy(header[0:4], "RIFF") - binary.LittleEndian.PutUint32(header[4:8], uint32(36+dataLen)) - copy(header[8:12], "WAVE") - copy(header[12:16], "fmt ") - binary.LittleEndian.PutUint32(header[16:20], 16) // SubChunk1Size - binary.LittleEndian.PutUint16(header[20:22], 1) // PCM format - binary.LittleEndian.PutUint16(header[22:24], 1) // NumChannels - binary.LittleEndian.PutUint32(header[24:28], uint32(sampleRate)) - binary.LittleEndian.PutUint32(header[28:32], uint32(byteRate)) - binary.LittleEndian.PutUint16(header[32:34], uint16(blockAlign)) - binary.LittleEndian.PutUint16(header[34:36], 16) // BitsPerSample - copy(header[36:40], "data") - binary.LittleEndian.PutUint32(header[40:44], uint32(dataLen)) - - return header -} - -// FormatContentType 返回音频格式对应的 HTTP Content-Type。 -func FormatContentType(format string) string { - switch format { - case "mp3": - return "audio/mpeg" - case "wav": - return "audio/wav" - case "ogg_opus": - return "audio/ogg" - case "pcm": - return "audio/pcm" - default: - return "application/octet-stream" - } -} - -// MapOpenAIFormat 将 OpenAI TTS response_format 映射为火山 API 支持的格式。 -// OpenAI 支持: mp3, opus, aac, flac, wav, pcm -// 火山支持: mp3, ogg_opus, pcm, wav(流式不推荐) -func MapOpenAIFormat(openaiFormat string) string { - switch openaiFormat { - case "mp3": - return "mp3" - case "opus": - return "ogg_opus" - case "wav": - return "wav" - case "pcm": - return "pcm" - case "aac", "flac": - return "mp3" // 火山不支持 aac/flac,降级到 mp3 - default: - return "mp3" - } -} - -func Synthesis(config *dto.ByteDanceTTSConfig, httpClient *HTTPClient, text string, speed float64, voice string, requestFormat string) (*dto.SynthesisResult, error) { +func Synthesis(config *dto.ByteDanceTTSConfig, httpClient *HTTPClient, text string, speed float64) (*dto.SynthesisResult, error) { reqID := uuid.NewString() speechRate := convertSpeedToSpeechRate(speed) - speaker := config.Speaker - if voice != "" { - speaker = voice - } - - model := config.Model - if model == "" { - model = "seed-icl-2.0" // 默认走音色复刻路由,匹配 X-Api-Resource-Id=seed-icl-2.0 - } - - // 决定实际输出格式:优先用请求中指定的格式,否则用配置中的格式,最后默认 mp3 - outputFormat := config.Format - if requestFormat != "" { - outputFormat = requestFormat - } - if outputFormat == "" { - outputFormat = "mp3" - } - - // 根据输出格式确定 API 请求格式(wav → pcm + 本端封装 header) - apiFormat, needWavHeader := resolveAPIFormat(outputFormat) - - sampleRate := config.SampleRate - if sampleRate == 0 { - sampleRate = 24000 - } - // 构造请求体:严格按 v3 API 文档 JSON 结构 req := ttsRequest{ - User: ttsUser{UID: reqID}, - Namespace: "UnidirectionalTTS", + User: ttsUser{UID: "uid"}, + Namespace: "BidirectionalTTS", ReqParams: ttsReqParams{ Text: text, - Speaker: speaker, - Model: model, + Speaker: config.Speaker, AudioParams: ttsAudioParams{ - Format: apiFormat, - SampleRate: sampleRate, + Format: "wav", + SampleRate: 24000, SpeechRate: speechRate, }, }, @@ -220,17 +114,16 @@ func Synthesis(config *dto.ByteDanceTTSConfig, httpClient *HTTPClient, text stri return nil, fmt.Errorf("marshal TTS request: %w", err) } // 诊断日志:记录实际发到上游的请求体(去 model/speaker/resource 关键字段) - log.Printf("TTS upstream request: X-Api-Resource-Id=%s speaker=%s model=%s namespace=UnidirectionalTTS body=%s", - config.ResourceId, speaker, model, string(body)) + log.Printf("TTS upstream request: X-Api-Resource-Id=%s speaker=%s namespace=BidirectionalTTS body=%s", + config.ResourceId, config.Speaker, string(body)) // 鉴权 header 按 v3 新版控制台方式(Connection 由 Go http 默认 keep-alive) headers := map[string]string{ - "Content-Type": "application/json", - // 请求用量返回,合成结束时响应中携带 usage 字段 - "X-Control-Require-Usage-Tokens-Return": "*", - "X-Api-Resource-Id": config.ResourceId, // 模型路由(seed-tts-2.0 / seed-icl-2.0) - "X-Api-Request-Id": reqID, - "X-Api-Key": config.ApiKey, // v3 鉴权 key + "Content-Type": "application/json", + "Connection": "keep-alive", + "X-Api-Resource-Id": config.ResourceId, + "X-Api-Request-Id": reqID, + "X-Api-Key": config.ApiKey, } resp, err := httpClient.PostStream(config.URL, headers, body, config.Timeout) @@ -309,14 +202,5 @@ func Synthesis(config *dto.ByteDanceTTSConfig, httpClient *HTTPClient, text stri return nil, fmt.Errorf("no audio data received from TTS service") } - // 若输出格式为 wav,需要在 pcm 数据前拼装完整的 wav header - if needWavHeader { - wavHeader := buildWavHeader(len(audioData), sampleRate) - wavData := make([]byte, 0, len(wavHeader)+len(audioData)) - wavData = append(wavData, wavHeader...) - wavData = append(wavData, audioData...) - audioData = wavData - } - - return &dto.SynthesisResult{AudioData: audioData, ReqID: reqID, Format: outputFormat}, nil + return &dto.SynthesisResult{AudioData: audioData, ReqID: reqID, Format: "wav"}, nil } diff --git a/controller/tts.go b/controller/tts.go index 1d5413f..66e4c54 100644 --- a/controller/tts.go +++ b/controller/tts.go @@ -115,14 +115,8 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { speed = common.MaxSpeed } - // 将 OpenAI response_format 映射为火山 API 支持的格式 - var requestFormat string - if req.ResponseFormat != "" { - requestFormat = volcano.MapOpenAIFormat(req.ResponseFormat) - } - ttsStart := time.Now() - result, err := volcano.Synthesis(&setting.TTSConfig, volcanoClient, req.Input, speed, req.Voice, requestFormat) + result, err := volcano.Synthesis(&setting.TTSConfig, volcanoClient, req.Input, speed) duration := time.Since(ttsStart) if err != nil { @@ -135,7 +129,7 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { service.GlobalStats.AddRequest(true, duration, "") - w.Header().Set("Content-Type", volcano.FormatContentType(result.Format)) + 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) diff --git a/docker-compose.yml b/docker-compose.yml index 5c80fe2..87e7105 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,8 +11,6 @@ services: - BYTEDANCE_TTS_RESOURCE_ID=${BYTEDANCE_TTS_RESOURCE_ID} - BYTEDANCE_TTS_SPEAKER=${BYTEDANCE_TTS_SPEAKER} - BYTEDANCE_TTS_TIMEOUT=${BYTEDANCE_TTS_TIMEOUT:-30s} - - BYTEDANCE_TTS_FORMAT=${BYTEDANCE_TTS_FORMAT:-mp3} - - BYTEDANCE_TTS_SAMPLE_RATE=${BYTEDANCE_TTS_SAMPLE_RATE:-24000} - OPENAI_TTS_API_KEY=${OPENAI_TTS_API_KEY:-} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} - PORT=8080 diff --git a/dto/tts.go b/dto/tts.go index a208a8a..fedaff8 100644 --- a/dto/tts.go +++ b/dto/tts.go @@ -30,11 +30,8 @@ type ByteDanceTTSConfig struct { ApiKey string ResourceId string Speaker string - Model string // v3 声音复刻/语音大模型 子模型版本,复刻音色必填 URL string Timeout time.Duration - Format string // 音频编码格式: mp3/ogg_opus/pcm/wav(wav内部用pcm请求再封装header) - SampleRate int // 采样率: 8000/16000/22050/24000/32000/44100/48000 } type SynthesisResult struct { diff --git a/setting/config.go b/setting/config.go index 8443ca6..bfc405d 100644 --- a/setting/config.go +++ b/setting/config.go @@ -113,11 +113,6 @@ func InitTTSConfig() error { apiKey := os.Getenv("BYTEDANCE_TTS_API_KEY") resourceId := os.Getenv("BYTEDANCE_TTS_RESOURCE_ID") speaker := os.Getenv("BYTEDANCE_TTS_SPEAKER") - model := os.Getenv("BYTEDANCE_TTS_MODEL") - if model == "" { - model = "seed-icl-2.0" // 默认走音色复刻路由,匹配 X-Api-Resource-Id=seed-icl-2.0 - } - missingVars := []string{} if apiKey == "" { missingVars = append(missingVars, "BYTEDANCE_TTS_API_KEY") @@ -144,35 +139,12 @@ func InitTTSConfig() error { } } - // 音频格式,默认 mp3(文档默认值,流式场景中 wav 会多次返回 header,不推荐) - format := os.Getenv("BYTEDANCE_TTS_FORMAT") - if format == "" { - format = "mp3" - } - - // 采样率,默认 24000 - sampleRate := 24000 - if srStr := os.Getenv("BYTEDANCE_TTS_SAMPLE_RATE"); srStr != "" { - if sr, err := fmt.Sscanf(srStr, "%d", &sampleRate); err != nil || sr != 1 { - log.Printf("无效的采样率设置 '%s',使用默认值: 24000", srStr) - sampleRate = 24000 - } - validRates := map[int]bool{8000: true, 16000: true, 22050: true, 24000: true, 32000: true, 44100: true, 48000: true} - if !validRates[sampleRate] { - log.Printf("不支持的采样率 %d,使用默认值: 24000", sampleRate) - sampleRate = 24000 - } - } - TTSConfig = dto.ByteDanceTTSConfig{ ApiKey: apiKey, ResourceId: resourceId, Speaker: speaker, - Model: model, URL: url, Timeout: timeout, - Format: format, - SampleRate: sampleRate, } return nil } @@ -227,8 +199,6 @@ func LogStartupSummary() { if TTSConfigErr != nil { log.Printf("火山 TTS 整体: 初始化失败,%d 个必填项缺失,/v1/audio/speech 路由将全部返回 500", missingCount) } else { - log.Printf("火山 TTS 可选项: model=%s, format=%s, sample_rate=%d, timeout=%v", - TTSConfig.Model, TTSConfig.Format, TTSConfig.SampleRate, TTSConfig.Timeout) log.Printf("火山 TTS 整体: 初始化成功") } } @@ -263,9 +233,6 @@ func CheckEnvironmentVariables() map[string]interface{} { optionalVars := map[string]bool{ - "BYTEDANCE_TTS_MODEL": TTSConfig.Model != "" && TTSConfig.Model != "seed-icl-2.0", - "BYTEDANCE_TTS_FORMAT": TTSConfig.Format != "" && TTSConfig.Format != "mp3", - "BYTEDANCE_TTS_SAMPLE_RATE": TTSConfig.SampleRate != 24000, "OPENAI_TTS_API_KEY": len(Auth.APIKeys) > 0, "ALLOWED_ORIGINS": CORS.AllowAll || len(CORS.Origins) > 0, "PORT": Server.Port != common.DefaultPort,