mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 16:46:17 +00:00
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>
133 lines
3.8 KiB
Go
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
|
|
}
|