feat(audio): Manager STT dispatch with channel override

Add Transcribe() method to Manager with STT provider dispatch and channel-specific overrides. Implements channelSTTOverrides map, WithChannel context propagation, RegisterChannelSTT registration, and resolveSTTChain fallback logic. Includes manager-level unit tests.
This commit is contained in:
viettranx committed 2026-04-15 11:24:57 +07:00
1 parent 9f88f12c2a
commit 8c8849365f
3 files changed
+265 -12

No files matched your search

+31 -12
View File
@@ -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
+88
View File
@@ -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
}
+146
View File
@@ -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)
}
}