diff --git a/internal/audio/manager.go b/internal/audio/manager.go index 5488e2d4..13932672 100644 --- a/internal/audio/manager.go +++ b/internal/audio/manager.go @@ -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) { diff --git a/internal/audio/manager_music_test.go b/internal/audio/manager_music_test.go new file mode 100644 index 00000000..0d31eb69 --- /dev/null +++ b/internal/audio/manager_music_test.go @@ -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 + }()) +} diff --git a/internal/audio/types.go b/internal/audio/types.go index 1cc88227..1997c4d9 100644 --- a/internal/audio/types.go +++ b/internal/audio/types.go @@ -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) }