mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
feat(tts): add method selector (speaker | radio) to TTS speak
/setup/tts/speak now accepts a "method" field (and the CLI a --method flag): "radio" (default, LOCAL_INTERNET_RADIO, no app_key, replaces source) or "speaker" (POST /speaker notification, ducks+resumes, honours volume). The speaker method defaults the app_key to "aftertouch" when none is configured, since the speaker validates it via GET /v1/auth which we answer 200 regardless. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
56c4ae4e2d
commit
e6d5588b99
@@ -63,7 +63,12 @@ func ttsSpeakCmd() *cli.Command {
|
||||
&cli.IntFlag{
|
||||
Name: "volume",
|
||||
Aliases: []string{"v"},
|
||||
Usage: "Playback volume (0-100, 0 = service default)",
|
||||
Usage: "Playback volume (0-100, 0 = service default; only honoured by --method speaker)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "method",
|
||||
Usage: "Playback method: 'radio' (LOCAL_INTERNET_RADIO, no app_key, replaces source) or 'speaker' (/speaker notification, ducks+resumes, supports volume)",
|
||||
Value: "radio",
|
||||
},
|
||||
),
|
||||
Action: ttsSpeak,
|
||||
@@ -100,6 +105,10 @@ func ttsSpeak(c *cli.Context) error {
|
||||
payload["volume"] = c.Int("volume")
|
||||
}
|
||||
|
||||
if m := c.String("method"); m != "" {
|
||||
payload["method"] = m
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal request: %w", err)
|
||||
|
||||
@@ -27,13 +27,20 @@ type ttsSpeakRequest struct {
|
||||
Voice string `json:"voice,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Volume *int `json:"volume,omitempty"`
|
||||
// Method selects how the clip is played on the speaker:
|
||||
// "radio" (default) — LOCAL_INTERNET_RADIO via /custom/v1/playback,
|
||||
// no app_key; replaces the current source.
|
||||
// "speaker" — POST /speaker notification; ducks and resumes
|
||||
// the current playback, supports volume, but
|
||||
// requires the speaker to accept the app_key
|
||||
// (validated via GET /v1/auth, which we answer 200).
|
||||
Method string `json:"method,omitempty"`
|
||||
}
|
||||
|
||||
// HandleTTSSpeak synthesizes the requested text, then tells the target speaker
|
||||
// to play it as a LOCAL_INTERNET_RADIO ContentItem via the /custom/v1/playback
|
||||
// proxy. This is the same path the "ding" health check uses and, unlike the
|
||||
// /speaker notification endpoint, needs no Bose app_key (which speakers
|
||||
// validate against the now-dead Bose cloud).
|
||||
// HandleTTSSpeak synthesizes the requested text and plays it on the target
|
||||
// speaker. Two playback methods (see ttsSpeakRequest.Method): the default
|
||||
// LOCAL_INTERNET_RADIO path (like the "ding", no app_key) or the /speaker
|
||||
// notification path (ducks/resumes and supports volume).
|
||||
func (s *Server) HandleTTSSpeak(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -71,20 +78,50 @@ func (s *Server) HandleTTSSpeak(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
location := buildCustomPlaybackURL(svc.BaseURL(), playURL, "AfterTouch TTS: "+req.Text)
|
||||
|
||||
c := client.NewClientFromHost(host)
|
||||
if err := c.SelectLocalInternetRadio(location, "", "AfterTouch TTS", ""); err != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error":%q}`, "play: "+err.Error()), http.StatusBadGateway)
|
||||
return
|
||||
method := strings.ToLower(strings.TrimSpace(req.Method))
|
||||
|
||||
resp := map[string]interface{}{"status": "ok", "host": host, "url": playURL}
|
||||
|
||||
switch method {
|
||||
case "speaker":
|
||||
appKey := svc.AppKey()
|
||||
if appKey == "" {
|
||||
// The speaker validates the app_key via GET /v1/auth, which we
|
||||
// answer 200 regardless, so any non-empty value works.
|
||||
appKey = "aftertouch"
|
||||
}
|
||||
|
||||
volume := svc.DefaultVolume()
|
||||
if req.Volume != nil {
|
||||
volume = *req.Volume
|
||||
}
|
||||
|
||||
var playErr error
|
||||
if volume > 0 {
|
||||
playErr = c.PlayURL(playURL, appKey, "AfterTouch TTS", req.Text, "", volume)
|
||||
} else {
|
||||
playErr = c.PlayURL(playURL, appKey, "AfterTouch TTS", req.Text, "")
|
||||
}
|
||||
|
||||
if playErr != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error":%q}`, "play (speaker): "+playErr.Error()), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
resp["method"] = "speaker"
|
||||
default: // "radio" or unset
|
||||
location := buildCustomPlaybackURL(svc.BaseURL(), playURL, "AfterTouch TTS: "+req.Text)
|
||||
if err := c.SelectLocalInternetRadio(location, "", "AfterTouch TTS", ""); err != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error":%q}`, "play (radio): "+err.Error()), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
resp["method"] = "radio"
|
||||
resp["location"] = location
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "ok",
|
||||
"host": host,
|
||||
"url": playURL,
|
||||
"location": location,
|
||||
}); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user