From 5001059e76f0d588d2f56467221f1df0c47229d3 Mon Sep 17 00:00:00 2001 From: viettranx Date: Wed, 15 Apr 2026 00:01:25 +0700 Subject: [PATCH] feat(audio): add ElevenLabs Music provider --- internal/audio/elevenlabs/music.go | 83 +++++++++++++++ internal/audio/elevenlabs/music_test.go | 135 ++++++++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 internal/audio/elevenlabs/music.go create mode 100644 internal/audio/elevenlabs/music_test.go diff --git a/internal/audio/elevenlabs/music.go b/internal/audio/elevenlabs/music.go new file mode 100644 index 00000000..0e7299a4 --- /dev/null +++ b/internal/audio/elevenlabs/music.go @@ -0,0 +1,83 @@ +package elevenlabs + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/audio" +) + +// musicDefaultTimeoutMs is the timeout for music generation — longer than TTS/SFX +// because generation can take 30-60+ seconds for full tracks. +const musicDefaultTimeoutMs = 300_000 + +// MusicProvider generates music via POST /v1/music. +// ElevenLabs Music API spec: prompt-driven, binary MP3 response. +type MusicProvider struct { + cfg Config + c *client +} + +// NewMusicProvider returns an ElevenLabs music provider. +func NewMusicProvider(cfg Config) *MusicProvider { + cfg = cfg.withDefaults() + timeoutMs := max(cfg.TimeoutMs, musicDefaultTimeoutMs) + return &MusicProvider{ + cfg: cfg, + c: newClient(cfg.APIKey, cfg.BaseURL, timeoutMs), + } +} + +// Name returns the stable provider identifier used by the Manager. +func (p *MusicProvider) Name() string { return "elevenlabs" } + +// GenerateMusic produces an MP3 from opts.Prompt via POST /v1/music. +// Duration is expressed in seconds; the API takes music_length_ms. +// Lyrics are appended to the prompt (ElevenLabs music API has no separate lyrics field). +func (p *MusicProvider) GenerateMusic(ctx context.Context, opts audio.MusicOptions) (*audio.AudioResult, error) { + modelID := opts.Model + if modelID == "" { + modelID = "music_v1" + } + + prompt := opts.Prompt + if opts.Lyrics != "" { + prompt = opts.Prompt + "\n\nLyrics:\n" + opts.Lyrics + } + + body := map[string]any{ + "prompt": prompt, + "model_id": modelID, + } + if opts.Duration > 0 { + body["music_length_ms"] = opts.Duration * 1000 + } + if opts.Instrumental { + body["force_instrumental"] = true + } + + bodyJSON, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal elevenlabs music request: %w", err) + } + + // music_length_ms is only valid with prompt (not composition_plan) — our path always uses prompt. + path := "/v1/music?output_format=mp3_44100_128" + audioBytes, err := p.c.postJSON(ctx, path, bodyJSON, time.Duration(musicDefaultTimeoutMs)*time.Millisecond) + if err != nil { + return nil, fmt.Errorf("elevenlabs music: %w", err) + } + if len(audioBytes) == 0 { + return nil, fmt.Errorf("empty audio response from ElevenLabs music") + } + + return &audio.AudioResult{ + Audio: audioBytes, + Extension: "mp3", + MimeType: "audio/mpeg", + Model: modelID, + Provider: "elevenlabs", + }, nil +} diff --git a/internal/audio/elevenlabs/music_test.go b/internal/audio/elevenlabs/music_test.go new file mode 100644 index 00000000..1d17b6b9 --- /dev/null +++ b/internal/audio/elevenlabs/music_test.go @@ -0,0 +1,135 @@ +package elevenlabs + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/audio" +) + +func TestMusicProvider_GenerateMusic(t *testing.T) { + makeServer := func(t *testing.T, wantPath string, statusCode int, respBody []byte) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if !strings.HasPrefix(r.URL.Path, "/v1/music") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("output_format") != "mp3_44100_128" { + t.Errorf("missing output_format query param, got %q", r.URL.Query().Get("output_format")) + } + if r.Header.Get("xi-api-key") != "test-key" { + t.Errorf("missing xi-api-key: got %q", r.Header.Get("xi-api-key")) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("missing Content-Type: got %q", r.Header.Get("Content-Type")) + } + _, _ = io.ReadAll(r.Body) // drain + w.WriteHeader(statusCode) + _, _ = w.Write(respBody) + })) + } + + musicBytes := []byte("MUSIC_BYTES") + + t.Run("default_model", func(t *testing.T) { + srv := makeServer(t, "/v1/music", http.StatusOK, musicBytes) + defer srv.Close() + + p := NewMusicProvider(Config{APIKey: "test-key", BaseURL: srv.URL}) + res, err := p.GenerateMusic(context.Background(), audio.MusicOptions{ + Prompt: "upbeat jazz", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(res.Audio) != string(musicBytes) { + t.Errorf("audio: want %q, got %q", musicBytes, res.Audio) + } + if res.Extension != "mp3" { + t.Errorf("extension: want mp3, got %s", res.Extension) + } + if res.MimeType != "audio/mpeg" { + t.Errorf("mimetype: want audio/mpeg, got %s", res.MimeType) + } + if res.Model != "music_v1" { + t.Errorf("model: want music_v1, got %s", res.Model) + } + }) + + t.Run("custom_model_and_duration", func(t *testing.T) { + srv := makeServer(t, "/v1/music", http.StatusOK, musicBytes) + defer srv.Close() + + // Verify request body includes duration and model + var capturedBody []byte + bodySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(musicBytes) + })) + defer bodySrv.Close() + + p := NewMusicProvider(Config{APIKey: "test-key", BaseURL: bodySrv.URL}) + _, err := p.GenerateMusic(context.Background(), audio.MusicOptions{ + Prompt: "chill lofi", + Model: "music_v2", + Duration: 30, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + body := string(capturedBody) + if !strings.Contains(body, `"music_v2"`) { + t.Errorf("body missing model_id: %s", body) + } + if !strings.Contains(body, `"music_length_ms"`) { + t.Errorf("body missing music_length_ms: %s", body) + } + if !strings.Contains(body, "30000") { + t.Errorf("body missing 30000 ms: %s", body) + } + }) + + t.Run("instrumental_flag", func(t *testing.T) { + var capturedBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(musicBytes) + })) + defer srv.Close() + + p := NewMusicProvider(Config{APIKey: "test-key", BaseURL: srv.URL}) + _, err := p.GenerateMusic(context.Background(), audio.MusicOptions{ + Prompt: "epic orchestral", + Instrumental: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(string(capturedBody), `"force_instrumental":true`) { + t.Errorf("body missing force_instrumental:true: %s", capturedBody) + } + }) + + t.Run("error_429_propagates", func(t *testing.T) { + srv := makeServer(t, "/v1/music", http.StatusTooManyRequests, []byte(`{"detail":"rate limit exceeded"}`)) + defer srv.Close() + + p := NewMusicProvider(Config{APIKey: "test-key", BaseURL: srv.URL}) + _, err := p.GenerateMusic(context.Background(), audio.MusicOptions{Prompt: "test"}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "429") { + t.Errorf("error should mention 429: %v", err) + } + }) +}