mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
Adds text-to-speech that synthesizes higher-quality audio (Google Cloud
TTS) and plays it on a speaker via the /speaker endpoint. Because Cloud
TTS returns audio bytes (not a fetchable URL), the service caches the
clip and hosts it at GET /media/tts/{id}, mirroring the "ding" endpoint,
then points the speaker at that local URL.
The design is a pluggable Provider interface (pkg/service/tts) wrapping
two modes:
- translate: hands the speaker the (undocumented) Google Translate URL
directly (no credentials), reusing models.BuildTranslateTTSURL.
- google-cloud: REST API key auth (no SDK/gRPC), bytes cached locally.
Surfaces:
- service: POST /mgmt/tts/speak, GET /mgmt/tts/config, GET /media/tts/{id};
configured via TTS_PROVIDER / TTS_GOOGLE_API_KEY / TTS_LANGUAGE /
TTS_VOICE / TTS_APP_KEY / TTS_VOLUME.
- CLI: `soundtouch-cli tts speak` (calls the service with mgmt Basic Auth).
- web: a "TTS" source view (like Play URL / TuneIn), proxied to the
service via /api/device-speak/{id}.
The /speaker app_key requirement and model limitations still apply; see
docs/content/docs/reference/SPEAKER-ENDPOINT.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
140 lines
3.6 KiB
Go
140 lines
3.6 KiB
Go
package models
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
)
|
|
|
|
// Error constants for speaker validation
|
|
var (
|
|
ErrInvalidURL = errors.New("URL cannot be empty")
|
|
ErrInvalidAppKey = errors.New("app key cannot be empty")
|
|
ErrInvalidService = errors.New("service cannot be empty")
|
|
ErrInvalidVolume = errors.New("volume must be between 0 and 100")
|
|
)
|
|
|
|
// PlayInfo represents the request body for the /speaker endpoint to play TTS or URL content
|
|
type PlayInfo struct {
|
|
XMLName xml.Name `xml:"play_info"`
|
|
URL string `xml:"url"`
|
|
AppKey string `xml:"app_key"`
|
|
Service string `xml:"service"`
|
|
Message string `xml:"message"`
|
|
Reason string `xml:"reason"`
|
|
Volume *int `xml:"volume,omitempty"`
|
|
}
|
|
|
|
// SpeakerResponse represents the response from the /speaker endpoint
|
|
type SpeakerResponse struct {
|
|
XMLName xml.Name `xml:"status"`
|
|
Value string `xml:",chardata"`
|
|
}
|
|
|
|
// SpeakerPlayStatus represents the status during speaker playback
|
|
type SpeakerPlayStatus struct {
|
|
Service string `json:"service"`
|
|
Message string `json:"message"`
|
|
Reason string `json:"reason"`
|
|
Volume int `json:"volume,omitempty"`
|
|
}
|
|
|
|
// NewPlayInfo creates a new PlayInfo instance for TTS or URL playback
|
|
func NewPlayInfo(url, appKey, service, message, reason string) *PlayInfo {
|
|
return &PlayInfo{
|
|
XMLName: xml.Name{Local: "play_info"},
|
|
URL: url,
|
|
AppKey: appKey,
|
|
Service: service,
|
|
Message: message,
|
|
Reason: reason,
|
|
}
|
|
}
|
|
|
|
// SetVolume sets the volume level for playback
|
|
func (p *PlayInfo) SetVolume(volume int) *PlayInfo {
|
|
p.Volume = &volume
|
|
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
|
|
ttsURL := BuildTranslateTTSURL(text, language)
|
|
|
|
playInfo := &PlayInfo{
|
|
XMLName: xml.Name{Local: "play_info"},
|
|
URL: ttsURL,
|
|
AppKey: appKey,
|
|
Service: "TTS Notification",
|
|
Message: "Google TTS",
|
|
Reason: text,
|
|
}
|
|
|
|
if len(volume) > 0 {
|
|
playInfo.Volume = &volume[0]
|
|
}
|
|
|
|
return playInfo
|
|
}
|
|
|
|
// NewURLPlayInfo creates a PlayInfo for URL content playback
|
|
func NewURLPlayInfo(url, appKey, service, message, reason string, volume ...int) *PlayInfo {
|
|
playInfo := &PlayInfo{
|
|
XMLName: xml.Name{Local: "play_info"},
|
|
URL: url,
|
|
AppKey: appKey,
|
|
Service: service,
|
|
Message: message,
|
|
Reason: reason,
|
|
}
|
|
|
|
if len(volume) > 0 {
|
|
playInfo.Volume = &volume[0]
|
|
}
|
|
|
|
return playInfo
|
|
}
|
|
|
|
// Validate validates the PlayInfo request
|
|
func (p *PlayInfo) Validate() error {
|
|
if p.URL == "" {
|
|
return ErrInvalidURL
|
|
}
|
|
|
|
if p.AppKey == "" {
|
|
return ErrInvalidAppKey
|
|
}
|
|
|
|
if p.Service == "" {
|
|
return ErrInvalidService
|
|
}
|
|
|
|
if p.Volume != nil && (*p.Volume < 0 || *p.Volume > 100) {
|
|
return ErrInvalidVolume
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// String returns a string representation of the PlayInfo
|
|
func (p *PlayInfo) String() string {
|
|
volumeStr := "current"
|
|
if p.Volume != nil {
|
|
volumeStr = string(rune(*p.Volume))
|
|
}
|
|
|
|
return "Service: " + p.Service + ", Message: " + p.Message + ", Volume: " + volumeStr
|
|
}
|