mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-17 02:14:09 +00:00
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:
1 parent
9f88f12c2a
commit
8c8849365f
3 files changed
+265
-12
No files matched your search
+31
-12
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user