refactor(adapter): 抽出 provider 抽象层(阶段0),controller 不再依赖火山实现
- 新增 adapter/provider:Provider/Capabilities/Request/Credentials/MetricsRecorder/UpstreamError + 注册表 - 火山实现 Provider(provider.go),Synthesis 收敛为私有 synthesize,提供 BuildRequest 过渡映射 - controller 经 provider.Get(name).Synthesize 调用,voice 路由改在 Request 上覆盖 - UpstreamError 上移到 provider 包,火山用类型别名沿用旧名 - setting 新增 GetTTSRequest/GetDefaultFormat/GetDefaultProviderName - main.go 空导入注册火山 - docs/UPSTREAM_ADAPTER_GUIDE.md 更新为 v1.1(阶段0已落地)
This commit is contained in:
@@ -1,27 +1,9 @@
|
||||
package volcano
|
||||
|
||||
import "fmt"
|
||||
import "github.com/volcano-tts/tts-api/adapter/provider"
|
||||
|
||||
// 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
|
||||
}
|
||||
// UpstreamError 是 provider.UpstreamError 的别名。
|
||||
//
|
||||
// 通用错误类型已上移到 provider 包,这样主干(controller)只需 import provider 即可
|
||||
// 判断上游错误,不必依赖具体适配器。火山包内部沿用旧名,构造处零改动。
|
||||
type UpstreamError = provider.UpstreamError
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package volcano
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
"github.com/volcano-tts/tts-api/adapter/provider"
|
||||
"github.com/volcano-tts/tts-api/dto"
|
||||
)
|
||||
|
||||
// Provider 是火山 v3 TTS 的 provider.Provider 实现。
|
||||
// 内部持有复用的 HTTPClient(keep-alive)。
|
||||
type Provider struct {
|
||||
client *HTTPClient
|
||||
}
|
||||
|
||||
// New 构造火山 provider。在 init() 里注册,主干经 provider.Get("volcano") 取用。
|
||||
func New() *Provider {
|
||||
return &Provider{client: NewHTTPClient()}
|
||||
}
|
||||
|
||||
// Name 实现 provider.Provider。
|
||||
func (p *Provider) Name() string { return "volcano" }
|
||||
|
||||
// Capabilities 实现 provider.Provider。
|
||||
// 火山支持多音色、倍率语速、字符计费,并需要 resource_id 额外维度。
|
||||
func (p *Provider) Capabilities() provider.Capabilities {
|
||||
return provider.Capabilities{
|
||||
Formats: []string{"mp3", "wav", "pcm", "ogg_opus", "aac", "flac"},
|
||||
Speed: true,
|
||||
Usage: true,
|
||||
Voices: true,
|
||||
ExtraScopes: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Synthesize 实现 provider.Provider:从归一化 Request 还原火山 Options 后执行合成。
|
||||
// 行为与原 Synthesis(controller 直接调用)完全一致,仅入口形态变化。
|
||||
func (p *Provider) Synthesize(ctx context.Context, req provider.Request, mtr provider.MetricsRecorder) (*dto.SynthesisResult, error) {
|
||||
if mtr == nil {
|
||||
mtr = provider.NoopMetrics()
|
||||
}
|
||||
opts := optionsFromRequest(req)
|
||||
return synthesize(ctx, p.client, opts, req.Format, mtr)
|
||||
}
|
||||
|
||||
// optionsFromRequest 把 provider.Request 还原成火山 Options。
|
||||
// 厂商私有参数从 Credentials.Scope(resource_id)与 Request.Extra 取回,与 BuildRequest 对称。
|
||||
func optionsFromRequest(req provider.Request) Options {
|
||||
opts := Options{
|
||||
APIKey: req.Credentials.APIKey,
|
||||
ResourceID: req.Credentials.Scope["resource_id"],
|
||||
Text: req.Text,
|
||||
Speaker: req.VoiceKey,
|
||||
Model: req.Model,
|
||||
Format: req.Format,
|
||||
SampleRate: req.SampleRate,
|
||||
SpeechRate: convertSpeedToSpeechRate(req.Speed),
|
||||
UID: "uid",
|
||||
}
|
||||
if v := req.Extra["uid"]; v != "" {
|
||||
opts.UID = v
|
||||
}
|
||||
if v := req.Extra["bit_rate"]; v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
opts.BitRate = n
|
||||
}
|
||||
}
|
||||
if v := req.Extra["loudness_rate"]; v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
opts.LoudnessRate = n
|
||||
}
|
||||
}
|
||||
opts.EnableSubtitle = req.Extra["enable_subtitle"] == "1"
|
||||
opts.EnableTimestamp = req.Extra["enable_timestamp"] == "1"
|
||||
if v := req.Extra["additions"]; v != "" {
|
||||
var adds Additions
|
||||
if err := json.Unmarshal([]byte(v), &adds); err == nil {
|
||||
opts.Additions = &adds
|
||||
}
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// BuildRequest 把火山 Options 映射成归一化 provider.Request。
|
||||
// 阶段0使用:setting 继续构造 volcano.Options,再经此函数转成 provider.Request 交给主干,
|
||||
// 从而让 controller 不再依赖具体实现。
|
||||
// 阶段2(配置命名空间化)后,此函数被"直接构造 provider.Request"取代。
|
||||
func BuildRequest(opts Options) provider.Request {
|
||||
req := provider.Request{
|
||||
Text: opts.Text,
|
||||
VoiceKey: opts.Speaker,
|
||||
Model: opts.Model,
|
||||
SampleRate: opts.SampleRate,
|
||||
Credentials: provider.Credentials{
|
||||
APIKey: opts.APIKey,
|
||||
Scope: map[string]string{"resource_id": opts.ResourceID},
|
||||
},
|
||||
Extra: map[string]string{},
|
||||
}
|
||||
if opts.UID != "" && opts.UID != "uid" {
|
||||
req.Extra["uid"] = opts.UID
|
||||
}
|
||||
if opts.BitRate != 0 {
|
||||
req.Extra["bit_rate"] = strconv.Itoa(opts.BitRate)
|
||||
}
|
||||
if opts.LoudnessRate != 0 {
|
||||
req.Extra["loudness_rate"] = strconv.Itoa(opts.LoudnessRate)
|
||||
}
|
||||
if opts.EnableSubtitle {
|
||||
req.Extra["enable_subtitle"] = "1"
|
||||
}
|
||||
if opts.EnableTimestamp {
|
||||
req.Extra["enable_timestamp"] = "1"
|
||||
}
|
||||
if opts.Additions != nil && !opts.Additions.IsZero() {
|
||||
if b, err := json.Marshal(opts.Additions); err == nil {
|
||||
req.Extra["additions"] = string(b)
|
||||
}
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// init 注册火山 provider。主干经 provider.Get("volcano") 取用,新增上游不改主干。
|
||||
func init() {
|
||||
provider.Register(New())
|
||||
}
|
||||
@@ -9,53 +9,27 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/volcano-tts/tts-api/adapter/provider"
|
||||
"github.com/volcano-tts/tts-api/common"
|
||||
"github.com/volcano-tts/tts-api/dto"
|
||||
"github.com/volcano-tts/tts-api/telemetry"
|
||||
)
|
||||
|
||||
// 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 一次,返回组装好的结果。
|
||||
// synthesize 调用火山 v3 一次,返回组装好的结果(包内私有,由 provider.Provider.Synthesize 调用)。
|
||||
//
|
||||
// 入参:
|
||||
// - ctx:超时控制
|
||||
// - client:复用的 HTTPClient
|
||||
// - opts:从 setting 构造的完整参数(text 字段会被 text 覆盖)
|
||||
// - text:本次合成的实际文本
|
||||
// - opts:已还原好的完整参数(Text / SpeechRate 已由调用方填入)
|
||||
// - clientFormat:客户端期望的最终格式,"wav" 内部转 pcm 后本地拼 wav 头
|
||||
// - speed:OpenAI 风格的 speed(倍率,0.5~2.0)
|
||||
// - mtr:可选埋点;传 nil 等价于 nopMetrics
|
||||
func Synthesis(
|
||||
// - mtr:埋点;调用方负责保证非 nil
|
||||
func synthesize(
|
||||
ctx context.Context,
|
||||
client *HTTPClient,
|
||||
opts Options,
|
||||
text string,
|
||||
clientFormat string,
|
||||
speed float64,
|
||||
mtr MetricsRecorder,
|
||||
mtr provider.MetricsRecorder,
|
||||
) (*dto.SynthesisResult, error) {
|
||||
if mtr == nil {
|
||||
mtr = nopMetrics{}
|
||||
}
|
||||
opts.Text = text
|
||||
opts.SpeechRate = convertSpeedToSpeechRate(speed)
|
||||
|
||||
reqID := newRequestID()
|
||||
|
||||
upstreamFormat := resolveUpstreamFormat(clientFormat)
|
||||
@@ -145,7 +119,7 @@ func Synthesis(
|
||||
mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, "ok", duration, parsed.FirstChunk, parsed.Chunks, len(finalData), 0)
|
||||
|
||||
log.Printf("TTS 合成成功 - 音色=%s 格式=%s 文本=%d字 音频=%d字节 分片=%d 耗时=%v",
|
||||
telemetry.MaskSpeaker(opts.Speaker), clientFormat, len(text), len(finalData), parsed.Chunks, duration)
|
||||
telemetry.MaskSpeaker(opts.Speaker), clientFormat, len(opts.Text), len(finalData), parsed.Chunks, duration)
|
||||
|
||||
return &dto.SynthesisResult{
|
||||
AudioData: finalData,
|
||||
|
||||
Reference in New Issue
Block a user