Files
Cyrene_v2/internal/runtime/multimodal/voice/chain.go
T
Aska 73dabb9dde
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(voice): 阿里百炼 STT/TTS 主链路接通(base64 STT + cosyvoice TTS按voice_id + REST /audio/* + voice_input.deviceId)
- voice/dashscope.go 新建:DashscopeSTT(qwen-audio-3.0-asr-flash, base64 data URI→text) + DashscopeTTS(cosyvoice 按 voice_id, 取非流式 24h URL 下载字节)
- cloud.go newDashboardSTT/TTS 由空 endpoint stub 改为真实百炼 adapter
- chain.go gating 从 DashscopeAPIKey!="" 改为 voiceConfigured(cfg); silent 兜底保留
- config.VoiceConfig 扩展 DASHSCOPE_BASE_URL/VOICE_STT_*/VOICE_TTS_* 字段(必填 DASHSCOPE_API_KEY)
- gateway: POST /api/v1/audio/{transcribe,speech} (owner+审计+MaxBytesReader); voiceInputRequest 增 deviceId 并透传 runner.Run
- 实测: cosyvoice-v3.5-plus+longxiaochun 回 418(引擎拒绝), 改用 cosyvoice-v3-flash+longxiaochun_v3; TTS 你好我是昔涟->106124B wav -> STT 回 你好我是西联, 链返回真实字节/文本
- 单测: dashscope_test/config voice defaults/gateway audio_test; go build/vet/test 全绿
- 声音复刻(响昔涟音色)待公网参考音频; 不做 create_voice
2026-09-08 06:06:02 +08:00

122 lines
3.6 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
}
// 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) {
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, nil
}
lastErr = err
}
return "", fmt.Errorf("voice: %w: %v", ErrProviderFailed, lastErr)
}
// TTS walks the chain for synthesized audio.
func (c *Chain) TTS(ctx context.Context, text string) (Audio, 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, 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)
if voiceConfigured(cfg) {
steps = append(steps, CloudProvider(newDashboardSTT(cfg), newDashboardTTS(cfg)))
}
switch cfg.TTSProvider {
case "edge-tts", "edge":
steps = append(steps, EdgeTTSProvider(newEdgeTTS()))
case "espeak":
steps = append(steps, EspeakProvider(newEspeak()))
}
steps = append(steps, SilentProvider())
return NewChain(steps...)
}
// 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 != ""
}