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:
dev
2026-10-11 13:39:09 +08:00
parent ec82e895c3
commit 5c130f0be4
10 changed files with 386 additions and 101 deletions
+117
View File
@@ -0,0 +1,117 @@
// Package provider 定义上游 TTS 服务的统一抽象。
//
// 设计要点(与 docs/UPSTREAM_ADAPTER_GUIDE.md §3 对齐):
// - 主干(controller/setting/metrics)只依赖本包,不依赖任何具体适配器;
// - 具体适配器(如 adapter/volcano)实现 Provider 并在自己的 init() 里注册;
// - 新增上游 = 写一个新适配器 + main.go 加一行空导入,主干零改动。
package provider
import (
"context"
"fmt"
"time"
"github.com/volcano-tts/tts-api/dto"
)
// Provider 是一个上游 TTS 服务的统一抽象。
// 实现者只需要关心:把我的能力说清楚、把一次合成做完。
type Provider interface {
// Name 稳定标识,用于配置键、库表字段、metrics label。
// 约定全小写无空格:volcano / openai / azure / aliyun / gpt-sovits。
Name() string
// Capabilities 声明本适配器支持什么。
// 主干据此做校验与降级,不靠 if-else 猜实现。
Capabilities() Capabilities
// Synthesize 执行一次合成,阻塞到完成。
// 必须遵守:ctx 取消即返回;上游不支持 clientFormat 时按 Capabilities 降级,
// 并在 Result.Format 里如实回报真实格式。
Synthesize(ctx context.Context, req Request, mtr MetricsRecorder) (*dto.SynthesisResult, error)
}
// Capabilities 声明适配器支持的能力,主干据此做校验与降级。
type Capabilities struct {
// 上游原生能产出的格式;客户端要的格式不在其中时,主干负责降级或拒绝。
Formats []string
// 是否支持用倍率控制语速。不支持时主干忽略 speed 而不是报错。
Speed bool
// 是否按字符计费并回传用量(决定 UpstreamUsage 是否有意义)。
Usage bool
// 是否支持多音色路由。不支持时该 provider 只能配一个默认音色。
Voices bool
// 是否需要资源/部署/项目 ID 这类额外维度(火山需要,OpenAI 不需要)。
ExtraScopes bool
}
// Request 是主干交给适配器的、已归一化的合成请求。
// 注意:这里刻意不出现任何厂商专属字段。
type Request struct {
Text string
VoiceKey string // voices 表的主键语义(对外 voice 名或上游音色 ID)
Model string
Format string // 客户端期望格式(主干已归一化:mp3/wav/pcm/ogg_opus)
SampleRate int
Speed float64 // 1.0 = 原速
Language string
Extra map[string]string // 厂商专属配置,从 voices/settings 的 JSON 列读
// Credentials 由 provider 自己解释:
// 火山读 api_key + resource_id;OpenAI 只读 api_key。
Credentials Credentials
}
// Credentials 承载上游鉴权与路由维度。
// Scope 是厂商额外凭证/路由维度:火山 = resource_id;Azure = region;自建 = base_url。
// 本包不解释 Scope 的内容,原样交给对应 provider。
type Credentials struct {
APIKey string
Scope map[string]string
}
// 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) {}
// NoopMetrics 返回一个 MetricsRecorder 的 no-op 实现。
// 适配器在未注入 recorder 时可把它作为默认值,保证埋点逻辑永远可运行。
func NoopMetrics() MetricsRecorder { return nopMetrics{} }
// UpstreamError 表示上游 TTS 返回的业务错误或传输错误。
// 各适配器用它报告失败,并带上错误发生的阶段(Stage),供主干归一错误与打 metrics label。
// 通用化到本包,是为了让主干(controller)只依赖 provider 包即可判断上游错误,
// 不必 import 具体适配器。
type UpstreamError struct {
Code int // 上游业务/HTTP 错误码;0 表示传输/本地错误
Message string
Stage string // "request"/"http"/"stream"/"wrap" - 出错阶段
Wrapped error
}
func (e *UpstreamError) Error() string {
if e.Wrapped != nil {
return fmt.Sprintf("upstream %s: code=%d %s: %v", e.Stage, e.Code, e.Message, e.Wrapped)
}
return fmt.Sprintf("upstream %s: code=%d %s", e.Stage, e.Code, e.Message)
}
func (e *UpstreamError) Unwrap() error { return e.Wrapped }
// IsAuth 当上游返回认证/权限类错误时返回 true(HTTP 401/403)。
func (e *UpstreamError) IsAuth() bool {
return e.Code == 401 || e.Code == 403
}
+33
View File
@@ -0,0 +1,33 @@
package provider
import "sort"
// registry 保存所有已注册的上游适配器。
// 适配器在自己的 init() 里调用 Register;主干只通过 Get/Names 访问。
var registry = map[string]Provider{}
// Register 注册一个上游适配器。通常在适配器包的 init() 里调用。
// 重名注册会 panic —— 说明有重名 bug,应立即暴露。
func Register(p Provider) {
name := p.Name()
if _, ok := registry[name]; ok {
panic("provider: duplicate registration: " + name)
}
registry[name] = p
}
// Get 按名字取已注册的上游适配器;不存在返回 false。
func Get(name string) (Provider, bool) {
p, ok := registry[name]
return p, ok
}
// Names 返回所有已注册适配器的名字(排序稳定),供 admin UI 下拉框使用。
func Names() []string {
names := make([]string, 0, len(registry))
for name := range registry {
names = append(names, name)
}
sort.Strings(names)
return names
}
+6 -24
View File
@@ -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
+128
View File
@@ -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())
}
+7 -33
View File
@@ -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,