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>
This commit is contained in:
Tobias Gesellchen
2026-05-31 22:35:31 +02:00
co-authored by Claude Opus 4.8
parent 80cab16239
commit c852d07da1
23 changed files with 1667 additions and 31 deletions
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/urfave/cli/v2"
)
// ttsCommand assembles the `soundtouch-cli tts …` command group. Unlike
// `speaker tts` (which talks to a speaker directly using the Google Translate
// URL), these subcommands call the AfterTouch service, which synthesizes audio
// with the configured provider (e.g. Google Cloud TTS) and plays it on a
// speaker. They require --service-url.
func ttsCommand() *cli.Command {
return &cli.Command{
Name: "tts",
Usage: "Text-to-speech via the AfterTouch service (Google Cloud TTS or Google Translate)",
Description: "Sends text to the AfterTouch service, which synthesizes audio (or builds a\n" +
"direct URL) and plays it on a speaker via the /speaker endpoint.\n\n" +
"This differs from 'speaker tts', which talks to a speaker directly using\n" +
"the Google Translate URL. Use 'tts speak' for the service's configured\n" +
"provider (e.g. Google Cloud TTS).",
Subcommands: []*cli.Command{
ttsSpeakCmd(),
},
}
}
func ttsSpeakCmd() *cli.Command {
return &cli.Command{
Name: "speak",
Usage: "Synthesize text and play it on a speaker via the AfterTouch service",
Flags: append(CloudCommonFlags,
&cli.StringFlag{
Name: "text",
Aliases: []string{"t"},
Usage: "Text to speak",
Required: true,
},
&cli.StringFlag{
Name: "device",
Aliases: []string{"d"},
Usage: "Target device ID (the service resolves it to an IP)",
},
&cli.StringFlag{
Name: "speaker-host",
Usage: "Target speaker IP/hostname (alternative to --device)",
},
&cli.StringFlag{
Name: "language",
Aliases: []string{"l"},
Usage: "Language code (provider-specific; defaults to the service setting)",
},
&cli.StringFlag{
Name: "voice",
Usage: "Voice name (Google Cloud TTS; ignored by the translate provider)",
},
&cli.IntFlag{
Name: "volume",
Aliases: []string{"v"},
Usage: "Playback volume (0-100, 0 = service default)",
},
&cli.StringFlag{
Name: "mgmt-username",
Usage: "Management API username for HTTP Basic Auth",
Value: "admin",
EnvVars: []string{"MGMT_USERNAME"},
},
&cli.StringFlag{
Name: "mgmt-password",
Usage: "Management API password for HTTP Basic Auth",
EnvVars: []string{"MGMT_PASSWORD"},
},
),
Action: ttsSpeak,
}
}
func ttsSpeak(c *cli.Context) error {
serviceURL := strings.TrimRight(c.String("service-url"), "/")
device := c.String("device")
speakerHost := c.String("speaker-host")
if device == "" && speakerHost == "" {
return fmt.Errorf("one of --device or --speaker-host is required")
}
payload := map[string]interface{}{"text": c.String("text")}
if device != "" {
payload["deviceId"] = device
}
if speakerHost != "" {
payload["host"] = speakerHost
}
if l := c.String("language"); l != "" {
payload["language"] = l
}
if v := c.String("voice"); v != "" {
payload["voice"] = v
}
if c.IsSet("volume") {
payload["volume"] = c.Int("volume")
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequest(http.MethodPost, serviceURL+"/mgmt/tts/speak", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth(c.String("mgmt-username"), c.String("mgmt-password"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<12))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("service returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
PrintSuccess(fmt.Sprintf("Spoke %q", c.String("text")))
return nil
}
+1
View File
@@ -2315,6 +2315,7 @@ func main() {
// AfterTouch service management (sources, accounts, devices).
// Defined in cmd_cloud.go.
app.Commands = append(app.Commands, cloudCommand())
app.Commands = append(app.Commands, ttsCommand())
// Sort commands alphabetically (including subcommands and flags recursively)
sortCommands(app.Commands)
+108
View File
@@ -30,6 +30,7 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
"github.com/gesellix/bose-soundtouch/pkg/service/stockholm"
"github.com/gesellix/bose-soundtouch/pkg/service/tts"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/urfave/cli/v2"
@@ -176,6 +177,46 @@ func initMusicServices(config serviceConfig, server *handlers.Server) {
}
}
// initTTSService builds the text-to-speech service from config and registers it
// on the server. The Translate provider needs no credentials; the Google Cloud
// provider needs an API key. The clip cache (for synthesized audio) lives on the
// service and is served via GET /media/tts/{id}.
func initTTSService(config serviceConfig, server *handlers.Server) {
var provider tts.Provider
switch config.ttsProvider {
case tts.ProviderGoogleCloud:
if config.ttsGoogleAPIKey == "" {
log.Printf("[TTS] Provider 'google-cloud' selected but no --tts-google-api-key set; synthesis will fail until one is provided")
}
cloud := tts.NewCloudProvider(config.ttsGoogleAPIKey)
if config.ttsGoogleEndpoint != "" {
cloud.SetEndpoint(config.ttsGoogleEndpoint)
}
provider = cloud
case tts.ProviderTranslate, "":
provider = tts.NewTranslateProvider()
default:
log.Printf("[TTS] Unknown provider %q; falling back to 'translate'", sanitizeLog(config.ttsProvider))
provider = tts.NewTranslateProvider()
}
svc := tts.NewService(provider, tts.Config{
BaseURL: config.serverURL,
AppKey: config.ttsAppKey,
DefaultLanguage: config.ttsLanguage,
DefaultVoice: config.ttsVoice,
DefaultVolume: config.ttsVolume,
})
server.SetTTSService(svc)
log.Printf("TTS service initialized (provider: %s)", provider.Name())
}
// logBufferCapacityFromEnv reads SOUNDTOUCH_LOG_BUFFER_LINES and
// returns a positive capacity. Invalid or unset values fall back
// to the default; a value of 0 or negative is treated as "disable"
@@ -358,6 +399,43 @@ func main() {
Usage: "Amazon LWA profile URL (for testing)",
EnvVars: []string{"AMAZON_PROFILE_URL"},
},
&cli.StringFlag{
Name: "tts-provider",
Usage: "Text-to-speech provider: 'translate' (Google Translate, no credentials) or 'google-cloud' (Google Cloud TTS, needs an API key)",
Value: "translate",
EnvVars: []string{"TTS_PROVIDER"},
},
&cli.StringFlag{
Name: "tts-google-api-key",
Usage: "Google Cloud Text-to-Speech API key (required when --tts-provider=google-cloud)",
EnvVars: []string{"TTS_GOOGLE_API_KEY"},
},
&cli.StringFlag{
Name: "tts-google-endpoint",
Usage: "Google Cloud TTS synthesize endpoint override (for testing)",
EnvVars: []string{"TTS_GOOGLE_ENDPOINT"},
},
&cli.StringFlag{
Name: "tts-language",
Usage: "Default TTS language code. Provider-specific: 'EN'/'DE' for translate, BCP-47 like 'en-US' for google-cloud",
EnvVars: []string{"TTS_LANGUAGE"},
},
&cli.StringFlag{
Name: "tts-voice",
Usage: "Default Google Cloud TTS voice name (e.g. en-US-Neural2-C); ignored by the translate provider",
EnvVars: []string{"TTS_VOICE"},
},
&cli.StringFlag{
Name: "tts-app-key",
Usage: "Bose /speaker app_key used to play TTS notifications on speakers",
EnvVars: []string{"TTS_APP_KEY"},
},
&cli.IntFlag{
Name: "tts-volume",
Usage: "Default TTS playback volume (0-100, 0 = keep current volume)",
Value: 0,
EnvVars: []string{"TTS_VOLUME"},
},
&cli.StringFlag{
Name: "mgmt-username",
Usage: "Management API username for HTTP Basic Auth",
@@ -445,6 +523,7 @@ func main() {
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
initMusicServices(config, server)
initTTSService(config, server)
// Load and set initial DNS discoveries
dnsDiscoveries, err := ds.LoadDNSDiscoveries()
@@ -604,6 +683,13 @@ type serviceConfig struct {
amazonProfileURL string
mgmtUsername string
mgmtPassword string
ttsProvider string
ttsGoogleAPIKey string
ttsGoogleEndpoint string
ttsLanguage string
ttsVoice string
ttsAppKey string
ttsVolume int
migrationEnabled bool
migrationDryRun bool
stockholmDir string
@@ -678,6 +764,13 @@ func loadConfig(c *cli.Context) serviceConfig {
amazonProfileURL := c.String("amazon-profile-url")
mgmtUsername := c.String("mgmt-username")
mgmtPassword := c.String("mgmt-password")
ttsProvider := c.String("tts-provider")
ttsGoogleAPIKey := c.String("tts-google-api-key")
ttsGoogleEndpoint := c.String("tts-google-endpoint")
ttsLanguage := c.String("tts-language")
ttsVoice := c.String("tts-voice")
ttsAppKey := c.String("tts-app-key")
ttsVolume := c.Int("tts-volume")
internalPaths := c.StringSlice("internal-paths")
migrationEnabled := c.Bool("migration-enabled")
migrationDryRun := c.Bool("migration-dry-run")
@@ -716,6 +809,13 @@ func loadConfig(c *cli.Context) serviceConfig {
amazonProfileURL: amazonProfileURL,
mgmtUsername: mgmtUsername,
mgmtPassword: mgmtPassword,
ttsProvider: ttsProvider,
ttsGoogleAPIKey: ttsGoogleAPIKey,
ttsGoogleEndpoint: ttsGoogleEndpoint,
ttsLanguage: ttsLanguage,
ttsVoice: ttsVoice,
ttsAppKey: ttsAppKey,
ttsVolume: ttsVolume,
migrationEnabled: migrationEnabled,
migrationDryRun: migrationDryRun,
stockholmDir: stockholmDir,
@@ -994,6 +1094,9 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
})
r.Get("/media/aftertouch-ding.wav", server.HandleDing)
// Synthesized TTS clips (Google Cloud provider). Served before the
// /media/* wildcard so the {id} param route takes precedence.
r.Get("/media/tts/{id}", server.HandleTTSMedia)
r.Get("/media/*", server.HandleMedia())
r.Get("/bmx-icons/*", server.HandleBmxIcons())
r.Get("/ced/*", server.HandleCedStatic())
@@ -1219,6 +1322,11 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Post("/prime", server.HandleMgmtPrimeDeviceAmazon)
})
r.Route("/tts", func(r chi.Router) {
r.Post("/speak", server.HandleMgmtTTSSpeak)
r.Get("/config", server.HandleMgmtTTSConfig)
})
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
})
})
+3
View File
@@ -48,6 +48,7 @@ GET /favicon.ico setupRoute
GET /health handlers.(*Server).HandleHealth-fm
GET /media/* handlers.(*Server).HandleMedia
GET /media/aftertouch-ding.wav handlers.(*Server).HandleDing-fm
GET /media/tts/{id} handlers.(*Server).HandleTTSMedia-fm
GET /mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm
GET /mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm
GET /mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm
@@ -58,6 +59,7 @@ GET /mgmt/devices/{deviceId}/events handlers.(
GET /mgmt/spotify/accounts handlers.(*Server).HandleMgmtSpotifyAccounts-fm
GET /mgmt/spotify/callback handlers.(*Server).HandleMgmtSpotifyCallback-fm
GET /mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
GET /mgmt/tts/config handlers.(*Server).HandleMgmtTTSConfig-fm
GET /setup/account-id-suggestions/{deviceId} handlers.(*Server).HandleAccountIDSuggestions-fm
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-fm
GET /setup/device-summary/{deviceId} handlers.(*Server).HandleDeviceSummary-fm
@@ -130,6 +132,7 @@ POST /mgmt/spotify/confirm handlers.(
POST /mgmt/spotify/entity handlers.(*Server).HandleMgmtSpotifyEntity-fm
POST /mgmt/spotify/init handlers.(*Server).HandleMgmtSpotifyInit-fm
POST /mgmt/spotify/prime handlers.(*Server).HandleMgmtPrimeDevice-fm
POST /mgmt/tts/speak handlers.(*Server).HandleMgmtTTSSpeak-fm
POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs handlers.(*Server).HandleBoseAccountToken-fm
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token handlers.(*Server).HandleBoseLegacyToken-fm
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs1 handlers.(*Server).HandleBoseToken-fm
+13
View File
@@ -81,6 +81,17 @@ func main() {
Usage: "AfterTouch service base URL (e.g. https://soundtouch.local). Required for custom stream URLs to work as presets via LOCAL_INTERNET_RADIO",
EnvVars: []string{"SERVICE_URL"},
},
&cli.StringFlag{
Name: "mgmt-username",
Usage: "AfterTouch management API username, used to proxy TTS to the service's /mgmt endpoints",
Value: "admin",
EnvVars: []string{"MGMT_USERNAME"},
},
&cli.StringFlag{
Name: "mgmt-password",
Usage: "AfterTouch management API password, used to proxy TTS to the service's /mgmt endpoints",
EnvVars: []string{"MGMT_PASSWORD"},
},
},
Action: func(c *cli.Context) error {
port := c.String("port")
@@ -115,6 +126,8 @@ func main() {
webApp.Date = date
webApp.RepoURL = repoURL
webApp.ServiceURL = strings.TrimRight(c.String("service-url"), "/")
webApp.MgmtUsername = c.String("mgmt-username")
webApp.MgmtPassword = c.String("mgmt-password")
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName)
+106 -28
View File
@@ -66,15 +66,15 @@ func main() {
Host: "192.0.2.100",
Port: 8090,
}
client := client.NewClient(config)
// Play TTS at current volume (language code "EN", "DE", etc.)
err := client.PlayTTS("Hello, this is a test message", "YOUR_APP_KEY", "EN")
if err != nil {
log.Fatal(err)
}
// Play TTS at specific volume (70)
err = client.PlayTTS("Volume test message", "YOUR_APP_KEY", "EN", 70)
if err != nil {
@@ -91,9 +91,9 @@ func main() {
Host: "192.0.2.100",
Port: 8090,
}
client := client.NewClient(config)
// Play audio from URL
err := client.PlayURL(
"https://example.com/audio.mp3",
@@ -114,7 +114,7 @@ func main() {
```go
func main() {
client := client.NewClient(config)
// Create custom play info
playInfo := models.NewPlayInfo(
"https://example.com/audio.mp3",
@@ -123,7 +123,7 @@ func main() {
"Custom Message",
"Custom Reason",
).SetVolume(60)
err := client.PlayCustom(playInfo)
if err != nil {
log.Fatal(err)
@@ -136,7 +136,7 @@ func main() {
```go
func main() {
client := client.NewClient(config)
// Uses GET request (fixed in v2025.02+)
err := client.PlayNotificationBeep()
if err != nil {
@@ -206,22 +206,22 @@ soundtouch-cli speaker url --help
The following language codes are supported for Google TTS:
| Code | Language |
|------|----------|
| EN | English |
| DE | German |
| ES | Spanish |
| FR | French |
| IT | Italian |
| NL | Dutch |
| PT | Portuguese |
| RU | Russian |
| ZH | Chinese |
| JA | Japanese |
| KO | Korean |
| AR | Arabic |
| HI | Hindi |
| TH | Thai |
| Code | Language |
|------|------------|
| EN | English |
| DE | German |
| ES | Spanish |
| FR | French |
| IT | Italian |
| NL | Dutch |
| PT | Portuguese |
| RU | Russian |
| ZH | Chinese |
| JA | Japanese |
| KO | Korean |
| AR | Arabic |
| HI | Hindi |
| TH | Thai |
## Behavior Notes
@@ -230,7 +230,7 @@ The following language codes are supported for Google TTS:
- Automatically restore the previous volume after playback completes
- If volume is 0 or omitted, content plays at current volume
2. **Content Interruption**:
2. **Content Interruption**:
- Currently playing content is paused during notification playback
- Original content resumes automatically after notification ends
- If currently playing content is already a notification, you may get an error
@@ -241,7 +241,7 @@ The following language codes are supported for Google TTS:
4. **Now Playing Display**:
- Service name appears in the "artist" field
- Message appears in the "album" field
- Message appears in the "album" field
- Reason appears in the "track" field
- Custom artwork can be included in URL-based content
@@ -264,6 +264,84 @@ Both TTS and URL playback require an `app_key` parameter. This appears to be use
You'll need to provide your own application key. The format and generation method for valid app keys is not documented in the official API.
## Google Cloud Text-to-Speech (via the AfterTouch service)
The direct `speaker tts` path above hands the speaker an (undocumented) Google
Translate URL to fetch. That endpoint is fine for short notifications but is
low quality, length-limited, and can change without notice.
For higher-quality speech (real voices, SSML, many languages) the AfterTouch
service can synthesize audio with **Google Cloud Text-to-Speech** and host it
locally for the speaker to play. Because Cloud TTS returns audio bytes from an
authenticated request (not a fetchable URL), the service caches the clip and
serves it at `GET /media/tts/{id}`, then tells the speaker to play that local
URL via `/speaker`. The same `app_key` constraint applies.
### Provider selection
The service picks a TTS provider via `--tts-provider` (env `TTS_PROVIDER`):
- `translate` (default): the Google Translate URL path. No credentials.
- `google-cloud`: Google Cloud TTS via a REST API key. No OAuth, no SDK.
### Configuration (soundtouch-service)
| Flag | Env | Purpose |
|------------------------|----------------------|---------------------------------------------------------------------------------|
| `--tts-provider` | `TTS_PROVIDER` | `translate` or `google-cloud` |
| `--tts-google-api-key` | `TTS_GOOGLE_API_KEY` | Google Cloud TTS API key (required for `google-cloud`) |
| `--tts-language` | `TTS_LANGUAGE` | Default language. `EN`/`DE` for translate, BCP-47 like `en-US` for google-cloud |
| `--tts-voice` | `TTS_VOICE` | Default Cloud TTS voice (e.g. `en-US-Neural2-C`); ignored by translate |
| `--tts-app-key` | `TTS_APP_KEY` | Bose `/speaker` app_key used to play the clip |
| `--tts-volume` | `TTS_VOLUME` | Default playback volume (0-100, 0 = keep current) |
Example:
```bash
TTS_PROVIDER=google-cloud \
TTS_GOOGLE_API_KEY=YOUR_GOOGLE_API_KEY \
TTS_LANGUAGE=en-US \
TTS_VOICE=en-US-Neural2-C \
TTS_APP_KEY=YOUR_APP_KEY \
soundtouch-service
```
### Triggering speech
Service management API (Basic Auth):
```bash
curl -u admin:change_me! -X POST http://soundtouch.local:8000/mgmt/tts/speak \
-H 'Content-Type: application/json' \
-d '{"host":"192.0.2.100","text":"Dinner is ready"}'
```
`deviceId` may be used instead of `host` (the service resolves it to an IP from
its datastore). Optional fields: `language`, `voice`, `volume`.
CLI (calls the service, not the speaker directly):
```bash
soundtouch-cli tts speak \
--service-url http://soundtouch.local:8000 \
--speaker-host 192.0.2.100 \
--text "Dinner is ready"
```
Web UI: the per-device controls include a "Say something…" box. soundtouch-web
proxies it to the service, so start it with `--service-url` (and `--mgmt-username`
/ `--mgmt-password` if you changed the defaults).
### Notes and limitations
- **The `app_key` still applies.** Cloud TTS does not bypass the `/speaker`
requirement; without a working `app_key` the speaker will reject playback.
- **Model support** is the same as the direct path (primarily ST-10 Series III).
- **Reachability:** the speaker must be able to reach the service's
`/media/tts/{id}` URL. The service builds it from its configured `server-url`.
- Synthesized clips are cached in memory for a short time and identical requests
reuse the same clip.
## Limitations
1. **Device Support**: Limited to specific SoundTouch models (primarily ST-10 Series III)
@@ -283,7 +361,7 @@ client.PlayTTS("Someone is at the front door", "home-automation-key", "EN", 80)
// Security alert
client.PlayURL(
"https://myserver.com/alerts/security-breach.mp3",
"security-system-key",
"security-system-key",
"Security System",
"Alert",
"Motion detected in restricted area",
@@ -297,7 +375,7 @@ client.PlayURL(
# Test connectivity
soundtouch-cli speaker beep --host 192.0.2.100
# Test TTS functionality
# Test TTS functionality
soundtouch-cli speaker tts --text "Testing TTS functionality" --app-key test-key --host 192.0.2.100
# Test URL playback
+12 -2
View File
@@ -58,14 +58,24 @@ func (p *PlayInfo) SetVolume(volume int) *PlayInfo {
return p
}
// BuildTranslateTTSURL builds the (undocumented) Google Translate TTS URL the
// speaker fetches directly for a /speaker notification. language is a short code
// such as "EN" or "DE". Shared by NewTTSPlayInfo and the TTS service's Translate
// provider so the query-string format lives in exactly one place.
//
// https://translate.google.com/translate_tts?ie=UTF-8&tl=de&client=aftertouch&q=Hallo+Wie+Geht%27s
func BuildTranslateTTSURL(text, language string) string {
return fmt.Sprintf("https://translate.google.com/translate_tts?ie=UTF-8&tl=%s&client=tw-ob&q=%s", language, url.QueryEscape(text))
}
// NewTTSPlayInfo creates a PlayInfo for Google TTS playback
func NewTTSPlayInfo(text, appKey, language string, volume ...int) *PlayInfo {
// URL encode the text for Google TTS
url := fmt.Sprintf("http://translate.google.com/translate_tts?ie=UTF-8&tl=%s&client=tw-ob&q=%s", language, url.QueryEscape(text))
ttsURL := BuildTranslateTTSURL(text, language)
playInfo := &PlayInfo{
XMLName: xml.Name{Local: "play_info"},
URL: url,
URL: ttsURL,
AppKey: appKey,
Service: "TTS Notification",
Message: "Google TTS",
+1 -1
View File
@@ -37,7 +37,7 @@ func TestNewTTSPlayInfo(t *testing.T) {
// Test without volume
playInfo := NewTTSPlayInfo("Hello World", "test-key", "EN")
expectedURL := "http://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=Hello+World"
expectedURL := "https://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=Hello+World"
if playInfo.URL != expectedURL {
t.Errorf("Expected URL '%s', got '%s'", expectedURL, playInfo.URL)
}
+168
View File
@@ -0,0 +1,168 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/service/tts"
"github.com/go-chi/chi/v5"
)
// ttsSpeakRequest is the JSON body for POST /mgmt/tts/speak. Either DeviceID
// (resolved to an IP via the datastore) or Host (an explicit IP/hostname) must
// be set. The remaining fields fall back to the service defaults when empty.
type ttsSpeakRequest struct {
DeviceID string `json:"deviceId,omitempty"`
Host string `json:"host,omitempty"`
Text string `json:"text"`
Language string `json:"language,omitempty"`
Voice string `json:"voice,omitempty"`
Format string `json:"format,omitempty"`
Volume *int `json:"volume,omitempty"`
}
// HandleMgmtTTSSpeak synthesizes the requested text (or builds a direct URL),
// then tells the target speaker to play it via the /speaker endpoint.
func (s *Server) HandleMgmtTTSSpeak(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
svc := s.ttsSvc()
if svc == nil {
http.Error(w, `{"error":"tts not configured"}`, http.StatusServiceUnavailable)
return
}
var req ttsSpeakRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid JSON body"}`, http.StatusBadRequest)
return
}
if strings.TrimSpace(req.Text) == "" {
http.Error(w, `{"error":"text is required"}`, http.StatusBadRequest)
return
}
host, err := s.resolveTTSHost(req)
if err != nil {
http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest)
return
}
playURL, err := svc.Prepare(r.Context(), tts.Request{
Text: req.Text,
Language: req.Language,
Voice: req.Voice,
Format: req.Format,
})
if err != nil {
http.Error(w, fmt.Sprintf(`{"error":%q}`, "synthesize: "+err.Error()), http.StatusBadGateway)
return
}
volume := svc.DefaultVolume()
if req.Volume != nil {
volume = *req.Volume
}
c := client.NewClientFromHost(host)
var playErr error
if volume > 0 {
playErr = c.PlayURL(playURL, svc.AppKey(), "AfterTouch TTS", req.Text, "", volume)
} else {
playErr = c.PlayURL(playURL, svc.AppKey(), "AfterTouch TTS", req.Text, "")
}
if playErr != nil {
http.Error(w, fmt.Sprintf(`{"error":%q}`, "play: "+playErr.Error()), http.StatusBadGateway)
return
}
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"status": "ok",
"host": host,
"url": playURL,
}); err != nil {
http.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}
// resolveTTSHost returns the speaker IP/hostname to target. An explicit Host
// wins; otherwise DeviceID is looked up in the datastore.
func (s *Server) resolveTTSHost(req ttsSpeakRequest) (string, error) {
if h := strings.TrimSpace(req.Host); h != "" {
return h, nil
}
if strings.TrimSpace(req.DeviceID) == "" {
return "", fmt.Errorf("either deviceId or host is required")
}
devices, err := s.ds.ListAllDevices()
if err != nil {
return "", fmt.Errorf("list devices: %w", err)
}
for i := range devices {
if devices[i].DeviceID == req.DeviceID {
if devices[i].IPAddress == "" {
return "", fmt.Errorf("device %s has no known IP address", req.DeviceID)
}
return devices[i].IPAddress, nil
}
}
return "", fmt.Errorf("device %s not found", req.DeviceID)
}
// HandleTTSMedia serves a synthesized clip by id for the speaker to fetch.
func (s *Server) HandleTTSMedia(w http.ResponseWriter, r *http.Request) {
svc := s.ttsSvc()
if svc == nil {
http.NotFound(w, r)
return
}
id := chi.URLParam(r, "id")
audio, contentType, ok := svc.Clip(id)
if !ok {
http.NotFound(w, r)
return
}
if contentType == "" {
contentType = "application/octet-stream"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", strconv.Itoa(len(audio)))
w.Header().Set("Cache-Control", "public, max-age=300")
_, _ = w.Write(audio)
}
// HandleMgmtTTSConfig reports the active TTS configuration (no secrets).
func (s *Server) HandleMgmtTTSConfig(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
svc := s.ttsSvc()
resp := map[string]interface{}{"configured": svc != nil}
if svc != nil {
resp["provider"] = svc.ProviderName()
resp["defaultLanguage"] = svc.DefaultLanguage()
resp["defaultVoice"] = svc.DefaultVoice()
resp["defaultVolume"] = svc.DefaultVolume()
resp["appKeyConfigured"] = svc.AppKey() != ""
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}
+179
View File
@@ -0,0 +1,179 @@
package handlers
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/tts"
"github.com/go-chi/chi/v5"
)
// ttsTestRouter wires just the TTS routes against a fresh server.
func ttsTestRouter(t *testing.T, baseURL string) (*chi.Mux, *Server) {
t.Helper()
ds := datastore.NewDataStore(t.TempDir())
server := NewServer(ds, nil, baseURL, false, false, false)
r := chi.NewRouter()
r.Get("/media/tts/{id}", server.HandleTTSMedia)
r.Post("/mgmt/tts/speak", server.HandleMgmtTTSSpeak)
r.Get("/mgmt/tts/config", server.HandleMgmtTTSConfig)
return r, server
}
// mockCloudTTS returns an httptest server that emits base64-encoded audio.
func mockCloudTTS(t *testing.T, audio string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{
"audioContent": base64.StdEncoding.EncodeToString([]byte(audio)),
})
}))
}
func TestHandleMgmtTTSConfigNotConfigured(t *testing.T) {
r, _ := ttsTestRouter(t, "http://localhost:8001")
req := httptest.NewRequest(http.MethodGet, "/mgmt/tts/config", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if body["configured"] != false {
t.Fatalf("configured = %v, want false", body["configured"])
}
}
func TestHandleMgmtTTSConfigConfigured(t *testing.T) {
r, server := ttsTestRouter(t, "http://localhost:8001")
server.SetTTSService(tts.NewService(tts.NewTranslateProvider(), tts.Config{AppKey: "k", DefaultLanguage: "EN"}))
req := httptest.NewRequest(http.MethodGet, "/mgmt/tts/config", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
var body map[string]interface{}
_ = json.Unmarshal(rec.Body.Bytes(), &body)
if body["configured"] != true {
t.Fatalf("configured = %v, want true", body["configured"])
}
if body["provider"] != tts.ProviderTranslate {
t.Fatalf("provider = %v, want %s", body["provider"], tts.ProviderTranslate)
}
if body["appKeyConfigured"] != true {
t.Fatalf("appKeyConfigured = %v, want true", body["appKeyConfigured"])
}
}
func TestHandleTTSMediaServesCachedClip(t *testing.T) {
const audio = "synth-bytes"
mock := mockCloudTTS(t, audio)
defer mock.Close()
r, server := ttsTestRouter(t, "http://localhost:8001")
provider := tts.NewCloudProvider("k")
provider.SetEndpoint(mock.URL)
svc := tts.NewService(provider, tts.Config{BaseURL: "http://localhost:8001", AppKey: "k"})
server.SetTTSService(svc)
// Populate the cache and learn the media id.
playURL, err := svc.Prepare(context.Background(), tts.Request{Text: "hello", Language: "en-US"})
if err != nil {
t.Fatalf("Prepare: %v", err)
}
id := strings.TrimPrefix(playURL, "http://localhost:8001/media/tts/")
if id == playURL {
t.Fatalf("unexpected play URL: %s", playURL)
}
req := httptest.NewRequest(http.MethodGet, "/media/tts/"+id, nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if rec.Body.String() != audio {
t.Fatalf("body = %q, want %q", rec.Body.String(), audio)
}
if ct := rec.Header().Get("Content-Type"); ct != "audio/mpeg" {
t.Fatalf("content-type = %q, want audio/mpeg", ct)
}
}
func TestHandleTTSMediaMissingClip(t *testing.T) {
r, server := ttsTestRouter(t, "http://localhost:8001")
server.SetTTSService(tts.NewService(tts.NewTranslateProvider(), tts.Config{}))
req := httptest.NewRequest(http.MethodGet, "/media/tts/does-not-exist.mp3", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
func TestHandleMgmtTTSSpeakNotConfigured(t *testing.T) {
r, _ := ttsTestRouter(t, "http://localhost:8001")
req := httptest.NewRequest(http.MethodPost, "/mgmt/tts/speak", strings.NewReader(`{"host":"192.0.2.10","text":"hi"}`))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
}
func TestHandleMgmtTTSSpeakValidation(t *testing.T) {
r, server := ttsTestRouter(t, "http://localhost:8001")
server.SetTTSService(tts.NewService(tts.NewTranslateProvider(), tts.Config{AppKey: "k"}))
cases := []struct {
name string
body string
want int
}{
{"empty text", `{"host":"192.0.2.10","text":" "}`, http.StatusBadRequest},
{"no target", `{"text":"hello"}`, http.StatusBadRequest},
{"bad json", `{not json}`, http.StatusBadRequest},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/mgmt/tts/speak", strings.NewReader(tc.body))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != tc.want {
t.Fatalf("status = %d, want %d", rec.Code, tc.want)
}
})
}
}
+18
View File
@@ -29,6 +29,7 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
"github.com/gesellix/bose-soundtouch/pkg/service/tts"
"github.com/miekg/dns"
)
@@ -66,6 +67,7 @@ type Server struct {
amazonClientSecret string
amazonRedirectURI string
amazonService *amazon.Service
ttsService *tts.Service
peerObserver *peerObserver
healthRegistry *health.Registry
logBuf *logbuf.Buffer
@@ -759,6 +761,22 @@ func (s *Server) SetSpotifyService(ss *spotify.Service) {
s.spotifyService = ss
}
// SetTTSService sets the text-to-speech service.
func (s *Server) SetTTSService(t *tts.Service) {
s.mu.Lock()
defer s.mu.Unlock()
s.ttsService = t
}
// ttsSvc returns the configured TTS service, or nil if none is set.
func (s *Server) ttsSvc() *tts.Service {
s.mu.RLock()
defer s.mu.RUnlock()
return s.ttsService
}
// GetRecordEnabled returns whether recording is enabled.
func (s *Server) GetRecordEnabled() bool {
s.mu.RLock()
+5
View File
@@ -42,6 +42,11 @@ type WebApp struct {
RepoURL string
ServiceURL string
// Management API credentials for proxying to the AfterTouch service's
// Basic-Auth-protected /mgmt endpoints (e.g. TTS synthesis).
MgmtUsername string
MgmtPassword string
discoveryStatus atomic.Value // stores *webtypes.DiscoveryStatus
}
+107
View File
@@ -0,0 +1,107 @@
package soundtouchweb
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
)
// HandleAPISpeakText synthesizes and plays text on a device. The Web UI talks
// to speakers directly for most controls, but TTS synthesis (Google Cloud) and
// the Bose app_key live in the AfterTouch service, so this proxies to the
// service's /mgmt/tts/speak endpoint, targeting the device by its IP/host.
func (app *WebApp) HandleAPISpeakText(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
if app.ServiceURL == "" {
app.sendError(w,
"TTS requires the AfterTouch service. Start soundtouch-web with --service-url <https://your-aftertouch-host>.",
http.StatusBadRequest)
return
}
var req struct {
Text string `json:"text"`
Language string `json:"language,omitempty"`
Voice string `json:"voice,omitempty"`
Volume *int `json:"volume,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
app.sendError(w, "Invalid request body", http.StatusBadRequest)
return
}
if strings.TrimSpace(req.Text) == "" {
app.sendError(w, "text is required", http.StatusBadRequest)
return
}
payload := map[string]interface{}{
"host": device.Client.Host(),
"text": req.Text,
}
if req.Language != "" {
payload["language"] = req.Language
}
if req.Voice != "" {
payload["voice"] = req.Voice
}
if req.Volume != nil {
payload["volume"] = *req.Volume
}
body, err := json.Marshal(payload)
if err != nil {
app.sendError(w, "Failed to build TTS request", http.StatusInternalServerError)
return
}
upstream, err := http.NewRequestWithContext(r.Context(), http.MethodPost, app.ServiceURL+"/mgmt/tts/speak", bytes.NewReader(body))
if err != nil {
app.sendError(w, "Failed to build TTS request", http.StatusInternalServerError)
return
}
upstream.Header.Set("Content-Type", "application/json")
if app.MgmtUsername != "" || app.MgmtPassword != "" {
upstream.SetBasicAuth(app.MgmtUsername, app.MgmtPassword)
}
resp, err := http.DefaultClient.Do(upstream)
if err != nil {
app.sendError(w, fmt.Sprintf("TTS service request failed: %v", err), http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<12))
if resp.StatusCode != http.StatusOK {
app.sendError(w, fmt.Sprintf("TTS service returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))), http.StatusBadGateway)
return
}
app.sendControlResponse(w, nil, fmt.Sprintf("Speaking: %q", req.Text))
}
+4
View File
@@ -79,6 +79,9 @@ func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscov
// Custom URL playback
r.Post("/api/play-url/{id}", app.HandlePlayURL)
// Text-to-speech (proxied to the AfterTouch service's /mgmt/tts/speak)
r.Post("/api/device-speak/{id}", app.HandleAPISpeakText)
// SPA routes — serve index.html for client-side routing
r.Get("/", app.serveIndex)
r.Get("/devices", app.serveIndex)
@@ -86,6 +89,7 @@ func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscov
r.Get("/tunein", app.serveIndex)
r.Get("/radiobrowser", app.serveIndex)
r.Get("/playurl", app.serveIndex)
r.Get("/tts", app.serveIndex)
}
func (app *WebApp) serveIndex(w http.ResponseWriter, _ *http.Request) {
@@ -50,4 +50,9 @@ export const api = {
headers: JSON_HEADERS,
body: JSON.stringify({ url, name, imageUrl, serviceUrl }),
}),
speak: (deviceId, text) => req(`/api/device-speak/${deviceId}`, {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify({ text }),
}),
};
@@ -11,6 +11,7 @@ import { Recents } from './components/Recents.js';
import { TuneInBrowser } from './components/TuneInBrowser.js';
import { RadioBrowser } from './components/RadioBrowser.js';
import { PlayURL } from './components/PlayURL.js';
import { TTS } from './components/TTS.js';
import { api } from './api.js';
const html = htm.bind(h);
@@ -75,6 +76,7 @@ function App() {
if (page === 'tunein') return 'TuneIn';
if (page === 'radiobrowser') return 'RadioBrowser';
if (page === 'playurl') return 'Play URL';
if (page === 'tts') return 'TTS';
return 'AfterTouch';
};
@@ -182,6 +184,16 @@ function App() {
>
<img src="/static/img/link-mono.svg" alt="Play URL" class="nav-url-icon" />
</a>
<a href="#" class="${page === 'tts' ? 'active' : ''}"
onClick=${(e) => { e.preventDefault(); navigate('tts'); }}
title="TTS"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
</svg>
</a>
<span class="nav-separator">|</span>
<button class="btn-icon" onClick=${discover} title="Discover">
<img src="/static/img/knob-mono.svg" alt="Discover" class="nav-discover-icon ${isDiscovering ? 'buzzing' : ''}" />
@@ -211,6 +223,8 @@ function App() {
<${RadioBrowser} key="radiobrowser-browser" devices=${devices} />
` : page === 'playurl' ? html`
<${PlayURL} key="play-url" devices=${devices} serverServiceUrl=${version?.service_url || ''} />
` : page === 'tts' ? html`
<${TTS} key="tts" devices=${devices} />
` : null}
</main>
@@ -0,0 +1,77 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import htm from 'htm';
import { api } from '../api.js';
const html = htm.bind(h);
// TTS is a "source" view (like PlayURL / TuneIn / RadioBrowser): enter text,
// pick a device, and the AfterTouch service synthesizes and plays it. Synthesis
// and credentials live in the service; this just collects text and a target.
export function TTS({ devices }) {
const [text, setText] = useState('');
const [pendingSpeak, setPendingSpeak] = useState(null);
const [status, setStatus] = useState(null);
function startSpeak() {
const trimmed = text.trim();
if (!trimmed) return;
setStatus(null);
setPendingSpeak({ text: trimmed });
}
async function speakOn(deviceId) {
const item = pendingSpeak;
setPendingSpeak(null);
setStatus('Speaking…');
try {
const resp = await api.speak(deviceId, item.text);
setStatus(resp.success ? 'Speaking' : 'Error: ' + (resp.error || 'Unknown error'));
} catch (e) {
setStatus('Error: ' + e.message);
}
}
const deviceEntries = Object.entries(devices);
return html`
<div class="tunein-browser">
<div class="tunein-toolbar">
<input
type="text"
class="tunein-search-input"
placeholder="Say something…"
value=${text}
onInput=${(e) => setText(e.target.value)}
onKeyDown=${(e) => e.key === 'Enter' && startSpeak()}
/>
<button class="btn-primary" onClick=${startSpeak} disabled=${!text.trim()}>🔊 Speak</button>
</div>
<div class="track-meta" style="margin-top:.4rem">
Uses the AfterTouch service's configured TTS provider. Requires soundtouch-web to be started with --service-url.
</div>
${status && html`<div class="track-meta" style="margin-top:.6rem">${status}</div>`}
${pendingSpeak ? html`
<div class="overlay" onClick=${() => setPendingSpeak(null)}>
<div class="device-picker" onClick=${(e) => e.stopPropagation()}>
<h3 class="picker-title">Speak on device</h3>
<p class="picker-item-name">${pendingSpeak.text}</p>
<div class="picker-devices">
${deviceEntries.length === 0 ? html`<p class="picker-no-devices">No devices found. Try discovering first.</p>` : null}
${deviceEntries.map(([id, d]) => html`
<button class="picker-device-btn" key=${id} onClick=${() => speakOn(id)}>
<div class="picker-device-info">
<span class="picker-device-name">${d.info?.name || id}</span>
<span class="picker-device-ip">${d.info?.ip_address || ''}</span>
</div>
</button>
`)}
</div>
<button class="btn-secondary picker-cancel" onClick=${() => setPendingSpeak(null)}>Cancel</button>
</div>
</div>
` : null}
</div>
`;
}
+109
View File
@@ -0,0 +1,109 @@
package tts
import (
"sync"
"time"
)
// clipCache is a small, bounded in-memory store of synthesized audio clips,
// mirroring the in-memory approach the "ding" endpoint uses. Entries expire
// after a TTL; when the cache is full the oldest entry is evicted. This is a
// best-effort cache for recently spoken clips, not durable storage.
type clipCache struct {
mu sync.Mutex
entries map[string]*clipEntry
ttl time.Duration
maxEntries int
now func() time.Time // injectable clock for tests
}
type clipEntry struct {
audio []byte
contentType string
storedAt time.Time
}
// newClipCache returns a cache holding at most maxEntries clips, each valid for
// ttl. Non-positive values fall back to sane defaults.
func newClipCache(ttl time.Duration, maxEntries int) *clipCache {
if ttl <= 0 {
ttl = 10 * time.Minute
}
if maxEntries <= 0 {
maxEntries = 32
}
return &clipCache{
entries: make(map[string]*clipEntry),
ttl: ttl,
maxEntries: maxEntries,
now: time.Now,
}
}
// put stores audio under id, evicting expired entries and, if still over
// capacity, the oldest remaining entry.
func (c *clipCache) put(id string, audio []byte, contentType string) {
c.mu.Lock()
defer c.mu.Unlock()
now := c.now()
c.evictExpiredLocked(now)
c.entries[id] = &clipEntry{
audio: audio,
contentType: contentType,
storedAt: now,
}
for len(c.entries) > c.maxEntries {
c.evictOldestLocked()
}
}
// get returns the audio and content type for id if present and not expired.
func (c *clipCache) get(id string) (audio []byte, contentType string, ok bool) {
c.mu.Lock()
defer c.mu.Unlock()
entry, found := c.entries[id]
if !found {
return nil, "", false
}
if c.now().Sub(entry.storedAt) > c.ttl {
delete(c.entries, id)
return nil, "", false
}
return entry.audio, entry.contentType, true
}
// evictExpiredLocked removes all entries older than the TTL. Caller holds mu.
func (c *clipCache) evictExpiredLocked(now time.Time) {
for id, entry := range c.entries {
if now.Sub(entry.storedAt) > c.ttl {
delete(c.entries, id)
}
}
}
// evictOldestLocked removes the single oldest entry. Caller holds mu.
func (c *clipCache) evictOldestLocked() {
var (
oldestID string
oldestAt time.Time
found bool
)
for id, entry := range c.entries {
if !found || entry.storedAt.Before(oldestAt) {
oldestID, oldestAt, found = id, entry.storedAt, true
}
}
if found {
delete(c.entries, oldestID)
}
}
+132
View File
@@ -0,0 +1,132 @@
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
}
+49
View File
@@ -0,0 +1,49 @@
// Package tts turns text into speaker-playable audio for the local service.
//
// SoundTouch speakers play a notification by fetching a URL themselves (via the
// /speaker endpoint). Two delivery shapes exist behind a single Provider
// interface:
//
// - Direct-URL providers (e.g. Google Translate) hand the speaker a URL it
// fetches directly. No local hosting needed; Result.DirectURL is set.
// - Synthesizing providers (e.g. Google Cloud TTS) return audio *bytes*. The
// Service caches them and serves them from a local /media/tts/{id} URL that
// the speaker can reach. Result.Audio is set.
//
// The Service (service.go) hides this distinction: callers ask it to Prepare a
// Request and get back a single playable URL.
package tts
import "context"
// Audio format identifiers for a Request.
const (
FormatMP3 = "mp3"
FormatWAV = "wav"
)
// Request describes one synthesis. Language and Voice are provider-specific:
// the Translate provider expects a short code like "EN"; Google Cloud expects a
// BCP-47 tag like "en-US" plus an optional voice name. The active provider is
// fixed per deployment, so the configured defaults are matched to it.
type Request struct {
Text string
Language string
Voice string
Format string // FormatMP3 (default) or FormatWAV
}
// Result is what a Provider returns. Exactly one of DirectURL or Audio is set.
type Result struct {
Audio []byte // synthesized bytes; nil for direct-URL providers
ContentType string // e.g. "audio/mpeg"; set alongside Audio
DirectURL string // speaker-fetchable URL; set instead of Audio
}
// Provider converts text to either a direct URL or audio bytes.
type Provider interface {
// Name returns the provider identifier (e.g. "translate", "google-cloud").
Name() string
// Synthesize converts req into a Result.
Synthesize(ctx context.Context, req Request) (Result, error)
}
+141
View File
@@ -0,0 +1,141 @@
package tts
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
)
// Config configures a Service.
type Config struct {
// BaseURL is the service's public URL the speaker can reach (e.g.
// "https://soundtouch.local"). Used to build /media/tts/{id} URLs for
// synthesized clips. Not needed for direct-URL providers.
BaseURL string
// AppKey is the Bose /speaker app_key. The Service does not use it itself;
// it is surfaced to the handler that POSTs play_info to the speaker.
AppKey string
// DefaultLanguage / DefaultVoice / DefaultVolume fill in unset Request
// fields. Defaults must match the active provider's expectations.
DefaultLanguage string
DefaultVoice string
DefaultVolume int
// CacheTTL and CacheMaxEntries bound the synthesized-clip cache.
CacheTTL time.Duration
CacheMaxEntries int
}
// Service orchestrates a Provider plus a clip cache to turn text into a single
// speaker-playable URL. It is provider-agnostic: callers use Prepare and never
// see the direct-URL vs. byte-synthesis distinction.
type Service struct {
provider Provider
cache *clipCache
cfg Config
}
// NewService builds a Service around the given provider and config.
func NewService(provider Provider, cfg Config) *Service {
return &Service{
provider: provider,
cache: newClipCache(cfg.CacheTTL, cfg.CacheMaxEntries),
cfg: cfg,
}
}
// ProviderName returns the active provider's identifier.
func (s *Service) ProviderName() string { return s.provider.Name() }
// AppKey returns the configured Bose /speaker app_key.
func (s *Service) AppKey() string { return s.cfg.AppKey }
// DefaultVolume returns the configured default playback volume (0 = current).
func (s *Service) DefaultVolume() int { return s.cfg.DefaultVolume }
// DefaultLanguage returns the configured default language.
func (s *Service) DefaultLanguage() string { return s.cfg.DefaultLanguage }
// DefaultVoice returns the configured default voice.
func (s *Service) DefaultVoice() string { return s.cfg.DefaultVoice }
// Prepare produces a speaker-playable URL for req. For direct-URL providers it
// returns the provider's URL; for synthesizing providers it caches the audio
// and returns a local /media/tts/{id} URL. Repeated identical requests reuse
// the cached clip.
func (s *Service) Prepare(ctx context.Context, req Request) (string, error) {
req = s.applyDefaults(req)
if strings.TrimSpace(req.Text) == "" {
return "", fmt.Errorf("tts: text is empty")
}
id := clipID(req)
if _, _, ok := s.cache.get(id); ok {
return s.mediaURL(id), nil
}
res, err := s.provider.Synthesize(ctx, req)
if err != nil {
return "", err
}
if res.DirectURL != "" {
return res.DirectURL, nil
}
if len(res.Audio) == 0 {
return "", fmt.Errorf("tts: provider returned no audio")
}
if strings.TrimSpace(s.cfg.BaseURL) == "" {
return "", fmt.Errorf("tts: provider returned audio but no base URL is configured to host it")
}
s.cache.put(id, res.Audio, res.ContentType)
return s.mediaURL(id), nil
}
// Clip returns the cached audio and content type for a media id, if present.
// Used by the /media/tts/{id} handler.
func (s *Service) Clip(id string) (audio []byte, contentType string, ok bool) {
return s.cache.get(id)
}
// applyDefaults fills unset Request fields from config.
func (s *Service) applyDefaults(req Request) Request {
if req.Language == "" {
req.Language = s.cfg.DefaultLanguage
}
if req.Voice == "" {
req.Voice = s.cfg.DefaultVoice
}
if req.Format == "" {
req.Format = FormatMP3
}
return req
}
// mediaURL builds the local URL the speaker fetches a cached clip from.
func (s *Service) mediaURL(id string) string {
return strings.TrimRight(s.cfg.BaseURL, "/") + "/media/tts/" + id
}
// clipID is a deterministic media id (hash + extension) so identical requests
// map to the same cached clip and URL.
func clipID(req Request) string {
sum := sha256.Sum256([]byte(req.Text + "|" + req.Voice + "|" + req.Language + "|" + req.Format))
ext := "mp3"
if req.Format == FormatWAV {
ext = "wav"
}
return hex.EncodeToString(sum[:16]) + "." + ext
}
+33
View File
@@ -0,0 +1,33 @@
package tts
import (
"context"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
// ProviderTranslate is the identifier for the Google Translate provider.
const ProviderTranslate = "translate"
// TranslateProvider hands the speaker an undocumented Google Translate TTS URL
// to fetch directly. No credentials, no local hosting; quality and length are
// limited and the endpoint can change without notice.
type TranslateProvider struct{}
// NewTranslateProvider returns a Translate provider.
func NewTranslateProvider() *TranslateProvider {
return &TranslateProvider{}
}
// Name implements Provider.
func (p *TranslateProvider) Name() string { return ProviderTranslate }
// Synthesize returns a Result whose DirectURL points at the Translate endpoint.
func (p *TranslateProvider) Synthesize(_ context.Context, req Request) (Result, error) {
language := req.Language
if language == "" {
language = "EN"
}
return Result{DirectURL: models.BuildTranslateTTSURL(req.Text, language)}, nil
}
+239
View File
@@ -0,0 +1,239 @@
package tts
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestTranslateProviderReturnsDirectURL(t *testing.T) {
p := NewTranslateProvider()
res, err := p.Synthesize(context.Background(), Request{Text: "Hello world", Language: "EN"})
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if res.DirectURL == "" {
t.Fatal("expected a DirectURL")
}
if len(res.Audio) != 0 {
t.Fatalf("translate provider should not return audio bytes, got %d", len(res.Audio))
}
if !strings.Contains(res.DirectURL, "translate_tts") || !strings.Contains(res.DirectURL, "tl=EN") {
t.Fatalf("unexpected DirectURL: %s", res.DirectURL)
}
}
func TestCloudProviderSynthesize(t *testing.T) {
const want = "fake-mp3-bytes"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("key"); got != "test-key" {
t.Errorf("expected api key in query, got %q", got)
}
body, _ := io.ReadAll(r.Body)
var req cloudSynthesizeRequest
if err := json.Unmarshal(body, &req); err != nil {
t.Errorf("decode request: %v", err)
}
if req.Input.Text != "Hi there" {
t.Errorf("unexpected text: %q", req.Input.Text)
}
if req.AudioConfig.AudioEncoding != "MP3" {
t.Errorf("unexpected encoding: %q", req.AudioConfig.AudioEncoding)
}
resp := cloudSynthesizeResponse{AudioContent: base64.StdEncoding.EncodeToString([]byte(want))}
_ = json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
p := NewCloudProvider("test-key")
p.SetEndpoint(srv.URL)
res, err := p.Synthesize(context.Background(), Request{Text: "Hi there", Language: "en-US", Format: FormatMP3})
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if string(res.Audio) != want {
t.Fatalf("audio = %q, want %q", res.Audio, want)
}
if res.ContentType != "audio/mpeg" {
t.Fatalf("content type = %q, want audio/mpeg", res.ContentType)
}
if res.DirectURL != "" {
t.Fatalf("cloud provider should not set DirectURL, got %q", res.DirectURL)
}
}
func TestCloudProviderHTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"denied"}`))
}))
defer srv.Close()
p := NewCloudProvider("test-key")
p.SetEndpoint(srv.URL)
if _, err := p.Synthesize(context.Background(), Request{Text: "x"}); err == nil {
t.Fatal("expected error on non-200 response")
}
}
func TestCloudProviderNoAPIKey(t *testing.T) {
p := NewCloudProvider("")
if _, err := p.Synthesize(context.Background(), Request{Text: "x"}); err == nil {
t.Fatal("expected error when no API key configured")
}
}
func TestClipCacheTTLEviction(t *testing.T) {
c := newClipCache(time.Minute, 10)
base := time.Now()
c.now = func() time.Time { return base }
c.put("a.mp3", []byte("audio"), "audio/mpeg")
if _, _, ok := c.get("a.mp3"); !ok {
t.Fatal("entry should be present immediately")
}
// Advance past the TTL.
c.now = func() time.Time { return base.Add(2 * time.Minute) }
if _, _, ok := c.get("a.mp3"); ok {
t.Fatal("entry should have expired")
}
}
func TestClipCacheCapacityEviction(t *testing.T) {
c := newClipCache(time.Hour, 2)
base := time.Now()
// Each put advances the clock so "oldest" is well-defined.
c.now = func() time.Time { return base }
c.put("a.mp3", []byte("a"), "audio/mpeg")
c.now = func() time.Time { return base.Add(time.Second) }
c.put("b.mp3", []byte("b"), "audio/mpeg")
c.now = func() time.Time { return base.Add(2 * time.Second) }
c.put("c.mp3", []byte("c"), "audio/mpeg")
if _, _, ok := c.get("a.mp3"); ok {
t.Fatal("oldest entry 'a' should have been evicted")
}
if _, _, ok := c.get("b.mp3"); !ok {
t.Fatal("entry 'b' should still be present")
}
if _, _, ok := c.get("c.mp3"); !ok {
t.Fatal("entry 'c' should still be present")
}
}
// stubProvider lets Service tests control the Result without real HTTP.
type stubProvider struct {
name string
res Result
calls int
}
func (p *stubProvider) Name() string { return p.name }
func (p *stubProvider) Synthesize(_ context.Context, _ Request) (Result, error) {
p.calls++
return p.res, nil
}
func TestServicePrepareDirectURL(t *testing.T) {
p := &stubProvider{name: ProviderTranslate, res: Result{DirectURL: "https://example.invalid/say.mp3"}}
svc := NewService(p, Config{})
url, err := svc.Prepare(context.Background(), Request{Text: "hello"})
if err != nil {
t.Fatalf("Prepare: %v", err)
}
if url != "https://example.invalid/say.mp3" {
t.Fatalf("url = %q", url)
}
}
func TestServicePrepareCachesAndReuses(t *testing.T) {
p := &stubProvider{name: ProviderGoogleCloud, res: Result{Audio: []byte("bytes"), ContentType: "audio/mpeg"}}
svc := NewService(p, Config{BaseURL: "https://soundtouch.local/"})
url1, err := svc.Prepare(context.Background(), Request{Text: "hello", Language: "en-US"})
if err != nil {
t.Fatalf("Prepare: %v", err)
}
if !strings.HasPrefix(url1, "https://soundtouch.local/media/tts/") {
t.Fatalf("unexpected media url: %s", url1)
}
// Identical request should hit the cache, not call the provider again.
url2, err := svc.Prepare(context.Background(), Request{Text: "hello", Language: "en-US"})
if err != nil {
t.Fatalf("Prepare (cached): %v", err)
}
if url1 != url2 {
t.Fatalf("expected stable url, got %q and %q", url1, url2)
}
if p.calls != 1 {
t.Fatalf("provider called %d times, want 1 (second should be cached)", p.calls)
}
// The clip should be retrievable for the media handler.
id := strings.TrimPrefix(url1, "https://soundtouch.local/media/tts/")
audio, ct, ok := svc.Clip(id)
if !ok {
t.Fatal("clip should be cached")
}
if string(audio) != "bytes" || ct != "audio/mpeg" {
t.Fatalf("clip = %q (%s)", audio, ct)
}
}
func TestServicePrepareAudioWithoutBaseURL(t *testing.T) {
p := &stubProvider{name: ProviderGoogleCloud, res: Result{Audio: []byte("bytes"), ContentType: "audio/mpeg"}}
svc := NewService(p, Config{}) // no BaseURL
if _, err := svc.Prepare(context.Background(), Request{Text: "hello"}); err == nil {
t.Fatal("expected error when hosting audio without a base URL")
}
}
func TestServicePrepareEmptyText(t *testing.T) {
p := &stubProvider{name: ProviderTranslate, res: Result{DirectURL: "x"}}
svc := NewService(p, Config{})
if _, err := svc.Prepare(context.Background(), Request{Text: " "}); err == nil {
t.Fatal("expected error on empty text")
}
}