mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 10:15:48 +00:00
feat(audio): ElevenLabs streaming TTS with model validation
Implement streaming TTS synthesis for ElevenLabs. Add model validation to buffered path. Add characterization tests for latency and error scenarios.
This commit is contained in:
1 parent
daa5adb88b
commit
d97dcf252c
4 files changed
+323
No files matched your search
@@ -38,6 +38,9 @@ func (p *TTSProvider) Synthesize(ctx context.Context, text string, opts audio.TT
|
||||
if modelID == "" {
|
||||
modelID = p.cfg.ModelID
|
||||
}
|
||||
if err := ValidateModel(modelID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Output format: MP3 128kbps default, Opus 64kbps for Telegram voice.
|
||||
outputFormat, ext, mime := "mp3_44100_128", "mp3", "audio/mpeg"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package elevenlabs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/audio"
|
||||
)
|
||||
|
||||
// SynthesizeStream streams ElevenLabs TTS audio via the /stream endpoint.
|
||||
// Unlike Synthesize, the HTTP body is handed to the caller as an io.ReadCloser
|
||||
// so bytes can be consumed as they arrive. Callers MUST Close the returned
|
||||
// Audio reader.
|
||||
//
|
||||
// Resolution order for voice/model: opts > configured defaults.
|
||||
// Model IDs are validated against AllowedElevenLabsModels before dispatch —
|
||||
// unknown IDs surface an i18n-keyed error without hitting the network.
|
||||
func (p *TTSProvider) SynthesizeStream(ctx context.Context, text string, opts audio.TTSOptions) (*audio.StreamResult, error) {
|
||||
voiceID := opts.Voice
|
||||
if voiceID == "" {
|
||||
voiceID = p.cfg.VoiceID
|
||||
}
|
||||
modelID := opts.Model
|
||||
if modelID == "" {
|
||||
modelID = p.cfg.ModelID
|
||||
}
|
||||
if err := ValidateModel(modelID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
outputFormat, ext, mime := "mp3_44100_128", "mp3", "audio/mpeg"
|
||||
if opts.Format == "opus" {
|
||||
outputFormat, ext, mime = "opus_48000_64", "ogg", "audio/ogg"
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"text": text,
|
||||
"model_id": modelID,
|
||||
"voice_settings": map[string]any{
|
||||
"stability": 0.5,
|
||||
"similarity_boost": 0.75,
|
||||
"style": 0.0,
|
||||
"use_speaker_boost": true,
|
||||
},
|
||||
}
|
||||
bodyJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal elevenlabs tts stream request: %w", err)
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/v1/text-to-speech/%s/stream?output_format=%s", voiceID, outputFormat)
|
||||
url := strings.TrimRight(p.c.baseURL, "/") + path
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyJSON))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("xi-api-key", p.c.apiKey)
|
||||
|
||||
// No Client.Timeout — streaming may legitimately outlast a per-request
|
||||
// deadline. Cancellation is honoured via the request context.
|
||||
hc := &http.Client{Timeout: 0}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("elevenlabs tts stream: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
errBody, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("ElevenLabs API error %d: %s", resp.StatusCode, truncate(errBody, 500))
|
||||
}
|
||||
|
||||
return &audio.StreamResult{
|
||||
Audio: resp.Body,
|
||||
Extension: ext,
|
||||
MimeType: mime,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package elevenlabs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/audio"
|
||||
)
|
||||
|
||||
// chunkedServer returns an httptest.Server that streams `chunks` back with
|
||||
// Flush between each. The handler also records the request path + headers.
|
||||
func chunkedServer(t *testing.T, chunks []string) (*httptest.Server, *atomic.Value) {
|
||||
t.Helper()
|
||||
lastPath := &atomic.Value{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath.Store(r.URL.RequestURI())
|
||||
if r.Header.Get("xi-api-key") == "" {
|
||||
t.Errorf("missing xi-api-key header")
|
||||
}
|
||||
w.Header().Set("Content-Type", "audio/mpeg")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher, _ := w.(http.Flusher)
|
||||
for _, c := range chunks {
|
||||
_, _ = w.Write([]byte(c))
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}))
|
||||
return srv, lastPath
|
||||
}
|
||||
|
||||
func TestSynthesizeStream_ReturnsChunkedReader(t *testing.T) {
|
||||
t.Parallel()
|
||||
chunks := []string{"aaa", "bbb", "ccc"}
|
||||
srv, lastPath := chunkedServer(t, chunks)
|
||||
defer srv.Close()
|
||||
|
||||
p := NewTTSProvider(Config{
|
||||
APIKey: "test-key",
|
||||
BaseURL: srv.URL,
|
||||
VoiceID: "V1",
|
||||
ModelID: "eleven_v3",
|
||||
TimeoutMs: 5000,
|
||||
})
|
||||
|
||||
res, err := p.SynthesizeStream(context.Background(), "hello world", audio.TTSOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("SynthesizeStream error: %v", err)
|
||||
}
|
||||
if res.Audio == nil {
|
||||
t.Fatal("StreamResult.Audio is nil")
|
||||
}
|
||||
defer res.Audio.Close()
|
||||
|
||||
if res.Extension != "mp3" {
|
||||
t.Errorf("Extension: got %q, want mp3", res.Extension)
|
||||
}
|
||||
if res.MimeType != "audio/mpeg" {
|
||||
t.Errorf("MimeType: got %q, want audio/mpeg", res.MimeType)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Audio)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
want := strings.Join(chunks, "")
|
||||
if string(body) != want {
|
||||
t.Errorf("body: got %q, want %q", string(body), want)
|
||||
}
|
||||
|
||||
// URL must hit the /stream sub-path with mp3 output format.
|
||||
path, _ := lastPath.Load().(string)
|
||||
if !strings.Contains(path, "/v1/text-to-speech/V1/stream") {
|
||||
t.Errorf("path: got %q, want contains /v1/text-to-speech/V1/stream", path)
|
||||
}
|
||||
if !strings.Contains(path, "output_format=mp3_44100_128") {
|
||||
t.Errorf("path: got %q, want contains output_format=mp3_44100_128", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynthesizeStream_VoiceOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, lastPath := chunkedServer(t, []string{"x"})
|
||||
defer srv.Close()
|
||||
|
||||
p := NewTTSProvider(Config{
|
||||
APIKey: "k",
|
||||
BaseURL: srv.URL,
|
||||
VoiceID: "DEFAULT",
|
||||
ModelID: "eleven_v3",
|
||||
})
|
||||
|
||||
res, err := p.SynthesizeStream(context.Background(), "hi", audio.TTSOptions{Voice: "OVR"})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
res.Audio.Close()
|
||||
|
||||
path, _ := lastPath.Load().(string)
|
||||
if !strings.Contains(path, "/v1/text-to-speech/OVR/stream") {
|
||||
t.Errorf("voice override not honoured — path=%q", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynthesizeStream_CancelContextClosesBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Server writes one chunk then blocks forever.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "audio/mpeg")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
_, _ = w.Write([]byte("a"))
|
||||
f.Flush()
|
||||
}
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewTTSProvider(Config{APIKey: "k", BaseURL: srv.URL, VoiceID: "V"})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
res, err := p.SynthesizeStream(ctx, "hi", audio.TTSOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
|
||||
// Cancel while body is still open — next Read should fail promptly.
|
||||
cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
buf := make([]byte, 64)
|
||||
for {
|
||||
_, err := res.Audio.Read(buf)
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
// ok — read returned an error after cancel.
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Read did not return after ctx cancel")
|
||||
}
|
||||
res.Audio.Close()
|
||||
}
|
||||
|
||||
func TestSynthesizeStream_OpusFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, lastPath := chunkedServer(t, []string{"o"})
|
||||
defer srv.Close()
|
||||
|
||||
p := NewTTSProvider(Config{APIKey: "k", BaseURL: srv.URL, VoiceID: "V", ModelID: "eleven_v3"})
|
||||
res, err := p.SynthesizeStream(context.Background(), "hi", audio.TTSOptions{Format: "opus"})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
res.Audio.Close()
|
||||
|
||||
if res.Extension != "ogg" {
|
||||
t.Errorf("Extension: got %q, want ogg", res.Extension)
|
||||
}
|
||||
if res.MimeType != "audio/ogg" {
|
||||
t.Errorf("MimeType: got %q, want audio/ogg", res.MimeType)
|
||||
}
|
||||
path, _ := lastPath.Load().(string)
|
||||
if !strings.Contains(path, "output_format=opus_48000_64") {
|
||||
t.Errorf("path: got %q, want contains output_format=opus_48000_64", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynthesizeStream_RejectsUnknownModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Server should never be reached — validation runs before HTTP.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("unexpected upstream request for invalid model")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewTTSProvider(Config{APIKey: "k", BaseURL: srv.URL, VoiceID: "V"})
|
||||
_, err := p.SynthesizeStream(context.Background(), "hi", audio.TTSOptions{Model: "bogus-model"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown model, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package elevenlabs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/audio"
|
||||
)
|
||||
|
||||
func TestSynthesize_RejectsUnknownModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Server must never be reached — validation runs before HTTP.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("unexpected upstream request for invalid model")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewTTSProvider(Config{APIKey: "k", BaseURL: srv.URL, VoiceID: "V"})
|
||||
_, err := p.Synthesize(context.Background(), "hi", audio.TTSOptions{Model: "bogus-model"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown model, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynthesize_EmptyModelPassesValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "audio/mpeg")
|
||||
_, _ = w.Write([]byte("AUDIO"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewTTSProvider(Config{APIKey: "k", BaseURL: srv.URL, VoiceID: "V"})
|
||||
res, err := p.Synthesize(context.Background(), "hi", audio.TTSOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Synthesize with empty model: %v", err)
|
||||
}
|
||||
if string(res.Audio) != "AUDIO" {
|
||||
t.Errorf("audio: got %q, want AUDIO", res.Audio)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user