From c852d07da1a1ec36318019db575e9bf704240186 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 31 May 2026 19:42:55 +0200 Subject: [PATCH] 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) --- cmd/soundtouch-cli/cmd_tts.go | 143 +++++++++++ cmd/soundtouch-cli/main.go | 1 + cmd/soundtouch-service/main.go | 108 ++++++++ .../testdata/router_routes.txt | 3 + cmd/soundtouch-web/main.go | 13 + .../docs/reference/SPEAKER-ENDPOINT.md | 134 ++++++++-- pkg/models/speaker.go | 14 +- pkg/models/speaker_test.go | 2 +- pkg/service/handlers/handlers_tts.go | 168 ++++++++++++ pkg/service/handlers/handlers_tts_test.go | 179 +++++++++++++ pkg/service/handlers/server.go | 18 ++ pkg/service/soundtouchweb/handler.go | 5 + pkg/service/soundtouchweb/handlers_tts.go | 107 ++++++++ pkg/service/soundtouchweb/mount.go | 4 + pkg/service/soundtouchweb/static/js/api.js | 5 + pkg/service/soundtouchweb/static/js/app.js | 14 + .../soundtouchweb/static/js/components/TTS.js | 77 ++++++ pkg/service/tts/cache.go | 109 ++++++++ pkg/service/tts/googlecloud.go | 132 ++++++++++ pkg/service/tts/provider.go | 49 ++++ pkg/service/tts/service.go | 141 +++++++++++ pkg/service/tts/translate.go | 33 +++ pkg/service/tts/tts_test.go | 239 ++++++++++++++++++ 23 files changed, 1667 insertions(+), 31 deletions(-) create mode 100644 cmd/soundtouch-cli/cmd_tts.go create mode 100644 pkg/service/handlers/handlers_tts.go create mode 100644 pkg/service/handlers/handlers_tts_test.go create mode 100644 pkg/service/soundtouchweb/handlers_tts.go create mode 100644 pkg/service/soundtouchweb/static/js/components/TTS.js create mode 100644 pkg/service/tts/cache.go create mode 100644 pkg/service/tts/googlecloud.go create mode 100644 pkg/service/tts/provider.go create mode 100644 pkg/service/tts/service.go create mode 100644 pkg/service/tts/translate.go create mode 100644 pkg/service/tts/tts_test.go diff --git a/cmd/soundtouch-cli/cmd_tts.go b/cmd/soundtouch-cli/cmd_tts.go new file mode 100644 index 0000000..00262b4 --- /dev/null +++ b/cmd/soundtouch-cli/cmd_tts.go @@ -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 +} diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go index e310418..318be4b 100644 --- a/cmd/soundtouch-cli/main.go +++ b/cmd/soundtouch-cli/main.go @@ -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) diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 3415747..3bd1a81 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -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) }) }) diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index adb9082..350093f 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -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 diff --git a/cmd/soundtouch-web/main.go b/cmd/soundtouch-web/main.go index f887cad..87eccc7 100644 --- a/cmd/soundtouch-web/main.go +++ b/cmd/soundtouch-web/main.go @@ -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) diff --git a/docs/content/docs/reference/SPEAKER-ENDPOINT.md b/docs/content/docs/reference/SPEAKER-ENDPOINT.md index baf6b72..d9a1632 100644 --- a/docs/content/docs/reference/SPEAKER-ENDPOINT.md +++ b/docs/content/docs/reference/SPEAKER-ENDPOINT.md @@ -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 diff --git a/pkg/models/speaker.go b/pkg/models/speaker.go index 1668da4..f1c0912 100644 --- a/pkg/models/speaker.go +++ b/pkg/models/speaker.go @@ -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", diff --git a/pkg/models/speaker_test.go b/pkg/models/speaker_test.go index 330bbc1..2ba0bff 100644 --- a/pkg/models/speaker_test.go +++ b/pkg/models/speaker_test.go @@ -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) } diff --git a/pkg/service/handlers/handlers_tts.go b/pkg/service/handlers/handlers_tts.go new file mode 100644 index 0000000..fd1b62e --- /dev/null +++ b/pkg/service/handlers/handlers_tts.go @@ -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) + } +} diff --git a/pkg/service/handlers/handlers_tts_test.go b/pkg/service/handlers/handlers_tts_test.go new file mode 100644 index 0000000..9b779bb --- /dev/null +++ b/pkg/service/handlers/handlers_tts_test.go @@ -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) + } + }) + } +} diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 905c687..956172a 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -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() diff --git a/pkg/service/soundtouchweb/handler.go b/pkg/service/soundtouchweb/handler.go index 5ff6b60..e4a1bd7 100644 --- a/pkg/service/soundtouchweb/handler.go +++ b/pkg/service/soundtouchweb/handler.go @@ -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 } diff --git a/pkg/service/soundtouchweb/handlers_tts.go b/pkg/service/soundtouchweb/handlers_tts.go new file mode 100644 index 0000000..970da55 --- /dev/null +++ b/pkg/service/soundtouchweb/handlers_tts.go @@ -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 .", + 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)) +} diff --git a/pkg/service/soundtouchweb/mount.go b/pkg/service/soundtouchweb/mount.go index b8a8f31..2f4e5b4 100644 --- a/pkg/service/soundtouchweb/mount.go +++ b/pkg/service/soundtouchweb/mount.go @@ -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) { diff --git a/pkg/service/soundtouchweb/static/js/api.js b/pkg/service/soundtouchweb/static/js/api.js index 291bba5..3690bf6 100644 --- a/pkg/service/soundtouchweb/static/js/api.js +++ b/pkg/service/soundtouchweb/static/js/api.js @@ -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 }), + }), }; diff --git a/pkg/service/soundtouchweb/static/js/app.js b/pkg/service/soundtouchweb/static/js/app.js index f2507cd..ac14430 100644 --- a/pkg/service/soundtouchweb/static/js/app.js +++ b/pkg/service/soundtouchweb/static/js/app.js @@ -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() { > Play URL + { e.preventDefault(); navigate('tts'); }} + title="TTS" + > + + | + +
+ Uses the AfterTouch service's configured TTS provider. Requires soundtouch-web to be started with --service-url. +
+ ${status && html`
${status}
`} + + ${pendingSpeak ? html` +
setPendingSpeak(null)}> +
e.stopPropagation()}> +

Speak on device

+

${pendingSpeak.text}

+
+ ${deviceEntries.length === 0 ? html`

No devices found. Try discovering first.

` : null} + ${deviceEntries.map(([id, d]) => html` + + `)} +
+ +
+
+ ` : null} + + `; +} \ No newline at end of file diff --git a/pkg/service/tts/cache.go b/pkg/service/tts/cache.go new file mode 100644 index 0000000..d7c5f81 --- /dev/null +++ b/pkg/service/tts/cache.go @@ -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) + } +} diff --git a/pkg/service/tts/googlecloud.go b/pkg/service/tts/googlecloud.go new file mode 100644 index 0000000..e3c2c7d --- /dev/null +++ b/pkg/service/tts/googlecloud.go @@ -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 +} diff --git a/pkg/service/tts/provider.go b/pkg/service/tts/provider.go new file mode 100644 index 0000000..6747ea3 --- /dev/null +++ b/pkg/service/tts/provider.go @@ -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) +} diff --git a/pkg/service/tts/service.go b/pkg/service/tts/service.go new file mode 100644 index 0000000..acd8751 --- /dev/null +++ b/pkg/service/tts/service.go @@ -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 +} diff --git a/pkg/service/tts/translate.go b/pkg/service/tts/translate.go new file mode 100644 index 0000000..4a64d50 --- /dev/null +++ b/pkg/service/tts/translate.go @@ -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 +} diff --git a/pkg/service/tts/tts_test.go b/pkg/service/tts/tts_test.go new file mode 100644 index 0000000..274550d --- /dev/null +++ b/pkg/service/tts/tts_test.go @@ -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") + } +}