Files
Bose-SoundTouch/cmd/soundtouch-cli/cmd_tts.go
T
Tobias GesellchenandClaude Opus 4.8 169c1c5b9f fix(tts): move TTS endpoints from /mgmt to /setup (no Basic Auth)
The TTS speak/config endpoints were under /mgmt (Basic-Auth protected),
but the soundtouch-web proxy and CLI authenticated with their own
mgmt-password default (empty) while the service defaults to "change_me!",
so speaking from -web returned 401.

This was also inconsistent: the Google API key is configured via the
unauthenticated /setup/settings, and Play URL already proxies to /setup,
so gating only TTS playback behind mgmt auth made no sense. Move
/mgmt/tts/{speak,config} to /setup/tts/{speak,config} (LAN-trust, like
the rest of the setup surface), rename the handlers accordingly, and drop
the now-unused mgmt-credential plumbing from soundtouch-web and the CLI
tts command.

Verified: POST /setup/tts/speak now reaches the handler without auth
(502 only because the test speaker IP is unreachable; previously 401).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00

132 lines
3.4 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/urfave/cli/v2"
)
// ttsCommand assembles the `soundtouch-cli tts …` command group. Unlike
// `speaker tts` (which talks to a speaker directly using the Google Translate
// URL), these subcommands call the AfterTouch service, which synthesizes audio
// with the configured provider (e.g. Google Cloud TTS) and plays it on a
// speaker. They require --service-url.
func ttsCommand() *cli.Command {
return &cli.Command{
Name: "tts",
Usage: "Text-to-speech via the AfterTouch service (Google Cloud TTS or Google Translate)",
Description: "Sends text to the AfterTouch service, which synthesizes audio (or builds a\n" +
"direct URL) and plays it on a speaker via the /speaker endpoint.\n\n" +
"This differs from 'speaker tts', which talks to a speaker directly using\n" +
"the Google Translate URL. Use 'tts speak' for the service's configured\n" +
"provider (e.g. Google Cloud TTS).",
Subcommands: []*cli.Command{
ttsSpeakCmd(),
},
}
}
func ttsSpeakCmd() *cli.Command {
return &cli.Command{
Name: "speak",
Usage: "Synthesize text and play it on a speaker via the AfterTouch service",
Flags: append(CloudCommonFlags,
&cli.StringFlag{
Name: "text",
Aliases: []string{"t"},
Usage: "Text to speak",
Required: true,
},
&cli.StringFlag{
Name: "device",
Aliases: []string{"d"},
Usage: "Target device ID (the service resolves it to an IP)",
},
&cli.StringFlag{
Name: "speaker-host",
Usage: "Target speaker IP/hostname (alternative to --device)",
},
&cli.StringFlag{
Name: "language",
Aliases: []string{"l"},
Usage: "Language code (provider-specific; defaults to the service setting)",
},
&cli.StringFlag{
Name: "voice",
Usage: "Voice name (Google Cloud TTS; ignored by the translate provider)",
},
&cli.IntFlag{
Name: "volume",
Aliases: []string{"v"},
Usage: "Playback volume (0-100, 0 = service default)",
},
),
Action: ttsSpeak,
}
}
func ttsSpeak(c *cli.Context) error {
serviceURL := strings.TrimRight(c.String("service-url"), "/")
device := c.String("device")
speakerHost := c.String("speaker-host")
if device == "" && speakerHost == "" {
return fmt.Errorf("one of --device or --speaker-host is required")
}
payload := map[string]interface{}{"text": c.String("text")}
if device != "" {
payload["deviceId"] = device
}
if speakerHost != "" {
payload["host"] = speakerHost
}
if l := c.String("language"); l != "" {
payload["language"] = l
}
if v := c.String("voice"); v != "" {
payload["voice"] = v
}
if c.IsSet("volume") {
payload["volume"] = c.Int("volume")
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequest(http.MethodPost, serviceURL+"/setup/tts/speak", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<12))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("service returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
PrintSuccess(fmt.Sprintf("Spoke %q", c.String("text")))
return nil
}