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
+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