Files
Tobias GesellchenandClaude Opus 4.8 c852d07da1 feat(tts): add Google Cloud Text-to-Speech via a pluggable provider
Adds text-to-speech that synthesizes higher-quality audio (Google Cloud
TTS) and plays it on a speaker via the /speaker endpoint. Because Cloud
TTS returns audio bytes (not a fetchable URL), the service caches the
clip and hosts it at GET /media/tts/{id}, mirroring the "ding" endpoint,
then points the speaker at that local URL.

The design is a pluggable Provider interface (pkg/service/tts) wrapping
two modes:
- translate: hands the speaker the (undocumented) Google Translate URL
  directly (no credentials), reusing models.BuildTranslateTTSURL.
- google-cloud: REST API key auth (no SDK/gRPC), bytes cached locally.

Surfaces:
- service: POST /mgmt/tts/speak, GET /mgmt/tts/config, GET /media/tts/{id};
  configured via TTS_PROVIDER / TTS_GOOGLE_API_KEY / TTS_LANGUAGE /
  TTS_VOICE / TTS_APP_KEY / TTS_VOLUME.
- CLI: `soundtouch-cli tts speak` (calls the service with mgmt Basic Auth).
- web: a "TTS" source view (like Play URL / TuneIn), proxied to the
  service via /api/device-speak/{id}.

The /speaker app_key requirement and model limitations still apply; see
docs/content/docs/reference/SPEAKER-ENDPOINT.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00

133 lines
3.8 KiB
Go

package tts
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
)
// ProviderGoogleCloud is the identifier for the Google Cloud TTS provider.
const ProviderGoogleCloud = "google-cloud"
// googleCloudSynthesizeURL is the REST synthesize endpoint. Authentication is a
// plain API key passed as the ?key= query parameter (no OAuth, no SDK).
const googleCloudSynthesizeURL = "https://texttospeech.googleapis.com/v1/text:synthesize"
// CloudProvider synthesizes speech via the Google Cloud Text-to-Speech REST API
// using an API key. It returns audio bytes for the Service to host locally.
type CloudProvider struct {
apiKey string
endpoint string // overridable for tests
httpClient *http.Client
}
// NewCloudProvider returns a Google Cloud TTS provider using the given API key.
func NewCloudProvider(apiKey string) *CloudProvider {
return &CloudProvider{
apiKey: apiKey,
endpoint: googleCloudSynthesizeURL,
httpClient: http.DefaultClient,
}
}
// SetEndpoint overrides the synthesize endpoint (for testing against a mock).
func (p *CloudProvider) SetEndpoint(url string) { p.endpoint = url }
// Name implements Provider.
func (p *CloudProvider) Name() string { return ProviderGoogleCloud }
// cloudSynthesizeRequest mirrors the Cloud TTS v1 synthesize request body.
type cloudSynthesizeRequest struct {
Input struct {
Text string `json:"text"`
} `json:"input"`
Voice struct {
LanguageCode string `json:"languageCode"`
Name string `json:"name,omitempty"`
} `json:"voice"`
AudioConfig struct {
AudioEncoding string `json:"audioEncoding"`
} `json:"audioConfig"`
}
// cloudSynthesizeResponse mirrors the Cloud TTS v1 synthesize response body.
// audioContent is base64-encoded audio in the requested encoding.
type cloudSynthesizeResponse struct {
AudioContent string `json:"audioContent"`
}
// Synthesize calls the Cloud TTS REST API and returns the decoded audio bytes.
func (p *CloudProvider) Synthesize(ctx context.Context, req Request) (Result, error) {
if p.apiKey == "" {
return Result{}, fmt.Errorf("google cloud tts: no API key configured")
}
encoding, contentType := "MP3", "audio/mpeg"
if req.Format == FormatWAV {
// LINEAR16 is returned wrapped in a WAV container.
encoding, contentType = "LINEAR16", "audio/wav"
}
language := req.Language
if language == "" {
language = "en-US"
}
var body cloudSynthesizeRequest
body.Input.Text = req.Text
body.Voice.LanguageCode = language
body.Voice.Name = req.Voice
body.AudioConfig.AudioEncoding = encoding
payload, err := json.Marshal(&body)
if err != nil {
return Result{}, fmt.Errorf("google cloud tts: marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint+"?key="+p.apiKey, bytes.NewReader(payload))
if err != nil {
return Result{}, fmt.Errorf("google cloud tts: build request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(httpReq)
if err != nil {
return Result{}, fmt.Errorf("google cloud tts: request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return Result{}, fmt.Errorf("google cloud tts: read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return Result{}, fmt.Errorf("google cloud tts: synthesize failed (%d): %s", resp.StatusCode, string(respBody))
}
var parsed cloudSynthesizeResponse
if err = json.Unmarshal(respBody, &parsed); err != nil {
return Result{}, fmt.Errorf("google cloud tts: parse response: %w", err)
}
audio, err := base64.StdEncoding.DecodeString(parsed.AudioContent)
if err != nil {
return Result{}, fmt.Errorf("google cloud tts: decode audio: %w", err)
}
if len(audio) == 0 {
return Result{}, fmt.Errorf("google cloud tts: empty audio content")
}
return Result{Audio: audio, ContentType: contentType}, nil
}