diff --git a/internal/audio/manager.go b/internal/audio/manager.go index 13932672..e5c1ac3f 100644 --- a/internal/audio/manager.go +++ b/internal/audio/manager.go @@ -6,6 +6,23 @@ import ( "log/slog" ) +// ctxKeyChannel is the context key for the current channel name (e.g. "telegram"). +// Set via WithChannel; read by resolveSTTChain to select channel-scoped providers. +type ctxKeyChannelType struct{} + +var ctxKeyChannel = ctxKeyChannelType{} + +// WithChannel returns a context that carries the channel name for STT chain resolution. +func WithChannel(ctx context.Context, channel string) context.Context { + return context.WithValue(ctx, ctxKeyChannel, channel) +} + +// channelFromCtx extracts the channel name from ctx, or "" if not set. +func channelFromCtx(ctx context.Context) string { + v, _ := ctx.Value(ctxKeyChannel).(string) + return v +} + // Manager orchestrates audio providers across TTS, STT, Music, and SFX // operations. Each op has its own provider map + primary/fallback chain. // @@ -17,9 +34,10 @@ type Manager struct { musicProviders map[string]MusicProvider sfxProviders map[string]SFXProvider - primary string // primary TTS provider - sttChain []string // STT fallback order (Phase 4) - musicChain []string // Music fallback order (Phase 3) + primary string // primary TTS provider + sttChain []string // STT fallback order (Phase 4) + musicChain []string // Music fallback order (Phase 3) + channelSTTOverrides map[string][]string // channel → provider key list (Phase 4) auto AutoMode mode Mode @@ -41,15 +59,16 @@ type ManagerConfig struct { // NewManager creates an audio manager with empty provider maps. func NewManager(cfg ManagerConfig) *Manager { m := &Manager{ - ttsProviders: make(map[string]TTSProvider), - sttProviders: make(map[string]STTProvider), - musicProviders: make(map[string]MusicProvider), - sfxProviders: make(map[string]SFXProvider), - primary: cfg.Primary, - auto: cfg.Auto, - mode: cfg.Mode, - maxLength: cfg.MaxLength, - timeoutMs: cfg.TimeoutMs, + ttsProviders: make(map[string]TTSProvider), + sttProviders: make(map[string]STTProvider), + musicProviders: make(map[string]MusicProvider), + sfxProviders: make(map[string]SFXProvider), + channelSTTOverrides: make(map[string][]string), + primary: cfg.Primary, + auto: cfg.Auto, + mode: cfg.Mode, + maxLength: cfg.MaxLength, + timeoutMs: cfg.TimeoutMs, } if m.auto == "" { m.auto = AutoOff diff --git a/internal/audio/manager_stt.go b/internal/audio/manager_stt.go new file mode 100644 index 00000000..122f69a9 --- /dev/null +++ b/internal/audio/manager_stt.go @@ -0,0 +1,88 @@ +package audio + +import ( + "context" + "errors" + "fmt" + "log/slog" +) + +// ErrAllSTTProvidersFailed is returned when every provider in the chain fails. +var ErrAllSTTProvidersFailed = errors.New("all STT providers failed") + +// defaultSTTChain is the built-in fallback order when no explicit chain is set. +var defaultSTTChain = []string{"elevenlabs", "proxy"} + +// Transcribe tries providers in chain order. Returns first success. +// Wraps last error with ErrAllSTTProvidersFailed on total failure. +func (m *Manager) Transcribe(ctx context.Context, in STTInput, opts STTOptions) (*TranscriptResult, error) { + chain := m.resolveSTTChain(ctx) + if len(chain) == 0 { + return nil, fmt.Errorf("%w: chain is empty", ErrAllSTTProvidersFailed) + } + + var lastErr error + for _, name := range chain { + p, ok := m.sttProviders[name] + if !ok { + slog.Warn("audio.stt provider not registered, skipping", "provider", name) + continue + } + res, err := p.Transcribe(ctx, in, opts) + if err == nil { + return res, nil + } + slog.Warn("audio.stt provider failed", "provider", name, "error", err) + lastErr = err + } + + if lastErr != nil { + return nil, fmt.Errorf("%w: %w", ErrAllSTTProvidersFailed, lastErr) + } + return nil, fmt.Errorf("%w: no providers matched in chain %v", ErrAllSTTProvidersFailed, chain) +} + +// SetSTTChain sets an explicit provider order for STT dispatch. +// Call before first use; not thread-safe after concurrent Transcribe calls. +func (m *Manager) SetSTTChain(chain []string) { + m.sttChain = chain +} + +// RegisterChannelSTT registers STT providers scoped to a specific channel name +// (e.g. "telegram"). Channel-scoped providers take precedence over the manager +// default chain when resolveSTTChain detects a matching channel in ctx. +func (m *Manager) RegisterChannelSTT(channelName string, providers ...STTProvider) { + if m.channelSTTOverrides == nil { + m.channelSTTOverrides = make(map[string][]string) + } + names := make([]string, 0, len(providers)) + for _, p := range providers { + m.sttProviders[p.Name()+":"+channelName] = p + names = append(names, p.Name()+":"+channelName) + } + m.channelSTTOverrides[channelName] = names +} + +// resolveSTTChain returns the ordered provider names for the current call. +// Precedence: (1) channel override from ctx, (2) explicit m.sttChain, +// (3) defaultSTTChain filtered to registered providers. +func (m *Manager) resolveSTTChain(ctx context.Context) []string { + // (1) Channel override: check if ctx carries a channel name. + if ch := channelFromCtx(ctx); ch != "" { + if overrides, ok := m.channelSTTOverrides[ch]; ok && len(overrides) > 0 { + return overrides + } + } + // (2) Explicit chain set via SetSTTChain. + if len(m.sttChain) > 0 { + return m.sttChain + } + // (3) Default: elevenlabs → proxy, filtered to what's registered. + var out []string + for _, name := range defaultSTTChain { + if _, ok := m.sttProviders[name]; ok { + out = append(out, name) + } + } + return out +} diff --git a/internal/audio/manager_stt_test.go b/internal/audio/manager_stt_test.go new file mode 100644 index 00000000..9f88f3e0 --- /dev/null +++ b/internal/audio/manager_stt_test.go @@ -0,0 +1,146 @@ +package audio + +import ( + "context" + "errors" + "testing" +) + +// mockSTT is a test STTProvider that either returns a fixed result or error. +type mockSTT struct { + name string + result *TranscriptResult + err error +} + +func (m *mockSTT) Name() string { return m.name } +func (m *mockSTT) Transcribe(_ context.Context, _ STTInput, _ STTOptions) (*TranscriptResult, error) { + return m.result, m.err +} + +func newTestManager() *Manager { + return NewManager(ManagerConfig{}) +} + +// Case 1: chain ["elevenlabs","proxy"] — Scribe succeeds, result.Provider="elevenlabs". +func TestManager_Transcribe_ChainSuccessFirst(t *testing.T) { + m := newTestManager() + m.RegisterSTT(&mockSTT{ + name: "elevenlabs", + result: &TranscriptResult{Text: "hello", Provider: "elevenlabs"}, + }) + m.RegisterSTT(&mockSTT{ + name: "proxy", + result: &TranscriptResult{Text: "world", Provider: "proxy"}, + }) + m.SetSTTChain([]string{"elevenlabs", "proxy"}) + + res, err := m.Transcribe(context.Background(), STTInput{}, STTOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.Provider != "elevenlabs" { + t.Errorf("expected provider 'elevenlabs', got %q", res.Provider) + } + if res.Text != "hello" { + t.Errorf("expected text 'hello', got %q", res.Text) + } +} + +// Case 2: Scribe fails → proxy succeeds → result.Provider="proxy". +func TestManager_Transcribe_FallsToProxy(t *testing.T) { + m := newTestManager() + m.RegisterSTT(&mockSTT{ + name: "elevenlabs", + err: errors.New("scribe 500"), + }) + m.RegisterSTT(&mockSTT{ + name: "proxy", + result: &TranscriptResult{Text: "fallback", Provider: "proxy"}, + }) + m.SetSTTChain([]string{"elevenlabs", "proxy"}) + + res, err := m.Transcribe(context.Background(), STTInput{}, STTOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.Provider != "proxy" { + t.Errorf("expected provider 'proxy', got %q", res.Provider) + } +} + +// Case 3: both fail → ErrAllSTTProvidersFailed. +func TestManager_Transcribe_AllFail(t *testing.T) { + m := newTestManager() + m.RegisterSTT(&mockSTT{name: "elevenlabs", err: errors.New("scribe error")}) + m.RegisterSTT(&mockSTT{name: "proxy", err: errors.New("proxy error")}) + m.SetSTTChain([]string{"elevenlabs", "proxy"}) + + _, err := m.Transcribe(context.Background(), STTInput{}, STTOptions{}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, ErrAllSTTProvidersFailed) { + t.Errorf("expected ErrAllSTTProvidersFailed, got: %v", err) + } +} + +// Case 4: empty chain → error. +func TestManager_Transcribe_EmptyChain(t *testing.T) { + m := newTestManager() + m.SetSTTChain([]string{}) + + _, err := m.Transcribe(context.Background(), STTInput{}, STTOptions{}) + if err == nil { + t.Fatal("expected error for empty chain, got nil") + } + if !errors.Is(err, ErrAllSTTProvidersFailed) { + t.Errorf("expected ErrAllSTTProvidersFailed, got: %v", err) + } +} + +// Case 5: unknown provider in chain is skipped with warn; known one succeeds. +func TestManager_Transcribe_UnknownSkipped(t *testing.T) { + m := newTestManager() + m.RegisterSTT(&mockSTT{ + name: "proxy", + result: &TranscriptResult{Text: "ok", Provider: "proxy"}, + }) + m.SetSTTChain([]string{"unknown_provider", "proxy"}) + + res, err := m.Transcribe(context.Background(), STTInput{}, STTOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.Provider != "proxy" { + t.Errorf("expected provider 'proxy', got %q", res.Provider) + } +} + +// Case 6: channel override wins over tenant chain. +func TestManager_Transcribe_ChannelOverrideWins(t *testing.T) { + m := newTestManager() + + // Register tenant-level provider. + m.RegisterSTT(&mockSTT{ + name: "elevenlabs", + result: &TranscriptResult{Text: "tenant", Provider: "elevenlabs"}, + }) + m.SetSTTChain([]string{"elevenlabs"}) + + // Register channel-scoped proxy that wins for "telegram". + channelProxy := &mockSTT{ + name: "proxy", + result: &TranscriptResult{Text: "channel-override", Provider: "proxy"}, + } + m.RegisterChannelSTT("telegram", channelProxy) + + ctx := WithChannel(context.Background(), "telegram") + res, err := m.Transcribe(ctx, STTInput{}, STTOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.Text != "channel-override" { + t.Errorf("expected channel override result, got %q", res.Text) + } +}