feat: 实现完整可观测性架构与火山v3适配器重构
重构整体架构: 1. 新增telemetry包实现零依赖的Prometheus指标系统 2. 新增metrics包集中管理业务埋点指标 3. 重构火山v3适配器,拆分client/request/response等模块 4. 替换旧的service/stats统计系统为标准指标埋点 5. 新增/metrics观测端点与完整仪表盘支持 功能更新: - 实现基于IP的限流与并发限制,添加指标埋点 - 重构TTS控制器,支持多格式输出与完整错误分类 - 更新.env.example配置示例,新增多项可选参数 - 替换旧的volcano适配器实现,支持完整的v3 API特性 - 清理冗余代码,移除service/stats与旧adapter实现
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
package volcano
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// 标准 PCM WAV 头(44 字节)。
|
||||
// 文档 3.3 节:流式场景不推荐 wav(会多次返回 wav header),
|
||||
// 本项目策略:上游走 pcm,本地拼一次标准头,避免拼接过个 header。
|
||||
type wavHeader struct {
|
||||
// RIFF chunk descriptor
|
||||
ChunkID [4]byte // "RIFF"
|
||||
ChunkSize uint32 // 36 + SubChunk2Size
|
||||
Format [4]byte // "WAVE"
|
||||
// fmt sub-chunk
|
||||
Subchunk1ID [4]byte // "fmt "
|
||||
Subchunk1Size uint32 // 16 for PCM
|
||||
AudioFormat uint16 // 1 = PCM
|
||||
NumChannels uint16
|
||||
SampleRate uint32
|
||||
ByteRate uint32
|
||||
BlockAlign uint16
|
||||
BitsPerSample uint16
|
||||
// data sub-chunk
|
||||
Subchunk2ID [4]byte // "data"
|
||||
Subchunk2Size uint32
|
||||
}
|
||||
|
||||
// WrapWAVHeader 把 PCM 原始字节封装成完整的 WAV 字节流。
|
||||
// sampleRate 决定 WAV 头里的采样率字段;pcm 视为 16-bit 单声道 little-endian。
|
||||
func WrapWAVHeader(pcm []byte, sampleRate int) ([]byte, error) {
|
||||
if sampleRate <= 0 {
|
||||
return nil, fmt.Errorf("invalid sample rate %d", sampleRate)
|
||||
}
|
||||
const channels uint16 = 1
|
||||
const bitsPerSample uint16 = 16
|
||||
blockAlign := channels * bitsPerSample / 8
|
||||
byteRate := uint32(sampleRate) * uint32(blockAlign)
|
||||
dataSize := uint32(len(pcm))
|
||||
|
||||
hdr := wavHeader{
|
||||
ChunkID: [4]byte{'R', 'I', 'F', 'F'},
|
||||
ChunkSize: 36 + dataSize,
|
||||
Format: [4]byte{'W', 'A', 'V', 'E'},
|
||||
Subchunk1ID: [4]byte{'f', 'm', 't', ' '},
|
||||
Subchunk1Size: 16,
|
||||
AudioFormat: 1,
|
||||
NumChannels: channels,
|
||||
SampleRate: uint32(sampleRate),
|
||||
ByteRate: byteRate,
|
||||
BlockAlign: blockAlign,
|
||||
BitsPerSample: bitsPerSample,
|
||||
Subchunk2ID: [4]byte{'d', 'a', 't', 'a'},
|
||||
Subchunk2Size: dataSize,
|
||||
}
|
||||
|
||||
out := make([]byte, 0, 44+len(pcm))
|
||||
out = append(out, hdr.ChunkID[:]...)
|
||||
out = binary.LittleEndian.AppendUint32(out, hdr.ChunkSize)
|
||||
out = append(out, hdr.Format[:]...)
|
||||
out = append(out, hdr.Subchunk1ID[:]...)
|
||||
out = binary.LittleEndian.AppendUint32(out, hdr.Subchunk1Size)
|
||||
out = binary.LittleEndian.AppendUint16(out, hdr.AudioFormat)
|
||||
out = binary.LittleEndian.AppendUint16(out, hdr.NumChannels)
|
||||
out = binary.LittleEndian.AppendUint32(out, hdr.SampleRate)
|
||||
out = binary.LittleEndian.AppendUint32(out, hdr.ByteRate)
|
||||
out = binary.LittleEndian.AppendUint16(out, hdr.BlockAlign)
|
||||
out = binary.LittleEndian.AppendUint16(out, hdr.BitsPerSample)
|
||||
out = append(out, hdr.Subchunk2ID[:]...)
|
||||
out = binary.LittleEndian.AppendUint32(out, hdr.Subchunk2Size)
|
||||
out = append(out, pcm...)
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package volcano
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTPClient 持有共享的 http.Client 以便复用连接(v3 keep-alive 1 分钟)。
|
||||
type HTTPClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewHTTPClient 构造默认配置的 HTTPClient。
|
||||
func NewHTTPClient() *HTTPClient {
|
||||
return &HTTPClient{
|
||||
client: &http.Client{
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 20,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// PostStream 发送一次流式请求,返回带上下文的 *http.Response。
|
||||
// 调用方负责关闭 resp.Body。
|
||||
func (h *HTTPClient) PostStream(ctx context.Context, url string, headers map[string]string, body []byte) (*http.Response, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
return h.client.Do(req)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package volcano
|
||||
|
||||
import "fmt"
|
||||
|
||||
// UpstreamError 表示火山 v3 返回的 业务错误(code != 0 且 != 20000000)或传输错误。
|
||||
// 包含上游错误码,便于 telemetry 把它作为 label。
|
||||
type UpstreamError struct {
|
||||
Code int
|
||||
Message string
|
||||
Stage string // "request"/"stream"/"http" - 出错阶段
|
||||
Wrapped error
|
||||
}
|
||||
|
||||
func (e *UpstreamError) Error() string {
|
||||
if e.Wrapped != nil {
|
||||
return fmt.Sprintf("volcano %s: code=%d %s: %v", e.Stage, e.Code, e.Message, e.Wrapped)
|
||||
}
|
||||
return fmt.Sprintf("volcano %s: code=%d %s", e.Stage, e.Code, e.Message)
|
||||
}
|
||||
|
||||
func (e *UpstreamError) Unwrap() error { return e.Wrapped }
|
||||
|
||||
// IsAuth 当上游返回认证/权限类错误时返回 true。
|
||||
func (e *UpstreamError) IsAuth() bool {
|
||||
return e.Code == 45000000 || e.Code == 55000000 ||
|
||||
e.Code == 401 || e.Code == 403
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package volcano
|
||||
|
||||
// Options 是火山 v3 TTS 适配器的完整调用参数集合。
|
||||
// 由 setting 包从环境变量构造,controller 直接透传,不做 OpenAI 侧映射。
|
||||
//
|
||||
// 字段顺序与文档 3.x 节一致,便于对照。
|
||||
type Options struct {
|
||||
// --- 鉴权 / 路由 ---
|
||||
APIKey string // X-Api-Key
|
||||
ResourceID string // X-Api-Resource-Id,决定模型版本与计费,如 seed-icl-2.0
|
||||
|
||||
// --- req_params 核心字段 ---
|
||||
Text string
|
||||
Speaker string
|
||||
Model string // 可空,仅复刻 2.0 生效;env 默认 seed-tts-2.0-standard
|
||||
UID string // user.uid,默认 "uid"
|
||||
|
||||
// --- audio_params ---
|
||||
Format string // 上游实际请求的 format:mp3 / pcm / ogg_opus
|
||||
SampleRate int // 8000/16000/22050/24000/32000/44100/48000
|
||||
BitRate int // 可选,仅 MP3 生效
|
||||
SpeechRate int // [-50, 100]
|
||||
LoudnessRate int // [-50, 100]
|
||||
EnableSubtitle bool // 复刻 2.0 生效,返回 TTSSubtitle
|
||||
EnableTimestamp bool // 复刻 1.0 生效,内嵌字级时间戳
|
||||
|
||||
// --- additions(扩展参数,JSON 字符串承载)---
|
||||
// 文档明确 additions 在请求体里必须是 string,内容是 JSON。
|
||||
// 这里直接存结构体,序列化时由 MarshalJSON 输出为 string。
|
||||
Additions *Additions
|
||||
}
|
||||
|
||||
// Additions 对应文档 3.4 节的扩展参数。
|
||||
// 注意:在请求体里 additions 是 JSON 字符串,所以 MarshalJSON 序列化为 string。
|
||||
type Additions struct {
|
||||
ModelType *int `json:"model_type,omitempty"` // 复刻 2.0 推荐显式指定,4=ICL V2、5=ICL V3
|
||||
ContextTexts []string `json:"context_texts,omitempty"` // 语音指令
|
||||
UseTagParser *bool `json:"use_tag_parser,omitempty"` // 复刻 2.0 expressive 启用语音标签 Cot
|
||||
ExplicitLanguage string `json:"explicit_language,omitempty"` // 明确语种
|
||||
ContextLanguage string `json:"context_language,omitempty"` // 参考语种
|
||||
SilenceDuration *int `json:"silence_duration,omitempty"` // 0~30000ms
|
||||
EnableLanguageDetector *bool `json:"enable_language_detector,omitempty"` // 自动识别语种
|
||||
DisableMarkdownFilter *bool `json:"disable_markdown_filter,omitempty"` // 是否解析 markdown
|
||||
DisableEmojiFilter *bool `json:"disable_emoji_filter,omitempty"` // 是否过滤 emoji
|
||||
MaxLengthFilterParenthesis *int `json:"max_length_to_filter_parenthesis,omitempty"`
|
||||
UnsupportedCharRatio *float64 `json:"unsupported_char_ratio_thresh,omitempty"`
|
||||
AIGCWatermark *bool `json:"aigc_watermark,omitempty"`
|
||||
AIGCMetadata any `json:"aigc_metadata,omitempty"`
|
||||
CacheConfig any `json:"cache_config,omitempty"`
|
||||
PostProcess any `json:"post_process,omitempty"`
|
||||
}
|
||||
|
||||
// IsZero 报告 Additions 是否为空(没有任何字段设置),用于在序列化前跳过 additions。
|
||||
func (a *Additions) IsZero() bool {
|
||||
if a == nil {
|
||||
return true
|
||||
}
|
||||
return a.ModelType == nil && a.ContextTexts == nil && a.UseTagParser == nil &&
|
||||
a.ExplicitLanguage == "" && a.ContextLanguage == "" && a.SilenceDuration == nil &&
|
||||
a.EnableLanguageDetector == nil && a.DisableMarkdownFilter == nil && a.DisableEmojiFilter == nil &&
|
||||
a.MaxLengthFilterParenthesis == nil && a.UnsupportedCharRatio == nil &&
|
||||
a.AIGCWatermark == nil && a.AIGCMetadata == nil && a.CacheConfig == nil && a.PostProcess == nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package volcano
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// requestBody 是真正发到上游 v3 端点的 JSON 顶层结构。
|
||||
type requestBody struct {
|
||||
User ttsUser `json:"user"`
|
||||
Namespace string `json:"namespace"`
|
||||
ReqParams ttsReqParams `json:"req_params"`
|
||||
}
|
||||
|
||||
type ttsUser struct {
|
||||
UID string `json:"uid"`
|
||||
}
|
||||
|
||||
type ttsReqParams struct {
|
||||
Text string `json:"text"`
|
||||
Speaker string `json:"speaker"`
|
||||
Model string `json:"model,omitempty"`
|
||||
AudioParams ttsAudioParams `json:"audio_params"`
|
||||
Additions string `json:"additions,omitempty"` // 注意:字符串
|
||||
}
|
||||
|
||||
type ttsAudioParams struct {
|
||||
Format string `json:"format"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
BitRate int `json:"bit_rate,omitempty"`
|
||||
SpeechRate int `json:"speech_rate"`
|
||||
LoudnessRate int `json:"loudness_rate,omitempty"`
|
||||
EnableSubtitle bool `json:"enable_subtitle,omitempty"`
|
||||
EnableTimestamp bool `json:"enable_timestamp,omitempty"`
|
||||
}
|
||||
|
||||
// buildRequest 把 Options 序列化为上游请求体 JSON。
|
||||
func buildRequest(opts Options) ([]byte, error) {
|
||||
if opts.Text == "" {
|
||||
return nil, fmt.Errorf("volcano: text is required")
|
||||
}
|
||||
if opts.Speaker == "" {
|
||||
return nil, fmt.Errorf("volcano: speaker is required")
|
||||
}
|
||||
if opts.ResourceID == "" {
|
||||
return nil, fmt.Errorf("volcano: resource id is required")
|
||||
}
|
||||
if opts.APIKey == "" {
|
||||
return nil, fmt.Errorf("volcano: api key is required")
|
||||
}
|
||||
|
||||
body := requestBody{
|
||||
User: ttsUser{UID: opts.UID},
|
||||
Namespace: "BidirectionalTTS",
|
||||
ReqParams: ttsReqParams{
|
||||
Text: opts.Text,
|
||||
Speaker: opts.Speaker,
|
||||
Model: opts.Model,
|
||||
AudioParams: ttsAudioParams{
|
||||
Format: opts.Format,
|
||||
SampleRate: opts.SampleRate,
|
||||
BitRate: opts.BitRate,
|
||||
SpeechRate: opts.SpeechRate,
|
||||
LoudnessRate: opts.LoudnessRate,
|
||||
EnableSubtitle: opts.EnableSubtitle,
|
||||
EnableTimestamp: opts.EnableTimestamp,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if opts.Additions != nil && !opts.Additions.IsZero() {
|
||||
// 文档明确 additions 字段为 JSON 字符串。
|
||||
raw, err := json.Marshal(opts.Additions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal additions: %w", err)
|
||||
}
|
||||
body.ReqParams.Additions = string(raw)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// convertSpeedToSpeechRate 把 OpenAI 风格的 speed(倍率)转换为 speech_rate(百分比)。
|
||||
// speech_rate 范围 [-50, 100],对应 0.5x ~ 2.0x。
|
||||
func convertSpeedToSpeechRate(speed float64) int {
|
||||
if speed <= 0 {
|
||||
speed = 1.0
|
||||
}
|
||||
rate := int((speed - 1.0) * 100)
|
||||
if rate < -50 {
|
||||
rate = -50
|
||||
}
|
||||
if rate > 100 {
|
||||
rate = 100
|
||||
}
|
||||
return rate
|
||||
}
|
||||
|
||||
// resolveUpstreamFormat 决定上游实际请求的 format。
|
||||
// - 客户端要求 wav -> 上游走 pcm,我们本地拼 header
|
||||
// - 其他 -> 直接用 clientFormat
|
||||
//
|
||||
// sampleRate 在 wav 走 pcm 的情况下也按原样传给上游(影响 PCM 的实际采样率)。
|
||||
func resolveUpstreamFormat(clientFormat string) string {
|
||||
if clientFormat == "wav" {
|
||||
return "pcm"
|
||||
}
|
||||
return clientFormat
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package volcano
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/volcano-tts/tts-api/dto"
|
||||
)
|
||||
|
||||
// ParsedStream 是一次流式响应的累计结果。
|
||||
type ParsedStream struct {
|
||||
AudioData []byte
|
||||
Chunks int
|
||||
TextWords int
|
||||
FirstChunk time.Duration // 从请求发起到收到第一个 sentence chunk 的耗时
|
||||
HasUsage bool
|
||||
Subtitles []dto.SubtitleEntry
|
||||
}
|
||||
|
||||
// ParseStream 读取 v3 chunked NDJSON 响应,按文档 5.1 节的 event 取值分类处理。
|
||||
//
|
||||
// 关键修复(对比原实现):只有 event == "sentence" 才是音频帧;
|
||||
// TTSSubtitle 单独收集,不会污染音频字节流。
|
||||
func ParseStream(body io.Reader, started time.Time) (*ParsedStream, error) {
|
||||
out := &ParsedStream{}
|
||||
scanner := bufio.NewScanner(body)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 8*1024*1024)
|
||||
|
||||
gotFirstChunk := false
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var resp dto.V3TTSResponse
|
||||
if err := json.Unmarshal(line, &resp); err != nil {
|
||||
log.Printf("volcano: 解析响应行失败: %v, line=%q", err, truncateForLog(line, 200))
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.Code != 0 && resp.Code != 20000000 {
|
||||
return nil, &UpstreamError{
|
||||
Code: resp.Code,
|
||||
Message: resp.Message,
|
||||
Stage: "stream",
|
||||
}
|
||||
}
|
||||
|
||||
if resp.Code == 20000000 {
|
||||
if resp.Usage != nil {
|
||||
out.TextWords = resp.Usage.TextWords
|
||||
out.HasUsage = true
|
||||
log.Printf("TTS 合成结束, usage: text_words=%d", out.TextWords)
|
||||
}
|
||||
for scanner.Scan() {
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// 事件分发:显式匹配已知事件,绝不把未知事件当作音频。
|
||||
switch resp.Event {
|
||||
case "TTSSentenceStart":
|
||||
log.Printf("Sentence start: sequence=%d, sentence=%s", resp.Sequence, resp.Sentence)
|
||||
case "TTSSentenceEnd":
|
||||
log.Printf("Sentence end: sequence=%d", resp.Sequence)
|
||||
case "TTSSubtitle":
|
||||
if resp.Data != "" {
|
||||
out.Subtitles = append(out.Subtitles, dto.SubtitleEntry{
|
||||
Text: resp.Sentence,
|
||||
Sequence: resp.Sequence,
|
||||
})
|
||||
}
|
||||
case "sentence":
|
||||
if resp.Data == "" {
|
||||
continue
|
||||
}
|
||||
chunk, err := base64.StdEncoding.DecodeString(resp.Data)
|
||||
if err != nil {
|
||||
return nil, &UpstreamError{
|
||||
Code: resp.Code,
|
||||
Message: fmt.Sprintf("decode audio chunk: %v", err),
|
||||
Stage: "stream",
|
||||
Wrapped: err,
|
||||
}
|
||||
}
|
||||
out.AudioData = append(out.AudioData, chunk...)
|
||||
out.Chunks++
|
||||
if !gotFirstChunk {
|
||||
out.FirstChunk = time.Since(started)
|
||||
gotFirstChunk = true
|
||||
}
|
||||
case "":
|
||||
// 传输帧,跳过
|
||||
default:
|
||||
log.Printf("volcano: 忽略未识别事件 event=%q sequence=%d", resp.Event, resp.Sequence)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, &UpstreamError{
|
||||
Code: 0,
|
||||
Message: fmt.Sprintf("read stream: %v", err),
|
||||
Stage: "stream",
|
||||
Wrapped: err,
|
||||
}
|
||||
}
|
||||
|
||||
if len(out.AudioData) == 0 {
|
||||
return nil, &UpstreamError{
|
||||
Code: 0,
|
||||
Message: "no audio data received from TTS service",
|
||||
Stage: "stream",
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadErrorBody 把非 200 响应的 body 读出来用于日志。
|
||||
func ReadErrorBody(body io.Reader) string {
|
||||
const max = 2048
|
||||
buf := make([]byte, max)
|
||||
n, err := io.ReadFull(body, buf)
|
||||
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
|
||||
return fmt.Sprintf("read body fail: %v", err)
|
||||
}
|
||||
return string(buf[:n])
|
||||
}
|
||||
|
||||
func truncateForLog(b []byte, max int) string {
|
||||
if len(b) > max {
|
||||
return string(b[:max]) + fmt.Sprintf("...(truncated, total %d bytes)", len(b))
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package volcano
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/volcano-tts/tts-api/dto"
|
||||
)
|
||||
|
||||
// MetricsRecorder 是适配器向上报告埋点的接口。
|
||||
// 适配器本身不依赖 telemetry 包,controller 在 main 启动时把 Meter 适配成实现;
|
||||
// 这样测试可以注入 mock,生产可以无侵入替换成 OTel。
|
||||
type MetricsRecorder interface {
|
||||
UpstreamStarted(speaker, model, format string)
|
||||
UpstreamFinished(speaker, model, format, status string, duration, ttfb time.Duration, chunks, audioBytes, errCode int)
|
||||
UpstreamUsage(model string, textWords int)
|
||||
}
|
||||
|
||||
// nopMetrics 是 MetricsRecorder 的 no-op 默认值。
|
||||
type nopMetrics struct{}
|
||||
|
||||
func (nopMetrics) UpstreamStarted(string, string, string) {}
|
||||
func (nopMetrics) UpstreamFinished(string, string, string, string, time.Duration, time.Duration, int, int, int) {
|
||||
}
|
||||
func (nopMetrics) UpstreamUsage(string, int) {}
|
||||
|
||||
// Synthesis 调用火山 v3 一次,返回组装好的结果。
|
||||
func Synthesis(
|
||||
ctx context.Context,
|
||||
client *HTTPClient,
|
||||
opts Options,
|
||||
text string,
|
||||
clientFormat string,
|
||||
speed float64,
|
||||
mtr MetricsRecorder,
|
||||
) (*dto.SynthesisResult, error) {
|
||||
if mtr == nil {
|
||||
mtr = nopMetrics{}
|
||||
}
|
||||
opts.Text = text
|
||||
opts.SpeechRate = convertSpeedToSpeechRate(speed)
|
||||
|
||||
reqID := newRequestID()
|
||||
|
||||
upstreamFormat := resolveUpstreamFormat(clientFormat)
|
||||
opts.Format = upstreamFormat
|
||||
if upstreamFormat != "pcm" && upstreamFormat != "mp3" && upstreamFormat != "ogg_opus" {
|
||||
opts.Format = "mp3"
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
mtr.UpstreamStarted(opts.Speaker, opts.Model, opts.Format)
|
||||
|
||||
body, err := buildRequest(opts)
|
||||
if err != nil {
|
||||
mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, "request_error", time.Since(started), 0, 0, 0, 0)
|
||||
return nil, &UpstreamError{Code: 0, Message: err.Error(), Stage: "request", Wrapped: err}
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Connection": "keep-alive",
|
||||
"X-Api-Resource-Id": opts.ResourceID,
|
||||
"X-Api-Request-Id": reqID,
|
||||
"X-Api-Key": opts.APIKey,
|
||||
"X-Control-Require-Usage-Tokens-Return": "*",
|
||||
}
|
||||
|
||||
log.Printf("TTS upstream: resource_id=%s speaker=%s model=%q format=%s sample_rate=%d speech_rate=%d additions=%q",
|
||||
opts.ResourceID, opts.Speaker, opts.Model, opts.Format, opts.SampleRate, opts.SpeechRate, extractAdditionsForLog(body))
|
||||
|
||||
resp, err := client.PostStream(ctx, "https://openspeech.bytedance.com/api/v3/tts/unidirectional", headers, body)
|
||||
if err != nil {
|
||||
mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, "transport_error", time.Since(started), 0, 0, 0, 0)
|
||||
return nil, &UpstreamError{Code: 0, Message: err.Error(), Stage: "request", Wrapped: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
rawBody := ReadErrorBody(resp.Body)
|
||||
mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, fmt.Sprintf("http_%d", resp.StatusCode), time.Since(started), 0, 0, 0, resp.StatusCode)
|
||||
return nil, &UpstreamError{
|
||||
Code: resp.StatusCode,
|
||||
Message: fmt.Sprintf("upstream http %d: %s", resp.StatusCode, rawBody),
|
||||
Stage: "http",
|
||||
}
|
||||
}
|
||||
|
||||
parsed, err := ParseStream(resp.Body, started)
|
||||
if err != nil {
|
||||
ue, _ := err.(*UpstreamError)
|
||||
code := 0
|
||||
if ue != nil {
|
||||
code = ue.Code
|
||||
}
|
||||
mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, "stream_error", time.Since(started), 0, 0, 0, code)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
duration := time.Since(started)
|
||||
|
||||
finalData := parsed.AudioData
|
||||
finalFormat := clientFormat
|
||||
sampleRate := opts.SampleRate
|
||||
if clientFormat == "wav" {
|
||||
wav, wrapErr := WrapWAVHeader(parsed.AudioData, opts.SampleRate)
|
||||
if wrapErr != nil {
|
||||
mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, "wrap_error", duration, parsed.FirstChunk, parsed.Chunks, len(parsed.AudioData), 0)
|
||||
return nil, &UpstreamError{Code: 0, Message: wrapErr.Error(), Stage: "wrap", Wrapped: wrapErr}
|
||||
}
|
||||
finalData = wav
|
||||
}
|
||||
|
||||
if parsed.HasUsage {
|
||||
mtr.UpstreamUsage(opts.Model, parsed.TextWords)
|
||||
}
|
||||
mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, "ok", duration, parsed.FirstChunk, parsed.Chunks, len(finalData), 0)
|
||||
|
||||
return &dto.SynthesisResult{
|
||||
AudioData: finalData,
|
||||
Format: finalFormat,
|
||||
SampleRate: sampleRate,
|
||||
ReqID: reqID,
|
||||
TextWords: parsed.TextWords,
|
||||
Chunks: parsed.Chunks,
|
||||
AudioBytes: len(finalData),
|
||||
TTFB: parsed.FirstChunk,
|
||||
Duration: duration,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// newRequestID 16 字节随机 ID(hex 编码),无外部依赖。
|
||||
func newRequestID() string {
|
||||
var b [16]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// extractAdditionsForLog 从已编码的请求体里取 additions 字段值,便于日志展示。
|
||||
func extractAdditionsForLog(body []byte) string {
|
||||
const key = `"additions":"`
|
||||
idx := bytesIndex(body, key)
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := body[idx+len(key):]
|
||||
end := bytesIndex(rest, []byte{'"'})
|
||||
if end < 0 {
|
||||
return ""
|
||||
}
|
||||
return string(rest[:end])
|
||||
}
|
||||
|
||||
func bytesIndex(haystack []byte, needle string) int {
|
||||
if len(needle) == 0 {
|
||||
return 0
|
||||
}
|
||||
outer:
|
||||
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||||
for j := 0; j < len(needle); j++ {
|
||||
if haystack[i+j] != needle[j] {
|
||||
continue outer
|
||||
}
|
||||
}
|
||||
return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
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/dto"
|
||||
)
|
||||
|
||||
// 火山引擎 TTS v3 HTTP 单向流式 API 客户端。
|
||||
// 官方文档:https://www.volcengine.com/docs/6561/1598757
|
||||
// 官方 Go 示例:请求体仅含 req_params;严格按单文件参考实现的请求结构。
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// --- 请求体结构(对应火山 v3 API 请求 JSON) ---
|
||||
|
||||
type ttsRequest struct {
|
||||
User ttsUser `json:"user"`
|
||||
Namespace string `json:"namespace"`
|
||||
ReqParams ttsReqParams `json:"req_params"`
|
||||
}
|
||||
|
||||
type ttsUser struct {
|
||||
UID string `json:"uid"`
|
||||
}
|
||||
|
||||
type ttsReqParams struct {
|
||||
Text string `json:"text"`
|
||||
Speaker string `json:"speaker"`
|
||||
AudioParams ttsAudioParams `json:"audio_params"`
|
||||
}
|
||||
|
||||
type ttsAudioParams struct {
|
||||
Format string `json:"format"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
SpeechRate int `json:"speech_rate"`
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
func Synthesis(config *dto.ByteDanceTTSConfig, httpClient *HTTPClient, text string, speed float64) (*dto.SynthesisResult, error) {
|
||||
reqID := uuid.NewString()
|
||||
speechRate := convertSpeedToSpeechRate(speed)
|
||||
|
||||
// 构造请求体:严格按 v3 API 文档 JSON 结构
|
||||
req := ttsRequest{
|
||||
User: ttsUser{UID: "uid"},
|
||||
Namespace: "BidirectionalTTS",
|
||||
ReqParams: ttsReqParams{
|
||||
Text: text,
|
||||
Speaker: config.Speaker,
|
||||
AudioParams: ttsAudioParams{
|
||||
Format: "wav",
|
||||
SampleRate: 24000,
|
||||
SpeechRate: speechRate,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal TTS request: %w", err)
|
||||
}
|
||||
// 诊断日志:记录实际发到上游的请求体(去 model/speaker/resource 关键字段)
|
||||
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",
|
||||
"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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send TTS request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
log.Printf("TTS service error: status=%d, read body fail: %v", resp.StatusCode, readErr)
|
||||
} else {
|
||||
log.Printf("TTS service error: status=%d, body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
return nil, fmt.Errorf("TTS service error: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var audioData []byte
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
// 初始 1MB / 最大 8MB,与示例同量级,留足 TTS 长文本 room
|
||||
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 {
|
||||
return nil, fmt.Errorf("decode audio chunk: %w", err)
|
||||
}
|
||||
audioData = append(audioData, chunk...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read TTS stream: %w", err)
|
||||
}
|
||||
|
||||
if len(audioData) == 0 {
|
||||
return nil, fmt.Errorf("no audio data received from TTS service")
|
||||
}
|
||||
|
||||
return &dto.SynthesisResult{AudioData: audioData, ReqID: reqID, Format: "wav"}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user