feat(tts): default to /speaker playback; drop /v1/auth debug dump

Confirmed working on a real speaker (Bose_Lisa/27.0.6): the speaker GETs
/v1/auth at audionotification.api.bosecm.com (DNS-redirected to us) with
the app_key in an "Apikeyheader" header, and an empty 200 is sufficient.

- Make "speaker" the default playback method (ducks + resumes the current
  playback, supports volume) for the speak endpoint, the CLI --method flag,
  and the web UI button; "radio" remains opt-in.
- Remove the temporary full-request debug dump from /v1/auth now that the
  contract is understood; document it in the handler comment instead.

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 22c3142a79
commit 80cfb03f6e
3 changed files with 30 additions and 37 deletions
+2 -2
View File
@@ -54,8 +54,8 @@ func ttsCloudCmd() *cli.Command {
},
&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",
Usage: "Playback method: 'speaker' (/speaker notification, ducks+resumes, supports volume) or 'radio' (LOCAL_INTERNET_RADIO, no app_key, replaces source)",
Value: "speaker",
},
),
Action: ttsCloud,
@@ -318,8 +318,9 @@ curl -X POST http://soundtouch.local:8000/setup/tts/speak \
```
`deviceId` may be used instead of `host` (the service resolves it to an IP from its datastore). Optional fields: `language`, `voice`, `volume`, and `method`
(`radio`, the default LOCAL_INTERNET_RADIO path, or `speaker`, the /speaker
notification path that ducks and resumes playback).
(`speaker`, the default /speaker notification path that ducks and resumes
playback, or `radio`, the LOCAL_INTERNET_RADIO path that needs no app_key but
replaces the current source).
CLI (`speaker tts-cloud` routes through the service for Cloud TTS, in contrast
to `speaker tts` which sends a Google Translate URL straight to the speaker):
+25 -33
View File
@@ -4,9 +4,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httputil"
"net/url"
"strconv"
"strings"
@@ -28,19 +26,19 @@ type ttsSpeakRequest struct {
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,
// "speaker" (default) — POST /speaker notification; ducks and resumes
// the current playback, supports volume. Requires
// the speaker to accept the app_key (validated via
// GET /v1/auth, which we answer 200).
// "radio" — 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 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).
// /speaker notification path (ducks/resumes and supports volume) or the
// LOCAL_INTERNET_RADIO path (like the "ding", no app_key, replaces the source).
func (s *Server) HandleTTSSpeak(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -84,7 +82,16 @@ func (s *Server) HandleTTSSpeak(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{"status": "ok", "host": host, "url": playURL}
switch method {
case "speaker":
case "radio":
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
default: // "speaker" or unset — ducks and resumes the current playback
appKey := svc.AppKey()
if appKey == "" {
// The speaker validates the app_key via GET /v1/auth, which we
@@ -110,15 +117,6 @@ func (s *Server) HandleTTSSpeak(w http.ResponseWriter, r *http.Request) {
}
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(resp); err != nil {
@@ -136,20 +134,14 @@ func buildCustomPlaybackURL(base, audioURL, name string) string {
return base + "/custom/v1/playback/" + encoded + "?name=" + url.QueryEscape(name)
}
// HandleSpeakerAuth accepts the app_key the speaker presents when validating a
// /speaker notification. Real Bose validated against its cloud; as the cloud
// replacement we always accept (200) so the speaker doesn't report an invalid
// app key and refuse the notification.
func (s *Server) HandleSpeakerAuth(w http.ResponseWriter, r *http.Request) {
// TEMP DEBUG: dump the full request so we can see how the speaker presents
// the app_key (query param / header / body) and what a valid response might
// need to look like. Remove once the /speaker auth contract is understood.
if dump, err := httputil.DumpRequest(r, true); err == nil {
log.Printf("[TTS][/v1/auth DEBUG] %s", dump)
} else {
log.Printf("[TTS][/v1/auth DEBUG] dump failed: %v; method=%s url=%s headers=%v", err, r.Method, r.URL.String(), r.Header)
}
// HandleSpeakerAuth accepts the app_key a speaker presents when validating a
// /speaker notification. The speaker issues GET /v1/auth to the (now-dead) Bose
// host audionotification.api.bosecm.com — which AfterTouch's DNS interception
// points at us — with the key in an "Apikeyheader" header. An empty 200 is
// sufficient; real Bose validated against its cloud, but as the cloud
// replacement we always accept, so the speaker doesn't report an invalid app
// key (HandleInvalidAppKeyCb) and refuse the notification.
func (s *Server) HandleSpeakerAuth(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}