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 setting is present (today an // API key) 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 cfg.DashscopeAPIKey != "" { 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...) }