diff --git a/app/tts/engine/elevenlabs/api.go b/app/tts/engine/elevenlabs/api.go index fbb971d..c3b8c14 100644 --- a/app/tts/engine/elevenlabs/api.go +++ b/app/tts/engine/elevenlabs/api.go @@ -1,118 +1,74 @@ -package elevenlabs - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "nstudio/app/common/response" - "nstudio/app/common/util" - "nstudio/app/config" - "nstudio/app/tts/engine" -) - -func FetchModels() (map[string]engine.Model, error) { - apiKey := config.GetEngine().Api.ElevenLabs.ApiKey - if apiKey == "" { - return make(map[string]engine.Model, 0), response.Err(fmt.Errorf("Api key is empty")) - } - - modelsMap := make(map[string]engine.Model) - - client := &http.Client{} - defer client.CloseIdleConnections() - - request, err := http.NewRequest("GET", "https://api.elevenlabs.io/v1/models", nil) - if err != nil { - return modelsMap, response.Err(err) - } - request.Header.Set("xi-api-key", apiKey) - - httpResponse, err := client.Do(request) - if err != nil { - response.Error(util.MessageData{ - Summary: "Failed to fetch elevenlabs models", - Detail: err.Error(), - }) - return modelsMap, response.Err(err) - } - defer httpResponse.Body.Close() - - if httpResponse.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(httpResponse.Body) - return make(map[string]engine.Model, 0), response.Err(errors.New(string(bodyBytes))) - } - - bodyBytes, err := io.ReadAll(httpResponse.Body) - if err != nil { - return make(map[string]engine.Model, 0), response.Err(err) - } - - var modelsResponse []ModelResponse - err = json.Unmarshal(bodyBytes, &modelsResponse) - if err != nil { - return make(map[string]engine.Model, 0), response.Err(err) - } - - for _, m := range modelsResponse { - model := engine.Model{ - ID: m.ModelID, - Name: m.Name, - Engine: "elevenlabs", - } - modelsMap[m.ModelID] = model - } - - return modelsMap, nil -} - -func FetchVoices() ([]engine.Voice, error) { - apiKey := config.GetEngine().Api.ElevenLabs.ApiKey - if apiKey == "" { - return make([]engine.Voice, 0), response.Err(fmt.Errorf("api key is empty")) - } - - client := &http.Client{} - defer client.CloseIdleConnections() - - request, err := http.NewRequest("GET", "https://api.elevenlabs.io/v1/voices", nil) - if err != nil { - return make([]engine.Voice, 0), response.Err(fmt.Errorf("creating request failed: %w", err)) - } - - request.Header.Set("xi-api-key", apiKey) - - httpResponse, err := client.Do(request) - if err != nil { - return make([]engine.Voice, 0), response.Err(fmt.Errorf("performing request failed: %w", err)) - } - defer httpResponse.Body.Close() - - if httpResponse.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(httpResponse.Body) - return make([]engine.Voice, 0), response.Err(fmt.Errorf("unexpected status code: %d, response: %s", httpResponse.StatusCode, string(bodyBytes))) - } - - bodyBytes, err := io.ReadAll(httpResponse.Body) - if err != nil { - return make([]engine.Voice, 0), response.Err(fmt.Errorf("reading response body failed: %w", err)) - } - - var voicesResp VoicesResponse - err = json.Unmarshal(bodyBytes, &voicesResp) - if err != nil { - return make([]engine.Voice, 0), response.Err(fmt.Errorf("parsing JSON failed: %w", err)) - } - - responseVoices := make([]engine.Voice, 0, len(voicesResp.Voices)) - for _, vd := range voicesResp.Voices { - voice := engine.Voice{ - ID: vd.VoiceID, - Name: vd.Name, - Gender: vd.Labels.Gender, - } - responseVoices = append(responseVoices, voice) - } - return responseVoices, nil -} +package elevenlabs + +import ( + "encoding/json" + "fmt" + "nstudio/app/common/response" + "nstudio/app/config" + "nstudio/app/tts/engine" + "nstudio/app/tts/engine/internal/httpapi" +) + +func FetchModels() (map[string]engine.Model, error) { + apiKey := config.GetEngine().Api.ElevenLabs.ApiKey + if apiKey == "" { + return make(map[string]engine.Model, 0), response.Err(fmt.Errorf("Api key is empty")) + } + + body, err := httpapi.Do(httpapi.Request{ + Method: "GET", + URL: "https://api.elevenlabs.io/v1/models", + Headers: map[string]string{"xi-api-key": apiKey}, + }) + if err != nil { + return make(map[string]engine.Model, 0), err + } + + var modelsResponse []ModelResponse + if err := json.Unmarshal(body, &modelsResponse); err != nil { + return make(map[string]engine.Model, 0), response.Err(err) + } + + modelsMap := make(map[string]engine.Model) + for _, m := range modelsResponse { + modelsMap[m.ModelID] = engine.Model{ + ID: m.ModelID, + Name: m.Name, + Engine: "elevenlabs", + } + } + + return modelsMap, nil +} + +func FetchVoices() ([]engine.Voice, error) { + apiKey := config.GetEngine().Api.ElevenLabs.ApiKey + if apiKey == "" { + return make([]engine.Voice, 0), response.Err(fmt.Errorf("api key is empty")) + } + + body, err := httpapi.Do(httpapi.Request{ + Method: "GET", + URL: "https://api.elevenlabs.io/v1/voices", + Headers: map[string]string{"xi-api-key": apiKey}, + }) + if err != nil { + return make([]engine.Voice, 0), err + } + + var voicesResp VoicesResponse + if err := json.Unmarshal(body, &voicesResp); err != nil { + return make([]engine.Voice, 0), response.Err(fmt.Errorf("parsing JSON failed: %w", err)) + } + + responseVoices := make([]engine.Voice, 0, len(voicesResp.Voices)) + for _, vd := range voicesResp.Voices { + responseVoices = append(responseVoices, engine.Voice{ + ID: vd.VoiceID, + Name: vd.Name, + Gender: vd.Labels.Gender, + }) + } + + return responseVoices, nil +} diff --git a/app/tts/engine/elevenlabs/elevenlabs.go b/app/tts/engine/elevenlabs/elevenlabs.go index 76bca41..f64b91d 100644 --- a/app/tts/engine/elevenlabs/elevenlabs.go +++ b/app/tts/engine/elevenlabs/elevenlabs.go @@ -1,17 +1,15 @@ package elevenlabs import ( - "bytes" "encoding/json" "fmt" - "io" - "net/http" commonAudio "nstudio/app/common/audio" "nstudio/app/common/response" "nstudio/app/common/util" "nstudio/app/common/util/fileIndex" "nstudio/app/config" "nstudio/app/tts/engine" + "nstudio/app/tts/engine/internal/httpapi" ) type ElevenLabs struct { @@ -72,8 +70,6 @@ func (labs *ElevenLabs) Play(message util.CharacterMessage) error { return response.Success(util.MessageData{ Summary: "ElevenLabs finished playing audio", }) - - return nil } func (labs *ElevenLabs) Save(messages []util.CharacterMessage, play bool) error { @@ -186,46 +182,17 @@ func (labs *ElevenLabs) sendRequest(voiceID string, data ElevenLabsRequest) ([]b return nil, response.Err(fmt.Errorf("Elevenlabs API Key is not set")) } - jsonData, err := json.Marshal(data) - if err != nil { - return nil, response.Err(fmt.Errorf("failed to marshal request body: %v", err)) - } - url := fmt.Sprintf("https://api.elevenlabs.io/v1/text-to-speech/%s?output_format=%s", voiceID, labs.outputType) - httpRequest, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) - if err != nil { - return nil, response.Err(fmt.Errorf("failed to create HTTP request: %v", err)) - } - - httpRequest.Header.Set("xi-api-key", apiKey) - httpRequest.Header.Set("Content-Type", "application/json") - - client := &http.Client{} - defer client.CloseIdleConnections() - - httpResponse, err := client.Do(httpRequest) - if err != nil { - return nil, response.Err(fmt.Errorf("failed to send HTTP request: %v", err)) - } - defer httpResponse.Body.Close() - - if httpResponse.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(httpResponse.Body) - return nil, response.Err(fmt.Errorf("request failed with status %d: %s", httpResponse.StatusCode, string(bodyBytes))) - } - - responseData, err := io.ReadAll(httpResponse.Body) - if err != nil { - return nil, response.Err(fmt.Errorf("failed to read response body: %v", err)) - } - - response.Success(util.MessageData{ - Summary: "ElevenLabs request succeeded", - Detail: "Response Status: " + httpResponse.Status, + return httpapi.Do(httpapi.Request{ + Method: "POST", + URL: url, + Headers: map[string]string{ + "xi-api-key": apiKey, + "Content-Type": "application/json", + }, + Body: data, }) - - return responseData, nil } // diff --git a/app/tts/engine/gemini/api.go b/app/tts/engine/gemini/api.go index 76e637a..1083890 100644 --- a/app/tts/engine/gemini/api.go +++ b/app/tts/engine/gemini/api.go @@ -1,67 +1,55 @@ -package gemini - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "net/http" - "nstudio/app/common/response" - "nstudio/app/config" - "time" -) - -func (gemini *Gemini) sendRequest(request GeminiRequest, modelName string) ([]byte, error) { - apiKey := config.GetEngine().Api.Gemini.ApiKey - if apiKey == "" { - return nil, fmt.Errorf("Gemini API key is not configured") - } - - jsonData, err := json.Marshal(request) - if err != nil { - return nil, response.Err(err) - } - - url := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s", modelName, apiKey) - - httpRequest, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) - if err != nil { - return nil, response.Err(err) - } - - httpRequest.Header.Set("Content-Type", "application/json") - - client := &http.Client{Timeout: 30 * time.Second} - httpResponse, err := client.Do(httpRequest) - if err != nil { - return nil, response.Err(err) - } - defer httpResponse.Body.Close() - - if httpResponse.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(httpResponse.Body) - return nil, fmt.Errorf("Gemini API error: %s - %s", httpResponse.Status, string(bodyBytes)) - } - - var geminiResponse GeminiResponse - if err := json.NewDecoder(httpResponse.Body).Decode(&geminiResponse); err != nil { - return nil, response.Err(err) - } - - if len(geminiResponse.Candidates) == 0 || len(geminiResponse.Candidates[0].Content.Parts) == 0 { - return nil, fmt.Errorf("no content in Gemini response") - } - - base64Data := geminiResponse.Candidates[0].Content.Parts[0].InlineData.Data - if base64Data == "" { - return nil, fmt.Errorf("no audio data in Gemini response") - } - - audioData, err := base64.StdEncoding.DecodeString(base64Data) - if err != nil { - return nil, response.Err(err) - } - - return audioData, nil -} +package gemini + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "nstudio/app/common/response" + "nstudio/app/config" + "nstudio/app/tts/engine/internal/httpapi" +) + +func (gemini *Gemini) sendRequest(request GeminiRequest, modelName string) ([]byte, error) { + apiKey := config.GetEngine().Api.Gemini.ApiKey + if apiKey == "" { + return nil, fmt.Errorf("Gemini API key is not configured") + } + + url := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent", modelName) + + // Send the API key via the documented header instead of a URL query param + // so the secret does not leak into logs, proxies, or error bodies. + body, err := httpapi.Do(httpapi.Request{ + Method: "POST", + URL: url, + Headers: map[string]string{ + "x-goog-api-key": apiKey, + "Content-Type": "application/json", + }, + Body: request, + }) + if err != nil { + return nil, err + } + + var geminiResponse GeminiResponse + if err := json.Unmarshal(body, &geminiResponse); err != nil { + return nil, response.Err(err) + } + + if len(geminiResponse.Candidates) == 0 || len(geminiResponse.Candidates[0].Content.Parts) == 0 { + return nil, fmt.Errorf("no content in Gemini response") + } + + base64Data := geminiResponse.Candidates[0].Content.Parts[0].InlineData.Data + if base64Data == "" { + return nil, fmt.Errorf("no audio data in Gemini response") + } + + audioData, err := base64.StdEncoding.DecodeString(base64Data) + if err != nil { + return nil, response.Err(err) + } + + return audioData, nil +} diff --git a/app/tts/engine/google/api.go b/app/tts/engine/google/api.go index 294151c..ed02a52 100644 --- a/app/tts/engine/google/api.go +++ b/app/tts/engine/google/api.go @@ -5,27 +5,19 @@ import ( "fmt" "nstudio/app/common/response" "nstudio/app/common/util" - "nstudio/app/config" "nstudio/app/tts/engine" "strings" - texttospeech "cloud.google.com/go/texttospeech/apiv1" "cloud.google.com/go/texttospeech/apiv1/texttospeechpb" - "google.golang.org/api/option" ) func (google *Google) sendRequest(data GoogleRequest) ([]byte, error) { ctx := context.Background() - apiKey := config.GetEngine().Api.Google.ApiKey - if apiKey == "" { - return nil, response.Err(fmt.Errorf("Google Cloud API key is not set")) - } - client, err := texttospeech.NewClient(ctx, option.WithAPIKey(apiKey)) + client, err := google.getClient(ctx) if err != nil { - return nil, response.Err(fmt.Errorf("Failed to create Google TTS client: %v", err)) + return nil, err } - defer client.Close() audioEncoding := texttospeechpb.AudioEncoding_MP3 if data.AudioConfig.AudioEncoding == "LINEAR16" { @@ -103,16 +95,11 @@ func (google *Google) fetchVoices(model string) ([]engine.Voice, error) { } ctx := context.Background() - apiKey := config.GetEngine().Api.Google.ApiKey - if apiKey == "" { - return nil, response.Err(fmt.Errorf("Google Cloud API key is not set")) - } - client, err := texttospeech.NewClient(ctx, option.WithAPIKey(apiKey)) + client, err := google.getClient(ctx) if err != nil { - return nil, response.Err(fmt.Errorf("Failed to create Google TTS client: %v", err)) + return nil, err } - defer client.Close() request := &texttospeechpb.ListVoicesRequest{} dataResponse, err := client.ListVoices(ctx, request) diff --git a/app/tts/engine/google/google.go b/app/tts/engine/google/google.go index d889ecd..3e7a8ba 100644 --- a/app/tts/engine/google/google.go +++ b/app/tts/engine/google/google.go @@ -1,6 +1,7 @@ package google import ( + "context" "encoding/json" "fmt" "nstudio/app/common/audio" @@ -11,12 +12,42 @@ import ( "nstudio/app/tts/engine" "strings" "sync" + + texttospeech "cloud.google.com/go/texttospeech/apiv1" + "google.golang.org/api/option" ) type Google struct { Models map[string]Model voiceCache map[string][]engine.Voice mu sync.RWMutex + + clientMu sync.Mutex + client *texttospeech.Client +} + +// getClient lazily creates and caches a single TextToSpeech client, reusing +// it across requests so each call does not repeat auth and connection setup. +func (google *Google) getClient(ctx context.Context) (*texttospeech.Client, error) { + google.clientMu.Lock() + defer google.clientMu.Unlock() + + if google.client != nil { + return google.client, nil + } + + apiKey := config.GetEngine().Api.Google.ApiKey + if apiKey == "" { + return nil, response.Err(fmt.Errorf("Google Cloud API key is not set")) + } + + client, err := texttospeech.NewClient(ctx, option.WithAPIKey(apiKey)) + if err != nil { + return nil, response.Err(fmt.Errorf("Failed to create Google TTS client: %v", err)) + } + + google.client = client + return client, nil } func (google *Google) Initialize() error { @@ -32,6 +63,14 @@ func (google *Google) Start(modelName string) error { } func (google *Google) Stop(modelName string) error { + google.clientMu.Lock() + defer google.clientMu.Unlock() + if google.client != nil { + if err := google.client.Close(); err != nil { + return response.Err(err) + } + google.client = nil + } return nil } diff --git a/app/tts/engine/internal/httpapi/httpapi.go b/app/tts/engine/internal/httpapi/httpapi.go new file mode 100644 index 0000000..f4c7d4a --- /dev/null +++ b/app/tts/engine/internal/httpapi/httpapi.go @@ -0,0 +1,74 @@ +// Package httpapi centralizes the JSON-over-HTTP request scaffolding shared by +// the API-backed TTS engines (ElevenLabs, OpenAI, Gemini). Each engine used to +// hand-roll the same marshal/new-request/set-headers/do/close/status-check/read +// sequence; Do collapses that into one place with a shared, timeout-bounded +// client. +package httpapi + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "nstudio/app/common/response" +) + +// DefaultTimeout bounds each HTTP call so a hung endpoint cannot block a pooled +// engine instance forever. +const DefaultTimeout = 30 * time.Second + +var client = &http.Client{Timeout: DefaultTimeout} + +// Request describes a single HTTP request with an optional JSON body. +type Request struct { + Method string + URL string + Headers map[string]string + // Body, when non-nil, is JSON-encoded and sent as the request body. The + // caller is responsible for setting the Content-Type header. + Body any +} + +// Do performs req and returns the response body on a 2xx status. On a non-2xx +// status it returns an error containing the status code and response body. All +// returned errors are already routed through response.Err (logged), so callers +// can return them directly without re-wrapping. +func Do(req Request) ([]byte, error) { + var bodyReader io.Reader + if req.Body != nil { + encoded, err := json.Marshal(req.Body) + if err != nil { + return nil, response.Err(fmt.Errorf("failed to marshal request body: %w", err)) + } + bodyReader = bytes.NewReader(encoded) + } + + httpRequest, err := http.NewRequest(req.Method, req.URL, bodyReader) + if err != nil { + return nil, response.Err(fmt.Errorf("failed to create HTTP request: %w", err)) + } + + for key, value := range req.Headers { + httpRequest.Header.Set(key, value) + } + + httpResponse, err := client.Do(httpRequest) + if err != nil { + return nil, response.Err(fmt.Errorf("failed to send HTTP request: %w", err)) + } + defer httpResponse.Body.Close() + + body, err := io.ReadAll(httpResponse.Body) + if err != nil { + return nil, response.Err(fmt.Errorf("failed to read response body: %w", err)) + } + + if httpResponse.StatusCode < 200 || httpResponse.StatusCode >= 300 { + return nil, response.Err(fmt.Errorf("request failed with status %d: %s", httpResponse.StatusCode, string(body))) + } + + return body, nil +} diff --git a/app/tts/engine/openai/api.go b/app/tts/engine/openai/api.go index 6851147..854304a 100644 --- a/app/tts/engine/openai/api.go +++ b/app/tts/engine/openai/api.go @@ -1,58 +1,25 @@ -package openai - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "nstudio/app/common/response" - "nstudio/app/common/util" - "nstudio/app/config" -) - -func (openAI *OpenAI) sendRequest(data OpenAIRequest) ([]byte, error) { - apiKey := config.GetEngine().Api.OpenAI.ApiKey - if apiKey == "" { - return nil, response.Err(fmt.Errorf("OpenAI API key is not set")) - } - - jsonData, err := json.Marshal(data) - if err != nil { - return nil, response.Err(fmt.Errorf("Failed to marshal httpRequest body: %v", err)) - } - - httpRequest, err := http.NewRequest("POST", "https://api.openai.com/v1/audio/speech", bytes.NewBuffer(jsonData)) - if err != nil { - return nil, response.Err(fmt.Errorf("Failed to create HTTP httpRequest: %v", err)) - } - - httpRequest.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) - httpRequest.Header.Set("Content-Type", "application/json") - - client := &http.Client{} - defer client.CloseIdleConnections() - - httpResponse, err := client.Do(httpRequest) - if err != nil { - return nil, response.Err(fmt.Errorf("Failed to send HTTP httpRequest: %v", err)) - } - defer httpResponse.Body.Close() - - if httpResponse.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(httpResponse.Body) - return nil, response.Err(fmt.Errorf("HttpRequest failed with status %d: %s", httpResponse.StatusCode, string(bodyBytes))) - } - - responseData, err := io.ReadAll(httpResponse.Body) - if err != nil { - return nil, response.Err(fmt.Errorf("Failed to read response body: %v", err)) - } - - response.Success(util.MessageData{ - Summary: "Request succeeded?", - Detail: "Response Status: " + httpResponse.Status, - }) - - return responseData, nil -} +package openai + +import ( + "fmt" + "nstudio/app/common/response" + "nstudio/app/config" + "nstudio/app/tts/engine/internal/httpapi" +) + +func (openAI *OpenAI) sendRequest(data OpenAIRequest) ([]byte, error) { + apiKey := config.GetEngine().Api.OpenAI.ApiKey + if apiKey == "" { + return nil, response.Err(fmt.Errorf("OpenAI API key is not set")) + } + + return httpapi.Do(httpapi.Request{ + Method: "POST", + URL: "https://api.openai.com/v1/audio/speech", + Headers: map[string]string{ + "Authorization": "Bearer " + apiKey, + "Content-Type": "application/json", + }, + Body: data, + }) +}