Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions cmd/speak.go
Original file line number Diff line number Diff line change
Expand Up @@ -458,22 +458,28 @@ func resolveVoice(ctx context.Context, client *elevenlabs.Client, voiceInput str

ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
voices, err := client.ListVoices(ctx, voiceInput)
voices, err := client.ListVoices(ctx, "")
if err != nil {
return "", err
}
voiceInputLower := strings.ToLower(voiceInput)

// First, check for exact match (case-insensitive)
for _, v := range voices {
if strings.ToLower(v.Name) == voiceInputLower {
fmt.Fprintf(os.Stderr, "using voice %s (%s)\n", v.Name, v.VoiceID)
return v.VoiceID, nil
}
}
if len(voices) > 0 {
v := voices[0]
fmt.Fprintf(os.Stderr, "using closest voice match %s (%s)\n", v.Name, v.VoiceID)
return v.VoiceID, nil

// Then, check for substring match (case-insensitive)
for _, v := range voices {
if strings.Contains(strings.ToLower(v.Name), voiceInputLower) {
fmt.Fprintf(os.Stderr, "using voice %s (%s)\n", v.Name, v.VoiceID)
return v.VoiceID, nil
}
}

return "", fmt.Errorf("voice %q not found; try 'sag voices' or -v '?'", voiceInput)
}

Expand Down
36 changes: 25 additions & 11 deletions cmd/speak_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,27 +151,45 @@ func TestResolveVoicePassThroughID(t *testing.T) {
}
}

func TestResolveVoiceClosestMatch(t *testing.T) {
func TestResolveVoiceNoMatch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if _, err := w.Write([]byte(`{"voices":[{"voice_id":"id1","name":"Near","category":"premade"}]}`)); err != nil {
t.Fatalf("write response: %v", err)
}
}))
defer srv.Close()

client := elevenlabs.NewClient("key", srv.URL)
_, err := resolveVoice(context.Background(), client, "nothing-match")
if err == nil {
t.Fatalf("expected error for non-matching voice")
}
if !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected 'not found' error, got %q", err.Error())
}
}

func TestResolveVoicePartialMatch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if _, err := w.Write([]byte(`{"voices":[{"voice_id":"id1","name":"Sarah","category":"premade"},{"voice_id":"id2","name":"Roger - Casual","category":"premade"}]}`)); err != nil {
t.Fatalf("write response: %v", err)
}
}))
defer srv.Close()

restore, read := captureStderr(t)
defer restore()

client := elevenlabs.NewClient("key", srv.URL)
id, err := resolveVoice(context.Background(), client, "nothing-match")
id, err := resolveVoice(context.Background(), client, "roger")
if err != nil {
t.Fatalf("resolveVoice error: %v", err)
}
if id != "id1" {
t.Fatalf("expected closest id1, got %q", id)
if id != "id2" {
t.Fatalf("expected id2 for partial match 'roger', got %q", id)
}
if out := read(); !strings.Contains(out, "using closest voice match") {
t.Fatalf("expected closest match notice, got %q", out)
if out := read(); !strings.Contains(out, "using voice") {
t.Fatalf("expected 'using voice' notice, got %q", out)
}
}

Expand Down Expand Up @@ -356,11 +374,7 @@ func captureStderr(t *testing.T) (restore func(), read func() string) {

func TestResolveVoiceByName(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// ensure search param contains name
if !strings.Contains(r.URL.RawQuery, "search=roger") {
t.Fatalf("expected search param to contain 'roger', got %s", r.URL.RawQuery)
}
if _, err := w.Write([]byte(`{"voices":[{"voice_id":"id-roger","name":"Roger","category":"premade"}]}`)); err != nil {
if _, err := w.Write([]byte(`{"voices":[{"voice_id":"id-sarah","name":"Sarah","category":"premade"},{"voice_id":"id-roger","name":"Roger","category":"premade"}]}`)); err != nil {
t.Fatalf("write response: %v", err)
}
}))
Expand Down
23 changes: 16 additions & 7 deletions internal/elevenlabs/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/http"
"net/url"
"path"
"strings"
"time"
)

Expand Down Expand Up @@ -47,18 +48,14 @@ type listVoicesResponse struct {
Next *string `json:"next_page_token,omitempty"`
}

// ListVoices fetches voices; search filters by name substring when provided.
// ListVoices fetches available voices. If search is non-empty, results are
// filtered client-side by name substring (v1/voices does not support server-side filtering).
func (c *Client) ListVoices(ctx context.Context, search string) ([]Voice, error) {
u, err := url.Parse(c.baseURL)
if err != nil {
return nil, err
}
u.Path = path.Join(u.Path, "/v1/voices")
if search != "" {
q := u.Query()
q.Set("search", search)
u.RawQuery = q.Encode()
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
Expand All @@ -83,7 +80,19 @@ func (c *Client) ListVoices(ctx context.Context, search string) ([]Voice, error)
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, err
}
return body.Voices, nil

// Client-side filtering since v1/voices doesn't support search parameter
if search == "" {
return body.Voices, nil
}
searchLower := strings.ToLower(search)
filtered := make([]Voice, 0, len(body.Voices))
for _, v := range body.Voices {
if strings.Contains(strings.ToLower(v.Name), searchLower) {
filtered = append(filtered, v)
}
}
return filtered, nil
}

// TTSRequest configures a text-to-speech request payload.
Expand Down
26 changes: 20 additions & 6 deletions internal/elevenlabs/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,8 @@ func TestListVoices(t *testing.T) {
if r.URL.Path != "/v1/voices" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if search := r.URL.Query().Get("search"); search != "roger" {
t.Fatalf("expected search query 'roger', got %q", search)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"voices":[{"voice_id":"id1","name":"Roger","category":"premade"}]}`))
_, _ = w.Write([]byte(`{"voices":[{"voice_id":"id1","name":"Sarah","category":"premade"},{"voice_id":"id2","name":"Roger","category":"premade"}]}`))
}))
defer srv.Close()

Expand All @@ -36,8 +33,25 @@ func TestListVoices(t *testing.T) {
if err != nil {
t.Fatalf("ListVoices error: %v", err)
}
if len(voices) != 1 || voices[0].VoiceID != "id1" {
t.Fatalf("unexpected voices: %+v", voices)
if len(voices) != 1 || voices[0].VoiceID != "id2" {
t.Fatalf("expected 1 voice (Roger/id2), got: %+v", voices)
}
}

func TestListVoicesNoFilter(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"voices":[{"voice_id":"id1","name":"Roger","category":"premade"},{"voice_id":"id2","name":"Sarah","category":"premade"}]}`))
}))
defer srv.Close()

c := NewClient("key", srv.URL)
voices, err := c.ListVoices(context.Background(), "")
if err != nil {
t.Fatalf("ListVoices error: %v", err)
}
if len(voices) != 2 {
t.Fatalf("expected 2 voices, got: %+v", voices)
}
}

Expand Down