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 != "" }