feat(audio): Manager dispatch for Music + SFX with fallback chain

This commit is contained in:
viettranx committed 2026-04-15 11:24:57 +07:00
1 parent d3f226c7a7
commit 545c27beb5
3 files changed
+227 -4

No files matched your search

+88
View File
@@ -139,6 +139,94 @@ func (m *Manager) SynthesizeStream(ctx context.Context, text string, opts TTSOpt
return sp.SynthesizeStream(ctx, text, opts)
}
// ---- Music dispatch ----
// GenerateMusic tries registered music providers in chain order until one succeeds.
// Chain order: elevenlabs first (if registered), then remaining providers.
// Override order by setting m.musicChain before the first call.
func (m *Manager) GenerateMusic(ctx context.Context, opts MusicOptions) (*AudioResult, error) {
chain := m.resolveMusicChain()
if len(chain) == 0 {
return nil, fmt.Errorf("no music providers registered")
}
var lastErr error
for _, name := range chain {
p, ok := m.musicProviders[name]
if !ok {
slog.Info("audio.music provider not registered, skipping", "provider", name)
continue
}
if res, err := p.GenerateMusic(ctx, opts); err == nil {
return res, nil
} else {
slog.Warn("audio.music provider failed", "provider", name, "error", err)
lastErr = err
}
}
if lastErr != nil {
return nil, fmt.Errorf("all music providers failed: %w", lastErr)
}
return nil, fmt.Errorf("no music providers registered")
}
// GenerateSFX tries SFX providers in order: elevenlabs first, then any other registered.
func (m *Manager) GenerateSFX(ctx context.Context, opts SFXOptions) (*AudioResult, error) {
order := m.resolveSFXChain()
if len(order) == 0 {
return nil, fmt.Errorf("no sfx providers registered")
}
var lastErr error
for _, name := range order {
p, ok := m.sfxProviders[name]
if !ok {
continue
}
if res, err := p.GenerateSFX(ctx, opts); err == nil {
return res, nil
} else {
slog.Warn("audio.sfx provider failed", "provider", name, "error", err)
lastErr = err
}
}
if lastErr != nil {
return nil, fmt.Errorf("all sfx providers failed: %w", lastErr)
}
return nil, fmt.Errorf("no sfx providers registered")
}
// resolveMusicChain returns the ordered provider names for music generation.
// If m.musicChain is set explicitly it is used as-is; otherwise elevenlabs is
// preferred and remaining providers follow in registration order.
func (m *Manager) resolveMusicChain() []string {
if len(m.musicChain) > 0 {
return m.musicChain
}
out := make([]string, 0, len(m.musicProviders))
if _, ok := m.musicProviders["elevenlabs"]; ok {
out = append(out, "elevenlabs")
}
for name := range m.musicProviders {
if name != "elevenlabs" {
out = append(out, name)
}
}
return out
}
// resolveSFXChain returns the ordered provider names for SFX generation.
func (m *Manager) resolveSFXChain() []string {
out := make([]string, 0, len(m.sfxProviders))
if _, ok := m.sfxProviders["elevenlabs"]; ok {
out = append(out, "elevenlabs")
}
for name := range m.sfxProviders {
if name != "elevenlabs" {
out = append(out, name)
}
}
return out
}
// SynthesizeWithFallback tries primary first, then any other registered
// provider on error. Returns first success or aggregate failure.
func (m *Manager) SynthesizeWithFallback(ctx context.Context, text string, opts TTSOptions) (*SynthResult, error) {
+130
View File
@@ -0,0 +1,130 @@
package audio
import (
"context"
"errors"
"testing"
)
// ---- Fake providers for testing ----
type fakeMusic struct {
name string
res *AudioResult
err error
}
func (f *fakeMusic) Name() string { return f.name }
func (f *fakeMusic) GenerateMusic(_ context.Context, _ MusicOptions) (*AudioResult, error) {
return f.res, f.err
}
type fakeSFX struct {
name string
res *AudioResult
err error
}
func (f *fakeSFX) Name() string { return f.name }
func (f *fakeSFX) GenerateSFX(_ context.Context, _ SFXOptions) (*AudioResult, error) {
return f.res, f.err
}
// ---- GenerateMusic tests ----
func TestManager_GenerateMusic_primary_succeeds(t *testing.T) {
m := NewManager(ManagerConfig{})
want := &AudioResult{Audio: []byte("music"), Extension: "mp3", MimeType: "audio/mpeg"}
m.RegisterMusic(&fakeMusic{name: "elevenlabs", res: want})
got, err := m.GenerateMusic(context.Background(), MusicOptions{Prompt: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(got.Audio) != string(want.Audio) {
t.Errorf("audio: want %q, got %q", want.Audio, got.Audio)
}
}
func TestManager_GenerateMusic_primary_fails_secondary_succeeds(t *testing.T) {
m := NewManager(ManagerConfig{})
want := &AudioResult{Audio: []byte("fallback_music"), Extension: "mp3", MimeType: "audio/mpeg"}
m.RegisterMusic(&fakeMusic{name: "elevenlabs", err: errors.New("elevenlabs down")})
m.RegisterMusic(&fakeMusic{name: "minimax", res: want})
got, err := m.GenerateMusic(context.Background(), MusicOptions{Prompt: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(got.Audio) != string(want.Audio) {
t.Errorf("audio: want %q, got %q", want.Audio, got.Audio)
}
}
func TestManager_GenerateMusic_all_fail(t *testing.T) {
m := NewManager(ManagerConfig{})
m.RegisterMusic(&fakeMusic{name: "elevenlabs", err: errors.New("elevenlabs down")})
m.RegisterMusic(&fakeMusic{name: "minimax", err: errors.New("minimax down")})
_, err := m.GenerateMusic(context.Background(), MusicOptions{Prompt: "test"})
if err == nil {
t.Fatal("expected error, got nil")
}
if !containsStr(err.Error(), "all music providers failed") {
t.Errorf("error should mention 'all music providers failed': %v", err)
}
}
func TestManager_GenerateMusic_no_providers(t *testing.T) {
m := NewManager(ManagerConfig{})
_, err := m.GenerateMusic(context.Background(), MusicOptions{Prompt: "test"})
if err == nil {
t.Fatal("expected error, got nil")
}
if !containsStr(err.Error(), "no music providers") {
t.Errorf("error should mention 'no music providers': %v", err)
}
}
// ---- GenerateSFX tests ----
func TestManager_GenerateSFX_elevenlabs_succeeds(t *testing.T) {
m := NewManager(ManagerConfig{})
want := &AudioResult{Audio: []byte("sfx_bytes"), Extension: "mp3", MimeType: "audio/mpeg"}
m.RegisterSFX(&fakeSFX{name: "elevenlabs", res: want})
got, err := m.GenerateSFX(context.Background(), SFXOptions{Prompt: "explosion"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(got.Audio) != string(want.Audio) {
t.Errorf("audio: want %q, got %q", want.Audio, got.Audio)
}
}
func TestManager_GenerateSFX_elevenlabs_fails_fallback(t *testing.T) {
m := NewManager(ManagerConfig{})
want := &AudioResult{Audio: []byte("fallback_sfx"), Extension: "mp3", MimeType: "audio/mpeg"}
m.RegisterSFX(&fakeSFX{name: "elevenlabs", err: errors.New("elevenlabs sfx down")})
m.RegisterSFX(&fakeSFX{name: "other_sfx", res: want})
got, err := m.GenerateSFX(context.Background(), SFXOptions{Prompt: "click"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(got.Audio) != string(want.Audio) {
t.Errorf("audio: want %q, got %q", want.Audio, got.Audio)
}
}
func containsStr(s, sub string) bool {
return len(sub) == 0 || (len(s) >= len(sub) && func() bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}())
}
+9 -4
View File
@@ -109,12 +109,15 @@ type MusicProvider interface {
// MusicOptions controls music generation.
type MusicOptions struct {
Prompt string
Lyrics string
Duration int // seconds
Prompt string
Lyrics string
Instrumental bool
Duration int // seconds (ElevenLabs: converts to music_length_ms)
Model string // provider-specific model override
TimeoutSec int // 0 = provider default
}
// ---- SFX (stubs — implementations land in Phase 3) ----
// ---- SFX (implementations land in Phase 3) ----
// SFXProvider generates short sound effects from a prompt.
type SFXProvider interface {
@@ -133,4 +136,6 @@ type AudioResult struct {
Audio []byte
Extension string
MimeType string
Model string // actual model used (optional, for observability)
Provider string // provider name that produced the audio (optional, for observability)
}