diff --git a/app/tts/engine/elevenlabs/api.go b/app/tts/engine/elevenlabs/api.go index fbb971d..07a826b 100644 --- a/app/tts/engine/elevenlabs/api.go +++ b/app/tts/engine/elevenlabs/api.go @@ -20,16 +20,13 @@ func FetchModels() (map[string]engine.Model, error) { 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) + httpResponse, err := httpClient.Do(request) if err != nil { response.Error(util.MessageData{ Summary: "Failed to fetch elevenlabs models", @@ -73,9 +70,6 @@ func FetchVoices() ([]engine.Voice, error) { 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)) @@ -83,7 +77,7 @@ func FetchVoices() ([]engine.Voice, error) { request.Header.Set("xi-api-key", apiKey) - httpResponse, err := client.Do(request) + httpResponse, err := httpClient.Do(request) if err != nil { return make([]engine.Voice, 0), response.Err(fmt.Errorf("performing request failed: %w", err)) } diff --git a/app/tts/engine/elevenlabs/elevenlabs.go b/app/tts/engine/elevenlabs/elevenlabs.go index 76bca41..e3f73d8 100644 --- a/app/tts/engine/elevenlabs/elevenlabs.go +++ b/app/tts/engine/elevenlabs/elevenlabs.go @@ -12,8 +12,13 @@ import ( "nstudio/app/common/util/fileIndex" "nstudio/app/config" "nstudio/app/tts/engine" + "time" ) +// requestTimeout bounds each HTTP call so a hung endpoint cannot block a +// pooled engine instance forever. +const requestTimeout = 30 * time.Second + type ElevenLabs struct { Models map[string]Model outputType string @@ -21,6 +26,8 @@ type ElevenLabs struct { var voices = make([]engine.Voice, 0) +var httpClient = &http.Client{Timeout: requestTimeout} + // func (labs *ElevenLabs) Initialize() error { var err error @@ -201,10 +208,7 @@ func (labs *ElevenLabs) sendRequest(voiceID string, data ElevenLabsRequest) ([]b 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) + httpResponse, err := httpClient.Do(httpRequest) if err != nil { return nil, response.Err(fmt.Errorf("failed to send HTTP request: %v", err)) } diff --git a/app/tts/engine/gemini/api.go b/app/tts/engine/gemini/api.go index 76e637a..48a708b 100644 --- a/app/tts/engine/gemini/api.go +++ b/app/tts/engine/gemini/api.go @@ -23,13 +23,16 @@ func (gemini *Gemini) sendRequest(request GeminiRequest, modelName string) ([]by return nil, response.Err(err) } - url := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s", modelName, apiKey) + url := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent", modelName) httpRequest, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) if err != nil { return nil, response.Err(err) } + // 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. + httpRequest.Header.Set("x-goog-api-key", apiKey) httpRequest.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 30 * time.Second} 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/openai/api.go b/app/tts/engine/openai/api.go index 6851147..6adbc8c 100644 --- a/app/tts/engine/openai/api.go +++ b/app/tts/engine/openai/api.go @@ -9,8 +9,15 @@ import ( "nstudio/app/common/response" "nstudio/app/common/util" "nstudio/app/config" + "time" ) +// requestTimeout bounds each HTTP call so a hung endpoint cannot block a +// pooled engine instance forever. +const requestTimeout = 30 * time.Second + +var httpClient = &http.Client{Timeout: requestTimeout} + func (openAI *OpenAI) sendRequest(data OpenAIRequest) ([]byte, error) { apiKey := config.GetEngine().Api.OpenAI.ApiKey if apiKey == "" { @@ -30,10 +37,7 @@ func (openAI *OpenAI) sendRequest(data OpenAIRequest) ([]byte, error) { 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) + httpResponse, err := httpClient.Do(httpRequest) if err != nil { return nil, response.Err(fmt.Errorf("Failed to send HTTP httpRequest: %v", err)) }