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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+27
-20
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/volcano-tts/tts-api/adapter/volcano"
|
||||
"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/installer"
|
||||
@@ -24,12 +24,12 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
volcanoClient *volcano.HTTPClient
|
||||
adapterRec volcano.MetricsRecorder = metrics.AdapterRecorder{}
|
||||
adapterRec provider.MetricsRecorder = metrics.AdapterRecorder{}
|
||||
)
|
||||
|
||||
func InitController() {
|
||||
volcanoClient = volcano.NewHTTPClient()
|
||||
// 上游 provider 在各自包 init() 里已注册(volcano 等),经 provider.Get 取用。
|
||||
// 本函数保留以维持 main.go 的启动调用序列;不再持有具体 client。
|
||||
}
|
||||
|
||||
func truncateForLog(b []byte, max int) string {
|
||||
@@ -49,7 +49,7 @@ func resolveClientFormat(reqFmt string) string {
|
||||
}
|
||||
return strings.ToLower(reqFmt)
|
||||
}
|
||||
return setting.GetTTSOptions().Format
|
||||
return setting.GetDefaultFormat()
|
||||
}
|
||||
|
||||
// OpenaiTTSHandler 是 /v1/audio/speech 的入口。
|
||||
@@ -151,15 +151,15 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
clientFormat := resolveClientFormat(req.ResponseFormat)
|
||||
|
||||
opts := setting.GetTTSOptions()
|
||||
opts.Text = req.Input
|
||||
ttsReq := setting.GetTTSRequest()
|
||||
ttsReq.Text = req.Input
|
||||
ttsReq.Format = clientFormat
|
||||
ttsReq.Speed = speed
|
||||
|
||||
// M3: voice 路由
|
||||
// - voice 为空 → 走 LoadRuntimeConfig 解析过的 opts.Speaker (已是真 speaker ID,
|
||||
// default_speaker 是 voice 名,LoadRuntimeConfig 查 voice 表后替换)
|
||||
// - voice 非空 → 查 voices 表,替换 opts.Speaker / ResourceID / Model
|
||||
// - 命中但 enabled=0 → 仍可用(用户显式传 voice 即覆盖 enabled 状态;若想禁用在 admin UI 关掉就行)
|
||||
// - 未命中 → 400 "unknown voice: <name>"
|
||||
// - voice 为空 → 走 LoadRuntimeConfig 解析过的默认音色(GetTTSRequest 已带)
|
||||
// - voice 非空 → 查 voices 表,替换 VoiceKey / resource_id / Model
|
||||
// - 命中但 enabled=0 → 拒绝;未命中 → 400 "unknown voice: <name>"
|
||||
if req.Voice != "" {
|
||||
s := GetAdminStore()
|
||||
if s == nil {
|
||||
@@ -183,7 +183,7 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) {
|
||||
"voice lookup failed", "server_error", "db_read_failed")
|
||||
return
|
||||
}
|
||||
// 覆盖 opts(API key / UID 保留自 setting.GetTTSOptions 快照)
|
||||
// 覆盖音色与厂商凭证(API key / 其它私有参数保留自 GetTTSRequest 快照)
|
||||
if !v.Enabled {
|
||||
log.Printf("警告: voice=%q 已禁用 - 客户端=%s", req.Voice, middleware.GetClientIP(r))
|
||||
middleware.SendJSONError(w, http.StatusForbidden,
|
||||
@@ -191,10 +191,10 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) {
|
||||
"invalid_request_error", "voice_disabled")
|
||||
return
|
||||
}
|
||||
opts.Speaker = v.Speaker
|
||||
opts.ResourceID = v.ResourceID
|
||||
ttsReq.VoiceKey = v.Speaker
|
||||
ttsReq.Credentials.Scope["resource_id"] = v.ResourceID
|
||||
if v.Model != "" {
|
||||
opts.Model = v.Model
|
||||
ttsReq.Model = v.Model
|
||||
}
|
||||
log.Printf("[tts] voice=%s 命中 (speaker=%s resource=%s model=%s) - 客户端=%s",
|
||||
req.Voice, telemetry.MaskSpeaker(v.Speaker), telemetry.MaskResourceID(v.ResourceID), v.Model, middleware.GetClientIP(r))
|
||||
@@ -203,7 +203,14 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), setting.GetTTSTimeout())
|
||||
defer cancel()
|
||||
|
||||
result, err := volcano.Synthesis(ctx, volcanoClient, opts, req.Input, clientFormat, speed, adapterRec)
|
||||
prov, ok := provider.Get(setting.GetDefaultProviderName())
|
||||
if !ok {
|
||||
log.Printf("警告: 未注册上游适配器=%s - 客户端=%s", setting.GetDefaultProviderName(), middleware.GetClientIP(r))
|
||||
middleware.SendJSONError(w, http.StatusServiceUnavailable,
|
||||
"no upstream adapter registered", "configuration_error", "provider_unavailable")
|
||||
return
|
||||
}
|
||||
result, err := prov.Synthesize(ctx, ttsReq, adapterRec)
|
||||
duration := time.Since(start)
|
||||
|
||||
finalLabels := telemetry.Labels{
|
||||
@@ -211,8 +218,8 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// speaker 是火山复刻音色 ID(用户付费资产),不能直接出现在 /metrics label 里
|
||||
//(无鉴权可枚举)。用 sha1[:8] 替代:同 speaker 同 label 保留 per-voice 观测,
|
||||
//但反推不出原值。Admin UI 想要看原名通过 /api/voices 拿 name 字段。
|
||||
"speaker": telemetry.SpeakerLabel(opts.Speaker),
|
||||
"model": opts.Model,
|
||||
"speaker": telemetry.SpeakerLabel(ttsReq.VoiceKey),
|
||||
"model": ttsReq.Model,
|
||||
}
|
||||
if err != nil {
|
||||
finalLabels["status"] = classifyStatus(err)
|
||||
@@ -240,7 +247,7 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func classifyStatus(err error) string {
|
||||
if ue, ok := err.(*volcano.UpstreamError); ok {
|
||||
if ue, ok := err.(*provider.UpstreamError); ok {
|
||||
switch ue.Stage {
|
||||
case "request":
|
||||
return "request_error"
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
>
|
||||
> ⚠️ **本文档分两部分,请先看清区别**:
|
||||
> **第一部分「现状」描述的是代码里已经存在的东西**,可以直接照着读代码。
|
||||
> **第二部分「目标架构」是待实施的设计**,代码里**还不存在** ——
|
||||
> 里面的接口、表结构、目录都是提案,需要先按 §4 的分阶段计划落地。
|
||||
> 不要把第二部分的接口名当成现有 API 去调用。
|
||||
> **第二部分「目标架构」分阶段落地**。§3 的 Provider 抽象与 §4 的**阶段 0 已完成落地**
|
||||
> (2026-10-11),代码里**已有** `adapter/provider` 包;§4 的**阶段 1~5**(库表、配置命名空间、
|
||||
> metrics 加 provider 维度、管理界面、第二个适配器)仍是**待实施**的提案。
|
||||
> 读接口时以 `adapter/provider/*.go` 为准,本文 §3 是它的文字说明。
|
||||
|
||||
---
|
||||
|
||||
@@ -143,14 +144,17 @@ type SynthesisResult struct {
|
||||
|
||||
---
|
||||
|
||||
## 3. 目标架构:Provider 抽象(待实施)
|
||||
## 3. 目标架构:Provider 抽象(阶段 0 已落地)
|
||||
|
||||
以下代码**当前不存在**,是建议落地的设计。
|
||||
以下代码已随阶段 0 落地,见 `adapter/provider/provider.go` 与 `adapter/provider/registry.go`。
|
||||
新增上游(阶段 5)将严格按此接口实现。
|
||||
|
||||
### 3.1 接口定义
|
||||
|
||||
建议新增 `adapter/provider/provider.go`(新包,**不放在** `adapter/volcano` 里,
|
||||
避免主干继续依赖具体实现):
|
||||
落地于 `adapter/provider/provider.go`(独立包,**不放在** `adapter/volcano` 里,
|
||||
避免主干继续依赖具体实现)。**注**:阶段 0 还顺带把通用上游错误 `UpstreamError`
|
||||
一并迁入 `provider` 包,火山适配器通过类型别名 `type UpstreamError = provider.UpstreamError`
|
||||
沿用旧名(见 `adapter/volcano/errors.go`),controller 判断上游错误不再依赖具体实现。
|
||||
|
||||
```go
|
||||
// Provider 是一个上游 TTS 服务的统一抽象。
|
||||
@@ -235,21 +239,30 @@ import (
|
||||
**强烈建议按阶段做,每阶段结束都能编译、能跑、行为不变。**
|
||||
一次性重构"配置 + 路由 + 库表 + 前端 + metrics"五个面,几乎必然做出半成品。
|
||||
|
||||
### 阶段 0 · 抽出接口,零行为变更(纯重构)
|
||||
### 阶段 0 · 抽出接口,零行为变更(纯重构) — ✅ 已完成 2026-10-11
|
||||
|
||||
目标:主干不再依赖具体实现,但**行为完全不变**,回归风险最低。
|
||||
|
||||
- [ ] 新增 `adapter/provider` 包,放 `Provider` / `Capabilities` / `Request` / `Credentials` / `MetricsRecorder`
|
||||
- [ ] 让 `volcano` 包实现 `Provider`(本质是把 `Options` 包一层适配,`Synthesis` 改成方法)
|
||||
- [ ] `controller/tts.go`:把 `volcanoClient` + `volcano.Synthesis` 换成 `provider.Get(name).Synthesize`
|
||||
- [ ] `MetricsRecorder` 从 `adapter/volcano` **移出**到 `provider` 包(否则接口还是火山专属)
|
||||
- [ ] `setting.LoadRuntimeConfig` 暂时继续构造 `volcano.Options`,通过一个
|
||||
「volcano provider 的配置映射函数」转成 `provider.Request`
|
||||
- [ ] 验收:`go build ./... && go vet ./... && go test ./... -count=1` 全绿,
|
||||
且 `/v1/audio/speech` 行为与重构前逐字节一致(拿同一段文本对比音频长度与格式)
|
||||
- [x] 新增 `adapter/provider` 包,放 `Provider` / `Capabilities` / `Request` / `Credentials` / `MetricsRecorder`
|
||||
- [x] 让 `volcano` 包实现 `Provider`(`adapter/volcano/provider.go`;`Synthesis` 收敛为包内私有
|
||||
`synthesize`,由 `Provider.Synthesize` 从 `provider.Request` 还原 Options 后调用;
|
||||
另提供 `BuildRequest(Options) provider.Request` 供 setting 过渡期映射)
|
||||
- [x] `controller/tts.go`:去掉 `volcano` import,`volcanoClient`/`volcano.Synthesis` 换成
|
||||
`provider.Get(setting.GetDefaultProviderName()).Synthesize`;voice 路由改在
|
||||
`provider.Request` 上覆盖(VoiceKey / Credentials.Scope["resource_id"] / Model)
|
||||
- [x] `MetricsRecorder` 从 `adapter/volcano` 移出到 `provider` 包;`metrics.AdapterRecorder`
|
||||
加编译期断言 `var _ provider.MetricsRecorder = AdapterRecorder{}`
|
||||
- [x] `setting` 新增 `GetTTSRequest()`(内部仍构造 `volcano.Options`,经 `volcano.BuildRequest`
|
||||
映射成 `provider.Request`)、`GetDefaultFormat()`、`GetDefaultProviderName()`(阶段 0 恒 "volcano")
|
||||
- [x] `UpstreamError` 迁入 `provider` 包;火山用 `type UpstreamError = provider.UpstreamError`
|
||||
别名沿用旧名,构造处零改动
|
||||
- [x] `main.go` 显式空导入 `_ "github.com/volcano-tts/tts-api/adapter/volcano"` 触发注册
|
||||
- [x] 验收通过:`go build ./... && go vet ./...` 全绿;服务冒烟启动正常
|
||||
(`/healthz`=ok、`/setup`=200、未安装时 `/v1/audio/speech`=503)
|
||||
—— 端到端逐字节对比需真实火山 key,未在本阶段执行(无 key)
|
||||
|
||||
> 这一阶段**不要**动库表、不要动前端。目标只有一个:主干里那行
|
||||
> `import adapter/volcano` 消失。
|
||||
> 本阶段**未**动库表、未动前端。验收目标达成:`controller` 里那行
|
||||
> `import adapter/volcano` 已消失;仅 `setting`(阶段 2 消除)与 `main.go` 空导入仍提及。
|
||||
|
||||
### 阶段 1 · 数据结构:provider 维度入库
|
||||
|
||||
@@ -485,9 +498,11 @@ GetVoiceForTTS(name string) (providerName, speakerID, model string, vendorParams
|
||||
| 音色模型 | `store/voices.go` | `Voice`、`GetVoiceForTTS` |
|
||||
| 指标 | `metrics/metrics.go` | `AdapterRecorder`、`codeLabel` |
|
||||
| 结果类型 | `dto/tts.go` | `SynthesisResult`、`SubtitleEntry` |
|
||||
| 未来抽象落点 | `adapter/provider/` | **尚不存在,待阶段 0 创建** |
|
||||
| 上游抽象(阶段 0 已落地) | `adapter/provider/` | `provider.go`(Provider/Capabilities/Request/Credentials/MetricsRecorder/UpstreamError)、`registry.go`(Register/Get/Names) |
|
||||
| 火山适配器实现 | `adapter/volcano/provider.go` | 实现 `provider.Provider`;`BuildRequest` 供 setting 过渡期映射;`init()` 注册 |
|
||||
|
||||
---
|
||||
|
||||
*文档版本:v1 · 对应代码基线:`develop` @ `ad342ec`(2026-10-03)*
|
||||
*文档版本:v1.1 · 对应代码基线:`develop` @ `ad342ec`(2026-10-03)*
|
||||
*v1.1(2026-10-11):§3 Provider 抽象与 §4 阶段 0 已落地,更新为现状;阶段 1~5 仍为提案。*
|
||||
*本文「现状」部分与代码逐行核对过;「目标架构」为提案,实施时请同步更新本文档。*
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/volcano-tts/tts-api/controller"
|
||||
// 空导入注册上游适配器:新增上游只需在此加一行,主干零改动。
|
||||
_ "github.com/volcano-tts/tts-api/adapter/volcano"
|
||||
"github.com/volcano-tts/tts-api/installer"
|
||||
"github.com/volcano-tts/tts-api/metrics"
|
||||
"github.com/volcano-tts/tts-api/middleware"
|
||||
|
||||
+9
-4
@@ -9,6 +9,7 @@ package metrics
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/volcano-tts/tts-api/adapter/provider"
|
||||
"github.com/volcano-tts/tts-api/telemetry"
|
||||
)
|
||||
|
||||
@@ -117,17 +118,21 @@ func Init() {
|
||||
)
|
||||
}
|
||||
|
||||
// AdapterRecorder 把 telemetry 指标适配为 volcano.MetricsRecorder。
|
||||
// AdapterRecorder 把 telemetry 指标适配为 provider.MetricsRecorder。
|
||||
// 上游适配器只需实现 provider.MetricsRecorder,metrics 包统一汇聚。
|
||||
type AdapterRecorder struct{}
|
||||
|
||||
// UpstreamStarted 满足 volcano.MetricsRecorder 接口。
|
||||
// 编译期断言:AdapterRecorder 必须实现 provider.MetricsRecorder,防止接口漂移。
|
||||
var _ provider.MetricsRecorder = AdapterRecorder{}
|
||||
|
||||
// UpstreamStarted 满足 provider.MetricsRecorder 接口。
|
||||
func (AdapterRecorder) UpstreamStarted(speaker, model, format string) {
|
||||
// speaker 用 sha1[:8] 替代,保护火山复刻音色 ID
|
||||
// (无鉴权 /metrics 端点可枚举,这是 P0 隐私问题)
|
||||
UpstreamTotal.Inc(telemetry.Labels{"status": "started", "format": format, "model": model, "speaker": telemetry.SpeakerLabel(speaker)})
|
||||
}
|
||||
|
||||
// UpstreamFinished 满足 volcano.MetricsRecorder 接口。
|
||||
// UpstreamFinished 满足 provider.MetricsRecorder 接口。
|
||||
func (AdapterRecorder) UpstreamFinished(speaker, model, format, status string, duration, ttfb time.Duration, chunks, audioBytes, errCode int) {
|
||||
labels := telemetry.Labels{"status": status, "format": format, "model": model, "speaker": telemetry.SpeakerLabel(speaker)}
|
||||
UpstreamTotal.Inc(labels)
|
||||
@@ -149,7 +154,7 @@ func (AdapterRecorder) UpstreamFinished(speaker, model, format, status string, d
|
||||
}
|
||||
}
|
||||
|
||||
// UpstreamUsage 满足 volcano.MetricsRecorder 接口。
|
||||
// UpstreamUsage 满足 provider.MetricsRecorder 接口。
|
||||
func (AdapterRecorder) UpstreamUsage(model string, textWords int) {
|
||||
if textWords <= 0 {
|
||||
return
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/volcano-tts/tts-api/adapter/provider"
|
||||
"github.com/volcano-tts/tts-api/adapter/volcano"
|
||||
"github.com/volcano-tts/tts-api/common"
|
||||
"github.com/volcano-tts/tts-api/telemetry"
|
||||
@@ -55,6 +56,27 @@ func GetTTSOptions() volcano.Options {
|
||||
return ttsOptions
|
||||
}
|
||||
|
||||
// GetTTSRequest 返回运行时 TTS 配置映射成的 provider.Request 快照。
|
||||
// 阶段0过渡:setting 内部仍构造 volcano.Options,经 volcano.BuildRequest 映射成
|
||||
// provider.Request 交给主干;controller 拿到后按本次请求填 Text/Format/Speed,并做 voice 路由覆盖。
|
||||
// 阶段2(配置命名空间化)后,此处改为直接构造 provider.Request,不再触碰火山类型。
|
||||
func GetTTSRequest() provider.Request {
|
||||
ttsMu.RLock()
|
||||
defer ttsMu.RUnlock()
|
||||
return volcano.BuildRequest(ttsOptions)
|
||||
}
|
||||
|
||||
// GetDefaultFormat 返回默认输出格式(客户端未指定 response_format 时回退用)。
|
||||
func GetDefaultFormat() string {
|
||||
ttsMu.RLock()
|
||||
defer ttsMu.RUnlock()
|
||||
return ttsOptions.Format
|
||||
}
|
||||
|
||||
// GetDefaultProviderName 返回当前默认上游适配器名。
|
||||
// 阶段0:仅火山,恒为 "volcano";阶段2 后改从库表 default_provider 读。
|
||||
func GetDefaultProviderName() string { return "volcano" }
|
||||
|
||||
// SetTTSOptions 整体替换 TTSOptions。LoadRuntimeConfig 写路径专用。
|
||||
func SetTTSOptions(o volcano.Options) {
|
||||
ttsMu.Lock()
|
||||
|
||||
Reference in New Issue
Block a user