Files
Volcano-Engine-TTS-UI/adapter/volcano/volcano.go
T
sun 21b86bfcfe feat: 新增音频格式与采样率配置,完善OpenAI兼容格式映射
1.  新增BYTEDANCE_TTS_FORMAT和BYTEDANCE_TTS_SAMPLE_RATE环境变量配置
2.  实现OpenAI TTS格式到火山API格式的映射转换
3.  自动处理wav格式:内部用pcm请求后封装标准WAV头
4.  动态设置响应Content-Type,适配不同输出格式
5.  更新文档与配置示例,完善日志与错误处理
2026-06-27 23:28:35 +08:00

296 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package volcano
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
"github.com/google/uuid"
"github.com/volcano-tts/tts-api/dto"
)
type HTTPClient struct {
client *http.Client
}
func NewHTTPClient() *HTTPClient {
return &HTTPClient{
client: &http.Client{
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)
}
// convertSpeedToSpeechRate 把 OpenAI 风格的 speed(倍率)转成火山 v3 的 speech_rate(百分比)。
// 文档规定 speech_rate 范围 [-50, 100],对应 0.5x ~ 2.0x 倍速。
// 输入超出范围会被截断到边界值。
func convertSpeedToSpeechRate(speed float64) int {
rate := int((speed - 1.0) * 100)
if rate < -50 {
rate = -50
}
if rate > 100 {
rate = 100
}
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) {
reqID := uuid.NewString()
speechRate := convertSpeedToSpeechRate(speed)
speaker := config.Speaker
if voice != "" {
speaker = voice
}
model := config.Model
if model == "" {
model = "seed-tts-2.0-standard"
}
// 决定实际输出格式:优先用请求中指定的格式,否则用配置中的格式,最后默认 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 HTTP Chunked 单向流式 API 文档构造请求体
// https://www.volcengine.com/docs/6561/1598757
params := map[string]interface{}{
"user": map[string]interface{}{
"uid": reqID,
},
"namespace": "UnidirectionalTTS",
"req_params": map[string]interface{}{
"text": text,
"speaker": speaker,
"model": model,
"audio_params": map[string]interface{}{
"format": apiFormat,
"sample_rate": sampleRate,
"speech_rate": speechRate,
},
},
}
// 鉴权 header 按文档 v3 新版控制台方式
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,
// 请求用量返回,使合成结束时携带 usage 字段
"X-Control-Require-Usage-Tokens-Return": "*",
}
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
}
// code=20000000 表示合成结束
if v3Resp.Code == 20000000 {
if v3Resp.Usage != nil {
log.Printf("TTS synthesis completed, usage: %+v", v3Resp.Usage)
}
// 跳过后续可能的空行
for scanner.Scan() {
}
break
}
// 非零 code 为错误
if v3Resp.Code != 0 {
log.Printf("TTS service error: code=%d, message=%s, event=%s", v3Resp.Code, v3Resp.Message, v3Resp.Event)
return nil, fmt.Errorf("TTS service error: %s", v3Resp.Message)
}
// 根据 event 字段分类处理
switch v3Resp.Event {
case "TTSSentenceStart":
log.Printf("Sentence start: sequence=%d, sentence=%s", v3Resp.Sequence, v3Resp.Sentence)
case "TTSSentenceEnd":
log.Printf("Sentence end: sequence=%d", v3Resp.Sequence)
default:
// 音频数据 chunk:data 字段为 base64 编码的音频片段
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...)
}
}
}
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")
}
// 若输出格式为 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
}