- voice/Chain+Service: STTWithProvider/TTSWithProvider(真实档位cloud/local/edge-tts/espeak/silent), 旧方法改薄包装
- gateway: transcribe空文本带{text,provider} / voice_output空音频带provider / speech空音频tts_empty+X-Voice-Provider头
- middleware: CORS Access-Control-Expose-Headers: X-Voice-Provider
- 可选接口 voiceProviderReporter(type断言透传), 自定义实现回退silent
- 新增测试10个(provider_signal/voice_signal), build/vet/test全绿+交叉编译
- Docs/api-client v0.23 + api-reference CORS 同步
152 lines
4.9 KiB
Go
152 lines
4.9 KiB
Go
package voice
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
|
)
|
|
|
|
// Chain is a degrading provider list. STT and TTS walk the steps in order,
|
|
// returning the first successful result and moving to the next step when a
|
|
// provider is unavailable (nil direction) or fails. It implements STT and TTS so
|
|
// it can be used anywhere a single provider is expected.
|
|
type Chain struct {
|
|
steps []Provider
|
|
cloner VoiceCloner
|
|
}
|
|
|
|
// NewChain builds a degrading chain from the given providers in the order they
|
|
// should be tried. Passing a SilentProvider last guarantees the chain always
|
|
// succeeds.
|
|
func NewChain(steps ...Provider) *Chain {
|
|
return &Chain{steps: steps}
|
|
}
|
|
|
|
// STT walks the chain for a transcription.
|
|
func (c *Chain) STT(ctx context.Context, audio Audio) (string, error) {
|
|
text, _, err := c.STTWithProvider(ctx, audio)
|
|
return text, err
|
|
}
|
|
|
|
// STTWithProvider is STT but also reports the name of the provider rung that
|
|
// produced the transcription (cloud / local / edge-tts / silent), so a caller can
|
|
// surface which tier actually served the request.
|
|
func (c *Chain) STTWithProvider(ctx context.Context, audio Audio) (string, string, error) {
|
|
if len(c.steps) == 0 {
|
|
return "", "", ErrProviderFailed
|
|
}
|
|
var lastErr error
|
|
for _, p := range c.steps {
|
|
stt := p.STT()
|
|
if stt == nil {
|
|
continue
|
|
}
|
|
text, err := stt.STT(ctx, audio)
|
|
if err == nil {
|
|
return text, p.Name(), nil
|
|
}
|
|
lastErr = err
|
|
}
|
|
return "", "", fmt.Errorf("voice: %w: %v", ErrProviderFailed, lastErr)
|
|
}
|
|
|
|
// CloneVoice creates a custom voice via the cloud provider's voice-enrollment
|
|
// endpoint (声音复刻 create_voice). A chain without a cloud rung returns
|
|
// ErrNotConfigured. It is not a degradation step — the caller gets one result.
|
|
func (c *Chain) CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error) {
|
|
if c.cloner == nil {
|
|
return "", ErrNotConfigured
|
|
}
|
|
return c.cloner.CloneVoice(ctx, referenceURL, prefix)
|
|
}
|
|
|
|
// TTS walks the chain for synthesized audio.
|
|
func (c *Chain) TTS(ctx context.Context, text string) (Audio, error) {
|
|
audio, _, err := c.TTSWithProvider(ctx, text)
|
|
return audio, err
|
|
}
|
|
|
|
// TTSWithProvider is TTS but also reports the name of the provider rung that
|
|
// produced the audio.
|
|
func (c *Chain) TTSWithProvider(ctx context.Context, text string) (Audio, string, error) {
|
|
if len(c.steps) == 0 {
|
|
return Audio{}, "", ErrProviderFailed
|
|
}
|
|
var lastErr error
|
|
for _, p := range c.steps {
|
|
tts := p.TTS()
|
|
if tts == nil {
|
|
continue
|
|
}
|
|
audio, err := tts.TTS(ctx, text)
|
|
if err == nil {
|
|
return audio, p.Name(), nil
|
|
}
|
|
lastErr = err
|
|
}
|
|
return Audio{}, "", fmt.Errorf("voice: %w: %v", ErrProviderFailed, lastErr)
|
|
}
|
|
|
|
// Option customizes a chain built by New.
|
|
type Option func(*chainConfig)
|
|
|
|
// chainConfig carries the assembled steps for New.
|
|
type chainConfig struct {
|
|
steps []Provider
|
|
}
|
|
|
|
// WithProviders overrides the provider steps assembled from config. Tests use it
|
|
// to inject deterministic mock providers instead of the real adaptation-point
|
|
// rungs.
|
|
func WithProviders(steps ...Provider) Option {
|
|
return func(c *chainConfig) { c.steps = steps }
|
|
}
|
|
|
|
// New assembles a chain from cfg using the fixed degradation order
|
|
// cloud → local → edge-tts/espeak → silent.
|
|
//
|
|
// - cloud is included only when a cloud voice credential or model is present
|
|
// (voiceConfigured) so a mis-configured build degrades quietly.
|
|
// - local is a pure adaptation point and is only included when supplied via
|
|
// WithProviders; by default it is skipped.
|
|
// - the edge-tts or espeak rung is included according to cfg.TTSProvider.
|
|
// - silent is always appended last so the chain never fails outright.
|
|
//
|
|
// opts may supply a fully assembled step list to override this mapping.
|
|
func New(cfg config.VoiceConfig, opts ...Option) *Chain {
|
|
conf := &chainConfig{}
|
|
for _, o := range opts {
|
|
o(conf)
|
|
}
|
|
if len(conf.steps) > 0 {
|
|
return NewChain(conf.steps...)
|
|
}
|
|
|
|
steps := make([]Provider, 0, 5)
|
|
var cloner VoiceCloner
|
|
if voiceConfigured(cfg) {
|
|
steps = append(steps, CloudProvider(newDashboardSTT(cfg), newDashboardTTS(cfg)))
|
|
cloner = newDashboardCloner(cfg)
|
|
}
|
|
switch cfg.TTSProvider {
|
|
case "edge-tts", "edge":
|
|
steps = append(steps, EdgeTTSProvider(newEdgeTTS()))
|
|
case "espeak":
|
|
steps = append(steps, EspeakProvider(newEspeak()))
|
|
}
|
|
steps = append(steps, SilentProvider())
|
|
chain := NewChain(steps...)
|
|
chain.cloner = cloner
|
|
return chain
|
|
}
|
|
|
|
// voiceConfigured reports whether any cloud voice credential or model is set, so
|
|
// the cloud rung is only added when the runtime intends to use DashScope. Any
|
|
// single present field (API key, base URL, or an STT/TTS model) makes the cloud
|
|
// rung worth trying; a missing key then makes the adapter return ErrNotConfigured
|
|
// and the chain degrades quietly.
|
|
func voiceConfigured(cfg config.VoiceConfig) bool {
|
|
return cfg.DashscopeAPIKey != "" || cfg.DashscopeBaseURL != "" || cfg.STTModel != "" || cfg.TTSModel != ""
|
|
}
|