mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
feat(audio): Manager dispatch for Music + SFX with fallback chain
This commit is contained in:
1 parent
d3f226c7a7
commit
545c27beb5
3 files changed
+227
-4
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user