feat(audio): add ElevenLabs Music provider

This commit is contained in:
viettranx committed 2026-04-15 11:24:57 +07:00
1 parent cf16cf53db
commit 5001059e76
2 files changed
+218

No files matched your search

+83
View File
@@ -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
}
+135
View File
@@ -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)
}
})
}