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:
Tobias Gesellchen
2026-05-31 22:35:31 +02:00
co-authored by Claude Opus 4.8
parent 56c4ae4e2d
commit e6d5588b99
2 changed files with 63 additions and 17 deletions
+10 -1
View File
@@ -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)
+53 -16
View File
@@ -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)
}
}